From 22853336b63afaf2076242f9fda0f9102f7b0e5e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 15:47:56 +0800 Subject: [PATCH 01/17] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/overseas-warehouse/pages/LastMileOrdersView.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 62e7e87c..bf226db3 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -818,6 +818,11 @@ async function reviewReturn(source: object) { } function canConfirmRelabel(row: { relabelStatusCode?: string }) { return row.relabelStatusCode === 'CUSTOMER_CONFIRM_PENDING' } function canReworkRelabel(row: { relabelStatusCode?: string }) { return ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(row.relabelStatusCode ?? '') } +function hasRelabelActions(row: LastMileOrderListItem) { + return access.can(permission.relabelOrderStatus) + && Boolean(row.relabelOrderNo) + && (canConfirmRelabel(row) || canReworkRelabel(row)) +} function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] { return [ { key: 'detail', label: '查看', priority: 500 }, From 6bcfb6ba0b614619d2be1cd2826e11fb7654353d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 18:25:17 +0800 Subject: [PATCH 02/17] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/overseas-warehouse/AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md index b16b216e..dc8cf66e 100644 --- a/src/modules/overseas-warehouse/AGENTS.md +++ b/src/modules/overseas-warehouse/AGENTS.md @@ -17,6 +17,8 @@ | 关键词 | 页面/路由入口 | API 与联查 | |---|---|---| +| 退货预报审核、退货驳回 | `pages/LastMileOrdersView.vue`、`api/lastMileOrders.ts` 的 `reviewReturnOrder` | 尾程订单内 `RETURN_RELABEL + PENDING_REVIEW` 行进入审核,来源退货号取 `sourceOrderNo`;请求 `/platform/v1/last-leg/return-orders/{returnNo}/review` 携带 `X-Customer-Id`,结论 `APPROVE/REJECT`,驳回必填原因。按钮权限入口为 `permissions.ts` 的 `returnOrderReview`;服务端归 `returnreceipt/`,通过后才能签收,不是仓储转运审核或换标完工确认 | +| 换标申请审核、待审核按钮与驳回原因 | `pages/RelabelOrdersView.vue`、`pages/RelabelOrderDetailView.vue`、`api/relabelOrders.ts` | 列表审核动作仅在 `PENDING_REVIEW` 显示,动作选择为审核通过/已驳回,驳回原因必填;审核后不再显示审核按钮。回归见 `pages/RelabelOrdersView.test.ts`、`pages/RelabelOrderDetailView.test.ts`;PDA 完工、客户确认与申请审核分别定位,不混用状态入口 | | 排车车型、托位容量与限重提示 | `dispatchVehicleSpecs.ts`、`components/SmartDispatchDialog.vue`、`pages/DispatchesView.vue` | 预排车型与排车编辑选项共用规格入口:大车 30 托/40000 LB,小车 15 托/10000 LB;超重提示在预排组件,不能把前端提示当作服务端允许提交的依据。回归见 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts` | | 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 | | 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写。新增暂扣只在 `INBOUND_COMPLETED` 开放,解除暂扣保持可用;暂扣不阻断入库计划,只限制后续排车和出库。人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` | From 2e9e8819b6ff577e11bc3b185656e45671ac2ed2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 19:33:53 +0800 Subject: [PATCH 03/17] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layoutAcceptance.test.ts | 12 +++++++ .../pages/InboundOrderDetailView.vue | 35 +++++++++++++------ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/modules/overseas-warehouse/layoutAcceptance.test.ts b/src/modules/overseas-warehouse/layoutAcceptance.test.ts index 81b0b92f..9c9f5706 100644 --- a/src/modules/overseas-warehouse/layoutAcceptance.test.ts +++ b/src/modules/overseas-warehouse/layoutAcceptance.test.ts @@ -78,4 +78,16 @@ describe('overseas warehouse 1920px layout acceptance', () => { expect(lastMileDetail).toContain(':title="isSingleLineBusinessField(field) ? String(field.value) : undefined"') expect(lastMileDetail).toContain('.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }') }) + + it('keeps the inbound detail compact and puts pallet facts before appointment metadata', () => { + const inbound = page('InboundOrderDetailView.vue') + expect(inbound).toContain('grid-template-columns: repeat(5, minmax(0, 1fr))') + expect(inbound).toContain('class="section-heading"') + expect(inbound).toContain('.detail-section { min-width: 0; padding: 0;') + expect(inbound).toContain(':deep(.density-detail-header) { border-bottom: 1px solid var(--cc-border); }') + expect(inbound).toContain('.appointment-fields { grid-template-columns:repeat(4,minmax(0,1fr)); }') + expect(inbound).toContain('class="compact-section-heading"') + expect(inbound.indexOf('class="detail-section pallet-section"')).toBeLessThan(inbound.indexOf('class="detail-section appointment-section"')) + expect(inbound).not.toContain("fields: ['状态', '预约状态'") + }) }) diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue index a041e8dc..b15cd077 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue @@ -28,14 +28,14 @@ const fieldValues = computed>(() => ({ 海外仓: receivingWarehouse(detail.value?.overseasWarehouseCode, detail.value?.overseasWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0, 应拣货数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0, 实际拣货数: detail.value?.pickedPackageCount ?? detail.value?.allocations.reduce((sum, item) => sum + item.pickedPackageCount, 0) ?? 0, '货物毛重(kg)': Number(detail.value?.totalGrossWeightKg || 0), '货物净重(kg)': Number(detail.value?.totalNetWeightKg || 0), - '计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0), 状态: status(detail.value?.status), + '计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0), 预约状态: '—', 预约送仓时间: '—', 预计装车时间: '—', 排车状态: '—', 创建时间: formatOverseasDateTime(detail.value?.createdAt), 更新时间: formatOverseasDateTime(detail.value?.updatedAt), 备注: text(detail.value?.remark), })) -const groups = [ +const summaryGroups = [ { title: '单据信息', fields: ['入库单号', '入仓码', '类型', '货主', '关联订单'] }, { title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '应拣货数', '实际拣货数', '货物毛重(kg)', '货物净重(kg)', '计费体积(m³)', '总托盘数'] }, - { title: '预约与排车', fields: ['状态', '预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注'] }, ] +const appointmentFields = ['预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注'] const feeColumns: DataTableColumn[] = [{ key: 'index', label: '序号', width: 70, align: 'center' }, { key: 'name', label: '费用项目', minWidth: 180 }, { key: 'currency', label: '币种', width: 100 }, { key: 'amount', label: '金额', width: 140, align: 'right' }, { key: 'remark', label: '备注', minWidth: 200 }] const palletColumns: DataTableColumn[] = [ { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 }, @@ -60,18 +60,31 @@ onMounted(load) From db9e496b35046d90c28d5a7fcc6e3e934e4cac71 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 19:45:27 +0800 Subject: [PATCH 04/17] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/overseas-warehouse/layoutAcceptance.test.ts | 4 ++++ .../overseas-warehouse/pages/InboundOrderDetailView.vue | 8 +++++--- .../overseas-warehouse/pages/InventoryDetailView.vue | 3 ++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/modules/overseas-warehouse/layoutAcceptance.test.ts b/src/modules/overseas-warehouse/layoutAcceptance.test.ts index 9c9f5706..986296b7 100644 --- a/src/modules/overseas-warehouse/layoutAcceptance.test.ts +++ b/src/modules/overseas-warehouse/layoutAcceptance.test.ts @@ -61,6 +61,8 @@ describe('overseas warehouse 1920px layout acceptance', () => { expect(page('DispatchDetailView.vue')).toContain('density-fields') expect(page('DeliveryOrderDetailView.vue')).toContain('density-fields') expect(page('InventoryDetailView.vue')).toContain('density-fields') + expect(page('InventoryDetailView.vue').match(/class="inventory-table-wrap"/g)).toHaveLength(2) + expect(page('InventoryDetailView.vue')).toContain('.inventory-table-wrap { padding:0 var(--admin-space-5) var(--admin-space-4); }') const outbounds = page('OutboundsView.vue') expect(outbounds).toContain('.create-outbound-form :deep(.el-form-item__content > .el-input-number) { width: 100%; }') expect(outbounds).not.toContain('.create-outbound-form :deep(.el-select)') @@ -87,6 +89,8 @@ describe('overseas warehouse 1920px layout acceptance', () => { expect(inbound).toContain(':deep(.density-detail-header) { border-bottom: 1px solid var(--cc-border); }') expect(inbound).toContain('.appointment-fields { grid-template-columns:repeat(4,minmax(0,1fr)); }') expect(inbound).toContain('class="compact-section-heading"') + expect(inbound.match(/class="detail-table-wrap"/g)).toHaveLength(2) + expect(inbound).toContain('.detail-table-wrap { padding:var(--admin-space-3) var(--admin-space-5) var(--admin-space-4); }') expect(inbound.indexOf('class="detail-section pallet-section"')).toBeLessThan(inbound.indexOf('class="detail-section appointment-section"')) expect(inbound).not.toContain("fields: ['状态', '预约状态'") }) diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue index b15cd077..84257703 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue @@ -61,9 +61,9 @@ onMounted(load) diff --git a/src/modules/overseas-warehouse/pages/InventoryDetailView.vue b/src/modules/overseas-warehouse/pages/InventoryDetailView.vue index a7441dbb..bc79a2f9 100644 --- a/src/modules/overseas-warehouse/pages/InventoryDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InventoryDetailView.vue @@ -31,11 +31,12 @@ function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted( From 592f01eb1fd5b62831fe5da27d9f4eaed8027186 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 19:57:51 +0800 Subject: [PATCH 05/17] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/overseas-warehouse/layoutAcceptance.test.ts | 2 +- src/modules/overseas-warehouse/pages/InventoryDetailView.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/overseas-warehouse/layoutAcceptance.test.ts b/src/modules/overseas-warehouse/layoutAcceptance.test.ts index 986296b7..d3a285d5 100644 --- a/src/modules/overseas-warehouse/layoutAcceptance.test.ts +++ b/src/modules/overseas-warehouse/layoutAcceptance.test.ts @@ -61,7 +61,7 @@ describe('overseas warehouse 1920px layout acceptance', () => { expect(page('DispatchDetailView.vue')).toContain('density-fields') expect(page('DeliveryOrderDetailView.vue')).toContain('density-fields') expect(page('InventoryDetailView.vue')).toContain('density-fields') - expect(page('InventoryDetailView.vue').match(/class="inventory-table-wrap"/g)).toHaveLength(2) + expect(page('InventoryDetailView.vue').match(/class="inventory-table-wrap"/g)).toHaveLength(3) expect(page('InventoryDetailView.vue')).toContain('.inventory-table-wrap { padding:0 var(--admin-space-5) var(--admin-space-4); }') const outbounds = page('OutboundsView.vue') expect(outbounds).toContain('.create-outbound-form :deep(.el-form-item__content > .el-input-number) { width: 100%; }') diff --git a/src/modules/overseas-warehouse/pages/InventoryDetailView.vue b/src/modules/overseas-warehouse/pages/InventoryDetailView.vue index bc79a2f9..6a1de101 100644 --- a/src/modules/overseas-warehouse/pages/InventoryDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InventoryDetailView.vue @@ -31,7 +31,7 @@ function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted( From 7586f9d78348782b151c7c82c3e6ae53a743e67e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 22:04:59 +0800 Subject: [PATCH 08/17] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/LastMileOrdersView.behavior.test.ts | 28 ++++++++++++++----- .../pages/LastMileOrdersView.vue | 2 +- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 49206ad4..ec295788 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -177,8 +177,8 @@ describe('LastMileOrdersView pending order and container conversion', () => { it.each([ [[], ['查看'], false], [['inbound-order:create'], ['查看', '生成入库计划'], false], - [['last-mile-order:edit'], ['查看', '暂扣订单', '人工修改节点'], false], - [['inbound-order:create', 'last-mile-order:edit'], ['查看', '生成入库计划', '暂扣订单', ''], true], + [['last-mile-order:edit'], ['查看', '人工修改节点'], false], + [['inbound-order:create', 'last-mile-order:edit'], ['查看', '生成入库计划', '人工修改节点'], false], ])('only collapses row actions above three for %j', async (allowed, labels, overflow) => { canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action)) vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 }) @@ -213,9 +213,9 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it.each([ - ['PENDING_ASSIGNMENT', true], - ['INBOUND_COMPLETED', false], - ])('only enables a new hold after inbound completion for %s', async (currentStatus, disabled) => { + ['PENDING_ASSIGNMENT', false], + ['INBOUND_COMPLETED', true], + ])('only shows a new hold after inbound completion for %s', async (currentStatus, visible) => { canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit') vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', detained: false, @@ -223,8 +223,22 @@ describe('LastMileOrdersView pending order and container conversion', () => { const wrapper = await mountView() const holdButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button') - .find(item => item.text().includes('暂扣订单'))! - expect((holdButton.element as HTMLButtonElement).disabled).toBe(disabled) + .find(item => item.text().includes('暂扣订单')) + expect(Boolean(holdButton)).toBe(visible) + if (holdButton) expect((holdButton.element as HTMLButtonElement).disabled).toBe(false) + }) + + it('keeps release hold available for an already detained order', async () => { + canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit') + vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ + id: 'order-held', customerId, orderNo: 'LM-HELD', currentStatus: 'READY_FOR_DISPATCH', orderType: 'FIRST_LEG', detained: true, + }], page: 1, size: 10, total: 1, pages: 1 }) + + const wrapper = await mountView() + const releaseButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button') + .find(item => item.text().includes('解除暂扣')) + expect(releaseButton).toBeTruthy() + expect((releaseButton!.element as HTMLButtonElement).disabled).toBe(false) }) it('removes generation and hold actions after an inbound plan has been submitted', async () => { diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 8b4a1d16..24ff3dd5 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -833,7 +833,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] { { key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' }, { key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' }, { key: 'inbound-plan', label: '生成入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) && !inboundPlanCreated }, - { key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || !inboundPlanCreated || row.currentStatusCode === 'INBOUND_COMPLETED'), disabled: !row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED' }, + { key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') }, { key: 'manual-nodes', label: '人工修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) }, { key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) }, { key: 'rework-relabel', label: '重新换标', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canReworkRelabel(row) }, From 3f78e1d087f4526c22f9f9fdf04b8c3ffdfc4698 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 22:13:56 +0800 Subject: [PATCH 09/17] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/pda-releases/pages/PdaReleasesView.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/pda-releases/pages/PdaReleasesView.vue b/src/modules/pda-releases/pages/PdaReleasesView.vue index 692bee1f..4d547cad 100644 --- a/src/modules/pda-releases/pages/PdaReleasesView.vue +++ b/src/modules/pda-releases/pages/PdaReleasesView.vue @@ -165,11 +165,11 @@ function download(row: PdaRelease) {
- - + +
- + From b9e5bf3fecca1c38b458b71ea89e26ae436e1c98 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 23:39:39 +0800 Subject: [PATCH 10/17] fix: preserve stable Admin proxy port --- scripts/deploy-production.sh | 6 +++--- src/deployment/blueGreenDeployment.test.mjs | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index e3499ef7..c27fb0b1 100644 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -62,12 +62,12 @@ compose() { case "$service" in admin-web) base_port=9001 - nginx_config=/www/server/panel/vhost/nginx/admin.buggoglobal.com.conf + nginx_config=/www/server/panel/vhost/nginx/00-blue-green-admin-web.conf legacy_container=firstleg-prod-admin-web ;; client-web) base_port=9002 - nginx_config=/www/server/panel/vhost/nginx/app.buggoglobal.com.conf + nginx_config=/www/server/panel/vhost/nginx/00-blue-green-client-web.conf legacy_container=firstleg-prod-client-web ;; *) echo "不支持蓝绿部署的前端服务: $service" >&2; exit 2 ;; @@ -102,7 +102,7 @@ fi if [ "$active_slot" = blue ]; then target_slot=green; else target_slot=blue; fi [ "$active_slot" = legacy ] && target_slot=green -if [ "$target_slot" = blue ]; then target_port="$base_port"; else target_port=$((base_port + 10000)); fi +if [ "$target_slot" = blue ]; then target_port=$((base_port + 20000)); else target_port=$((base_port + 10000)); fi candidate="${legacy_container}-${target_slot}" docker rm -f "$candidate" >/dev/null 2>&1 || true diff --git a/src/deployment/blueGreenDeployment.test.mjs b/src/deployment/blueGreenDeployment.test.mjs index 9fa90096..ab0275c3 100644 --- a/src/deployment/blueGreenDeployment.test.mjs +++ b/src/deployment/blueGreenDeployment.test.mjs @@ -15,7 +15,11 @@ test('production frontend starts and validates the candidate before switching ng }) test('production frontend supports both slots and restores nginx on failure', () => { + assert.match(script, /target_port=\$\(\(base_port \+ 20000\)\)/) assert.match(script, /target_port=\$\(\(base_port \+ 10000\)\)/) + assert.match(script, /00-blue-green-admin-web\.conf/) + assert.match(script, /00-blue-green-client-web\.conf/) + assert.doesNotMatch(script, /nginx_config=\/www\/server\/panel\/vhost\/nginx\/(?:admin|app)\.buggoglobal\.com\.conf/) assert.match(script, /cp -f "\$nginx_config" "\$backup"/) assert.match(script, /cp -f "\$backup" "\$nginx_config"/) assert.match(script, /docker rm -f "\$candidate"/) From 3e8fa3c5d7fdbd2dbb15cb72a01ffe5acf679bf1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 21 Sep 2026 10:44:14 +0800 Subject: [PATCH 11/17] fix: allow manual FBA quote when rate is missing --- src/modules/inbounds/fee/api.test.ts | 12 +++++ src/modules/inbounds/fee/api.ts | 12 ++++- src/modules/inbounds/fee/types.ts | 6 +-- src/modules/inbounds/index.ts | 1 + .../pages/UnifiedOrdersView.test.ts | 5 ++ .../pages/UnifiedOrdersView.vue | 48 ++++++++++++++----- 6 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/modules/inbounds/fee/api.test.ts b/src/modules/inbounds/fee/api.test.ts index 95f7aab9..0725148d 100644 --- a/src/modules/inbounds/fee/api.test.ts +++ b/src/modules/inbounds/fee/api.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ApiResponseError, http } from '@/shared/api/http' import { adjustPublishedInboundFee, + createManualInboundQuote, confirmInboundFee, createFeeActionIdempotencyKey, createFeeAdjustmentIdempotencyKey, @@ -69,6 +70,17 @@ describe('inbound fee API', () => { }) }) + it('creates a manual FBA quote through the dedicated fallback endpoint', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, rateCode: 'MANUAL_QUOTE' } }) + const payload = { selectedWeightKg: 62.5, selectedVolumeCbm: 0.384, unitPrice: 12.2, reason: 'FBA价格未维护' } + + await expect(createManualInboundQuote('10001', '88', payload, 'fee-manual-1')) + .resolves.toMatchObject({ rateCode: 'MANUAL_QUOTE' }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/manual-quote', payload, { + params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-manual-1' }, + }) + }) + it('preserves backend conflict metadata for stale-version recovery', async () => { vi.mocked(http.put).mockResolvedValue({ code: 'A0409', message: '费用已被其他操作员修改,请刷新后重试', data: null, diff --git a/src/modules/inbounds/fee/api.ts b/src/modules/inbounds/fee/api.ts index 27993cfb..cebe6bf9 100644 --- a/src/modules/inbounds/fee/api.ts +++ b/src/modules/inbounds/fee/api.ts @@ -23,6 +23,16 @@ export async function calculateInboundFee(tenantId: string, inboundId: string, p return unwrapApiResponse(response) } +export async function createManualInboundQuote(tenantId: string, inboundId: string, payload: { + selectedWeightKg: number; selectedVolumeCbm: number; unitPrice: number; reason: string +}, idempotencyKey: string) { + const response = await http.post>( + `${feeBasePath(inboundId)}/manual-quote`, payload, + { params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } }, + ) + return unwrapApiResponse(response) +} + async function transitionInboundFee( tenantId: string, inboundId: string, @@ -63,7 +73,7 @@ export function createFeeAdjustmentIdempotencyKey() { return createFeeActionIdempotencyKey('adjust') } -export function createFeeActionIdempotencyKey(action: 'adjust' | 'calculate' | 'confirm' | 'send') { +export function createFeeActionIdempotencyKey(action: 'adjust' | 'calculate' | 'manual' | 'confirm' | 'send') { const uuid = globalThis.crypto?.randomUUID?.() if (uuid) return `fee-${action}-${uuid}` return `fee-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 12)}` diff --git a/src/modules/inbounds/fee/types.ts b/src/modules/inbounds/fee/types.ts index 1e3b814b..f658cd07 100644 --- a/src/modules/inbounds/fee/types.ts +++ b/src/modules/inbounds/fee/types.ts @@ -13,9 +13,9 @@ export interface InboundFeeSnapshot { chargeRuleId: string ruleCode: string chargeRuleVersion: number - carrierRateId: string + carrierRateId: string | null rateCode: string - carrierRateVersion: number + carrierRateVersion: number | null customerId: string customerName: string originWarehouseId: string @@ -45,7 +45,7 @@ export interface InboundFeeSnapshot { additionalAmount: number totalAmount: number currency: string - rateEffectiveFrom: string + rateEffectiveFrom: string | null rateEffectiveTo: string | null calculatedAt: string confirmedAt: string | null diff --git a/src/modules/inbounds/index.ts b/src/modules/inbounds/index.ts index 1aadb9cf..8772c58b 100644 --- a/src/modules/inbounds/index.ts +++ b/src/modules/inbounds/index.ts @@ -8,6 +8,7 @@ export { default as InboundFeePanel } from './fee/InboundFeePanel.vue' export { adjustPublishedInboundFee, calculateInboundFee, + createManualInboundQuote, confirmInboundFee, createFeeAdjustmentIdempotencyKey, createFeeActionIdempotencyKey, diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index 3c8db07c..09f8a540 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -34,6 +34,11 @@ describe('UnifiedOrdersView contract', () => { expect(sendFeeSource).toContain('adjustPublishedInboundFee') expect(source).toContain('价格表参考单价') expect(source).toContain('本次对客单价') + expect(openDialogSource).toContain("feeConfirmPriceMissing.value = true") + expect(sendFeeSource).toContain('createManualInboundQuote') + expect(source).toContain("feeConfirmPriceMissing ? '未命中'") + expect(source).toContain("feeConfirmPriceMissing ? '人工定价原因'") + expect(source).not.toContain('
固定附加费
') expect(sendFeeSource).toContain("ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)") }) diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index e6fffb40..69f9514f 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -66,6 +66,7 @@ import { adjustPublishedInboundFee, downloadWeightImage, calculateInboundFee, + createManualInboundQuote, confirmInboundFee, createFeeAdjustmentIdempotencyKey, createFeeActionIdempotencyKey, @@ -258,24 +259,27 @@ const feeConfirmWeight = ref(null) const feeConfirmVolume = ref(null) const feeConfirmReferenceLoading = ref(false) const feeConfirmReference = ref(null) +const feeConfirmPriceMissing = ref(false) const feeConfirmUnitPrice = ref(null) const feeConfirmPriceReason = ref('') type FeeConfirmAttempt = { fingerprint: string calculateKey: string + manualKey: string adjustKey: string confirmKey: string sendKey: string } let feeConfirmAttempt: FeeConfirmAttempt | null = null const feeConfirmPriceChanged = computed(() => { + if (feeConfirmPriceMissing.value) return true const reference = Number(feeConfirmReference.value?.unitPrice) const current = Number(feeConfirmUnitPrice.value) return reference >= 0 && current >= 0 && Math.abs(reference - current) > 0.000001 }) const feeConfirmPriceUnit = computed(() => { const snapshot = feeConfirmReference.value - return snapshot ? `${snapshot.currency} / ${snapshot.chargeUnit}` : '—' + return snapshot ? `${snapshot.currency} / ${snapshot.chargeUnit}` : 'CNY / 计费单位' }) const feeConfirmEstimatedTotal = computed(() => { const snapshot = feeConfirmReference.value @@ -1669,6 +1673,7 @@ async function openFeeConfirmDialog() { : measuredGrossWeight > 0 ? measuredGrossWeight : measuredNetWeight > 0 ? measuredNetWeight : null feeConfirmVolume.value = Number(actualTotals.volume) > 0 ? Number(actualTotals.volume) : null feeConfirmReference.value = null + feeConfirmPriceMissing.value = false feeConfirmUnitPrice.value = null feeConfirmPriceReason.value = '' feeConfirmAttempt = null @@ -1693,8 +1698,14 @@ async function openFeeConfirmDialog() { feeConfirmReference.value = snapshot feeConfirmUnitPrice.value = Number(snapshot.unitPrice) } catch (error) { - ElMessage.error(getApiErrorDetails(error, '价格表参考单价加载失败,暂不能推送费用').message) - feeConfirmOpen.value = false + const details = getApiErrorDetails(error, '价格表参考单价加载失败') + if (details.code === 'A0404') { + feeConfirmPriceMissing.value = true + ElMessage.warning('当前FBA价格未命中,请填写本次对客单价和人工定价原因') + } else { + ElMessage.error(details.message) + feeConfirmOpen.value = false + } } finally { feeConfirmReferenceLoading.value = false } @@ -1715,6 +1726,7 @@ function currentFeeConfirmAttempt( feeConfirmAttempt = { fingerprint, calculateKey: createFeeActionIdempotencyKey('calculate'), + manualKey: createFeeActionIdempotencyKey('manual'), adjustKey: createFeeAdjustmentIdempotencyKey(), confirmKey: createFeeActionIdempotencyKey('confirm'), sendKey: createFeeActionIdempotencyKey('send'), @@ -1747,14 +1759,25 @@ async function executeConfirmAndSendFee() { try { const tenantId = String(summary.tenantId) const inboundId = String(summary.inboundId) - let snapshot = await calculateInboundFee(tenantId, inboundId, { - selectedWeightKg, - selectedVolumeCbm, - weightSource: 'ACTUAL', - volumeSource: 'ACTUAL', - }, attempt.calculateKey) + let snapshot: InboundFeeSnapshot + try { + snapshot = await calculateInboundFee(tenantId, inboundId, { + selectedWeightKg, selectedVolumeCbm, weightSource: 'ACTUAL', volumeSource: 'ACTUAL', + }, attempt.calculateKey) + } catch (error) { + if (getApiErrorDetails(error, '费用试算失败').code !== 'A0404') throw error + feeConfirmPriceMissing.value = true + if (feeConfirmPriceReason.value.trim().length < 2) { + ElMessage.warning('价格未命中,请填写至少 2 个字符的人工定价原因') + return + } + snapshot = await createManualInboundQuote(tenantId, inboundId, { + selectedWeightKg, selectedVolumeCbm, unitPrice: selectedUnitPrice, + reason: feeConfirmPriceReason.value.trim(), + }, attempt.manualKey) + } const calculatedUnitPrice = Number(snapshot.unitPrice) - if (Math.abs(selectedUnitPrice - calculatedUnitPrice) > 0.000001) { + if (snapshot.rateCode !== 'MANUAL_QUOTE' && Math.abs(selectedUnitPrice - calculatedUnitPrice) > 0.000001) { snapshot = await adjustPublishedInboundFee(tenantId, inboundId, { expectedFeeVersion: snapshot.feeVersion, chargeableQuantity: Number(snapshot.chargeableQuantity), @@ -2311,13 +2334,12 @@ onBeforeUnmount(() => {
kg
m³
-
{{ feeConfirmPriceUnit }}
+
{{ feeConfirmPriceUnit }}
{{ feeConfirmPriceUnit }}
- +
参考计费量
{{ formatNumber(Number(feeConfirmReference.chargeableQuantity), feeConfirmReference.roundingScale) }} {{ feeConfirmReference.chargeUnit }}
-
固定附加费
{{ feeConfirmMoney(Number(feeConfirmReference.additionalAmount || 0), feeConfirmReference.currency) }}
按当前单价预计合计
{{ feeConfirmEstimatedTotal == null ? '—' : feeConfirmMoney(feeConfirmEstimatedTotal, feeConfirmReference.currency) }}

参考数据

From e6771e8e86b619d0dc6036197bfb902764c727fc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 21 Sep 2026 11:17:11 +0800 Subject: [PATCH 12/17] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + .../inbounds/fee/InboundFeePanel.test.ts | 1 + src/modules/inbounds/fee/InboundFeePanel.vue | 35 +++++++++++++++---- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a679be38..385d7d2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ | 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | | 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | +| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 | | PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 | | 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 | | 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI | diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts index 3930dfb7..c53e01cc 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.test.ts +++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts @@ -381,4 +381,5 @@ describe('InboundFeePanel', () => { expect(document.body.textContent).toContain('系统已刷新最新快照') expect(document.body.textContent).toContain('当前输入仍保留') }) + }) diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue index ed9609f0..50f7ce59 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.vue +++ b/src/modules/inbounds/fee/InboundFeePanel.vue @@ -47,6 +47,7 @@ const loadError = ref(null) const adjustmentError = ref(null) const conflictRefreshed = ref(false) const snapshot = ref(null) +const hasLoadedOnce = ref(false) const adjustOpen = ref(false) const trialOpen = ref(false) const trialLoading = ref(false) @@ -183,12 +184,25 @@ async function loadFee(options: { preserveAdjustment?: boolean } = {}) { if (!options.preserveAdjustment && adjustOpen.value) fillAdjustmentForm(result) } catch (error) { if (sequence !== loadSequence) return - snapshot.value = null - emit('loaded', null) - if (error instanceof ApiResponseError && error.code === 'A0404') notFound.value = true - else loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试') + const hadSnapshot = Boolean(snapshot.value) + if (error instanceof ApiResponseError && error.code === 'A0404') { + notFound.value = !hadSnapshot + if (!hadSnapshot) { + snapshot.value = null + emit('loaded', null) + } + } else { + loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试') + if (!hadSnapshot) { + snapshot.value = null + emit('loaded', null) + } + } } finally { - if (sequence === loadSequence) loading.value = false + if (sequence === loadSequence) { + loading.value = false + hasLoadedOnce.value = true + } } } @@ -353,6 +367,7 @@ watch( () => { adjustOpen.value = false snapshot.value = null + hasLoadedOnce.value = false void loadFee() }, { immediate: true }, @@ -435,11 +450,18 @@ defineExpose({ reload: loadFee })

需要权限:firstleg:fba-pricing:view

-
+
正在加载费用快照
+ + +

{{ loadError.message }}

+ +
+

{{ loadError.message }}

@@ -597,6 +619,7 @@ defineExpose({ reload: loadFee }) .fee-heading-actions .el-button { margin-left: 0; } .fee-refresh-button .is-spinning { animation: fee-refresh-spin 0.8s linear infinite; } .fee-loading { padding: 20px 0; } +.fee-refresh-error { margin-bottom: 10px; } .inbound-fee-panel :deep(.el-alert p) { margin: 5px 0 0; line-height: 1.55; } .inbound-fee-panel :deep(.el-alert .el-button) { margin-top: 8px; } .empty-note { margin: 0; color: var(--cc-muted); font-size: 12px; } From 12dc5dfd82ac88980623a0b0dce24eafa12a325d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 21 Sep 2026 23:19:17 +0800 Subject: [PATCH 13/17] feat: add mojia sku sync admin page --- src/core/navigation/adminNavigation.ts | 1 + .../overseas-warehouse/api/mojiaSkuSync.ts | 67 +++++++ .../pages/MojiaSkuSyncView.vue | 167 ++++++++++++++++++ src/modules/overseas-warehouse/permissions.ts | 3 + src/modules/overseas-warehouse/routes.ts | 6 + 5 files changed, 244 insertions(+) create mode 100644 src/modules/overseas-warehouse/api/mojiaSkuSync.ts create mode 100644 src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 34c738c8..63f677a5 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -170,6 +170,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' }, { key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' }, { key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' }, + { key: 'mojia-sku-sync', title: '一件代发 SKU 同步', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list' }, ], }, { diff --git a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts new file mode 100644 index 00000000..81aa0ef2 --- /dev/null +++ b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts @@ -0,0 +1,67 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type MojiaSkuSyncStatus = 'PENDING' | 'SYNCING' | 'SUCCESS' | 'FAILED' + +export interface MojiaSkuSyncRecord { + id: string + skuCode: string + lengthCm: number + widthCm: number + heightCm: number + weightKg: number + imageFileId?: string | null + imageUrl?: string | null + measuredAt?: string | null + receivedAt?: string | null + syncStatus: MojiaSkuSyncStatus + syncFailureCode?: string | null + syncFailureReason?: string | null + lingxingSkuId?: string | null + revision: number + updatedAt?: string | null +} + +export interface MojiaSkuSyncPage { + records: MojiaSkuSyncRecord[] + page: number + size: number + total: number + pages: number +} + +export interface MojiaSkuSyncQuery { + page: number + size: number + skuCode?: string + syncStatus?: MojiaSkuSyncStatus +} + +const basePath = '/platform/v1/firstleg/mojia-sku-sync' + +export async function listMojiaSkuSync(query: MojiaSkuSyncQuery) { + const response = await http.get, ApiEnvelope>( + basePath, { params: query }, + ) + return unwrapApiResponse(response) +} + +export async function updateMojiaSkuSync(id: string, data: { + skuCode: string + lengthCm: number + widthCm: number + heightCm: number + weightKg: number + revision: number +}) { + const response = await http.put, ApiEnvelope>( + `${basePath}/${encodeURIComponent(id)}`, data, + ) + return unwrapApiResponse(response) +} + +export async function retryMojiaSkuSync(id: string, revision: number) { + const response = await http.post, ApiEnvelope>( + `${basePath}/${encodeURIComponent(id)}/retry`, { revision }, + ) + return unwrapApiResponse(response) +} diff --git a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue new file mode 100644 index 00000000..44140a25 --- /dev/null +++ b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/src/modules/overseas-warehouse/permissions.ts b/src/modules/overseas-warehouse/permissions.ts index 4879b9e0..e73a8a7c 100644 --- a/src/modules/overseas-warehouse/permissions.ts +++ b/src/modules/overseas-warehouse/permissions.ts @@ -49,4 +49,7 @@ export const overseasWarehousePermissions = { productList: 'platform:overseas-warehouse:product:list', productCreate: 'platform:overseas-warehouse:product:create', productEdit: 'platform:overseas-warehouse:product:edit', + mojiaSkuSyncList: 'platform:mojia-sku-sync:list', + mojiaSkuSyncEdit: 'platform:mojia-sku-sync:edit', + mojiaSkuSyncRetry: 'platform:mojia-sku-sync:retry', } as const diff --git a/src/modules/overseas-warehouse/routes.ts b/src/modules/overseas-warehouse/routes.ts index 4394c76d..7155dc5a 100644 --- a/src/modules/overseas-warehouse/routes.ts +++ b/src/modules/overseas-warehouse/routes.ts @@ -130,4 +130,10 @@ export const overseasWarehouseRoutes = [ component: () => import('./pages/PalletDetailView.vue'), meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false }, }, + { + path: 'general/mojia-sku-sync', + name: 'mojia-sku-sync', + component: () => import('./pages/MojiaSkuSyncView.vue'), + meta: { title: '一件代发 SKU 同步', description: '查看墨家测量数据并同步领星 SKU 尺寸重量', permission: permission.mojiaSkuSyncList }, + }, ] satisfies RouteRecordRaw[] From 3cb8b5b70effaca2939268334867176b480e3fc3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 21 Sep 2026 23:32:12 +0800 Subject: [PATCH 14/17] fix: keep mojia sku sync statuses to three states --- src/modules/overseas-warehouse/api/mojiaSkuSync.ts | 2 +- src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts index 81aa0ef2..e9e68004 100644 --- a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts +++ b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts @@ -1,6 +1,6 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -export type MojiaSkuSyncStatus = 'PENDING' | 'SYNCING' | 'SUCCESS' | 'FAILED' +export type MojiaSkuSyncStatus = 'PENDING' | 'SUCCESS' | 'FAILED' export interface MojiaSkuSyncRecord { id: string diff --git a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue index 44140a25..025d1417 100644 --- a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue +++ b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue @@ -47,10 +47,10 @@ const columns: DataTableColumn[] = [ ] const statusLabel: Record = { - PENDING: '待同步', SYNCING: '同步中', SUCCESS: '同步成功', FAILED: '同步失败', + PENDING: '待同步', SUCCESS: '同步成功', FAILED: '同步失败', } const statusType: Record = { - PENDING: 'warning', SYNCING: 'info', SUCCESS: 'success', FAILED: 'danger', + PENDING: 'warning', SUCCESS: 'success', FAILED: 'danger', } async function load() { From 358ad38335f6c88e3b36ae81bbaa07bc9ba708e8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 22 Sep 2026 09:38:56 +0800 Subject: [PATCH 15/17] feat: add overseas warehouse location management --- src/core/navigation/adminNavigation.ts | 1 + .../overseas-warehouse/api/inboundPlans.ts | 27 +++++++- .../overseas-warehouse/api/locations.ts | 64 +++++++++++++++++++ .../pages/InboundOrderDetailView.vue | 30 ++++++++- .../pages/LocationsView.vue | 50 +++++++++++++++ .../pages/prototypeContract.test.ts | 13 +++- src/modules/overseas-warehouse/routes.ts | 6 ++ 7 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 src/modules/overseas-warehouse/api/locations.ts create mode 100644 src/modules/overseas-warehouse/pages/LocationsView.vue diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 63f677a5..63c0bb50 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -112,6 +112,7 @@ export const adminNavigation: AdminNavigationNode[] = [ children: [ { key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' }, { key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' }, + { key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' }, { key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' }, { key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' }, { key: 'fba-delivery', title: '派送单列表', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' }, diff --git a/src/modules/overseas-warehouse/api/inboundPlans.ts b/src/modules/overseas-warehouse/api/inboundPlans.ts index 4593bfce..dc9c5d3d 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.ts @@ -53,6 +53,7 @@ export interface InboundPlanDetailDto { customerName?: string | null orderType?: string | null containerNo?: string | null + overseasWarehouseId?: string | null overseasWarehouseCode?: string | null overseasWarehouseName?: string | null status?: string | null @@ -67,7 +68,7 @@ export interface InboundPlanDetailDto { updatedAt?: string | null remark?: string | null lines: Array<{ id: string; orderId: string; inboundCode?: string | null; fbaWarehouseCode?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; grossWeightKg?: number | null; netWeightKg?: number | null; volumeCbm?: number | null; requiredPackageCount: number; plannedPalletCount: number }> - pallets: Array<{ id: string; palletNo?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; capacityCbm?: number | null; allocatedVolumeCbm?: number | null; sequenceNo: number; warehousePalletId?: string | null; warehouseLocationId?: string | null; warehouseResourceLockId?: string | null; assignmentStatus?: string | null; lockedAt?: string | null; actualVolumeCbm?: number | null; actualPackageCount: number }> + pallets: Array<{ id: string; palletNo?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; capacityCbm?: number | null; allocatedVolumeCbm?: number | null; sequenceNo: number; warehousePalletId?: string | null; warehouseLocationId?: string | null; warehouseLocationCode?: string | null; warehouseLocationType?: string | null; warehouseLocationStatus?: string | null; sharedOverflow?: boolean | null; assignmentStatus?: string | null; lockedAt?: string | null; actualVolumeCbm?: number | null; actualPackageCount: number; version?: number }> allocations: Array<{ lineId: string; palletId: string; palletNo: string; palletOwnerPlanNo: string; palletOwnedByCurrentPlan: boolean; allocatedVolumeCbm?: number | null; plannedPackageCount: number; countedPackageCount: number; pickedPackageCount: number; actualVolumeCbm?: number | null; status?: string | null; version: number }> } @@ -79,3 +80,27 @@ export async function listInboundPlans(query: InboundPlanQuery) { export async function getInboundPlan(planNo: string, customerId: string) { return unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } })) } + +export interface PlatformInventoryMoveRequest { + overseasWarehouseId: string + sourcePalletCode: string + targetLocationCode: string + expectedPalletVersion: number + reason: string + idempotencyKey: string +} + +export interface PlatformInventoryMoveResult { + commandId: string + status: string + canMove?: boolean + blockReason?: string | null + sourceLocationCode?: string | null + targetLocationCode?: string | null + sharedOverflow?: boolean | null +} + +export async function moveInboundPallet(payload: PlatformInventoryMoveRequest) { + const response = await overseasHttp.post, ApiEnvelope>('/platform/v1/last-leg/inventory-moves', payload, { headers: { 'Idempotency-Key': payload.idempotencyKey } }) + return unwrapApiResponse(response) +} diff --git a/src/modules/overseas-warehouse/api/locations.ts b/src/modules/overseas-warehouse/api/locations.ts new file mode 100644 index 00000000..c575f255 --- /dev/null +++ b/src/modules/overseas-warehouse/api/locations.ts @@ -0,0 +1,64 @@ +import { overseasHttp } from '@/shared/api/overseasHttp' +import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' + +export type OverseasLocationType = 'VIRTUAL' | 'NORMAL' | 'SHARED_OVERFLOW' +export type OverseasLocationStatus = 'ACTIVE' | 'IDLE' | 'OCCUPIED' | 'INACTIVE' | 'DISABLED' + +export interface OverseasLocationDto { + id: string + warehouseId: string + warehouseCode?: string | null + warehouseName?: string | null + zoneCode: string + zoneName?: string | null + locationCode: string + locationName?: string | null + locationType: OverseasLocationType + sharedOverflow: boolean + status: OverseasLocationStatus + maxLoadKg?: number | string | null + occupiedPalletCount?: number | null + version: number + remark?: string | null + createdAt?: string | null + updatedAt?: string | null +} + +export interface SaveOverseasLocationPayload { + zoneCode: string + locationCode: string + locationType: 'NORMAL' + maxLoadKg: number + status: 'ACTIVE' | 'INACTIVE' + remark?: string | null +} + +export interface OverseasLocationQuery { page?: number; size?: number; zoneCode?: string; keyword?: string; locationType?: OverseasLocationType | ''; status?: OverseasLocationStatus | '' } + +function base(warehouseId: string) { return `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations` } + +export async function listOverseasLocations(warehouseId: string, query: OverseasLocationQuery = {}) { + const response = await overseasHttp.get>, ApiEnvelope>>(base(warehouseId), { params: { page: query.page ?? 1, size: query.size ?? 20, ...query } }) + return normalizeOverseasPage(unwrapApiResponse(response)) +} + +export async function createOverseasLocation(warehouseId: string, payload: SaveOverseasLocationPayload) { + const response = await overseasHttp.post, ApiEnvelope>(base(warehouseId), payload) + return unwrapApiResponse(response) +} + +export async function updateOverseasLocation(warehouseId: string, locationId: string, payload: SaveOverseasLocationPayload & { version: number }) { + const response = await overseasHttp.put, ApiEnvelope>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, payload) + return unwrapApiResponse(response) +} + +export async function changeOverseasLocationStatus(warehouseId: string, locationId: string, payload: SaveOverseasLocationPayload & { version: number }) { + const response = await overseasHttp.put, ApiEnvelope>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, payload) + return unwrapApiResponse(response) +} + +export async function deleteOverseasLocation(warehouseId: string, locationId: string, version: number) { + const response = await overseasHttp.delete, ApiEnvelope>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, { params: { version } }) + return unwrapApiResponse(response) +} diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue index 84257703..5a95845c 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue @@ -1,12 +1,15 @@ + + + + diff --git a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts index 6057670b..66f1bfc5 100644 --- a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts +++ b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts @@ -37,6 +37,7 @@ describe('overseas warehouse first-batch prototype contract', () => { ['PalletsView.vue', 'overseas-pallets-query'], ['CartonsView.vue', 'overseas-cartons-query'], ['ProductsView.vue', 'overseas-products-query'], + ['LocationsView.vue', 'overseas-locations-query'], ]) const keys = [...expected.entries()].map(([fileName, preferenceKey]) => { const value = source(fileName) @@ -127,6 +128,7 @@ describe('overseas warehouse first-batch prototype contract', () => { expect(value).toContain('@back="back"') expect(value).not.toContain('AppDrawer') if (fileName === 'LastMileOrderDetailView.vue') expect(value).toContain('title="新增费用"') + else if (fileName === 'InboundOrderDetailView.vue') expect(value).toContain('移库') else expect(value).not.toContain('AppDialog') } }) @@ -342,10 +344,17 @@ describe('overseas warehouse first-batch prototype contract', () => { expect(value).toContain('(?:)?查看<\/el-button>/) } } + const locations = source('LocationsView.vue') + expect(locations).toContain('海外仓库位管理') + expect(locations).toContain('库位编码') }) it('connects the dispatch prototype detail to the real overseas API', () => { @@ -387,12 +396,12 @@ describe('overseas warehouse first-batch prototype contract', () => { expect(columnKeys('LastMileOrdersView.vue')).toEqual([ 'actions', 'selection', 'orderNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse', 'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets', - 'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo', + 'cartons', 'cartonDifference', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo', 'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status', 'createdAt', 'internalRemark', 'customerRemark', 'warehouseRemark', ]) expect(columnKeys('InboundOrdersView.vue')).toEqual([ - 'actions', 'selection', 'orderNo', 'inboundCode', 'lxCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'volume', + 'actions', 'selection', 'orderNo', 'inboundCode', 'lxCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'grossWeight', 'netWeight', 'volume', 'pallets', 'assigned', 'mergeAllowed', 'urgent', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt', ]) expect(columnKeys('InventoryView.vue')).toEqual([ diff --git a/src/modules/overseas-warehouse/routes.ts b/src/modules/overseas-warehouse/routes.ts index 7155dc5a..9357f8d2 100644 --- a/src/modules/overseas-warehouse/routes.ts +++ b/src/modules/overseas-warehouse/routes.ts @@ -130,6 +130,12 @@ export const overseasWarehouseRoutes = [ component: () => import('./pages/PalletDetailView.vue'), meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false }, }, + { + path: 'overseas-warehouse/locations', + name: 'overseas-locations', + component: () => import('./pages/LocationsView.vue'), + meta: { title: '库位管理', description: '维护海外仓普通库位与 A0 共享溢出位', permission: permission.locationList }, + }, { path: 'general/mojia-sku-sync', name: 'mojia-sku-sync', From 4391a69c7d2635cb86c8314aa502073d9a230d4d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 22 Sep 2026 11:33:08 +0800 Subject: [PATCH 16/17] feat: show lingxing sync switch status --- .../overseas-warehouse/api/mojiaSkuSync.ts | 11 ++++++++++ .../pages/MojiaSkuSyncView.vue | 22 ++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts index e9e68004..0ba0d4f8 100644 --- a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts +++ b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts @@ -36,6 +36,10 @@ export interface MojiaSkuSyncQuery { syncStatus?: MojiaSkuSyncStatus } +export interface MojiaSkuSyncConfig { + autoSyncEnabled: boolean +} + const basePath = '/platform/v1/firstleg/mojia-sku-sync' export async function listMojiaSkuSync(query: MojiaSkuSyncQuery) { @@ -45,6 +49,13 @@ export async function listMojiaSkuSync(query: MojiaSkuSyncQuery) { return unwrapApiResponse(response) } +export async function getMojiaSkuSyncConfig() { + const response = await http.get, ApiEnvelope>( + `${basePath}/config`, + ) + return unwrapApiResponse(response) +} + export async function updateMojiaSkuSync(id: string, data: { skuCode: string lengthCm: number diff --git a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue index 025d1417..7dba08a0 100644 --- a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue +++ b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue @@ -10,7 +10,7 @@ import AppDialog from '@/shared/components/AppDialog.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getApiErrorMessage } from '@/shared/api/http' import { - listMojiaSkuSync, retryMojiaSkuSync, updateMojiaSkuSync, + getMojiaSkuSyncConfig, listMojiaSkuSync, retryMojiaSkuSync, updateMojiaSkuSync, type MojiaSkuSyncRecord, type MojiaSkuSyncStatus, } from '../api/mojiaSkuSync' @@ -25,6 +25,7 @@ const canRetry = computed(() => access.can(permission.retry)) const rows = ref([]) const total = ref(0) const loading = ref(false) +const autoSyncEnabled = ref(false) const error = ref('') const actionId = ref('') const page = ref(1) @@ -65,6 +66,14 @@ async function load() { } finally { loading.value = false } } +async function loadConfig() { + try { + autoSyncEnabled.value = (await getMojiaSkuSyncConfig()).autoSyncEnabled + } catch (cause) { + error.value = getApiErrorMessage(cause, '自动同步配置加载失败') + } +} + function search() { page.value = 1; void load() } function reset() { form.skuCode = ''; form.syncStatus = ''; search() } function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 19) : '—' } @@ -104,7 +113,7 @@ async function retry(row: MojiaSkuSyncRecord) { finally { actionId.value = '' } } -onMounted(() => { void load() }) +onMounted(() => { void Promise.all([loadConfig(), load()]) }) @@ -179,5 +205,8 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) }) .sync-pagination { display: flex; justify-content: flex-end; padding: var(--admin-space-4); } .sync-edit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; } .sync-edit-grid :deep(.el-input-number) { width: 100%; } +.connection-result { display: grid; gap: var(--admin-space-4); } +.connection-result__sku { color: var(--cc-muted); } +.connection-result__sku strong { color: var(--cc-text); } @media (max-width: 900px) { .sync-filters .el-input { width: 180px; } }