优化
This commit is contained in:
@@ -55,7 +55,7 @@
|
||||
| 入库单扣货、解除、凭证与历史 | `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` 状态 |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||
|
||||
@@ -21,7 +21,7 @@ const container = {
|
||||
} satisfies ContainerBookingResource
|
||||
|
||||
const candidate = {
|
||||
oceanBookingId: '101', bookingNo: 'OB-101', requirementId: '202', containerType: '40HQ', capacityCbm: 68,
|
||||
oceanBookingId: '101', bookingNo: 'OB-101', billOfLadingNo: 'BL-101', requirementId: '202', containerType: '40HQ', capacityCbm: 68,
|
||||
maxWeightKg: 19500, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1,
|
||||
customerName: '测试客户', warehouseName: '义乌仓', channelName: '美森正班',
|
||||
}
|
||||
@@ -36,7 +36,7 @@ describe('OceanBookingLinkDialog', () => {
|
||||
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
|
||||
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 40HQ × 1')
|
||||
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 提单号 BL-101 · 40HQ × 1')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ async function loadCandidates() {
|
||||
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
|
||||
oceanBookingId: candidate.oceanBookingId,
|
||||
requirementId: candidate.requirementId,
|
||||
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName?.trim() || '未关联客户'} · ${candidate.warehouseName} · ${candidate.channelName}`,
|
||||
label: `${candidate.bookingNo} · 提单号 ${candidate.billOfLadingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName?.trim() || '未关联客户'} · ${candidate.warehouseName} · ${candidate.channelName}`,
|
||||
}))
|
||||
} catch (cause) {
|
||||
if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '已放舱订舱候选加载失败')
|
||||
|
||||
@@ -655,7 +655,7 @@ describe('ContainersView', () => {
|
||||
|
||||
it.each([undefined, null, '', '客户一'])('renders optional booking customer %s without leaking a missing value', async (customerName) => {
|
||||
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [{
|
||||
oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', requirementId: '45315',
|
||||
oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', billOfLadingNo: 'BL-OPTIONAL-CUSTOMER', requirementId: '45315',
|
||||
containerType: '40HQ', remainingQuantity: 1, customerName, warehouseName: '义乌仓', channelName: 'EXX海卡',
|
||||
}], page: 1, size: 200, total: 1, pages: 1 } as never)
|
||||
const router = createTestRouter()
|
||||
@@ -667,7 +667,7 @@ describe('ContainersView', () => {
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
|
||||
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
|
||||
await flushPromises()
|
||||
const label = `OB-OPTIONAL-CUSTOMER · 40HQ × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡`
|
||||
const label = `OB-OPTIONAL-CUSTOMER · 提单号 BL-OPTIONAL-CUSTOMER · 40HQ × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡`
|
||||
expect(document.body.textContent).toContain(label)
|
||||
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toBe(label)
|
||||
wrapper.unmount()
|
||||
@@ -698,9 +698,9 @@ describe('ContainersView', () => {
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HQ', page: 1, size: 200 })
|
||||
expect(getOceanBooking).not.toHaveBeenCalled()
|
||||
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HQ', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', billOfLadingNo: 'BL-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HQ', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 45HQ × 1')
|
||||
expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 提单号 BL-CAPACITY-SNAPSHOT · 45HQ × 1')
|
||||
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ key: string }> }).candidates[0]?.key).toBe('315:45315')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -729,9 +729,9 @@ describe('ContainersView', () => {
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
|
||||
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
|
||||
resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', requirementId: '404', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', billOfLadingNo: 'BL-LATEST', requirementId: '404', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await flushPromises()
|
||||
resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', requirementId: '202', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', billOfLadingNo: 'BL-STALE', requirementId: '202', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await flushPromises()
|
||||
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2)
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('ocean bookings api', () => {
|
||||
})
|
||||
|
||||
it('uses the frozen single-query link candidate contract and normalizes long ids', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', requirementId: 9007199254740994, containerType: '40HQ', capacityCbm: '68', maxWeightKg: '19500', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } })
|
||||
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', billOfLadingNo: 'BL-1', requirementId: 9007199254740994, containerType: '40HQ', capacityCbm: '68', maxWeightKg: '19500', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } })
|
||||
const query = { warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 }
|
||||
|
||||
const result = await listOceanBookingLinkCandidates(query)
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface OceanBookingQuery { page: number; size: number; keyword?: strin
|
||||
export interface OceanBookingLinkCandidate {
|
||||
oceanBookingId: string
|
||||
bookingNo: string
|
||||
billOfLadingNo: string
|
||||
requirementId: string
|
||||
containerType: string
|
||||
capacityCbm: number
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|---|---|---|
|
||||
| 排车车型、托位容量与限重提示 | `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` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `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/` |
|
||||
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。路线只读并由已排托盘关联的 FBA 仓去重后以英文逗号连接,预排和普通编辑均不接收人工路线。一次转派送仍生成一张车辆级出库单,响应的 `deliveries` 按目的 FBA 仓返回多张派送单。页面能力必须由正式路由、API 调用和测试共同证明 |
|
||||
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型,状态须覆盖 `WAIT_PICK/待拣货`、`PICKING/拣货中`、`PICKED/已拣货`、`WAIT_OUTBOUND/待出库`、`OUTBOUNDED/已出库` 和 `CANCELLED/已取消`;仅未开始的 `WAIT_PICK/WAIT_OUTBOUND` 允许取消。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
|
||||
|
||||
@@ -212,6 +212,21 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['PENDING_ASSIGNMENT', true],
|
||||
['INBOUND_COMPLETED', false],
|
||||
])('only enables a new hold after inbound completion for %s', async (currentStatus, disabled) => {
|
||||
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,
|
||||
}], page: 1, size: 10, total: 1, pages: 1 })
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('keeps the original pending order table and does not render a view switch or container table', async () => {
|
||||
const wrapper = await openPending()
|
||||
expect(wrapper.text()).toContain('尾程单号')
|
||||
@@ -320,6 +335,22 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(payload).not.toHaveProperty('plannedWarehouseName')
|
||||
})
|
||||
|
||||
it('allows a historically held order to generate its inbound plan', async () => {
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow({
|
||||
detained: true,
|
||||
orderTypeCode: 'ONE_CLICK_DROPSHIP',
|
||||
currentStatusCode: 'PENDING_ASSIGNMENT',
|
||||
})])
|
||||
await button(wrapper, '生成入库计划')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getContainerInboundPlanPreview).toHaveBeenCalledWith(bookingId)
|
||||
const confirm = [...document.body.querySelectorAll('button')]
|
||||
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
|
||||
expect(confirm.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => {
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' })])
|
||||
|
||||
@@ -492,7 +492,6 @@ function idempotencyKey(prefix: string) {
|
||||
function openInboundPlan(source?: object) {
|
||||
const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null)
|
||||
if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
|
||||
if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return }
|
||||
actionTarget.value = target
|
||||
const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1
|
||||
inboundPlanMinimumPalletCount.value = Math.max(1, volumeMinimum,
|
||||
@@ -564,6 +563,10 @@ async function submitInboundPlan() {
|
||||
}
|
||||
function openHold(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
if (!row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED') {
|
||||
ElMessage.warning('货物完成入库后才能暂扣;暂扣只限制后续排车和出库')
|
||||
return
|
||||
}
|
||||
actionTarget.value = row
|
||||
Object.assign(holdForm, { held: !row.detained, chargeable: row.detained ? false : true, reason: '' })
|
||||
actionError.value = null
|
||||
@@ -747,7 +750,6 @@ function readinessType(readiness: ContainerInboundPlanPreviewOrderDto['readiness
|
||||
function blockerText(blockers: string[]) {
|
||||
const labels: Record<string, string> = {
|
||||
MISSING_INBOUND_CODE: '缺少入仓码',
|
||||
ORDER_DETAINED: '订单已暂扣',
|
||||
ORDER_STATUS_NOT_READY: '订单状态未就绪',
|
||||
CONTAINER_NO_INCONSISTENT: '货柜号不一致',
|
||||
RECEIVING_WAREHOUSE_INCONSISTENT: '收货仓不一致',
|
||||
@@ -882,7 +884,7 @@ onMounted(() => { void load() })
|
||||
</section>
|
||||
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="warning" v-if="access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW'" size="small" @click="openTransferReview(row)">审核</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" size="small" @click="openInboundPlan(row)">入库计划</el-button><template v-if="rowActionCount(row) <= 3 && access.can(permission.lastMileOrderTransfer)"><el-button class="density-action" data-tone="warning" size="small" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-button><el-button class="density-action" data-tone="neutral" size="small" @click="openManualNodes(row)">人工修改节点</el-button></template><el-dropdown v-if="rowActionCount(row) > 3" trigger="click"><el-button class="density-action" :aria-label="`更多操作 · ${row.orderNo || row.dispatchNo || '当前记录'}`" title="更多操作" data-tone="neutral" size="small" ><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item><el-dropdown-item v-if="access.can(permission.relabelOrderStatus) && row.relabelOrderNo && canConfirmRelabel(row)" @click="confirmRelabel(row)">换标完成确认</el-dropdown-item><el-dropdown-item v-if="access.can(permission.relabelOrderStatus) && row.relabelOrderNo && canReworkRelabel(row)" @click="reworkRelabel(row)">重新换标</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="warning" v-if="access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW'" size="small" @click="openTransferReview(row)">审核</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderCreate)" size="small" @click="openInboundPlan(row)">入库计划</el-button><template v-if="rowActionCount(row) <= 3 && access.can(permission.lastMileOrderTransfer)"><el-button class="density-action" data-tone="warning" :disabled="!row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED'" size="small" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-button><el-button class="density-action" data-tone="neutral" size="small" @click="openManualNodes(row)">人工修改节点</el-button></template><el-dropdown v-if="rowActionCount(row) > 3" trigger="click"><el-button class="density-action" :aria-label="`更多操作 · ${row.orderNo || row.dispatchNo || '当前记录'}`" title="更多操作" data-tone="neutral" size="small" ><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" :disabled="!row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED'" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item><el-dropdown-item v-if="access.can(permission.relabelOrderStatus) && row.relabelOrderNo && canConfirmRelabel(row)" @click="confirmRelabel(row)">换标完成确认</el-dropdown-item><el-dropdown-item v-if="access.can(permission.relabelOrderStatus) && row.relabelOrderNo && canReworkRelabel(row)" @click="reworkRelabel(row)">重新换标</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
|
||||
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template>
|
||||
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
|
||||
Reference in New Issue
Block a user