diff --git a/README.md b/README.md index 2c5af9f..22d94ec 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,6 @@ pnpm test 企业资料和资质列表使用真实租户接口,覆盖 `PROFILE_REQUIRED`、`PENDING`、`ACTIVE`、`REJECTED`、`DISABLED` 五种企业状态。企业名称、法定名称和联系人信息支持真实保存;营业执照、法人身份证、货代资质证书和银行开户许可证使用文件服务分别上传,接受 PDF、JPG、PNG 且不超过 5MB,四类齐全后显式提交审核,文件预览通过当前商户 Token 临时下载并在关闭弹窗后释放。 -头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。收发货单位通过 `/merchant/v1/partners/candidates` 一次读取当前租户启用候选;后端主数据类型 `CUSTOMER / SUPPLIER / BOTH` 不等同于订单角色,同一候选可用于发货方或收货方并分别保存快照。订单写请求以发货方 ID 填充后端强制校验的主往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 +头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 联调远端 Gateway 时可在启动命令前设置 `API_PROXY_TARGET=http://8.163.30.128:9000`;进程环境变量优先于 `.env` 文件。浏览器仍统一请求同源 `/api`,源码中不写死业务服务地址。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。2026-07-31 测试 Gateway 登录和真实角色列表读取正常;Warehouse 服务已注册,但仓库和往来单位业务查询仍返回 `S0500`,头程草稿创建和相关浏览器流程需待后端修复后继续验收。 diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index e7fde50..23135d9 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -95,8 +95,8 @@ export interface FirstLegOrderSaveRequest { destinationWarehouseName: string expectedShipDate?: string customerRemark?: string - sender: FirstLegOrderPartyPayload - receiver: FirstLegOrderPartyPayload + sender?: FirstLegOrderPartyPayload + receiver?: FirstLegOrderPartyPayload cargoes: FirstLegOrderCargoPayload[] pickupMethod: ApiPickupMethod receivingWarehouseId: number | string diff --git a/src/modules/first-leg/data/firstLegPartySource.test.mjs b/src/modules/first-leg/data/firstLegPartySource.test.mjs index b033d9a..50cf89c 100644 --- a/src/modules/first-leg/data/firstLegPartySource.test.mjs +++ b/src/modules/first-leg/data/firstLegPartySource.test.mjs @@ -6,7 +6,7 @@ const source = readFileSync(new URL('./firstLegPartySource.ts', import.meta.url) const viewSource = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8') const apiSource = readFileSync(new URL('../api/partners.ts', import.meta.url), 'utf8') -test('keeps counterparty availability behind a replaceable read-only source', () => { +test('keeps the legacy counterparty source available without coupling it to first-leg ordering', () => { assert.match(source, /export interface FirstLegPartySource/) assert.match(source, /listAvailable\(role/) assert.match(source, /findAvailable\(id, role/) @@ -18,10 +18,8 @@ test('keeps counterparty availability behind a replaceable read-only source', () assert.match(source, /: \['SHIPPER', 'CONSIGNEE'\]/) assert.match(source, /await getMerchantPartnerCandidates\(\)/) assert.doesNotMatch(source, /getMerchantPartnerCandidates\('SENDER'\)|getMerchantPartnerCandidates\('RECEIVER'\)/) - assert.match(viewSource, /firstLegPartySource\.listAvailable\('SHIPPER'\)/) - assert.match(viewSource, /firstLegPartySource\.listAvailable\('CONSIGNEE'\)/) - assert.match(viewSource, /firstLegPartySource\.findAvailable\(id, role\)/) - assert.match(viewSource, /firstLegPartySource\.load\(\)/) + assert.doesNotMatch(viewSource, /firstLegPartySource/) + assert.doesNotMatch(viewSource, /发货单位|收货单位/) assert.doesNotMatch(viewSource, /DEMO_FIRST_LEG_PARTIES/) assert.match(apiSource, /url: '\/merchant\/v1\/partners\/candidates'/) assert.match(apiSource, /'CARRIER' \| 'CUSTOMS_BROKER' \| 'SHIPPER' \| 'CONSIGNEE'/) diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index 3869d02..4166adc 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -33,9 +33,11 @@ test('sends backend-required channel, customs, destination and cargo fields', () assert.match(storeSource, new RegExp(field)) } assert.match(storeSource, /containerMode: draft\.transportMode === 'FCL' \? 'FULL_CONTAINER'/) - assert.match(storeSource, /customerId: draft\.shipperPartyId/) - assert.match(storeSource, /customerName: customerName\.trim\(\) \|\| draft\.shipperPartySnapshot\.name/) + assert.doesNotMatch(storeSource, /customerId: draft\.shipperPartyId/) + assert.match(storeSource, /customerName: customerName\.trim\(\)/) assert.match(storeSource, /destinationWarehouseId: draft\.destinationType === 'PRIVATE' \? undefined : draft\.destinationWarehouseId/) + assert.match(storeSource, /draft\.pickupMethod === 'PICKUP'.*draft\.shipperPartySnapshot/s) + assert.match(storeSource, /draft\.destinationType === 'PRIVATE'.*draft\.consigneePartySnapshot/s) }) test('separates domestic receiving warehouses from public destinations', () => { diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index edcb366..ea69e08 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -67,12 +67,22 @@ function partyPayload(snapshot: FirstLegPartySnapshot): FirstLegOrderPartyPayloa } function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, customerName: string): FirstLegOrderSaveRequest { - if (!draft.shipperPartySnapshot || !draft.consigneePartySnapshot) throw new Error('请完整填写收发货信息') + const sender = draft.pickupMethod === 'PICKUP' && draft.shipperPartySnapshot + ? partyPayload({ + ...draft.shipperPartySnapshot, + name: draft.shipperPartySnapshot.name || customerName.trim() || '客户', + countryRegion: draft.shipperPartySnapshot.countryRegion || 'CN', + }) + : undefined + const receiver = draft.destinationType === 'PRIVATE' && draft.consigneePartySnapshot + ? partyPayload({ + ...draft.consigneePartySnapshot, + name: draft.destinationWarehouseName, + countryRegion: draft.destinationCountryCode.trim().toUpperCase(), + }) + : undefined return { - // 后端把 customerId 作为订单主往来单位校验并以服务端名称覆盖 customerName; - // 当前商户下单以所选发货单位作为主往来单位,收发货详情仍分别保存订单快照。 - customerId: draft.shipperPartyId, - customerName: customerName.trim() || draft.shipperPartySnapshot.name, + customerName: customerName.trim(), transportMode: apiTransport(draft), tradeTerm: draft.tradeTerm || undefined, destinationCountryCode: draft.destinationCountryCode.trim().toUpperCase(), @@ -80,8 +90,8 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust destinationWarehouseName: draft.destinationWarehouseName, expectedShipDate: draft.expectedDepartureDate || undefined, customerRemark: draft.customerRemark || undefined, - sender: partyPayload(draft.shipperPartySnapshot), - receiver: partyPayload(draft.consigneePartySnapshot), + sender, + receiver, cargoes: draft.items.map((item) => ({ cargoName: item.cargoNameCn, cargoNameEn: item.cargoNameEn || undefined, @@ -107,8 +117,8 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust containerMode: draft.transportMode === 'FCL' ? 'FULL_CONTAINER' : draft.transportMode === 'LCL' ? 'LCL' : undefined, customsInspection: draft.customsInspection, destinationType: draft.destinationType, - amazonWarehouseCode: draft.amazonWarehouseCode || undefined, - shipmentId: draft.shipmentId || undefined, + amazonWarehouseCode: draft.destinationType === 'FBA' ? draft.amazonWarehouseCode || undefined : undefined, + shipmentId: draft.destinationType === 'FBA' ? draft.shipmentId || undefined : undefined, referenceId: draft.referenceNo || undefined, } } diff --git a/src/modules/first-leg/utils/firstLegOrder.test.mjs b/src/modules/first-leg/utils/firstLegOrder.test.mjs index 1a318bc..7a95a4e 100644 --- a/src/modules/first-leg/utils/firstLegOrder.test.mjs +++ b/src/modules/first-leg/utils/firstLegOrder.test.mjs @@ -74,28 +74,36 @@ test('validates the confirmed minimum submission fields', () => { assert.deepEqual(validateFirstLegOrderDraft(validDraft()), []) const invalid = validDraft() - invalid.origin = '' invalid.receivingWarehouseId = '' invalid.items[0].grossWeightKg = 0 invalid.items[0].containerType = '' invalid.items[0].containerCount = 0 const errors = validateFirstLegOrderDraft(invalid) - assert.ok(errors.includes('请填写起运地')) assert.ok(errors.includes('请选择平台收货仓')) assert.ok(errors.includes('第 1 条货物毛重必须大于 0')) assert.ok(errors.includes('第 1 条货物需要填写箱型和柜量')) }) -test('requires enabled-party snapshots and order addresses before submission', () => { - const draft = validDraft() - draft.shipperPartyId = '' - draft.shipperPartySnapshot = null - draft.consigneePartySnapshot.address = '' +test('only requires sender for pickup and receiver for a private destination', () => { + const publicDraft = validDraft() + publicDraft.shipperPartySnapshot = null + publicDraft.consigneePartySnapshot = null + assert.deepEqual(validateFirstLegOrderDraft(publicDraft), []) - const errors = validateFirstLegOrderDraft(draft) - assert.ok(errors.includes('请选择发货单位')) - assert.ok(errors.includes('请填写本单收货地址')) + const pickupDraft = validDraft() + pickupDraft.pickupMethod = 'PICKUP' + pickupDraft.shipperPartySnapshot = null + assert.ok(validateFirstLegOrderDraft(pickupDraft).includes('请填写提货联系人')) + + const privateDraft = validDraft() + privateDraft.destinationType = 'PRIVATE' + privateDraft.destinationWarehouseId = '' + privateDraft.amazonWarehouseCode = '' + privateDraft.shipmentId = '' + privateDraft.referenceNo = '' + privateDraft.consigneePartySnapshot = null + assert.ok(validateFirstLegOrderDraft(privateDraft).includes('请填写收件人姓名')) }) test('requires FBA reference, warehouse, shipment, SKU and UPC fields', () => { diff --git a/src/modules/first-leg/utils/firstLegOrder.ts b/src/modules/first-leg/utils/firstLegOrder.ts index 5a6f874..97dd1c9 100644 --- a/src/modules/first-leg/utils/firstLegOrder.ts +++ b/src/modules/first-leg/utils/firstLegOrder.ts @@ -62,7 +62,6 @@ export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoI export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) { const errors: string[] = [] - if (!draft.origin.trim()) errors.push('请填写起运地') if (!draft.destination.trim()) errors.push('请填写目的地') if (!/^[A-Za-z]{2}$/.test(draft.destinationCountryCode.trim())) errors.push('请输入两位英文字母的目的国代码') if (!draft.destinationWarehouseName.trim()) errors.push('请填写目的仓名称') @@ -70,14 +69,19 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) { if (draft.destinationType === 'PRIVATE' && draft.destinationWarehouseId) errors.push('私人地址不能引用平台公共仓库') if (!draft.channelCode.trim() || !draft.channelName.trim()) errors.push('请填写渠道编码和渠道名称') if (!draft.receivingWarehouseId) errors.push('请选择平台收货仓') - if (!draft.shipperPartyId || !draft.shipperPartySnapshot || !draft.shipper.trim()) errors.push('请选择发货单位') - if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.contactName.trim()) errors.push('请填写发货联系人') - if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.phone.trim()) errors.push('请填写发货联系电话') - if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.address.trim()) errors.push('请填写本单发货地址') - if (!draft.consigneePartyId || !draft.consigneePartySnapshot || !draft.consignee.trim()) errors.push('请选择收货单位') - if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.contactName.trim()) errors.push('请填写收货联系人') - if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.phone.trim()) errors.push('请填写收货联系电话') - if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.address.trim()) errors.push('请填写本单收货地址') + if (draft.pickupMethod === 'PICKUP') { + if (!draft.shipperPartySnapshot?.contactName.trim()) errors.push('请填写提货联系人') + if (!draft.shipperPartySnapshot?.phone.trim()) errors.push('请填写提货联系电话') + if (!draft.shipperPartySnapshot?.city.trim()) errors.push('请填写提货城市') + if (!draft.shipperPartySnapshot?.address.trim()) errors.push('请填写提货地址') + } + if (draft.destinationType === 'PRIVATE') { + if (!draft.consigneePartySnapshot?.name.trim()) errors.push('请填写私人地址名称') + if (!draft.consigneePartySnapshot?.contactName.trim()) errors.push('请填写收件人姓名') + if (!draft.consigneePartySnapshot?.phone.trim()) errors.push('请填写收件人电话') + if (!draft.consigneePartySnapshot?.city.trim()) errors.push('请填写收件城市') + if (!draft.consigneePartySnapshot?.address.trim()) errors.push('请填写收件详细地址') + } if (draft.items.length === 0) errors.push('请至少添加一条货物明细') if (draft.destinationType === 'FBA') { diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index cb5c776..bfd5600 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -26,7 +26,7 @@ test('exposes real list, draft, submit, and detail workflow entries without canc assert.match(source, /function submitOrder\(\)/) assert.match(source, /async function openDetail\(order: FirstLegOrder\)/) assert.match(source, /Promise\.allSettled\(\[/) - assert.match(source, /store\.load\(\),\s+firstLegPartySource\.load\(\)/) + assert.match(source, /Promise\.allSettled\(\[\s*store\.load\(\),\s*companyRequest/) assert.match(source, /tenantStore\.loadCompanyContext\(\)/) assert.match(source, /await store\.loadDetail\(order\.id\)/) assert.doesNotMatch(source, /cancelOrder|取消订单/) @@ -98,7 +98,7 @@ test('does not expose arbitrary status mutation controls to customers', () => { test('requires an enabled platform receiving warehouse in the order form', () => { assert.match(source, /store\.warehouses\.filter\(\(warehouse\) => warehouse\.warehouseType === 'DOMESTIC'\)/) assert.match(source, /availableWarehouses\.value\.length === 0/) - assert.match(source, /label="平台收货仓"[^>]*required/) + assert.match(source, /label="国内收货仓"[^>]*required/) assert.match(source, /v-model="form\.receivingWarehouseId"/) assert.match(source, /selectedWarehouse\.businessHours/) assert.match(source, /class="selected-warehouse" aria-label="所选收货仓详情"/) @@ -129,9 +129,10 @@ test('collects the backend-required first-leg fields', () => { test('distills order creation into the confirmed prototype sections without fake pricing', () => { assert.match(source, /
选择送货方式、收货仓和运输渠道。
选择目的仓后,再填写本次运输安排。
单位信息会保存为本订单快照,后续主数据变更不会影响本单。