diff --git a/docs/business-index.md b/docs/business-index.md index e139b33..44898eb 100644 --- a/docs/business-index.md +++ b/docs/business-index.md @@ -18,7 +18,7 @@ | 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换。前端角色树到服务端权限的映射统一维护在 `contracts.ts` 的 `FRONTEND_TO_MERCHANT_PERMISSION_CODES`,海外入仓、采购、本土卡车和散板权限必须显式进入该映射 | | 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` | | 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` | -| 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `src/modules/first-leg/`;列表与批量入口 `views/FirstLegOrdersView.vue`,模板/导入 API `api/firstLeg.ts`,入仓详情 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts`,费用确认弹框 `components/InboundBillingPanel.vue`、`api/inboundBilling.ts` | 中文 XLSX 模板按当前客户生成地址、全部有效仓库和渠道下拉,最多 50 行,只创建草稿并支持部分/原子导入;导入结果只显示 Excel 行号,不展示内部 `clientRowId`。所有订单行均可进入费用确认弹框,尚未推送时展示明确空态;只有具备编辑权限且服务端返回当前 `SENT` 费用时才可确认并提交 `expectedFeeVersion` 与幂等键,成功后版本锁定、平台不得再修改。Backend `order/`、`firstleg/`、`warehouse/`,不得用内部 ID 补齐缺失业务编号 | +| 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `src/modules/first-leg/`;列表与批量入口 `views/FirstLegOrdersView.vue`,模板/导入 API `api/firstLeg.ts`,入仓详情 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts`,费用确认弹框 `components/InboundBillingPanel.vue`、`api/inboundBilling.ts` | 中文 XLSX 模板按当前客户生成地址、全部有效仓库和渠道下拉,最多 50 行,只创建草稿并支持部分/原子导入;导入结果只显示 Excel 行号,不展示内部 `clientRowId`。列表“确认计费重”仅在具备确认权限且服务端 `customerFeeConfirmationRequired=true` 时显示,已确认或未推送费用时隐藏;详情可查看费用;确认提交 `expectedFeeVersion` 与幂等键,成功后版本锁定、平台不得再修改。Backend `order/`、`firstleg/`、`warehouse/`,不得用内部 ID 补齐缺失业务编号 | | 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` | | 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 | | 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 | diff --git a/src/modules/first-leg/AGENTS.md b/src/modules/first-leg/AGENTS.md index d1e1479..d723bb0 100644 --- a/src/modules/first-leg/AGENTS.md +++ b/src/modules/first-leg/AGENTS.md @@ -25,7 +25,7 @@ - 头程订单只用当前正式读写契约;不得扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API、历史部分响应兼容或其他新旧并跑逻辑。触及该链路时同步删除生产分支、类型、文案、Mock 和测试。 - 快速下单只创建草稿,不提交、不审核;逐行失败必须回填原因与入仓码,不用内部 ID 代替业务编号。 -- 入库费用确认携带 `expectedFeeVersion` 与幂等键,金额与冻结状态只认服务端结果;所有订单行都可进入费用确认弹框,不按行类型隐藏。 +- 入库费用确认携带 `expectedFeeVersion` 与幂等键,金额与冻结状态只认服务端结果;列表“确认计费重”仅在具备确认权限且服务端 `customerFeeConfirmationRequired=true` 时显示,已确认或未推送费用的订单不显示;详情保留费用查看入口。 - 报关资料在开始后只读冻结资料,未装柜或无资格时不展示入口;`utils/customsEligibility.ts` 是唯一资格判断入口。 - 模块间协作只能从本模块 `public.ts` 导入,不穿透其他模块的 `views/` 或内部目录。 diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 1213a36..53e090a 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -642,15 +642,41 @@ test('opens billing-weight confirmation independently from order details', () => assert.match(source, /unavailable-message="未推送费用"/) }) -test('uses one published-fee dialog for billing-weight confirmation on every order row', () => { +test('only offers published-fee confirmation for pending orders with confirmation permission', () => { const actions = source.slice(source.indexOf('function getOrderRowActions'), source.indexOf('function getOrderRowActions') + 1800) assert.match(actions, /actions\.push\(\{ key: 'fee', label: '确认计费重', run: \(\) => openFeeConfirmation\(order\) \}\)/) - assert.doesNotMatch(actions, /customerFeeConfirmationRequired|label: '确认费用'|key: 'weight'/) + assert.match(actions, /if \(canConfirmInboundFee\.value && order\.businessSummary\.customerFeeConfirmationRequired\) \{\s*actions\.push\(\{ key: 'fee'/) + assert.doesNotMatch(actions, /label: '确认费用'|key: 'weight'/) assert.doesNotMatch(source, /ChargeableWeightDialog|weightConfirmationOrderId/) assert.match(source, /:row-actions="getOrderRowActions"/) assert.match(orderStoreSource, /customerFeeConfirmationRequired: summary\?\.customerFeeConfirmationRequired \?\? false/) }) +test('hides confirmation for confirmed, unpushed and unauthorized orders and refreshes after confirmation', () => { + const start = source.indexOf('function getOrderRowActions') + const end = source.indexOf('\nasync function copyOrder', start) + const body = source.slice(start, end) + .replace('function getOrderRowActions(row: object): TableAction[]', 'function getOrderRowActions(row)') + .replace('const order = row as FirstLegOrder', 'const order = row') + .replace('const actions: TableAction[]', 'const actions') + const actionsFor = new Function('canConfirmInboundFee', 'canCreateOrder', 'canEditOrder', + 'canOfferCancellation', 'eligibleCustomsDetail', 'openFeeConfirmation', + `${body}; return getOrderRowActions`) + for (const [permitted, pending] of [[true, true], [true, false], [false, true], [false, false]]) { + const opened = [] + const order = { id: 'order-1', businessSummary: { customerFeeConfirmationRequired: pending } } + const getActions = actionsFor({ value: permitted }, { value: false }, { value: false }, + () => false, () => false, row => opened.push(row.id)) + const confirm = getActions(order).filter(action => action.key === 'fee') + assert.equal(confirm.length, permitted && pending ? 1 : 0) + if (confirm.length) { + confirm[0].run() + assert.deepEqual(opened, ['order-1']) + } + } + assert.match(source, /function handleInboundFeeConfirmed\(\) \{\s*if \(!isCreatePage\.value && !isDetailPage\.value\) void loadOrderPage\(\)/) +}) + test('submits only selected draft orders in one batch action', () => { assert.match(source, /selectedDraftOrders = computed\(\(\) => selectedOrders\.value\.filter\(\(order\) => order\.status === 'DRAFT'\)\)/) assert.match(source, /Promise\.allSettled\(ids\.map\(id => store\.submitSaved/) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 8ba11aa..5180ce8 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -1361,7 +1361,9 @@ function getOrderRowActions(row: object): TableAction[] { if (canCreateOrder.value) { actions.push({ key: 'copy', label: '复制下单', loading: copyingOrderId.value === order.id, run: () => copyOrder(order) }) } - actions.push({ key: 'fee', label: '确认计费重', run: () => openFeeConfirmation(order) }) + if (canConfirmInboundFee.value && order.businessSummary.customerFeeConfirmationRequired) { + actions.push({ key: 'fee', label: '确认计费重', run: () => openFeeConfirmation(order) }) + } if (canEditOrder.value && canClientEditFirstLegOrder(order.status)) { actions.push({ key: 'edit', label: '编辑', loading: openingEditor.value, run: () => openEdit(order) }) }