优化
This commit is contained in:
@@ -99,13 +99,13 @@ defineExpose({ candidates, candidateKey, containerNo, save })
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppDialog class="admin-density-dialog" :model-value="modelValue" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
|
<AppDialog class="admin-density-dialog" :model-value="modelValue" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
|
||||||
<el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" />
|
<!-- <el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" /> -->
|
||||||
<el-form class="density-form" label-position="top">
|
<el-form class="density-form" label-position="top">
|
||||||
<el-form-item label="柜次号"><el-input :model-value="container?.cabinetNo || ''" disabled /></el-form-item>
|
<el-form-item label="柜次号"><el-input :model-value="container?.cabinetNo || ''" disabled /></el-form-item>
|
||||||
<el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item>
|
<el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item>
|
||||||
<el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item>
|
<el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
|
<!-- <p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p> -->
|
||||||
<LoadErrorState v-if="error" compact title="关联失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<LoadErrorState v-if="error" compact title="关联失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -518,7 +518,9 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
|||||||
<template>
|
<template>
|
||||||
<AdminPage class="container-detail-page admin-density">
|
<AdminPage class="container-detail-page admin-density">
|
||||||
<section class="container-overview surface-panel">
|
<section class="container-overview surface-panel">
|
||||||
<header class="container-overview__header"><el-button link class="density-back" type="primary" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>{{ isDomestic ? '送柜、装柜、出口报关与国内出仓' : '海运节点、目的港清关、提柜与还空' }}</p></div><StatusBadge v-if="detail" :status="primaryStatusText" /></header>
|
<header class="container-overview__header"><el-button link class="density-back" type="primary" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1>
|
||||||
|
<!-- <p>{{ isDomestic ? '送柜、装柜、出口报关与国内出仓' : '海运节点、目的港清关、提柜与还空' }}</p> -->
|
||||||
|
</div><StatusBadge v-if="detail" :status="primaryStatusText" /></header>
|
||||||
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待补录' }}</strong></div><div><span>{{ isDomestic ? '装柜清单号' : '当前海外阶段' }}</span><strong>{{ isDomestic ? (detail.planNo || '—') : primaryStatusText }}</strong></div><div><span>{{ isDomestic ? '柜型' : '起运仓' }}</span><strong>{{ isDomestic ? (detail.containerType || '—') : (detail.warehouseName || '—') }}</strong></div><div><span>{{ isDomestic ? '装柜仓库' : '目的仓 / 接柜仓' }}</span><strong :title="isDomestic ? detail.warehouseName || '—' : overseasDestinationText">{{ isDomestic ? (detail.warehouseName || '—') : overseasDestinationText }}</strong></div></div>
|
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待补录' }}</strong></div><div><span>{{ isDomestic ? '装柜清单号' : '当前海外阶段' }}</span><strong>{{ isDomestic ? (detail.planNo || '—') : primaryStatusText }}</strong></div><div><span>{{ isDomestic ? '柜型' : '起运仓' }}</span><strong>{{ isDomestic ? (detail.containerType || '—') : (detail.warehouseName || '—') }}</strong></div><div><span>{{ isDomestic ? '装柜仓库' : '目的仓 / 接柜仓' }}</span><strong :title="isDomestic ? detail.warehouseName || '—' : overseasDestinationText">{{ isDomestic ? (detail.warehouseName || '—') : overseasDestinationText }}</strong></div></div>
|
||||||
<div v-if="isOverseas && detail" class="overseas-stage-strip" :class="{ 'is-overdue': workbenchDetail?.terminalDrayage?.emptyReturnWarning.status === 'OVERDUE' }"><div><span>当前海外履约状态</span><strong>{{ primaryStatusText }}</strong></div><p>{{ emptyReturnWarningText }}</p></div>
|
<div v-if="isOverseas && detail" class="overseas-stage-strip" :class="{ 'is-overdue': workbenchDetail?.terminalDrayage?.emptyReturnWarning.status === 'OVERDUE' }"><div><span>当前海外履约状态</span><strong>{{ primaryStatusText }}</strong></div><p>{{ emptyReturnWarningText }}</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
| 关键词 | 页面/路由入口 | API 与联查 |
|
| 关键词 | 页面/路由入口 | API 与联查 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 仓储转运审核、通过与驳回 | `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`、`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` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../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/` |
|
| 入仓单、入仓计划、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`。一次转派送仍生成一张车辆级出库单,响应的 `deliveries` 按目的 FBA 仓返回多张派送单。页面能力必须由正式路由、API 调用和测试共同证明 |
|
| 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。一次转派送仍生成一张车辆级出库单,响应的 `deliveries` 按目的 FBA 仓返回多张派送单。页面能力必须由正式路由、API 调用和测试共同证明 |
|
||||||
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
|
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
|
||||||
|
|||||||
@@ -87,12 +87,13 @@ describe('last-mile order overseas API', () => {
|
|||||||
it('uses the manual inbound-plan endpoint with the complete form and idempotency evidence', async () => {
|
it('uses the manual inbound-plan endpoint with the complete form and idempotency evidence', async () => {
|
||||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} })
|
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} })
|
||||||
const payload = { orderId: '190000000000000099', customerId: '190000000000000001', expectedOrderVersion: 4,
|
const payload = { orderId: '190000000000000099', customerId: '190000000000000001', expectedOrderVersion: 4,
|
||||||
plannedArrivalDate: '2026-09-08', palletCount: 8, palletType: 'STANDARD' as const,
|
palletCount: 8, palletType: 'STANDARD' as const,
|
||||||
assigned: false, mergeRequired: false, urgent: true, remark: '加急' }
|
assigned: false, mergeRequired: false, urgent: true, remark: '加急' }
|
||||||
await createManualInboundPlan(payload, 'plan-key-1')
|
await createManualInboundPlan(payload, 'plan-key-1')
|
||||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/manual', payload,
|
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/manual', payload,
|
||||||
{ headers: { 'Idempotency-Key': 'plan-key-1' } })
|
{ headers: { 'Idempotency-Key': 'plan-key-1' } })
|
||||||
expect(payload).not.toHaveProperty('inboundCode')
|
expect(payload).not.toHaveProperty('inboundCode')
|
||||||
|
expect(payload).not.toHaveProperty('plannedArrivalDate')
|
||||||
expect(payload).not.toHaveProperty('plannedWarehouseId')
|
expect(payload).not.toHaveProperty('plannedWarehouseId')
|
||||||
expect(payload).not.toHaveProperty('plannedWarehouseCode')
|
expect(payload).not.toHaveProperty('plannedWarehouseCode')
|
||||||
expect(payload).not.toHaveProperty('plannedWarehouseName')
|
expect(payload).not.toHaveProperty('plannedWarehouseName')
|
||||||
|
|||||||
@@ -294,7 +294,6 @@ export interface ManualInboundPlanPayload {
|
|||||||
customerId: string
|
customerId: string
|
||||||
orderId: string
|
orderId: string
|
||||||
expectedOrderVersion: number
|
expectedOrderVersion: number
|
||||||
plannedArrivalDate: string
|
|
||||||
palletCount: number
|
palletCount: number
|
||||||
palletType: 'STANDARD' | 'WOOD' | 'PAPER'
|
palletType: 'STANDARD' | 'WOOD' | 'PAPER'
|
||||||
assigned: boolean
|
assigned: boolean
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ describe('last-mile order presentation adapter', () => {
|
|||||||
})
|
})
|
||||||
expect(row).toMatchObject({
|
expect(row).toMatchObject({
|
||||||
pallets: 3, cartons: 0, volume: 12.75, quantity: '—', volumeWeight: 88.6,
|
pallets: 3, cartons: 0, volume: 12.75, quantity: '—', volumeWeight: 88.6,
|
||||||
portEta: formatOverseasDateTime(estimatedArrivalAt), portAta: '—', warehouseEta: '—',
|
portEta: formatOverseasDateTime(estimatedArrivalAt), portAta: '—',
|
||||||
|
estimatedWarehouseAt: '', warehouseEta: '—',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
|||||||
volumeWeight: metric(dto.volumeWeightKg),
|
volumeWeight: metric(dto.volumeWeightKg),
|
||||||
portEta: formatOverseasDateTime(dto.estimatedArrivalAt),
|
portEta: formatOverseasDateTime(dto.estimatedArrivalAt),
|
||||||
portAta: formatOverseasDateTime(dto.actualArrivalAt),
|
portAta: formatOverseasDateTime(dto.actualArrivalAt),
|
||||||
|
estimatedWarehouseAt: String(dto.estimatedWarehouseAt ?? '').trim(),
|
||||||
warehouseEta: formatOverseasDateTime(dto.estimatedWarehouseAt),
|
warehouseEta: formatOverseasDateTime(dto.estimatedWarehouseAt),
|
||||||
inboundOrderNo: text(dto.inboundOrderNo),
|
inboundOrderNo: text(dto.inboundOrderNo),
|
||||||
dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
|
dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ function selectedRow(overrides: Record<string, unknown> = {}) {
|
|||||||
id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001',
|
id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001',
|
||||||
overseasWarehouseId: summary.overseasWarehouseId, overseasWarehouseCode: summary.overseasWarehouseCode,
|
overseasWarehouseId: summary.overseasWarehouseId, overseasWarehouseCode: summary.overseasWarehouseCode,
|
||||||
warehouse: summary.overseasWarehouseName, inboundCode: 'IN-LOCKED', version: 3, pallets: 2,
|
warehouse: summary.overseasWarehouseName, inboundCode: 'IN-LOCKED', version: 3, pallets: 2,
|
||||||
|
estimatedWarehouseAt: '2026-09-01T08:00:00', warehouseEta: '2026-09-01 08:00',
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,7 +262,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
|||||||
await button(wrapper, '生成入库计划')!.trigger('click')
|
await button(wrapper, '生成入库计划')!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(document.body.textContent).toContain('计划入库仓')
|
expect(document.body.textContent).toContain('计划入库仓')
|
||||||
expect(document.body.textContent).toContain('预计到仓日期')
|
expect(document.body.textContent).toContain('预计到仓时间')
|
||||||
expect(document.body.textContent).toContain('托盘类型')
|
expect(document.body.textContent).toContain('托盘类型')
|
||||||
const inboundCodeInput = wrapper.findAllComponents({ name: 'ElInput' })
|
const inboundCodeInput = wrapper.findAllComponents({ name: 'ElInput' })
|
||||||
.find((input) => input.props('placeholder') === '订单冻结入仓码')
|
.find((input) => input.props('placeholder') === '订单冻结入仓码')
|
||||||
@@ -270,6 +271,11 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
|||||||
.find((input) => input.props('placeholder') === '当前柜接柜仓')
|
.find((input) => input.props('placeholder') === '当前柜接柜仓')
|
||||||
expect(receivingWarehouseInput?.props('disabled')).toBe(true)
|
expect(receivingWarehouseInput?.props('disabled')).toBe(true)
|
||||||
expect(receivingWarehouseInput?.props('modelValue')).toBe('LAX01 · 洛杉矶接柜仓')
|
expect(receivingWarehouseInput?.props('modelValue')).toBe('LAX01 · 洛杉矶接柜仓')
|
||||||
|
const expectedArrivalInput = wrapper.findAllComponents({ name: 'ElInput' })
|
||||||
|
.find((input) => input.props('placeholder') === '当前柜预计到仓时间')
|
||||||
|
expect(expectedArrivalInput?.props('disabled')).toBe(true)
|
||||||
|
expect(expectedArrivalInput?.props('modelValue')).toBe('2026-09-01 08:00')
|
||||||
|
expect(document.body.textContent).toContain('同柜所有入库计划保持一致')
|
||||||
expect(document.body.textContent).toContain('接柜仓按柜统一')
|
expect(document.body.textContent).toContain('接柜仓按柜统一')
|
||||||
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
|
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
|
||||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||||
@@ -277,14 +283,11 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
|||||||
expect(listContainerPlanProgress).not.toHaveBeenCalled()
|
expect(listContainerPlanProgress).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the order-frozen inbound code but never sends it or a per-order receiving warehouse override', async () => {
|
it('shows cabinet-frozen facts but never sends arrival time or per-order warehouse overrides', async () => {
|
||||||
const wrapper = await openPending()
|
const wrapper = await openPending()
|
||||||
await selectRows(wrapper, [selectedRow()])
|
await selectRows(wrapper, [selectedRow()])
|
||||||
await button(wrapper, '生成入库计划')!.trigger('click')
|
await button(wrapper, '生成入库计划')!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const datePicker = wrapper.find('.command-form').findComponent({ name: 'ElDatePicker' })
|
|
||||||
datePicker.vm.$emit('update:modelValue', '2026-09-08')
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
const confirm = [...document.body.querySelectorAll('button')]
|
const confirm = [...document.body.querySelectorAll('button')]
|
||||||
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
|
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
|
||||||
confirm.click()
|
confirm.click()
|
||||||
@@ -292,15 +295,28 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
|||||||
|
|
||||||
expect(createManualInboundPlan).toHaveBeenCalledWith(expect.objectContaining({
|
expect(createManualInboundPlan).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
customerId, orderId: '2092873463568596994', expectedOrderVersion: 3,
|
customerId, orderId: '2092873463568596994', expectedOrderVersion: 3,
|
||||||
plannedArrivalDate: '2026-09-08', palletCount: 5, palletType: 'WOOD',
|
palletCount: 5, palletType: 'WOOD',
|
||||||
}), expect.stringContaining('manual-inbound-plan-'))
|
}), expect.stringContaining('manual-inbound-plan-'))
|
||||||
const payload = vi.mocked(createManualInboundPlan).mock.calls[0][0] as unknown as Record<string, unknown>
|
const payload = vi.mocked(createManualInboundPlan).mock.calls[0][0] as unknown as Record<string, unknown>
|
||||||
expect(payload).not.toHaveProperty('inboundCode')
|
expect(payload).not.toHaveProperty('inboundCode')
|
||||||
|
expect(payload).not.toHaveProperty('plannedArrivalDate')
|
||||||
expect(payload).not.toHaveProperty('plannedWarehouseId')
|
expect(payload).not.toHaveProperty('plannedWarehouseId')
|
||||||
expect(payload).not.toHaveProperty('plannedWarehouseCode')
|
expect(payload).not.toHaveProperty('plannedWarehouseCode')
|
||||||
expect(payload).not.toHaveProperty('plannedWarehouseName')
|
expect(payload).not.toHaveProperty('plannedWarehouseName')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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: '—' })])
|
||||||
|
await button(wrapper, '生成入库计划')!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const confirm = [...document.body.querySelectorAll('button')]
|
||||||
|
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
|
||||||
|
expect(confirm.disabled).toBe(true)
|
||||||
|
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('locks one-click dropship manual plans to the frozen source pallet count', async () => {
|
it('locks one-click dropship manual plans to the frozen source pallet count', async () => {
|
||||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({
|
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({
|
||||||
...preview,
|
...preview,
|
||||||
@@ -324,9 +340,6 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
|||||||
expect(palletInput.props('modelValue')).toBe(1)
|
expect(palletInput.props('modelValue')).toBe(1)
|
||||||
expect(document.body.textContent).toContain('固定按头程已封托托盘数整体入库')
|
expect(document.body.textContent).toContain('固定按头程已封托托盘数整体入库')
|
||||||
|
|
||||||
wrapper.find('.command-form').findComponent({ name: 'ElDatePicker' })
|
|
||||||
.vm.$emit('update:modelValue', '2026-09-08')
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
;([...document.body.querySelectorAll('button')]
|
;([...document.body.querySelectorAll('button')]
|
||||||
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement).click()
|
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement).click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|||||||
@@ -54,10 +54,12 @@ const inboundPlanSourcePalletCount = ref<number | null>(null)
|
|||||||
const inboundPlanPreviewLoading = ref(false)
|
const inboundPlanPreviewLoading = ref(false)
|
||||||
const inboundPlanPreviewError = ref('')
|
const inboundPlanPreviewError = ref('')
|
||||||
const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP')
|
const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP')
|
||||||
const inboundPlanSubmitDisabled = computed(() => inboundPlanIsDropship.value
|
const inboundPlanSubmitDisabled = computed(() => !actionTarget.value?.estimatedWarehouseAt
|
||||||
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|
|| (inboundPlanIsDropship.value
|
||||||
|| inboundPlanSourcePalletCount.value <= 0))
|
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|
||||||
const inboundPlanForm = reactive({ plannedArrivalDate: '', inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
|| inboundPlanSourcePalletCount.value <= 0)))
|
||||||
|
const inboundPlanForm = reactive({ inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||||
|
const inboundPlanExpectedArrival = computed(() => actionTarget.value?.warehouseEta || '—')
|
||||||
const holdOpen = ref(false)
|
const holdOpen = ref(false)
|
||||||
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
|
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
|
||||||
const manualNodesOpen = ref(false)
|
const manualNodesOpen = ref(false)
|
||||||
@@ -491,7 +493,7 @@ function openInboundPlan(source?: object) {
|
|||||||
typeof target.pallets === 'number' ? target.pallets : 0)
|
typeof target.pallets === 'number' ? target.pallets : 0)
|
||||||
inboundPlanSourcePalletCount.value = null
|
inboundPlanSourcePalletCount.value = null
|
||||||
inboundPlanPreviewError.value = ''
|
inboundPlanPreviewError.value = ''
|
||||||
Object.assign(inboundPlanForm, { plannedArrivalDate: '', inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: inboundPlanMinimumPalletCount.value, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
Object.assign(inboundPlanForm, { inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: inboundPlanMinimumPalletCount.value, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||||
actionError.value = null
|
actionError.value = null
|
||||||
inboundPlanOpen.value = true
|
inboundPlanOpen.value = true
|
||||||
void refreshManualPalletMinimum(target)
|
void refreshManualPalletMinimum(target)
|
||||||
@@ -531,15 +533,14 @@ async function submitInboundPlan() {
|
|||||||
ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划')
|
ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!row || !row.overseasWarehouseId || !inboundPlanForm.plannedArrivalDate || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
|
if (!row || !row.overseasWarehouseId || !row.estimatedWarehouseAt || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
|
||||||
ElMessage.warning('当前柜须已分配接柜仓,并完整填写预计到仓日期和托数')
|
ElMessage.warning('当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
actionLoading.value = true; actionError.value = null
|
actionLoading.value = true; actionError.value = null
|
||||||
try {
|
try {
|
||||||
const result = await createManualInboundPlan({
|
const result = await createManualInboundPlan({
|
||||||
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
|
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
|
||||||
plannedArrivalDate: inboundPlanForm.plannedArrivalDate,
|
|
||||||
palletCount: inboundPlanForm.palletCount,
|
palletCount: inboundPlanForm.palletCount,
|
||||||
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
|
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
|
||||||
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
|
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
|
||||||
@@ -967,7 +968,7 @@ onMounted(() => { void load() })
|
|||||||
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
|
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
|
||||||
<el-alert v-if="inboundPlanPreviewError" class="container-convert-alert" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
|
<el-alert v-if="inboundPlanPreviewError" class="container-convert-alert" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
|
||||||
<el-form class="command-form density-form" label-position="top">
|
<el-form class="command-form density-form" label-position="top">
|
||||||
<div class="command-form__grid"><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="预计到仓日期" required><el-date-picker v-model="inboundPlanForm.plannedArrivalDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="inboundPlanIsDropship ? inboundPlanMinimumPalletCount : 999" :disabled="inboundPlanIsDropship" controls-position="right" placeholder="请输入托数" /><small v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</small><small v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
|
<div class="command-form__grid"><el-form-item label="预计到仓时间"><el-input :model-value="inboundPlanExpectedArrival" disabled placeholder="当前柜预计到仓时间" /><small>取整柜预计到仓时间,同柜所有入库计划保持一致。</small></el-form-item><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="inboundPlanIsDropship ? inboundPlanMinimumPalletCount : 999" :disabled="inboundPlanIsDropship" controls-position="right" placeholder="请输入托数" /><small v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</small><small v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
|
||||||
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
|
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface LastMileOrderListItem {
|
|||||||
volumeWeight: number | '—'
|
volumeWeight: number | '—'
|
||||||
portEta: string
|
portEta: string
|
||||||
portAta: string
|
portAta: string
|
||||||
|
estimatedWarehouseAt: string
|
||||||
warehouseEta: string
|
warehouseEta: string
|
||||||
inboundOrderNo: string
|
inboundOrderNo: string
|
||||||
dispatchStatus: string
|
dispatchStatus: string
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
|
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import DataTable from '@/shared/components/DataTable.vue'
|
import DataTable from '@/shared/components/DataTable.vue'
|
||||||
|
import { printLocationLabels } from '../utils/locationLabelPrint'
|
||||||
import LocationsView from './LocationsView.vue'
|
import LocationsView from './LocationsView.vue'
|
||||||
|
|
||||||
const locationsSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouse-resources/locations/pages/LocationsView.vue'), 'utf8')
|
const locationsSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouse-resources/locations/pages/LocationsView.vue'), 'utf8')
|
||||||
@@ -28,6 +29,10 @@ vi.mock('../api/platformLocations', () => ({
|
|||||||
changePlatformLocationStatus: vi.fn(),
|
changePlatformLocationStatus: vi.fn(),
|
||||||
deletePlatformLocation: vi.fn(),
|
deletePlatformLocation: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
vi.mock('../utils/locationLabelPrint', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../utils/locationLabelPrint')>()
|
||||||
|
return { ...actual, printLocationLabels: vi.fn(() => true) }
|
||||||
|
})
|
||||||
|
|
||||||
class ResizeObserverStub implements ResizeObserver {
|
class ResizeObserverStub implements ResizeObserver {
|
||||||
observe() {}
|
observe() {}
|
||||||
@@ -177,6 +182,7 @@ describe('LocationsView', () => {
|
|||||||
location: availableLocation, warehouseCode: warehouse.warehouseCode, warehouseName: warehouse.warehouseName,
|
location: availableLocation, warehouseCode: warehouse.warehouseCode, warehouseName: warehouse.warehouseName,
|
||||||
})
|
})
|
||||||
vi.mocked(createPlatformLocation).mockReset()
|
vi.mocked(createPlatformLocation).mockReset()
|
||||||
|
vi.mocked(printLocationLabels).mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads only domestic warehouse candidates for querying and writes', async () => {
|
it('loads only domestic warehouse candidates for querying and writes', async () => {
|
||||||
@@ -236,7 +242,7 @@ describe('LocationsView', () => {
|
|||||||
expect(wrapper.text()).toContain('义乌收货仓')
|
expect(wrapper.text()).toContain('义乌收货仓')
|
||||||
expect(wrapper.text()).toContain('A1-01')
|
expect(wrapper.text()).toContain('A1-01')
|
||||||
expect(wrapper.findAll('thead th').map((item) => item.text().trim())).toEqual([
|
expect(wrapper.findAll('thead th').map((item) => item.text().trim())).toEqual([
|
||||||
'操作', '库位编码', '所属仓库', '库区', '库位类型', '状态',
|
'操作', '', '库位编码', '所属仓库', '库区', '库位类型', '状态',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -280,6 +286,7 @@ describe('LocationsView', () => {
|
|||||||
expect(bodyRows[1]?.text()).not.toContain('详情')
|
expect(bodyRows[1]?.text()).not.toContain('详情')
|
||||||
expect(bodyRows[1]?.text()).not.toContain('编辑')
|
expect(bodyRows[1]?.text()).not.toContain('编辑')
|
||||||
expect(bodyRows[1]?.text()).not.toContain('更多')
|
expect(bodyRows[1]?.text()).not.toContain('更多')
|
||||||
|
expect(bodyRows[1]?.text()).toContain('打印')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows all available actions directly when a row has no more than three', async () => {
|
it('shows all available actions directly when a row has no more than three', async () => {
|
||||||
@@ -287,6 +294,7 @@ describe('LocationsView', () => {
|
|||||||
const row = wrapper.find('tbody tr')
|
const row = wrapper.find('tbody tr')
|
||||||
|
|
||||||
expect(row.text()).not.toContain('详情')
|
expect(row.text()).not.toContain('详情')
|
||||||
|
expect(row.text()).toContain('打印')
|
||||||
expect(row.text()).toContain('停用')
|
expect(row.text()).toContain('停用')
|
||||||
expect(row.text()).not.toContain('更多')
|
expect(row.text()).not.toContain('更多')
|
||||||
})
|
})
|
||||||
@@ -300,6 +308,7 @@ describe('LocationsView', () => {
|
|||||||
|
|
||||||
expect(row.text()).not.toContain('详情')
|
expect(row.text()).not.toContain('详情')
|
||||||
expect(row.text()).toContain('编辑')
|
expect(row.text()).toContain('编辑')
|
||||||
|
expect(row.text()).toContain('打印')
|
||||||
expect(row.text()).toContain('启用')
|
expect(row.text()).toContain('启用')
|
||||||
expect(row.text()).toContain('删除')
|
expect(row.text()).toContain('删除')
|
||||||
expect(row.text()).not.toContain('更多')
|
expect(row.text()).not.toContain('更多')
|
||||||
@@ -338,7 +347,36 @@ describe('LocationsView', () => {
|
|||||||
|
|
||||||
expect(occupiedRow?.text()).not.toContain('详情')
|
expect(occupiedRow?.text()).not.toContain('详情')
|
||||||
expect(occupiedRow?.text()).not.toContain('编辑')
|
expect(occupiedRow?.text()).not.toContain('编辑')
|
||||||
expect(occupiedRow?.findAll('button')).toHaveLength(0)
|
expect(occupiedRow?.findAll('button').map((button) => button.text().trim())).toEqual(['打印'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prints one row directly and supports printing the selected current-page rows', async () => {
|
||||||
|
const wrapper = await mountView(['platform:location:list'])
|
||||||
|
const bodyRows = wrapper.findAll('tbody tr')
|
||||||
|
const rowPrint = bodyRows[0]?.findAll('button').find((button) => button.text().trim() === '打印')
|
||||||
|
|
||||||
|
await rowPrint!.trigger('click')
|
||||||
|
expect(printLocationLabels).toHaveBeenLastCalledWith([{
|
||||||
|
locationCode: 'A1-01',
|
||||||
|
locationName: 'A1 区 01 位',
|
||||||
|
warehouseCode: 'CN-YW-01',
|
||||||
|
warehouseName: '义乌收货仓',
|
||||||
|
zoneCode: 'A',
|
||||||
|
zoneName: '常规区',
|
||||||
|
}])
|
||||||
|
|
||||||
|
wrapper.findComponent(DataTable).vm.$emit('selection-change', [availableLocation, occupiedLocation])
|
||||||
|
await flushPromises()
|
||||||
|
const batchPrint = wrapper.find('.table-panel .data-table__toolbar').findAll('button')
|
||||||
|
.find((button) => button.text().includes('打印所选'))
|
||||||
|
expect(batchPrint?.attributes('disabled')).toBeUndefined()
|
||||||
|
expect(batchPrint?.text()).toContain('2')
|
||||||
|
await batchPrint!.trigger('click')
|
||||||
|
|
||||||
|
expect(printLocationLabels).toHaveBeenLastCalledWith(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ locationCode: 'A1-01' }),
|
||||||
|
expect.objectContaining({ locationCode: 'A1-02' }),
|
||||||
|
]))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears the warehouse on reset and immediately restores the full query', async () => {
|
it('clears the warehouse on reset and immediately restores the full query', async () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { MapPin, Plus, Save } from '@lucide/vue'
|
import { MapPin, Plus, Printer, Save } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type SavePlatformLocationRequest,
|
type SavePlatformLocationRequest,
|
||||||
} from '../api/platformLocations'
|
} from '../api/platformLocations'
|
||||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
|
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
|
||||||
|
import { printLocationLabels, type LocationLabelPrintItem } from '../utils/locationLabelPrint'
|
||||||
|
|
||||||
type EditorMode = 'create' | 'edit'
|
type EditorMode = 'create' | 'edit'
|
||||||
type LocationFilters = QueryModel & {
|
type LocationFilters = QueryModel & {
|
||||||
@@ -51,6 +52,7 @@ const warehouseError = ref<ApiErrorDetails | null>(null)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const rows = ref<PlatformLocationResource[]>([])
|
const rows = ref<PlatformLocationResource[]>([])
|
||||||
|
const selectedRows = ref<PlatformLocationResource[]>([])
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -170,7 +172,8 @@ const typeLabels: Record<PlatformLocationType, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
const columns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 240, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||||
|
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
|
||||||
{ key: 'location', label: '库位编码', minWidth: 190, slotName: 'location' },
|
{ key: 'location', label: '库位编码', minWidth: 190, slotName: 'location' },
|
||||||
{ key: 'warehouse', label: '所属仓库', minWidth: 210, slotName: 'warehouse' },
|
{ key: 'warehouse', label: '所属仓库', minWidth: 210, slotName: 'warehouse' },
|
||||||
{ key: 'zone', label: '库区', minWidth: 150, slotName: 'zone' },
|
{ key: 'zone', label: '库区', minWidth: 150, slotName: 'zone' },
|
||||||
@@ -239,6 +242,32 @@ function recordScope(row: PlatformLocationResource) {
|
|||||||
return { warehouseId: apiId(String(row.warehouseId)) }
|
return { warehouseId: apiId(String(row.warehouseId)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printItem(row: PlatformLocationResource): LocationLabelPrintItem {
|
||||||
|
const warehouse = warehouses.value.find((item) => String(item.id) === String(row.warehouseId))
|
||||||
|
return {
|
||||||
|
locationCode: row.locationCode,
|
||||||
|
locationName: row.locationName,
|
||||||
|
warehouseCode: row.warehouseCode || warehouse?.warehouseCode,
|
||||||
|
warehouseName: row.warehouseName || warehouse?.warehouseName,
|
||||||
|
zoneCode: row.zoneCode,
|
||||||
|
zoneName: row.zoneName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printRows(targets: readonly PlatformLocationResource[]) {
|
||||||
|
if (!targets.length) {
|
||||||
|
ElMessage.warning('请先勾选需要打印的库位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!printLocationLabels(targets.map(printItem))) {
|
||||||
|
ElMessage.warning('打印窗口被浏览器拦截,请允许弹出窗口后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectionChange(selection: object[]) {
|
||||||
|
selectedRows.value = selection as PlatformLocationResource[]
|
||||||
|
}
|
||||||
|
|
||||||
async function loadWarehouses() {
|
async function loadWarehouses() {
|
||||||
const sequence = ++warehouseLoadSequence
|
const sequence = ++warehouseLoadSequence
|
||||||
warehouseLoading.value = true
|
warehouseLoading.value = true
|
||||||
@@ -275,10 +304,12 @@ async function loadLocations() {
|
|||||||
const result = await listPlatformLocations(query)
|
const result = await listPlatformLocations(query)
|
||||||
if (sequence !== locationLoadSequence) return
|
if (sequence !== locationLoadSequence) return
|
||||||
rows.value = result.records
|
rows.value = result.records
|
||||||
|
selectedRows.value = []
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (sequence !== locationLoadSequence) return
|
if (sequence !== locationLoadSequence) return
|
||||||
rows.value = []
|
rows.value = []
|
||||||
|
selectedRows.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
loadError.value = getApiErrorDetails(error, '库位列表加载失败,请稍后重试')
|
loadError.value = getApiErrorDetails(error, '库位列表加载失败,请稍后重试')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -527,10 +558,20 @@ onMounted(() => {
|
|||||||
row-key="id"
|
row-key="id"
|
||||||
aria-label="库位列表"
|
aria-label="库位列表"
|
||||||
:empty-text="emptyDescription"
|
:empty-text="emptyDescription"
|
||||||
|
@selection-change="onSelectionChange"
|
||||||
>
|
>
|
||||||
<template #toolbar-actions><el-button v-if="access.can('platform:location:create')" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template>
|
<template #toolbar-actions>
|
||||||
|
<el-button :disabled="loading || !selectedRows.length" @click="printRows(selectedRows)"><Printer :size="16" />打印所选<span v-if="selectedRows.length">({{ selectedRows.length }})</span></el-button>
|
||||||
|
<el-button v-if="access.can('platform:location:create')" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button>
|
||||||
|
</template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="location-actions density-actions">
|
<div class="location-actions density-actions">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
class="density-action"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="printRows([row as PlatformLocationResource])"
|
||||||
|
>打印</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canEdit(row as PlatformLocationResource)"
|
v-if="canEdit(row as PlatformLocationResource)"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildLocationLabelPrintHtml } from './locationLabelPrint'
|
||||||
|
|
||||||
|
describe('location label printing', () => {
|
||||||
|
it('builds one 100 × 70 mm label per location with the code as the primary content', () => {
|
||||||
|
const html = buildLocationLabelPrintHtml([
|
||||||
|
{
|
||||||
|
locationCode: 'A1-01',
|
||||||
|
locationName: 'A1 区 01 位',
|
||||||
|
warehouseName: '义乌收货仓',
|
||||||
|
warehouseCode: 'CN-YW-01',
|
||||||
|
zoneName: '常规区',
|
||||||
|
zoneCode: 'A',
|
||||||
|
},
|
||||||
|
{ locationCode: 'B2-08', warehouseName: '深圳仓', zoneCode: 'B2' },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(html).toContain('@page { size: 100mm 70mm; margin: 0; }')
|
||||||
|
expect(html.match(/class="location-label"/g)).toHaveLength(2)
|
||||||
|
expect(html).toContain('<div class="location-code">A1-01</div>')
|
||||||
|
expect(html).toContain('义乌收货仓 · CN-YW-01')
|
||||||
|
expect(html).toContain('常规区 · A')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes server-provided label content before writing the print document', () => {
|
||||||
|
const html = buildLocationLabelPrintHtml([{
|
||||||
|
locationCode: '<img src=x onerror=alert(1)>',
|
||||||
|
warehouseName: 'A&B',
|
||||||
|
zoneName: '"危险区"',
|
||||||
|
}])
|
||||||
|
|
||||||
|
expect(html).not.toContain('<img src=x')
|
||||||
|
expect(html).toContain('<img src=x onerror=alert(1)>')
|
||||||
|
expect(html).toContain('A&B')
|
||||||
|
expect(html).toContain('"危险区"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export interface LocationLabelPrintItem {
|
||||||
|
locationCode: string
|
||||||
|
locationName?: string | null
|
||||||
|
warehouseCode?: string | null
|
||||||
|
warehouseName?: string | null
|
||||||
|
zoneCode?: string | null
|
||||||
|
zoneName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string | null | undefined) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function secondaryText(name: string | null | undefined, code: string | null | undefined) {
|
||||||
|
const values = [name, code].map((value) => String(value || '').trim()).filter(Boolean)
|
||||||
|
return [...new Set(values)].join(' · ') || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocationLabelPrintHtml(items: readonly LocationLabelPrintItem[]) {
|
||||||
|
const labels = items.map((item) => `
|
||||||
|
<section class="location-label">
|
||||||
|
<div class="label-kind">库位</div>
|
||||||
|
<div class="location-code">${escapeHtml(item.locationCode)}</div>
|
||||||
|
${item.locationName ? `<div class="location-name">${escapeHtml(item.locationName)}</div>` : ''}
|
||||||
|
<dl>
|
||||||
|
<div><dt>仓库</dt><dd>${escapeHtml(secondaryText(item.warehouseName, item.warehouseCode))}</dd></div>
|
||||||
|
<div><dt>库区</dt><dd>${escapeHtml(secondaryText(item.zoneName, item.zoneCode))}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>`).join('')
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>库位标签打印</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; color: #111827; font-family: Arial, "Microsoft YaHei", sans-serif; }
|
||||||
|
.location-label { width: 100mm; height: 70mm; padding: 7mm; display: flex; flex-direction: column; justify-content: center; border: 1px solid #d1d5db; break-after: page; page-break-after: always; }
|
||||||
|
.location-label:last-child { break-after: auto; page-break-after: auto; }
|
||||||
|
.label-kind { margin-bottom: 2mm; font-size: 11pt; font-weight: 700; letter-spacing: .24em; text-align: center; }
|
||||||
|
.location-code { overflow-wrap: anywhere; font-size: 34pt; font-weight: 800; line-height: 1.05; text-align: center; }
|
||||||
|
.location-name { margin-top: 2mm; overflow-wrap: anywhere; font-size: 12pt; font-weight: 600; text-align: center; }
|
||||||
|
dl { margin: 5mm 0 0; padding-top: 3mm; border-top: 1px solid #9ca3af; font-size: 10pt; }
|
||||||
|
dl div { display: grid; grid-template-columns: 12mm minmax(0, 1fr); gap: 2mm; margin-top: 1.5mm; }
|
||||||
|
dt { color: #4b5563; }
|
||||||
|
dd { margin: 0; overflow-wrap: anywhere; font-weight: 600; }
|
||||||
|
@page { size: 100mm 70mm; margin: 0; }
|
||||||
|
@media screen { body { display: grid; gap: 12px; justify-content: center; padding: 16px; background: #eef2f7; } .location-label { background: #fff; box-shadow: 0 4px 16px rgba(15, 23, 42, .12); } }
|
||||||
|
@media print { .location-label { border: 0; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>${labels}</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printLocationLabels(items: readonly LocationLabelPrintItem[]) {
|
||||||
|
if (!items.length) return false
|
||||||
|
const popup = window.open('', '_blank', 'noopener,noreferrer')
|
||||||
|
if (!popup) return false
|
||||||
|
popup.opener = null
|
||||||
|
popup.document.open()
|
||||||
|
popup.document.write(buildLocationLabelPrintHtml(items))
|
||||||
|
popup.document.close()
|
||||||
|
popup.focus()
|
||||||
|
popup.print()
|
||||||
|
return true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user