diff --git a/AGENTS.md b/AGENTS.md index de7adabb..ffb91f64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ | 费用项目、计费科目 | `modules/fee-items/AGENTS.md` | | 操作日志、审计日志、操作人 | `modules/audit/AGENTS.md` | | 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/AGENTS.md` | -| 海外采购、卡车、散板 | `modules/overseas-business/AGENTS.md` | +| 海外采购、卡车、散板、海外费用明细与仓租 | `modules/overseas-business/AGENTS.md` | | 海外仓业务合作仓与通用资料 | `modules/warehouses/AGENTS.md`、`modules/overseas-master-data/AGENTS.md` | | 海外仓业务国内出仓后船节点 | `modules/port-schedules/AGENTS.md`、`modules/ocean-bookings/` | | 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | @@ -91,7 +91,7 @@ - 新建客户时,公司联系电话同时初始化首个管理员登录手机号;创建后公司联系电话只属于企业资料,与首个管理员、成员及平台用户登录手机号独立。编辑客户资料不得提交账号手机号变更,账号手机号改动统一走用户管理并由 IAM/Auth 撤销对应会话。 - 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。 - 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。 -- 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。 +- 国内离仓状态按真实货柜展示:`ContainerDetailView.vue` 将权威状态传给 `ContainerCustomsPanel.vue`,页面不提供独立确认离仓按钮;报关完成不等于已离仓。不逐入仓提交离仓、不把拖车状态当阻断门禁或重复追加实际船节点。 - `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。 - `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。 - 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。 diff --git a/docs/business-index.md b/docs/business-index.md index ad53b656..a645e035 100644 --- a/docs/business-index.md +++ b/docs/business-index.md @@ -18,7 +18,7 @@ | 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 | | 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 | | 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg | -| 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 存在代替页面接入完成 | +| 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 价格未命中及私人地址 PRIVATE 允许人工头程报价,客户已确认费用禁止改价。`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 | @@ -29,7 +29,7 @@ | 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 | | 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 | | 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` | -| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 | +| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ACTUAL/ADD/MULTIPLY`;未启用默认调整规则时按实测原尺寸计算,仓库修正值替换对应实测边。测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的箱数、毛重汇总当前 `actualMeasurementRows`,计费体积默认取服务端已保存的 `outerMeasurementRows` 汇总;通过只读 `/fees/preview` 刷新当前重量、体积和人工单价的计费量与金额,预览未完成或失败时不得发送;最终按用户确认值提交,体积来源标记为 `OUTER`;不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 | | 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 | | 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 | | 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 | diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 6c67f278..da9ed4bc 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -49,14 +49,14 @@ describe('adminNavigation', () => { { title: '预约管理', children: ['预约列表'] }, { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配任务', '出车记录'] }, { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] }, - { title: '客户服务', children: ['异常订单'] }, - { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, + { title: '客户服务', children: ['异常订单', '客户回访'] }, + { title: '财务管理', children: ['海外费用明细', '客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, { title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] }, { title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] }, - { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, + { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限', 'PDA版本'] }, ]) expect(visible).toHaveLength(13) - expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58) + expect(visible.flatMap(node => node.children ?? [])).toHaveLength(61) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() }) @@ -172,6 +172,7 @@ describe('adminNavigation', () => { const paths = getNavigationLeaves(filtered).map((item) => item.path) expect(paths).toContain('/system/permissions') expect(paths).not.toContain('/prototype/finance/profit-report') + expect(paths).not.toContain('/system/pda-releases') expect(paths).not.toContain('/notifications/messages') expect(paths).not.toContain('/customers') expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '系统管理']) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 9ffd0e08..486970e8 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -157,7 +157,7 @@ export const adminNavigation: AdminNavigationNode[] = [ icon: PrototypeCustomerServiceIcon, children: [ { key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' }, - { key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true }, + { key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list' }, ], }, { @@ -165,6 +165,7 @@ export const adminNavigation: AdminNavigationNode[] = [ title: '财务管理', icon: PrototypeFinanceIcon, children: [ + { key: 'overseas-fees', title: '海外费用明细', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' }, { key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' }, { key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' }, { key: 'finance-receivable-statistics', title: '应收款统计表', icon: CircleDollarSign, path: '/finance/receivable-statistics', permission: 'platform:finance:receivable-statistics:list' }, @@ -222,7 +223,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management', hidden: true }, { key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings', hidden: true }, { key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' }, - { key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list', hidden: true }, + { key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list' }, ], }, ] diff --git a/src/modules/appointment-calendar/AppointmentCalendarDialogs.native.test.ts b/src/modules/appointment-calendar/AppointmentCalendarDialogs.native.test.ts index 918ca3af..9f6df90e 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarDialogs.native.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarDialogs.native.test.ts @@ -11,6 +11,7 @@ import { batchContext } from './batch.test-support' import type { ScheduleEvent } from './schedule' import { getWorkDetail, type WorkAction } from './workApi' import { workDetail, workItem } from './work.test-support' +import { getCargoVerification } from './cargoVerificationApi' const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record, push: vi.fn(), replace: vi.fn(), denied: new Set() })) vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() })) @@ -19,6 +20,7 @@ vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArriv vi.mock('./tripCalendar', async original => ({ ...await original(), listTripCalendar: vi.fn() })) vi.mock('./planningApi', async original => ({ ...await original(), listPlanningCargo: vi.fn(), previewPlanningBatch: vi.fn(), resolvePlanningSource: vi.fn(), createSourceAppointment: vi.fn() })) vi.mock('./workApi', async original => ({ ...await original(), getWorkDetail: vi.fn() })) +vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() })) enableAutoUnmount(afterEach) afterEach(() => { vi.unstubAllGlobals() }) const warehouse: WarehouseArrival = { sourceContainerBookingId: '9007199254740999', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 3, planCount: 0, canEdit: true } @@ -35,6 +37,7 @@ beforeEach(() => { vi.mocked(saveWarehouseArrival).mockResolvedValue({}) vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } }) + vi.mocked(getCargoVerification).mockResolvedValue({ source, verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: 20, effectiveShipmentReferences: ['FBA-TEST'], availablePackages: 20, sourceChanged: false, allowedActions: [], blockReasons: [] }) vi.mocked(previewPlanningBatch).mockResolvedValue(preview) vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment) vi.mocked(getWorkDetail).mockResolvedValue(workDetail()) @@ -52,6 +55,7 @@ async function create(wrapper: Awaited>) { const content = creationBody() await content.get('input[type="checkbox"]').setValue(true) await content.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click'); await flushPromises() + await content.get('input[placeholder="填写收货方提供的确认号"]').setValue('CONFIRM-NEW'); await flushPromises() return dialog } function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) } @@ -72,17 +76,19 @@ it('keeps the arrival context and typed local time across native fullscreen and await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises() expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String)) }) -it('locks the real arrival input after an unknown outcome and reuses its immutable request', async () => { +it('keeps the real arrival input editable after a failed attempt and submits the edited time with a fresh key', async () => { vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost')) const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')! dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises() await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises() - expect(dialog.get('input').attributes('disabled')).toBeDefined() - expect(dialog.text()).toContain('上次提交结果尚未确认') - expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('重试确认保存结果') + expect(dialog.get('input').attributes('disabled')).toBeUndefined() + expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('保存') + dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-23T09:30'); await flushPromises() await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises() - expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0]) + const calls = vi.mocked(saveWarehouseArrival).mock.calls + expect(calls[1]![2]).not.toBe(calls[0]![2]) + expect(calls[1]![1]).toEqual({ estimatedWarehouseArrivalAt: '2026-10-23T09:30:00', expectedVersion: 3 }) }) it('clears the Element arrival picker without saving an empty required time', async () => { const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises() @@ -114,17 +120,20 @@ it('preserves real FBA fields and entered values when expanding the create dialo expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true) expect(date.props('modelValue')).toBe('2026-10-25') await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises() - expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'PENDING' }), idempotencyKey: expect.any(String) })) + expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-NEW', appointmentAt: null }), idempotencyKey: expect.any(String) })) expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0) expect(creationBody().text()).toContain('AP-001') }) -it('shows every saved server field as readonly text including confirmed offsets and long remarks', async () => { +it('shows saved dates, confirmed offsets and long remarks without planned time ranges', async () => { const saved = { ...batchContext().appointment, plannedDate: '2026-10-26', plannedAt: '2026-10-26T08:00:00', plannedEndAt: '2026-10-26T18:00:00', timezone: 'America/Los_Angeles', appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-001', appointmentAt: '2026-11-01T01:30:00', appointmentEndAt: '2026-11-01T01:45:00', appointmentStartUtc: '2026-11-01T08:30:00Z', appointmentEndUtc: '2026-11-01T09:45:00Z', appointmentRemark: '完整预约备注'.repeat(30) } vi.mocked(createSourceAppointment).mockResolvedValue(saved) const wrapper = await open(); await create(wrapper) await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises() const receipt = creationBody().get('[aria-label="预约保存回执"]') - for (const text of ['AP-001', saved.plannedDate, saved.plannedAt, saved.plannedEndAt, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text) + for (const text of ['AP-001', saved.plannedDate, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text) + expect(receipt.text()).not.toMatch(/计划开始时段|计划结束时段/) + expect(receipt.text()).not.toContain(saved.plannedAt) + expect(receipt.text()).not.toContain(saved.plannedEndAt) expect(receipt.findAll('input, textarea, select')).toHaveLength(0) expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/) }) @@ -141,9 +150,10 @@ it('uses the same real continuous form through the original work detail deep lin const wrapper = await open(); await vi.dynamicImportSettled(); await flushPromises() const content = new DOMWrapper(Array.from(document.querySelectorAll('.el-dialog')).find(item => item.textContent?.includes('原业务预约办理'))!) await content.get('.primary-next-action').trigger('click'); await flushPromises() + await content.get('input[placeholder="填写收货方提供的确认号"]').setValue('CONFIRM-NEW'); await flushPromises() expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('continuous')).toBe(true) expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : []) await content.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises() - expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'PENDING' }) })) + expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-NEW', appointmentAt: null }) })) expect(content.text()).toContain('本次保存已完成') }) diff --git a/src/modules/appointment-calendar/AppointmentConfirmationCargo.test.ts b/src/modules/appointment-calendar/AppointmentConfirmationCargo.test.ts index 931cac25..2b004beb 100644 --- a/src/modules/appointment-calendar/AppointmentConfirmationCargo.test.ts +++ b/src/modules/appointment-calendar/AppointmentConfirmationCargo.test.ts @@ -12,7 +12,7 @@ vi.mock('./planningApi', () => ({ resolvePlanningOrder: vi.fn(), resolvePlanning vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() })) const source: ResolvedPlanningSource['source'] = { sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', orderId: '9007199254740993', inboundCode: 'IN-TEST', bookingNo: 'BK-TEST', containerNo: null, originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'BOOKING:1/ORDER:1', canFollowup: true, blockReasons: [] } const resolved: ResolvedPlanningSource = { source, coverage: { status: 'PENDING', appointments: [], missingFields: ['FBA_SHIPMENT_REFERENCES'], changeReviewRequired: false } } -const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] } +const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, effectiveShipmentReferences: [], availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] } const lookup = { orderId: source.orderId!, customerId: source.customerId, destinationKey: source.destinationKey } function render(active = true, disabled = false) { return mount(AppointmentConfirmationCargo, { props: { sources: [lookup], active, disabled }, global: { stubs: { @@ -32,9 +32,9 @@ it('names the missing material and supplements the canonical booking inside the const wrapper = render(); await flushPromises() expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup) expect(wrapper.text()).toContain('IN-TEST'); expect(wrapper.text()).toContain('缺少本票 FBA 货件号或追踪号') - expect(wrapper.text()).toContain('不能代替货件号'); expect(wrapper.text()).not.toContain(source.orderId) + expect(wrapper.text()).not.toContain(source.orderId) await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises() - expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20') + expect(wrapper.find('input[type=number]').exists()).toBe(false) await wrapper.get('form').trigger('submit'); await flushPromises() expect(saveCargoVerification).not.toHaveBeenCalled() await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST') @@ -44,6 +44,22 @@ it('names the missing material and supplements the canonical booking inside the expect(wrapper.text()).toContain('资料齐全,无需重复填写') expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount() }) +it('allows missing or changed quantities to be verified before saving shipment references', async () => { + for (const cargo of [ + { ...context, effectivePackages: null }, + { ...context, sourceChanged: true }, + ]) { + vi.mocked(getCargoVerification).mockResolvedValue(cargo) + vi.mocked(saveCargoVerification).mockClear() + const wrapper = render(); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises() + await wrapper.get('input[type=number]').setValue('20') + await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST') + await wrapper.get('form').trigger('submit'); await flushPromises() + expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ packageCount: 20, shipmentReferences: ['FBA-TEST'] })) + wrapper.unmount() + } +}) it('leaves pending arrangements lazy and reads only when formal confirmation is selected', async () => { const wrapper = render(false); await flushPromises() expect(resolvePlanningOrder).not.toHaveBeenCalled() @@ -52,8 +68,10 @@ it('leaves pending arrangements lazy and reads only when formal confirmation is }) it('does not ask for known materials to be entered again', async () => { vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } }) + vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectiveShipmentReferences: ['FBA-ORIGINAL', 'TRACK-ORIGINAL'] }) const wrapper = render(); await flushPromises() expect(wrapper.text()).toContain('资料齐全,无需重复填写') + expect(wrapper.text()).toContain('FBA-ORIGINAL、TRACK-ORIGINAL') expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false) expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount() }) diff --git a/src/modules/appointment-calendar/AppointmentConfirmationCargo.vue b/src/modules/appointment-calendar/AppointmentConfirmationCargo.vue index dd32444a..fd8e0e29 100644 --- a/src/modules/appointment-calendar/AppointmentConfirmationCargo.vue +++ b/src/modules/appointment-calendar/AppointmentConfirmationCargo.vue @@ -66,13 +66,13 @@ onBeforeUnmount(() => { ++generation; unregister?.() })

正式确认前:核对本次送仓货物

重新核对资料 -

本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。

- +
操作入仓码 / 外接登记号本票总箱数需要补充的资料
+
操作入仓码 / 外接登记号本票总箱数FBA 货件号 / 追踪号需要补充的资料
在此补齐资料— {{ row.resolved.source.inboundCode || row.resolved.source.bookingNo || '未提供业务编号' }} {{ row.cargo.effectivePackages ?? '待补充' }}{{ row.cargo.effectiveShipmentReferences.join('、') || '—' }} {{ issues(row).join(';') || '资料齐全,无需重复填写' }}
diff --git a/src/modules/appointment-calendar/AppointmentCreatePanel.vue b/src/modules/appointment-calendar/AppointmentCreatePanel.vue index 8da2ea1d..facd6cca 100644 --- a/src/modules/appointment-calendar/AppointmentCreatePanel.vue +++ b/src/modules/appointment-calendar/AppointmentCreatePanel.vue @@ -37,12 +37,12 @@ let generation = 0, selectionGeneration = 0, controller: AbortController | null const columns: DataTableColumn[] = [ { key: 'select', label: '选择', width: 60, slotName: 'select' }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, + { key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility', afterColumn: 'inboundCode' }, { prop: 'customerName', label: '客户', minWidth: 140, slotName: 'customer' }, { prop: 'containerNo', label: '柜号', minWidth: 145 }, { prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' }, { prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 }, { prop: 'packages', label: '箱数', width: 80 }, - { key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility' }, ] function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) } function reason(row: PlanningCargo) { @@ -129,13 +129,11 @@ onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsReque - - - + +
系统单号{{ created.appointmentNo || '—' }}目的仓时区{{ created.timezone || '待核实' }}
计划送仓日期{{ createdForm.plannedDate || '—' }}本次登记{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}
计划开始时段{{ createdForm.plannedAt || '—' }}计划结束时段{{ createdForm.plannedEndAt || '—' }}
外部约号{{ createdForm.confirmationNo || '—' }}
对方确认开始时间{{ createdForm.appointmentAt || '—' }}对方确认结束时间{{ createdForm.appointmentEndAt || '—' }}
确认开始时差{{ createdForm.startOffset || '—' }}确认结束时差{{ createdForm.endOffset || '—' }}
对方确认开始时间{{ createdForm.appointmentAt || '—' }}对方确认结束时间{{ createdForm.appointmentEndAt || '—' }}
确认开始时差{{ createdForm.startOffset || '—' }}确认结束时差{{ createdForm.endOffset || '—' }}
{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}{{ createdForm.appointmentRemark || '—' }}
-

预约已保存,请继续在原业务安排车辆。

diff --git a/src/modules/appointment-calendar/CargoVerificationForm.test.ts b/src/modules/appointment-calendar/CargoVerificationForm.test.ts index daee0cb2..1a5bd982 100644 --- a/src/modules/appointment-calendar/CargoVerificationForm.test.ts +++ b/src/modules/appointment-calendar/CargoVerificationForm.test.ts @@ -10,7 +10,7 @@ vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCa const source = { sourceKey: 'ORDER:9007199254740993', customerId: 'customer-1', destinationKey: 'FBA:ONT8' } const context: CargoVerificationContext = { source: { ...source, orderId: '9007199254740993', inboundCode: 'IN-TEST-001', bookingNo: null, containerNo: 'TEST-CN-001', originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null, canFollowup: true, blockReasons: [] }, - verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: null, availablePackages: null, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [], + verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: null, effectiveShipmentReferences: [], availablePackages: null, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [], } function render() { return mount(CargoVerificationForm, { props: { source }, global: { stubs: { ElButton: { props: ['nativeType'], template: '' }, LoadErrorState: { props: ['message'], template: '
{{ message }}
' }, ApiErrorMeta: true } } }) } beforeEach(() => { @@ -26,6 +26,15 @@ it('saves independent cargo verification before any appointment exists, preservi expect(vi.mocked(saveCargoVerification).mock.calls[0]![1]).not.toHaveProperty('appointmentStatus') expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() }) +it('prefills existing order shipment and tracking numbers without creating a verification record', async () => { + vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectivePackages: 20, effectiveShipmentReferences: ['FBA-ORIGINAL', 'TRACK-ORIGINAL'] }) + const wrapper = render(); await flushPromises() + expect((wrapper.get('textarea[aria-label="本票货件号"]').element as HTMLTextAreaElement).value).toBe('FBA-ORIGINAL\nTRACK-ORIGINAL') + expect(saveCargoVerification).not.toHaveBeenCalled() + await wrapper.get('form').trigger('submit'); await flushPromises() + expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ shipmentReferences: ['FBA-ORIGINAL', 'TRACK-ORIGINAL'] })) + wrapper.unmount() +}) it('uses the same key and payload after an unknown save outcome', async () => { vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('network lost')) const wrapper = render(); await flushPromises() diff --git a/src/modules/appointment-calendar/CargoVerificationForm.vue b/src/modules/appointment-calendar/CargoVerificationForm.vue index 5b02ec3d..8f69960b 100644 --- a/src/modules/appointment-calendar/CargoVerificationForm.vue +++ b/src/modules/appointment-calendar/CargoVerificationForm.vue @@ -27,7 +27,7 @@ async function load(preserve = false) { context.value = await getCargoVerification(props.source) if (!preserve) { const record = context.value.verification - Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' }) + Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: context.value.effectiveShipmentReferences.join('\n'), remark: record?.remark || '' }) baseline.value = JSON.stringify(form) } } catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') } @@ -59,14 +59,13 @@ onMounted(() => load())