From 3877af0b729aae984620d0345d212a2100e598c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 4 Aug 2026 12:01:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/modules/first-leg/api/firstLeg.ts | 4 +- .../data/firstLegPartySource.test.mjs | 8 +- .../first-leg/store/firstLegOrders.test.mjs | 6 +- src/modules/first-leg/store/firstLegOrders.ts | 28 +- .../first-leg/utils/firstLegOrder.test.mjs | 28 +- src/modules/first-leg/utils/firstLegOrder.ts | 22 +- .../views/FirstLegOrdersView.test.mjs | 24 +- .../first-leg/views/FirstLegOrdersView.vue | 241 ++++++++---------- 9 files changed, 182 insertions(+), 181 deletions(-) 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, /

下单信息<\/h3>/) - assert.match(source, /

发货与目的地<\/h3>/) assert.match(source, /

货物明细<\/h3>/) + assert.doesNotMatch(source, /

发货信息<\/h3>|

目的地信息<\/h3>/) assert.match(source, /class="segmented-options"/) + assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/) assert.match(source, /class="cargo-primary-grid"/) assert.match(source, /title="更多申报信息"/) assert.doesNotMatch(source, /费用预估|查看最新报价|cost-estimate/) @@ -149,17 +150,14 @@ test('shows field-level validation and concise guidance for required order input assert.match(source, /\.order-form :deep\(\.el-form-item__error\).*position: static/) }) -test('selects enabled counterparties and stores editable order snapshots', () => { - assert.match(source, /firstLegPartySource\.listAvailable\('SHIPPER'\)/) - assert.match(source, /firstLegPartySource\.findAvailable\(id, role\)/) - assert.match(source, /firstLegPartySource\.load\(\)/) - assert.match(source, /v-model="form\.shipperPartyId"/) - assert.match(source, /v-model="form\.consigneePartyId"/) +test('collects snapshots only for pickup and private destinations', () => { + assert.doesNotMatch(source, /firstLegPartySource|v-model="form\.(?:shipper|consignee)PartyId"/) + assert.match(source, /form\.pickupMethod === 'PICKUP'/) + assert.match(source, /form\.destinationType === 'PRIVATE'/) assert.match(source, /form\.shipperPartySnapshot\.contactName/) assert.match(source, /form\.consigneePartySnapshot\.address/) - assert.match(source, /保存为本订单快照/) - assert.match(source, /form\.shipperPartySnapshot\.name\} · 历史快照/) - assert.match(source, /form\.consigneePartySnapshot\.name\} · 历史快照/) + assert.match(source, /syncPrivateDestinationName/) + assert.match(source, /changePickupMethod/) }) test('shows fulfillment courier information as read-only client detail', () => { @@ -180,7 +178,7 @@ test('protects unsaved drafts and exposes recoverable load failures', () => { assert.match(source, /订单数据加载失败/) assert.match(source, /订单详情加载失败/) assert.match(source, /部分订单配套数据加载失败/) - assert.match(source, /往来单位加载失败/) + assert.doesNotMatch(source, /往来单位加载失败/) assert.match(source, /企业资料加载失败/) assert.match(source, /@click="loadPageData"/) assert.match(source, /@click="retryDetail"/) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 6833d29..a961dd1 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -10,8 +10,7 @@ import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrd import { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders' import { useTenantAdministrationStore } from '@/modules/tenant-management/public' import { useAuthStore } from '@/modules/auth/public' -import { createFirstLegPartySnapshot, type FirstLegPartyRole } from '@/modules/first-leg/data/firstLegParties' -import { firstLegPartySource } from '@/modules/first-leg/data/firstLegPartySource' +import type { FirstLegPartyRole, FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegParties' import type { FirstLegOrder, FirstLegOrderDraft } from '@/modules/first-leg/types/firstLegOrder' import { canClientEditFirstLegOrder, @@ -36,8 +35,6 @@ const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit')) const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')) const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType)) -const availableShippers = ref(firstLegPartySource.listAvailable('SHIPPER')) -const availableConsignees = ref(firstLegPartySource.listAvailable('CONSIGNEE')) const filters = ref({ keyword: '', status: '', transportMode: '', createdRange: [] }) const appliedFilters = ref({ ...filters.value }) const queryFields: QueryField[] = [ @@ -112,8 +109,6 @@ function persistPrivateAddresses() { const orderFormRules: FormRules = { transportMode: [{ required: true, message: '请选择运输方式', trigger: 'change' }], businessType: [{ required: true, message: '请选择业务类型', trigger: 'change' }], - origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }], - destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }], receivingWarehouseId: [{ required: true, message: '请选择平台收货仓', trigger: 'change' }], destinationCountryCode: [ { required: true, message: '请输入目的国代码', trigger: 'blur' }, @@ -125,8 +120,23 @@ const orderFormRules: FormRules = { pickupMethod: [{ required: true, message: '请选择提货方式', trigger: 'change' }], taxMode: [{ required: true, message: '请选择报关模式', trigger: 'change' }], destinationType: [{ required: true, message: '请选择目的仓类型', trigger: 'change' }], - shipperPartyId: [{ required: true, message: '请选择发货单位', trigger: 'change' }], - consigneePartyId: [{ required: true, message: '请选择收货单位', trigger: 'change' }], +} + +function createEmptyPartySnapshot(role: FirstLegPartyRole): FirstLegPartySnapshot { + return { + id: '', + code: '', + name: '', + role, + contactName: '', + phone: '', + email: '', + countryRegion: role === 'SHIPPER' ? 'CN' : 'US', + province: '', + city: '', + postalCode: '', + address: '', + } } function requiredTextRules(message: string): FormItemRule[] { @@ -231,24 +241,13 @@ async function loadPageData() { supportingDataLoading.value = true supportingDataErrors.value = [] companyAuditLoaded.value = false - availableShippers.value = [] - availableConsignees.value = [] - const companyRequest = canViewCompanyProfile.value ? tenantStore.loadCompanyContext() : Promise.resolve() - const [ordersResult, partiesResult, companyResult] = await Promise.allSettled([ + const [ordersResult, companyResult] = await Promise.allSettled([ store.load(), - firstLegPartySource.load(), companyRequest, ]) - - if (partiesResult.status === 'fulfilled') { - availableShippers.value = firstLegPartySource.listAvailable('SHIPPER') - availableConsignees.value = firstLegPartySource.listAvailable('CONSIGNEE') - } else { - supportingDataErrors.value.push(`往来单位加载失败:${errorMessage(partiesResult.reason)}`) - } if (canViewCompanyProfile.value && companyResult.status === 'fulfilled') { companyAuditLoaded.value = true } else if (companyResult.status === 'rejected') { @@ -344,38 +343,6 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft { } } -function selectParty(role: FirstLegPartyRole, id: string) { - const party = firstLegPartySource.findAvailable(id, role) - if (!party) return - const snapshot = createFirstLegPartySnapshot(party, role) - if (role === 'SHIPPER') { - form.shipperPartyId = party.id - form.shipperPartySnapshot = snapshot - form.shipper = party.name - form.origin = [snapshot.province, snapshot.city].filter(Boolean).join(' ') || snapshot.countryRegion || snapshot.name - } else { - form.consigneePartyId = party.id - form.consigneePartySnapshot = snapshot - form.consignee = party.name - form.destination = [snapshot.province, snapshot.city].filter(Boolean).join(' ') || snapshot.countryRegion || snapshot.name - if (/^[A-Za-z]{2}$/.test(snapshot.countryRegion)) form.destinationCountryCode = snapshot.countryRegion.toUpperCase() - } -} - -function clearParty(role: FirstLegPartyRole) { - if (role === 'SHIPPER') { - form.shipperPartyId = '' - form.shipperPartySnapshot = null - form.shipper = '' - form.origin = '' - } else { - form.consigneePartyId = '' - form.consigneePartySnapshot = null - form.consignee = '' - form.destination = '' - } -} - function selectChannel(code: string) { const channel = channelOptions.find((item) => item.code === code) form.channelName = channel?.name ?? '' @@ -394,11 +361,34 @@ function selectDestinationWarehouse(id: string) { function changeDestinationType() { form.destinationWarehouseId = '' form.destinationWarehouseName = '' + form.destination = '' form.amazonWarehouseCode = '' selectedPrivateAddressId.value = '' + form.consigneePartyId = '' + form.consigneePartySnapshot = form.destinationType === 'PRIVATE' ? createEmptyPartySnapshot('CONSIGNEE') : null + form.consignee = '' resetEditorValidation() } +function changePickupMethod() { + if (form.pickupMethod === 'PICKUP') { + form.shipperPartySnapshot ??= createEmptyPartySnapshot('SHIPPER') + } else { + form.shipperPartyId = '' + form.shipperPartySnapshot = null + form.shipper = '' + form.origin = '' + } + resetEditorValidation() +} + +function syncPrivateDestinationName(value: string) { + if (form.destinationType !== 'PRIVATE') return + form.destinationWarehouseName = value + form.destination = value.trim() + if (form.consigneePartySnapshot) form.consigneePartySnapshot.name = value +} + function selectPrivateAddress(id: string) { const address = privateAddresses.value.find((item) => item.id === id) if (!address) return @@ -412,18 +402,24 @@ function selectPrivateAddress(id: string) { } function savePrivateAddress() { - if (!form.consigneePartySnapshot || !form.consigneePartyId || !form.destinationWarehouseName.trim() || !form.destinationCountryCode.trim()) { - ElMessage.warning('请先完整填写收货单位、私人地址名称和目的国代码') + if (!form.consigneePartySnapshot || !form.destinationWarehouseName.trim() || !form.destinationCountryCode.trim() + || !form.consigneePartySnapshot.contactName.trim() || !form.consigneePartySnapshot.phone.trim() + || !form.consigneePartySnapshot.city.trim() || !form.consigneePartySnapshot.address.trim()) { + ElMessage.warning('请先完整填写私人地址和收件资料') return } const snapshot = { ...form.consigneePartySnapshot } - const existing = privateAddresses.value.find((item) => item.consigneePartyId === form.consigneePartyId && item.consigneePartySnapshot.address === snapshot.address) + snapshot.name = form.destinationWarehouseName.trim() + snapshot.countryRegion = form.destinationCountryCode.trim().toUpperCase() + const existing = privateAddresses.value.find((item) => item.consigneePartySnapshot.address === snapshot.address) + const partyId = existing?.consigneePartyId || snapshot.id || `private-party-${Date.now()}` + snapshot.id = partyId const saved: CachedPrivateAddress = { id: existing?.id ?? globalThis.crypto?.randomUUID?.() ?? `private-${Date.now()}`, label: `${form.destinationWarehouseName.trim()} · ${snapshot.address}`, - consigneePartyId: form.consigneePartyId, + consigneePartyId: partyId, consigneePartySnapshot: snapshot, - consignee: form.consignee, + consignee: snapshot.name, destination: form.destination, destinationCountryCode: form.destinationCountryCode.trim().toUpperCase(), destinationWarehouseName: form.destinationWarehouseName.trim(), @@ -444,22 +440,6 @@ function removePrivateAddress() { ElMessage.success('已从当前账号的私人地址中移除') } -function selectShipper(id: string) { - selectParty('SHIPPER', id) -} - -function selectConsignee(id: string) { - selectParty('CONSIGNEE', id) -} - -function clearShipper() { - clearParty('SHIPPER') -} - -function clearConsignee() { - clearParty('CONSIGNEE') -} - async function loadSelectedWarehouse(id: string | number | boolean | undefined) { const warehouseId = typeof id === 'string' ? id : typeof id === 'number' ? String(id) : '' if (!warehouseId) return @@ -744,10 +724,42 @@ async function searchOrders(model: QueryModel) {
-

下单信息

选择送货方式、收货仓和运输渠道。

-
- 自送货 · 快递我司提货自送货 · 卡车 - {{ warehouse.city ? `${warehouse.name} · ${warehouse.city}` : warehouse.name }} +

下单信息

选择目的仓后,再填写本次运输安排。

+
+ FBA 仓海外仓私人仓 + + +
保存当前地址移除
+
+ + 平台暂未维护可用的{{ form.destinationType === 'FBA' ? ' FBA 仓' : '海外仓' }},请联系平台管理员。 +
{{ form.destinationWarehouseName }} · {{ form.amazonWarehouseCode || '海外仓' }} · {{ form.destinationCountryCode }}
+ + + + + + 快递自送我司提货卡车自送 + + {{ warehouse.city ? `${warehouse.name} · ${warehouse.city}` : warehouse.name }} +
+
{{ selectedWarehouse.address ? `${selectedWarehouse.province}${selectedWarehouse.city} · ${selectedWarehouse.address}` : '仓库地址详情暂不可用' }}{{ selectedWarehouse.businessHours ? `收货时间:${selectedWarehouse.businessHours}` : '收货时间暂不可用' }}
+
买单报关退税报关 {{ item.label }} @@ -755,50 +767,6 @@ async function searchOrders(model: QueryModel) {
-
{{ selectedWarehouse.address ? `${selectedWarehouse.province}${selectedWarehouse.city} · ${selectedWarehouse.address}` : '仓库地址详情暂不可用' }}{{ selectedWarehouse.businessHours ? `收货时间:${selectedWarehouse.businessHours}` : '收货时间暂不可用' }}
-
- -
-

发货与目的地

单位信息会保存为本订单快照,后续主数据变更不会影响本单。

- FBA 仓海外仓私人地址 -
- - - - 保存当前地址 - 移除缓存 -
-
-
-

发货信息

- - -
- - - -
-
-
-

目的地信息

- - -
- - - -
-
-
-
- 平台暂未维护可用的{{ form.destinationType === 'FBA' ? ' FBA 仓' : '海外仓' }},请联系平台管理员。 - - - - - - -
@@ -908,7 +876,7 @@ async function searchOrders(model: QueryModel) { .order-form-section + .order-form-section { margin-top: 24px; padding-top: 22px; border-top: 1px solid var(--border); } .order-form-section h3 { margin: 0 0 16px; color: var(--navy); font-size: 16px; } .order-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 18px; } -.order-form-grid > *, .destination-fields > *, .party-contact-grid > *, .cargo-primary-grid > *, .cargo-secondary-grid > * { min-width: 0; } +.order-form-grid > *, .cargo-primary-grid > *, .cargo-secondary-grid > * { min-width: 0; } .order-form :deep(.el-select), .order-form :deep(.el-date-editor), .order-form :deep(.el-input-number) { width: 100%; } .form-span-full { grid-column: 1 / -1; } .prototype-section { padding: 2px 2px 0; } @@ -925,14 +893,22 @@ async function searchOrders(model: QueryModel) { .order-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; } .order-section-heading h3 { margin-bottom: 5px; } .order-section-heading p { margin: 0; color: var(--muted); font-size: 11px; } -.party-select-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } -.party-panel { min-width: 0; padding: 16px; border: 1px solid var(--border); background: #fbfcfc; } -.party-panel h4 { margin: 0 0 14px; color: var(--navy); font-size: 14px; } -.party-panel :deep(.el-alert) { margin-bottom: 14px; } -.party-contact-grid, .destination-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 12px; } -.destination-fields { margin-top: 16px; } -.private-address-toolbar { display: grid; grid-template-columns: minmax(220px, 1fr) auto auto; gap: 10px; align-items: center; margin-bottom: 16px; } +.prototype-form-flow { display: flex; max-width: 900px; flex-direction: column; padding-top: 4px; } +.prototype-form-row { display: grid; width: 100%; grid-template-columns: 140px minmax(0, 560px); align-items: start; margin-bottom: 18px; } +.prototype-form-row--wide { grid-template-columns: 140px minmax(0, 720px); } +.prototype-form-row--compact { grid-template-columns: 140px minmax(0, 420px); } +.prototype-form-row--short { grid-template-columns: 140px minmax(0, 280px); } +.prototype-form-row--tabs { grid-template-columns: 140px minmax(0, 520px); margin-bottom: 22px; } +.prototype-form-row :deep(.el-form-item__label) { width: auto !important; min-height: 40px; justify-content: flex-end; padding: 0 20px 0 0; line-height: 40px; } +.prototype-form-row :deep(.el-form-item__content) { display: block; min-width: 0; line-height: normal; } +.prototype-form-row :deep(.el-input__wrapper), .prototype-form-row :deep(.el-select__wrapper) { min-height: 40px; } +.address-book-control { display: grid; grid-template-columns: minmax(260px, 1fr) auto auto; gap: 10px; align-items: center; } +.address-book-control :deep(.el-button) { min-height: 40px; margin-left: 0; } .field-guidance { display: block; margin-top: 5px; color: var(--muted); line-height: 1.45; } +.selected-destination { display: flex; width: min(100%, 720px); align-items: center; gap: 9px; margin: -4px 0 18px 140px; padding: 10px 12px; background: #f2f7f6; color: var(--primary); } +.selected-destination span { color: var(--navy); font-size: 12px; font-weight: 600; } +.flow-divider { width: min(100%, 860px); margin: 4px 0 22px; border-top: 1px solid var(--border); } +.compact-transport-grid { margin-top: 18px; } .cargo-entry { padding: 16px; border: 1px solid var(--border); background: #fbfcfc; } .cargo-entry + .cargo-entry { margin-top: 12px; } .cargo-entry > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; } @@ -1000,7 +976,12 @@ async function searchOrders(model: QueryModel) { .order-table-toolbar .el-button { width: 100%; min-height: 40px; } .order-eligibility { flex-wrap: wrap; } .order-eligibility .el-button { width: 100%; min-height: 44px; } - .order-form-grid, .order-detail-grid, .party-select-grid, .party-contact-grid, .destination-fields, .private-address-toolbar, .cargo-primary-grid, .cargo-secondary-grid { grid-template-columns: 1fr; } + .order-form-grid, .order-detail-grid, .cargo-primary-grid, .cargo-secondary-grid { grid-template-columns: 1fr; } + .prototype-form-row, .prototype-form-row--wide, .prototype-form-row--compact, .prototype-form-row--short, .prototype-form-row--tabs { display: block; margin-bottom: 18px; } + .prototype-form-row :deep(.el-form-item__label) { min-height: auto; justify-content: flex-start; padding: 0 0 8px; line-height: 1.4; } + .selected-destination { width: 100%; margin-left: 0; } + .address-book-control { grid-template-columns: 1fr auto; } + .address-book-control :deep(.el-select) { grid-column: 1 / -1; } .segmented-options { width: 100%; align-items: stretch; flex-direction: column; } .segmented-options :deep(.el-radio-button__inner) { border: 1px solid var(--border); border-radius: 6px !important; box-shadow: none !important; } .warehouse-options { display: grid; width: 100%; grid-template-columns: 1fr; }