This commit is contained in:
2026-09-02 14:39:34 +08:00
parent 0708b4a262
commit 637b202559
20 changed files with 370 additions and 113 deletions
+1 -1
View File
@@ -66,7 +66,7 @@
| 关键词 | 页面/路由入口 | API 与联查 | | 关键词 | 页面/路由入口 | API 与联查 |
|---|---|---| |---|---|---|
| 尾程订单与柜级入仓计划进度 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 按 `sourceContainerBookingId` 消费 `/orders/container-summaries` 的 `allPlansReady`、计划统计与冻结 `plannedArrivalAt`,详情由服务端返回全柜订单且不受列表分页截断;不得在浏览器拼柜或自行推导就绪。整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子;转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档,并提交 ID/编码/名称快照。业务 API `modules/overseas-warehouse/api/lastMileOrders.ts`,Backend `lastleg-service/` | | 尾程订单与柜级入仓计划进度 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`,服务端在分页前排除 `allPlansReady=true`,按冻结 `plannedArrivalAt` 升序、空值最后返回待处理柜;卡片只展示服务端计划统计,详情弹框由服务端返回全柜订单且不受列表分页截断,不得在浏览器拼柜或自行推导就绪。整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子;转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档,并提交 ID/编码/名称快照。业务 API `modules/overseas-warehouse/api/lastMileOrders.ts`,Backend `lastleg-service/` |
| 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` | | 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 | | 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 |
| 海外出库、派送、异常与回访(范围外现存代码) | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue`、`ExceptionOrdersView.vue`、`CustomerVisitsView.vue` 及对应 `api/outbounds.ts`、`deliveryOrders.ts`、`exceptionOrders.ts`、`customerVisits.ts`;当前路由和导航已有注册 | 当前四期契约仍排除海外仓出库、派送及其派生运营页面;不得因代码、菜单、权限或 API 存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 | | 海外出库、派送、异常与回访(范围外现存代码) | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue`、`ExceptionOrdersView.vue`、`CustomerVisitsView.vue` 及对应 `api/outbounds.ts`、`deliveryOrders.ts`、`exceptionOrders.ts`、`customerVisits.ts`;当前路由和导航已有注册 | 当前四期契约仍排除海外仓出库、派送及其派生运营页面;不得因代码、菜单、权限或 API 存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 |
@@ -108,6 +108,14 @@ describe('InboundTaskList measurement scope', () => {
expect(source).not.toContain("name: 'inbound-order-detail', params: { inboundId: row.id }") expect(source).not.toContain("name: 'inbound-order-detail', params: { inboundId: row.id }")
}) })
it('keeps relationship data entry out of measurement and review task lists', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
expect(source).not.toContain('关联数据')
expect(source).not.toContain('InboundCodeRelationshipDialog')
expect(source).not.toContain('openRelationship')
})
it('closes only after navigating an approved review to the measurement tab', () => { it('closes only after navigating an approved review to the measurement tab', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8') const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
const handler = source.slice(source.indexOf('async function handleTaskChanged'), source.indexOf('function handleReviewSelectionChange')) const handler = source.slice(source.indexOf('async function handleTaskChanged'), source.indexOf('function handleReviewSelectionChange'))
@@ -157,6 +165,14 @@ describe('InboundTaskList measurement scope', () => {
expect(source).toContain("if (!value) return '—'") expect(source).toContain("if (!value) return '—'")
}) })
it('renders first-leg business types with the shared Chinese labels', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
expect(source).toContain("import { firstLegBusinessTypeLabel } from '@/shared/utils/firstLegBusinessLabels'")
expect(source).toContain('firstLegBusinessTypeLabel((row as InboundOrderSummary).businessType)')
expect(source).not.toContain('text((row as InboundOrderSummary).businessType)')
})
it('refreshes cached task lists when users return from measurement work', () => { it('refreshes cached task lists when users return from measurement work', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8') const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
@@ -2,7 +2,7 @@
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheckBig, Eye, Link2, Ruler, SearchCheck } from '@lucide/vue' import { CircleCheckBig, Eye, Ruler, SearchCheck } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses } from '@/modules/warehouses' import { listPlatformWarehouses } from '@/modules/warehouses'
@@ -12,8 +12,8 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { firstLegBusinessTypeLabel } from '@/shared/utils/firstLegBusinessLabels'
import { listPlatformInbounds } from '../core/api' import { listPlatformInbounds } from '../core/api'
import type { InboundOrderSummary } from '../core/types' import type { InboundOrderSummary } from '../core/types'
import InboundMeasurementDialog from '../measurement/InboundMeasurementDialog.vue' import InboundMeasurementDialog from '../measurement/InboundMeasurementDialog.vue'
@@ -48,8 +48,6 @@ const reviewDialogOpen = ref(false)
const reviewNavigationLoading = ref(false) const reviewNavigationLoading = ref(false)
const reviewApproved = ref(false) const reviewApproved = ref(false)
const reviewNavigationError = ref<ApiErrorDetails | null>(null) const reviewNavigationError = ref<ApiErrorDetails | null>(null)
const relationshipDialogOpen = ref(false)
const relationshipDialogInboundCode = ref('')
const batchReviewLoading = ref(false) const batchReviewLoading = ref(false)
const selectedReviewRows = ref<InboundOrderSummary[]>([]) const selectedReviewRows = ref<InboundOrderSummary[]>([])
let requestSerial = 0 let requestSerial = 0
@@ -200,13 +198,6 @@ function openDetail(row: InboundOrderSummary) {
void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } }) void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } })
} }
function openRelationship(row: InboundOrderSummary) {
const code = row.inboundCode?.trim()
if (!code) return
relationshipDialogInboundCode.value = code
relationshipDialogOpen.value = true
}
async function handleTaskChanged() { async function handleTaskChanged() {
const target = selectedInbound.value const target = selectedInbound.value
if (!isReview.value) { if (!isReview.value) {
@@ -386,14 +377,14 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span> <span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span>
</div> </div>
</template> </template>
<template #inboundCode="{ row }"><div class="inbound-code-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><el-button v-if="(row as InboundOrderSummary).inboundCode" link type="primary" @click="openRelationship(row as InboundOrderSummary)"><Link2 :size="14" />关联数据</el-button></div></template> <template #inboundCode="{ row }"><div class="inbound-code-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></div></template>
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template> <template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template> <template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template> <template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
<template #customerName="{ row }">{{ text((row as InboundOrderSummary).customerName) }}</template> <template #customerName="{ row }">{{ text((row as InboundOrderSummary).customerName) }}</template>
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template> <template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template> <template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
<template #businessType="{ row }">{{ text((row as InboundOrderSummary).businessType) }}</template> <template #businessType="{ row }">{{ firstLegBusinessTypeLabel((row as InboundOrderSummary).businessType) }}</template>
<template #channelName="{ row }">{{ text((row as InboundOrderSummary).channelName || (row as InboundOrderSummary).channelCode) }}</template> <template #channelName="{ row }">{{ text((row as InboundOrderSummary).channelName || (row as InboundOrderSummary).channelCode) }}</template>
<template #totalPackageCount="{ row }">{{ text((row as InboundOrderSummary).totalPackageCount) }}</template> <template #totalPackageCount="{ row }">{{ text((row as InboundOrderSummary).totalPackageCount) }}</template>
<template #palletCount="{ row }">{{ text((row as InboundOrderSummary).palletCount) }}</template> <template #palletCount="{ row }">{{ text((row as InboundOrderSummary).palletCount) }}</template>
@@ -447,7 +438,6 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
@submitted="handleMeasurementSubmitted" @submitted="handleMeasurementSubmitted"
@closed="clearSelectedInbound" @closed="clearSelectedInbound"
/> />
<InboundCodeRelationshipDialog v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</AdminPage> </AdminPage>
</template> </template>
@@ -460,7 +450,6 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
.primary-cell { display: grid; gap: 3px; min-width: 0; } .primary-cell { display: grid; gap: 3px; min-width: 0; }
.primary-cell strong, .primary-cell span { overflow-wrap: anywhere; } .primary-cell strong, .primary-cell span { overflow-wrap: anywhere; }
.inbound-code-cell { display: grid; gap: 3px; min-width: 0; } .inbound-code-cell { display: grid; gap: 3px; min-width: 0; }
.inbound-code-cell .el-button { width: fit-content; margin: 0; padding: 0; }
.primary-cell span, .muted-action { color: var(--cc-muted); font-size: 12px; } .primary-cell span, .muted-action { color: var(--cc-muted); font-size: 12px; }
@media (max-width: 520px) { @media (max-width: 520px) {
.row-actions { align-items: stretch; flex-direction: column; } .row-actions { align-items: stretch; flex-direction: column; }
@@ -118,6 +118,9 @@ describe('OceanBookingDetailView draft and release contract', () => {
it('keeps a DRAFT detail read-only when the user lacks write permission', () => { it('keeps a DRAFT detail read-only when the user lacks write permission', () => {
expect(source).toContain('const formWritable = computed(() => canSave.value)') expect(source).toContain('const formWritable = computed(() => canSave.value)')
expect(source).toContain(':disabled="!formWritable" class="booking-groups"') expect(source).toContain(':disabled="!formWritable" class="booking-groups"')
expect(source).toContain('.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:14px}')
expect(source).toContain('.booking-group:last-child{grid-column:1/-1}')
expect(source).toContain('.container-table-wrap{max-width:100%;overflow-x:auto')
expect(source).toContain('v-if="formWritable" class="container-actions"') expect(source).toContain('v-if="formWritable" class="container-actions"')
expect(source).toContain(':disabled="!formWritable"') expect(source).toContain(':disabled="!formWritable"')
}) })
@@ -494,5 +494,5 @@ onMounted(() => { void load(); void loadOptions() })
<style scoped> <style scoped>
.channel-code{display:block;margin-left:8px;color:var(--cc-muted);font-size:11px}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:5px;color:var(--cc-muted);font-size:12px;line-height:20px}.field-error{color:var(--el-color-danger)} .channel-code{display:block;margin-left:8px;color:var(--cc-muted);font-size:11px}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:5px;color:var(--cc-muted);font-size:12px;line-height:20px}.field-error{color:var(--el-color-danger)}
.booking-detail{min-width:0;max-width:1880px}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:16px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:16px}.actions{display:flex;flex-wrap:wrap;gap:8px}.booking-groups{display:grid;grid-template-columns:minmax(0,1.5fr) repeat(2,minmax(0,1fr));gap:14px}.booking-group{min-width:0;overflow:hidden;border:1px solid #e8edf3;border-radius:10px;background:#fff}.booking-group>h3{margin:0;padding:12px 14px;border-bottom:1px solid #eef2f6;color:#1f2937;font-size:14px}.booking-group__body{padding:12px 14px}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:10px;row-gap:8px}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:14px}.subsection{display:grid;gap:10px;padding:12px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.required-mark{margin-right:4px;color:var(--el-color-danger)}.container-table-wrap{overflow-x:auto;margin-bottom:10px}.container-table{width:100%;min-width:780px;table-layout:fixed;border-collapse:collapse;font-size:12px}.container-table th,.container-table td{padding:7px 6px;border-bottom:1px solid #eef2f6;text-align:left;vertical-align:middle}.container-table th{background:#fafafa;color:var(--cc-muted);font-weight:650;white-space:nowrap}.container-table th:first-child,.container-table td:first-child{width:56px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.container-table th:nth-child(5),.container-table td:nth-child(5),.container-table th:nth-child(6),.container-table td:nth-child(6){width:100px}.container-table td :deep(.el-select),.container-table td :deep(.el-input){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__action :deep(.el-button){width:28px;height:28px}.container-table__specification{color:var(--cc-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:10px;margin-bottom:14px;color:var(--cc-muted);font-size:12px}.add-container{margin:0}.history-panel strong{font-size:14px}@media(max-width:1599px){.booking-groups{grid-template-columns:repeat(2,minmax(0,1fr))}.booking-group:last-child{grid-column:1/-1}}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:12px}.actions :deep(.el-button){min-height:44px}} .booking-detail{min-width:0;max-width:1880px}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:16px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:16px}.actions{display:flex;flex-wrap:wrap;gap:8px}.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:14px}.booking-group{min-width:0;overflow:hidden;border:1px solid #e8edf3;border-radius:10px;background:#fff}.booking-group:last-child{grid-column:1/-1}.booking-group>h3{margin:0;padding:12px 14px;border-bottom:1px solid #eef2f6;color:#1f2937;font-size:14px}.booking-group__body{padding:12px 14px}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:10px;row-gap:8px}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:14px}.subsection{display:grid;gap:10px;padding:12px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.required-mark{margin-right:4px;color:var(--el-color-danger)}.container-table-wrap{max-width:100%;overflow-x:auto;margin-bottom:10px}.container-table{width:100%;min-width:780px;table-layout:fixed;border-collapse:collapse;font-size:12px}.container-table th,.container-table td{padding:7px 6px;border-bottom:1px solid #eef2f6;text-align:left;vertical-align:middle}.container-table th{background:#fafafa;color:var(--cc-muted);font-weight:650;white-space:nowrap}.container-table th:first-child,.container-table td:first-child{width:56px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.container-table th:nth-child(5),.container-table td:nth-child(5),.container-table th:nth-child(6),.container-table td:nth-child(6){width:100px}.container-table td :deep(.el-select),.container-table td :deep(.el-input){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__action :deep(.el-button){width:28px;height:28px}.container-table__specification{color:var(--cc-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:10px;margin-bottom:14px;color:var(--cc-muted);font-size:12px}.add-container{margin:0}.history-panel strong{font-size:14px}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:12px}.actions :deep(.el-button){min-height:44px}}
</style> </style>
@@ -49,9 +49,10 @@ defineEmits<{ back: [] }>()
.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); } .detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-group:nth-child(even) { border-right: 0; } .detail-group:nth-child(even) { border-right: 0; }
.detail-group h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; } .detail-group h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); } .detail-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
.detail-fields > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 126px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); } .detail-fields > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 126px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; } .detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; }
.detail-fields dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; } .detail-fields dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
.detail-fields dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; } .detail-fields dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
@media (max-width: 1100px) { .read-only-detail, .detail-fields { grid-template-columns: 1fr; } .detail-group { border-right: 0; } }
</style> </style>
@@ -0,0 +1,52 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const page = (name: string) => readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages', name), 'utf8')
const component = (name: string) => readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/components', name), 'utf8')
describe('overseas warehouse 1920px layout acceptance', () => {
it('keeps action columns first and wide enough for every visible action', () => {
const widths: Record<string, number> = {
'LastMileOrdersView.vue': 110,
'InboundOrdersView.vue': 190,
'DispatchesView.vue': 190,
'OutboundsView.vue': 100,
'DeliveryOrdersView.vue': 300,
'RelabelOrdersView.vue': 190,
'PalletsView.vue': 250,
'InventoryView.vue': 110,
}
for (const [name, width] of Object.entries(widths)) {
const source = page(name)
const columns = source.slice(source.indexOf('const columns'), source.indexOf('async function', source.indexOf('const columns')))
expect(columns).toContain(`{ key: 'actions', label: '操作', width: ${width}, fixed: 'left'`)
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("label:", columns.indexOf("key: 'actions'") + 20))
}
})
it('assigns explicit desktop widths to dense query inputs', () => {
for (const name of ['LastMileOrdersView.vue', 'InboundOrdersView.vue', 'DispatchesView.vue', 'OutboundsView.vue', 'DeliveryOrdersView.vue', 'RelabelOrdersView.vue', 'PalletsView.vue', 'InventoryView.vue']) {
const source = page(name)
const fieldLines = source.split(/\r?\n/).filter((line) => /type: '(input|select|daterange)'/.test(line))
expect(fieldLines.length, name).toBeGreaterThan(0)
expect(fieldLines.every((line) => line.includes('width:')), `${name} has a query field without width`).toBe(true)
}
})
it('uses four-field desktop density and full-width form controls', () => {
const sharedDetail = component('OverseasReadOnlyDetail.vue')
expect(sharedDetail).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))')
expect(page('LastMileOrderDetailView.vue')).toContain('.detail-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr))')
expect(page('InboundOrderDetailView.vue')).toContain('.detail-group:last-child .detail-fields{grid-template-columns:repeat(4,minmax(0,1fr))}')
expect(page('DispatchDetailView.vue')).toContain('.document-section { grid-column: 1 / -1; }')
expect(page('DispatchDetailView.vue')).toContain('.document-section .detail-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }')
expect(page('DeliveryOrderDetailView.vue')).toContain(':column="4"')
expect(page('InventoryDetailView.vue')).toContain('grid-template-columns:repeat(2,minmax(0,1fr))')
expect(page('OutboundsView.vue')).toContain('.create-outbound-form :deep(.el-input-number) { width: 100%; }')
expect(page('RelabelOrdersView.vue')).toContain('.el-form :deep(.el-date-editor) { width: 100%; }')
expect(page('PalletsView.vue')).toContain('.el-form :deep(.el-input-number) { width: 100%; }')
expect(page('RelabelOrdersView.vue')).toContain('.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }')
expect(page('PalletsView.vue')).toContain('.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }')
})
})
@@ -10,16 +10,152 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation' import { formatOverseasDateTime } from '../lastMilePresentation'
import { getDeliveryOrder, type DeliveryOrderDetail } from '../api/deliveryOrders' import { getDeliveryOrder, type DeliveryOrderDetail } from '../api/deliveryOrders'
const route = useRoute(); const router = useRouter(); const detail = ref<DeliveryOrderDetail | null>(null); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const activeTab = ref('order') const route = useRoute()
const deliveryNo = computed(() => String(route.params.deliveryNo || '')); const customerId = computed(() => String(route.query.customerId || '')) const router = useRouter()
const statusLabels: Record<string, string> = { WAIT_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成', SIGNED: '已签收', REJECTED: '已拒签', RETURN_PENDING: '退货处理中', CANCELLED: '已取消' } const detail = ref<DeliveryOrderDetail | null>(null)
const methodLabels: Record<string, string> = { SELF_DELIVERY: '自送', EXPRESS: '快递', THIRD_PARTY: '第三方配送' } const loading = ref(false)
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用' } const error = ref<ApiErrorDetails | null>(null)
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收' } const activeTab = ref('order')
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' } const deliveryNo = computed(() => String(route.params.deliveryNo || ''))
function back() { void router.push({ name: 'overseas-delivery-orders' }) } const customerId = computed(() => String(route.query.customerId || ''))
async function load() { if (!deliveryNo.value || !customerId.value) { error.value = { message: '缺少派送单客户范围,无法安全查询详情' }; return }; loading.value = true; error.value = null; try { detail.value = await getDeliveryOrder(deliveryNo.value, customerId.value) } catch (cause) { error.value = getApiErrorDetails(cause, '派送单详情加载失败') } finally { loading.value = false } } const statusLabels: Record<string, string> = {
WAIT_DELIVERY: '待派送',
DELIVERING: '派送中',
DELIVERED: '派送完成',
SIGNED: '已签收',
REJECTED: '已拒签',
RETURN_PENDING: '退货处理中',
CANCELLED: '已取消',
}
const methodLabels: Record<string, string> = {
SELF_DELIVERY: '自送',
EXPRESS: '快递',
THIRD_PARTY: '第三方配送',
}
const feeLabels: Record<string, string> = {
DELIVERY_FEE: '派送费用',
LOADING_FEE: '装车费',
APPOINTMENT_FEE: '买约费用',
}
const operationLabels: Record<string, string> = {
CREATE_DELIVERY: '创建派送单',
ASSIGN_VEHICLE: '车队分配',
DISPATCH: '开始派送',
SIGN: '签收',
}
function show(value: unknown) {
const text = String(value ?? '').trim()
return text || '—'
}
function back() {
void router.push({ name: 'overseas-delivery-orders' })
}
async function load() {
if (!deliveryNo.value || !customerId.value) {
error.value = { message: '缺少派送单客户范围,无法安全查询详情' }
return
}
loading.value = true
error.value = null
try {
detail.value = await getDeliveryOrder(deliveryNo.value, customerId.value)
} catch (cause) {
error.value = getApiErrorDetails(cause, '派送单详情加载失败')
} finally {
loading.value = false
}
}
load() load()
</script> </script>
<template><AdminPage><DetailPageLayout aria-label="派送单详情"><template #header><div class="detail-heading"><el-button @click="back"><ArrowLeft :size="16" />返回</el-button><div><h1>派送单详情 <StatusBadge v-if="detail" :status="statusLabels[detail.order.status] || detail.order.status" /></h1><p>派送单号 {{ deliveryNo }}</p></div></div></template><LoadErrorState v-if="error" title="派送单详情加载失败" :message="error.message" :loading="loading" @retry="load" /><section v-else v-loading="loading" class="surface-panel delivery-detail"><el-tabs v-model="activeTab"><el-tab-pane label="订单信息" name="order"><h3>订单信息</h3><el-descriptions v-if="detail" :column="2" border><el-descriptions-item label="派送单号">{{ show(detail.order.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[detail.order.status] || detail.order.status }}</el-descriptions-item><el-descriptions-item label="仓库">{{ show(detail.order.overseasWarehouseName || detail.order.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.order.waybillNo) }}</el-descriptions-item><el-descriptions-item label="派送人">{{ show(detail.order.carrierName) }}</el-descriptions-item><el-descriptions-item label="派送时间">{{ formatOverseasDateTime(detail.order.dispatchedAt || detail.order.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="提货方式">{{ show(detail.order.pickupMethod) }}</el-descriptions-item><el-descriptions-item label="装车类型">{{ show(detail.order.loadingType) }}</el-descriptions-item><el-descriptions-item label="派送方式">{{ methodLabels[detail.order.deliveryMethod || ''] || show(detail.order.deliveryMethod) }}</el-descriptions-item><el-descriptions-item label="司机/车队">{{ show(detail.order.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.order.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="总箱数">{{ detail.order.packageCount }}</el-descriptions-item><el-descriptions-item label="总重量(kg)">{{ show(detail.order.totalWeightKg) }}</el-descriptions-item><el-descriptions-item label="总体积(m³)">{{ show(detail.order.totalVolumeCbm) }}</el-descriptions-item><el-descriptions-item label="预计到达时间">{{ formatOverseasDateTime(detail.order.expectedArrivalAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.order.remark) }}</el-descriptions-item></el-descriptions></el-tab-pane><el-tab-pane label="SKU明细" name="sku"><h3>SKU明细</h3><el-table :data="detail?.skus || []" border stripe><el-table-column prop="inboundCode" label="入库码" min-width="150" /><el-table-column prop="customerName" label="客户" min-width="160" /><el-table-column prop="destinationWarehouseCode" label="目的仓编码" min-width="130" /><el-table-column prop="receivingAddress" label="收货地址" min-width="220" /><el-table-column prop="shipmentNo" label="货件编号" min-width="150" /><el-table-column prop="fbaTrackingNo" label="FBA追踪号" min-width="150" /><el-table-column prop="skuCode" label="SKU" min-width="130" /><el-table-column prop="packageCount" label="箱数" width="90" /><el-table-column prop="weightKg" label="重量" width="100" /><el-table-column prop="volumeCbm" label="体积" width="100" /></el-table></el-tab-pane><el-tab-pane label="费用明细" name="fees"><h3>派送单费用明细</h3><el-table :data="detail?.fees || []" border stripe><el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : '应收' }}</template></el-table-column><el-table-column label="费用类型" min-width="180"><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column><el-table-column prop="currency" label="币种" width="100" /><el-table-column prop="amount" label="金额" width="140" /><el-table-column prop="remark" label="备注" min-width="220" /></el-table></el-tab-pane><el-tab-pane label="操作记录" name="operations"><h3>操作记录</h3><el-table :data="detail?.operations || []" border stripe><el-table-column label="操作时间" min-width="170"><template #default="scope">{{ formatOverseasDateTime(scope.row.operatedAt) }}</template></el-table-column><el-table-column label="操作节点" min-width="160"><template #default="scope">{{ operationLabels[scope.row.operationNode] || scope.row.operationNode }}</template></el-table-column><el-table-column prop="operatorName" label="操作人" min-width="140" /><el-table-column prop="result" label="处理结果" min-width="220" /></el-table></el-tab-pane></el-tabs></section></DetailPageLayout></AdminPage></template> <template>
<style scoped>.detail-heading{display:flex;align-items:center;gap:14px}.detail-heading h1{display:flex;align-items:center;gap:10px;margin:0}.detail-heading p{margin:4px 0 0;color:var(--cc-muted)}.delivery-detail{padding:0 16px 20px}.delivery-detail h3{margin:12px 0}</style> <AdminPage
><DetailPageLayout aria-label="派送单详情"
><template #header
><div class="detail-heading">
<el-button @click="back"><ArrowLeft :size="16" />返回</el-button>
<div>
<h1>
派送单详情
<StatusBadge v-if="detail" :status="statusLabels[detail.order.status] || detail.order.status" />
</h1>
<p>派送单号 {{ deliveryNo }}</p>
</div>
</div></template
><LoadErrorState v-if="error" title="派送单详情加载失败" :message="error.message" :loading="loading" @retry="load" />
<section v-else v-loading="loading" class="surface-panel delivery-detail">
<el-tabs v-model="activeTab"
><el-tab-pane label="订单信息" name="order"
><h3>订单信息</h3>
<el-descriptions v-if="detail" :column="4" border
><el-descriptions-item label="派送单号">{{ show(detail.order.deliveryNo) }}</el-descriptions-item
><el-descriptions-item label="状态">{{ statusLabels[detail.order.status] || detail.order.status }}</el-descriptions-item
><el-descriptions-item label="仓库">{{ show(detail.order.overseasWarehouseName || detail.order.overseasWarehouseId) }}</el-descriptions-item
><el-descriptions-item label="运单号">{{ show(detail.order.waybillNo) }}</el-descriptions-item
><el-descriptions-item label="派送人">{{ show(detail.order.carrierName) }}</el-descriptions-item
><el-descriptions-item label="派送时间">{{ formatOverseasDateTime(detail.order.dispatchedAt || detail.order.plannedDeliveryAt) }}</el-descriptions-item
><el-descriptions-item label="提货方式">{{ show(detail.order.pickupMethod) }}</el-descriptions-item
><el-descriptions-item label="装车类型">{{ show(detail.order.loadingType) }}</el-descriptions-item
><el-descriptions-item label="派送方式">{{ methodLabels[detail.order.deliveryMethod || ''] || show(detail.order.deliveryMethod) }}</el-descriptions-item
><el-descriptions-item label="司机/车队">{{ show(detail.order.driverName) }}</el-descriptions-item
><el-descriptions-item label="车牌">{{ show(detail.order.vehiclePlate) }}</el-descriptions-item
><el-descriptions-item label="总箱数">{{ detail.order.packageCount }}</el-descriptions-item
><el-descriptions-item label="总重量(kg)">{{ show(detail.order.totalWeightKg) }}</el-descriptions-item
><el-descriptions-item label="总体积(m³)">{{ show(detail.order.totalVolumeCbm) }}</el-descriptions-item
><el-descriptions-item label="预计到达时间">{{ formatOverseasDateTime(detail.order.expectedArrivalAt) }}</el-descriptions-item
><el-descriptions-item label="备注">{{ show(detail.order.remark) }}</el-descriptions-item></el-descriptions
></el-tab-pane
><el-tab-pane label="SKU明细" name="sku"
><h3>SKU明细</h3>
<el-table :data="detail?.skus || []" border stripe
><el-table-column prop="inboundCode" label="入库码" min-width="150" /><el-table-column prop="customerName" label="客户" min-width="160" /><el-table-column
prop="destinationWarehouseCode"
label="目的仓编码"
min-width="130" /><el-table-column prop="receivingAddress" label="收货地址" min-width="220" /><el-table-column prop="shipmentNo" label="货件编号" min-width="150" /><el-table-column
prop="fbaTrackingNo"
label="FBA追踪号"
min-width="150" /><el-table-column prop="skuCode" label="SKU" min-width="130" /><el-table-column prop="packageCount" label="箱数" width="90" /><el-table-column prop="weightKg" label="重量" width="100" /><el-table-column
prop="volumeCbm"
label="体积"
width="100" /></el-table></el-tab-pane
><el-tab-pane label="费用明细" name="fees"
><h3>派送单费用明细</h3>
<el-table :data="detail?.fees || []" border stripe
><el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"
><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : '应收' }}</template></el-table-column
><el-table-column label="费用类型" min-width="180"
><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column
><el-table-column prop="currency" label="币种" width="100" /><el-table-column prop="amount" label="金额" width="140" /><el-table-column prop="remark" label="备注" min-width="220" /></el-table></el-tab-pane
><el-tab-pane label="操作记录" name="operations"
><h3>操作记录</h3>
<el-table :data="detail?.operations || []" border stripe
><el-table-column label="操作时间" min-width="170"
><template #default="scope">{{ formatOverseasDateTime(scope.row.operatedAt) }}</template></el-table-column
><el-table-column label="操作节点" min-width="160"
><template #default="scope">{{ operationLabels[scope.row.operationNode] || scope.row.operationNode }}</template></el-table-column
><el-table-column prop="operatorName" label="操作人" min-width="140" /><el-table-column prop="result" label="处理结果" min-width="220" /></el-table></el-tab-pane
></el-tabs></section></DetailPageLayout
></AdminPage>
</template>
<style scoped>
.detail-heading {
display: flex;
align-items: center;
gap: 14px;
}
.detail-heading h1 {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
}
.detail-heading p {
margin: 4px 0 0;
color: var(--cc-muted);
}
.delivery-detail {
padding: 0 16px 20px;
}
.delivery-detail h3 {
margin: 12px 0;
}
</style>
@@ -26,15 +26,15 @@ const rows = ref<DeliveryOrderRow[]>([]); const page = ref(1); const pageSize =
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]); const warehousesLoading = ref(false) const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]); const warehousesLoading = ref(false)
const filters = ref<QueryModel>({ documentNo: '', dispatchNo: '', waybillNo: '', warehouseId: '', status: '', vehiclePlate: '', driverName: '', deliveryMethod: '', plannedAt: [] }) const filters = ref<QueryModel>({ documentNo: '', dispatchNo: '', waybillNo: '', warehouseId: '', status: '', vehiclePlate: '', driverName: '', deliveryMethod: '', plannedAt: [] })
const fields = computed<QueryField[]>(() => [ const fields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待派送', value: 'WAIT_DELIVERY' }, { label: '派送中', value: 'DELIVERING' }, { label: '派送完成', value: 'DELIVERED' }, { label: '已签收(历史)', value: 'SIGNED' }, { label: '已拒签', value: 'REJECTED' }, { label: '退货处理中', value: 'RETURN_PENDING' }, { label: '已取消', value: 'CANCELLED' }] }, { key: 'status', type: 'select', label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '待派送', value: 'WAIT_DELIVERY' }, { label: '派送中', value: 'DELIVERING' }, { label: '派送完成', value: 'DELIVERED' }, { label: '已签收(历史)', value: 'SIGNED' }, { label: '已拒签', value: 'REJECTED' }, { label: '退货处理中', value: 'RETURN_PENDING' }, { label: '已取消', value: 'CANCELLED' }] },
{ key: 'documentNo', type: 'input', label: '派送单号', placeholder: '请输入派送单号' }, { key: 'documentNo', type: 'input', label: '派送单号', width: 190, placeholder: '请输入派送单号' },
{ key: 'dispatchNo', type: 'input', label: '排车单号', placeholder: '请输入排车单号' }, { key: 'dispatchNo', type: 'input', label: '排车单号', width: 190, placeholder: '请输入排车单号' },
{ key: 'waybillNo', type: 'input', label: '运单号', placeholder: '请输入运单号' }, { key: 'waybillNo', type: 'input', label: '运单号', width: 180, placeholder: '请输入运单号' },
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, loading: warehousesLoading.value } }, { key: 'warehouseId', type: 'select', label: '海外仓', width: 220, placeholder: '全部海外仓', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, loading: warehousesLoading.value } },
{ key: 'deliveryMethod', type: 'select', label: '派送方式', placeholder: '全部方式', options: [{ label: 'FBA自提', value: 'FBA_PICKUP' }, { label: '客户自提', value: 'CUSTOMER_PICKUP' }, { label: '卡派打托', value: 'TRUCK_PALLET' }, { label: '卡派地板', value: 'TRUCK_FLOOR' }, { label: '快递', value: 'EXPRESS' }, { label: '外配', value: 'OUTSOURCED' }, { label: '专车直送', value: 'DEDICATED_TRUCK' }, { label: '零担派送', value: 'LTL' }] }, { key: 'deliveryMethod', type: 'select', label: '派送方式', width: 170, placeholder: '全部方式', options: [{ label: 'FBA自提', value: 'FBA_PICKUP' }, { label: '客户自提', value: 'CUSTOMER_PICKUP' }, { label: '卡派打托', value: 'TRUCK_PALLET' }, { label: '卡派地板', value: 'TRUCK_FLOOR' }, { label: '快递', value: 'EXPRESS' }, { label: '外配', value: 'OUTSOURCED' }, { label: '专车直送', value: 'DEDICATED_TRUCK' }, { label: '零担派送', value: 'LTL' }] },
{ key: 'vehiclePlate', type: 'input', label: '车牌号', placeholder: '请输入车牌号' }, { key: 'vehiclePlate', type: 'input', label: '车牌号', width: 160, placeholder: '请输入车牌号' },
{ key: 'driverName', type: 'input', label: '司机', placeholder: '请输入司机或车队' }, { key: 'driverName', type: 'input', label: '司机', width: 180, placeholder: '请输入司机或车队' },
{ key: 'plannedAt', type: 'daterange', label: '预计派送时间' }, { key: 'plannedAt', type: 'daterange', label: '预计派送时间', width: 280 },
]) ])
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
@@ -78,4 +78,4 @@ onMounted(() => { void load(); void loadWarehouses() })
</script> </script>
<template><AdminPage><ListPageLayout aria-label="派送单列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="派送单筛选" preference-key="overseas-delivery-orders-query" @search="search" @reset="reset" /></template><template v-if="error" #state><LoadErrorState title="派送单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" 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-delivery-orders" row-key="id" empty-text="暂无派送单" @update:page="load()" @update:page-size="page = 1; load()"><template #actions="{ row }"><el-button size="small" type="primary" plain @click="open(row as DeliveryOrderRow)"><Eye :size="15" />详情</el-button><el-button v-if="access.can(permission.deliveryStatus) && row.status !== 'RETURN_PENDING'" size="small" plain @click="openStatus(row as DeliveryOrderRow)"><Pencil :size="15" />编辑状态</el-button><el-button v-if="access.can(permission.deliveryReturn) && !['RETURN_PENDING','CANCELLED'].includes(String(row.status))" size="small" type="warning" plain @click="openReturn(row as DeliveryOrderRow)"><RotateCcw :size="15" />标记退货</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></section></ListPageLayout></AdminPage><AppDialog v-model="statusOpen" title="编辑派送状态" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving" @confirm="submitStatus"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="statusRow?.deliveryNo" readonly /></el-form-item><el-form-item label="派送状态" required><el-select v-model="statusForm.status"><el-option label="待派送" value="WAIT_DELIVERY" /><el-option label="派送中" value="DELIVERING" /><el-option label="派送完成" value="DELIVERED" /><el-option label="已拒签" value="REJECTED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="备注"><el-input v-model="statusForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item><el-alert v-if="statusForm.status === 'DELIVERED'" title="保存为派送完成后,将自动生成一条待回访记录。" type="info" :closable="false" /></el-form></AppDialog><AppDialog v-model="returnOpen" title="标记退货" width="min(720px, calc(100vw - 28px))" confirm-text="确认转入退货" :loading="returnLoading" :confirm-loading="returnSaving" @confirm="submitReturn"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="returnRow?.deliveryNo" readonly /></el-form-item><el-form-item label="对应入仓单" required><el-select v-model="returnForm.inboundOrderId" placeholder="请选择该派送单对应的入仓单"><el-option v-for="option in returnOptions" :key="String(option.inboundOrderId)" :value="String(option.inboundOrderId)" :label="`${option.inboundCode} · ${option.lastMileOrderNo || '无尾程单号'}`" /></el-select></el-form-item><el-form-item label="退货原因" required><el-input v-model="returnForm.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item><div class="return-grid"><el-form-item label="退货去向" required><el-select v-model="returnForm.returnDirection"><el-option label="退回海外仓" value="OVERSEAS_WAREHOUSE" /><el-option label="退回客户" value="CUSTOMER" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="退回物流单号"><el-input v-model="returnForm.returnLogisticsNo" placeholder="可安排物流后补充" /></el-form-item><el-form-item label="箱数"><el-input-number v-model="returnForm.packageCount" :min="1" /></el-form-item><el-form-item label="SKU(可自由填写)"><el-input v-model="returnForm.skuCode" /></el-form-item></div><el-alert title="确认后会生成异常退货单,并将派送单标记为退货处理中。" type="warning" :closable="false" /></el-form></AppDialog></template> <template><AdminPage><ListPageLayout aria-label="派送单列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="派送单筛选" preference-key="overseas-delivery-orders-query" @search="search" @reset="reset" /></template><template v-if="error" #state><LoadErrorState title="派送单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" 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-delivery-orders" row-key="id" empty-text="暂无派送单" @update:page="load()" @update:page-size="page = 1; load()"><template #actions="{ row }"><el-button size="small" type="primary" plain @click="open(row as DeliveryOrderRow)"><Eye :size="15" />详情</el-button><el-button v-if="access.can(permission.deliveryStatus) && row.status !== 'RETURN_PENDING'" size="small" plain @click="openStatus(row as DeliveryOrderRow)"><Pencil :size="15" />编辑状态</el-button><el-button v-if="access.can(permission.deliveryReturn) && !['RETURN_PENDING','CANCELLED'].includes(String(row.status))" size="small" type="warning" plain @click="openReturn(row as DeliveryOrderRow)"><RotateCcw :size="15" />标记退货</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></section></ListPageLayout></AdminPage><AppDialog v-model="statusOpen" title="编辑派送状态" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving" @confirm="submitStatus"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="statusRow?.deliveryNo" readonly /></el-form-item><el-form-item label="派送状态" required><el-select v-model="statusForm.status"><el-option label="待派送" value="WAIT_DELIVERY" /><el-option label="派送中" value="DELIVERING" /><el-option label="派送完成" value="DELIVERED" /><el-option label="已拒签" value="REJECTED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="备注"><el-input v-model="statusForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item><el-alert v-if="statusForm.status === 'DELIVERED'" title="保存为派送完成后,将自动生成一条待回访记录。" type="info" :closable="false" /></el-form></AppDialog><AppDialog v-model="returnOpen" title="标记退货" width="min(720px, calc(100vw - 28px))" confirm-text="确认转入退货" :loading="returnLoading" :confirm-loading="returnSaving" @confirm="submitReturn"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="returnRow?.deliveryNo" readonly /></el-form-item><el-form-item label="对应入仓单" required><el-select v-model="returnForm.inboundOrderId" placeholder="请选择该派送单对应的入仓单"><el-option v-for="option in returnOptions" :key="String(option.inboundOrderId)" :value="String(option.inboundOrderId)" :label="`${option.inboundCode} · ${option.lastMileOrderNo || '无尾程单号'}`" /></el-select></el-form-item><el-form-item label="退货原因" required><el-input v-model="returnForm.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item><div class="return-grid"><el-form-item label="退货去向" required><el-select v-model="returnForm.returnDirection"><el-option label="退回海外仓" value="OVERSEAS_WAREHOUSE" /><el-option label="退回客户" value="CUSTOMER" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="退回物流单号"><el-input v-model="returnForm.returnLogisticsNo" placeholder="可安排物流后补充" /></el-form-item><el-form-item label="箱数"><el-input-number v-model="returnForm.packageCount" :min="1" /></el-form-item><el-form-item label="SKU(可自由填写)"><el-input v-model="returnForm.skuCode" /></el-form-item></div><el-alert title="确认后会生成异常退货单,并将派送单标记为退货处理中。" type="warning" :closable="false" /></el-form></AppDialog></template>
<style scoped>.return-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; } @media (max-width: 720px) { .return-grid { grid-template-columns: 1fr; } }</style> <style scoped>.return-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }.return-grid :deep(.el-input),.return-grid :deep(.el-select),.return-grid :deep(.el-input-number){width:100%} @media (max-width: 720px) { .return-grid { grid-template-columns: 1fr; } }</style>
@@ -149,9 +149,10 @@ onMounted(load)
.detail-section { min-width: 0; padding: 18px; } .detail-section { min-width: 0; padding: 18px; }
.summary-grid > .detail-section { border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); } .summary-grid > .detail-section { border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.summary-grid > .detail-section:nth-child(2) { border-right: 0; } .summary-grid > .detail-section:nth-child(2) { border-right: 0; }
.document-section { grid-column: 1 / 2; } .document-section { grid-column: 1 / -1; }
.detail-section h2 { margin: 0 0 14px; color: var(--el-color-primary); font-size: 16px; } .detail-section h2 { margin: 0 0 14px; color: var(--el-color-primary); font-size: 16px; }
.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); } .detail-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); }
.document-section .detail-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.detail-fields > div { display: grid; min-height: 48px; grid-template-columns: 180px minmax(0, 1fr); border-bottom: 1px solid var(--cc-border); } .detail-fields > div { display: grid; min-height: 48px; grid-template-columns: 180px minmax(0, 1fr); border-bottom: 1px solid var(--cc-border); }
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 9px 12px; } .detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 9px 12px; }
.detail-fields dt { color: var(--cc-muted); } .detail-fields dt { color: var(--cc-muted); }
@@ -162,6 +163,7 @@ onMounted(load)
.summary-grid { grid-template-columns: 1fr; } .summary-grid { grid-template-columns: 1fr; }
.summary-grid > .detail-section, .summary-grid > .detail-section:nth-child(2) { border-right: 0; } .summary-grid > .detail-section, .summary-grid > .detail-section:nth-child(2) { border-right: 0; }
.document-section { grid-column: auto; } .document-section { grid-column: auto; }
.detail-fields, .document-section .detail-fields { grid-template-columns: 1fr; }
.detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); }
} }
</style> </style>
@@ -59,14 +59,14 @@ const statusOptions = [
] ]
const fields = computed<QueryField[]>(() => [ const fields = computed<QueryField[]>(() => [
{ key: 'dispatchNo', type: 'input', label: '排车单号', placeholder: '请输入排车单号' }, { key: 'dispatchNo', type: 'input', label: '排车单号', width: 190, placeholder: '请输入排车单号' },
{ key: 'vehiclePlate', type: 'input', label: '车牌号', placeholder: '请输入车牌号' }, { key: 'vehiclePlate', type: 'input', label: '车牌号', width: 160, placeholder: '请输入车牌号' },
{ key: 'status', type: 'select', label: '车辆状态', placeholder: '全部状态', options: statusOptions }, { key: 'status', type: 'select', label: '车辆状态', width: 160, placeholder: '全部状态', options: statusOptions },
{ key: 'fbaAppointmentNo', type: 'input', label: 'FBA预约号', placeholder: '请输入FBA预约号' }, { key: 'fbaAppointmentNo', type: 'input', label: 'FBA预约号', width: 190, placeholder: '请输入FBA预约号' },
]) ])
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' }, { key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' },
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 }, { key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 },
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 }, { key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
@@ -56,6 +56,7 @@ onMounted(load)
</OverseasDetailShell></template> </OverseasDetailShell></template>
<style scoped> <style scoped>
.detail-section { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }.detail-groups { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); }.detail-group:last-child { border-right: 0; }.detail-group h2,.fee-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }.detail-fields>div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 120px minmax(0,1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }.detail-fields dt,.detail-fields dd { display:flex; min-width:0; align-items:center; padding:8px 10px; }.detail-fields dt { background:#f8fafc; color:var(--cc-muted); font-size:12px; }.detail-fields dd { margin:0; color:var(--cc-text); overflow-wrap:anywhere; }.fee-section { overflow:hidden; padding-top:16px; }.fee-section h2 { margin-right:18px; margin-left:18px; }.fee-total { display:flex; min-height:48px; align-items:center; justify-content:flex-end; gap:40px; padding:10px 18px; border-top:1px solid var(--cc-border); }.fee-total strong { min-width:120px; font-size:16px; } .detail-section { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }.detail-groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }.detail-group:nth-child(2) { border-right: 0; }.detail-group:last-child { grid-column: 1 / -1; border-right: 0; border-bottom: 0; }.detail-group h2,.fee-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }.detail-fields { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }.detail-group:last-child .detail-fields{grid-template-columns:repeat(4,minmax(0,1fr))}.detail-fields>div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 110px minmax(0,1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }.detail-fields dt,.detail-fields dd { display:flex; min-width:0; align-items:center; padding:8px 10px; }.detail-fields dt { background:#f8fafc; color:var(--cc-muted); font-size:12px; }.detail-fields dd { margin:0; color:var(--cc-text); overflow-wrap:anywhere; }.fee-section { overflow:hidden; padding-top:16px; }.fee-section h2 { margin-right:18px; margin-left:18px; }.fee-total { display:flex; min-height:48px; align-items:center; justify-content:flex-end; gap:40px; padding:10px 18px; border-top:1px solid var(--cc-border); }.fee-total strong { min-width:120px; font-size:16px; }
.pallet-section { overflow: hidden; padding-top: 16px; }.pallet-section h2 { margin: 0 18px 4px; color: var(--cc-text); font-size: 15px; }.pallet-note { margin: 0 18px 12px; color: var(--cc-muted); font-size: 12px; } .pallet-section { overflow: hidden; padding-top: 16px; }.pallet-section h2 { margin: 0 18px 4px; color: var(--cc-text); font-size: 15px; }.pallet-note { margin: 0 18px 12px; color: var(--cc-muted); font-size: 12px; }
@media(max-width:1200px){.detail-groups,.detail-fields,.detail-group:last-child .detail-fields{grid-template-columns:1fr}.detail-group:last-child{grid-column:auto}.detail-group{border-right:0}}
</style> </style>
@@ -25,4 +25,4 @@ function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(
<template v-else><section v-loading="loading" class="overview-panel" aria-label="库存概览"><section aria-labelledby="inventory-product-heading"><h2 id="inventory-product-heading">商品信息</h2><dl class="detail-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>{{ productValues[label as keyof typeof productValues] }}</dd></div></dl></section><section aria-labelledby="inventory-summary-heading"><h2 id="inventory-summary-heading">库存状态汇总</h2><dl class="detail-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>{{ stockValues[label as keyof typeof stockValues] }}</dd></div></dl></section></section> <template v-else><section v-loading="loading" class="overview-panel" aria-label="库存概览"><section aria-labelledby="inventory-product-heading"><h2 id="inventory-product-heading">商品信息</h2><dl class="detail-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>{{ productValues[label as keyof typeof productValues] }}</dd></div></dl></section><section aria-labelledby="inventory-summary-heading"><h2 id="inventory-summary-heading">库存状态汇总</h2><dl class="detail-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>{{ stockValues[label as keyof typeof stockValues] }}</dd></div></dl></section></section>
<section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细</h2><p class="ledger-note">按该库存记录的入库单范围展示,出库字段仅为只读事实。</p><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></section></template> <section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细</h2><p class="ledger-note">按该库存记录的入库单范围展示,出库字段仅为只读事实。</p><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></section></template>
</OverseasDetailShell></template> </OverseasDetailShell></template>
<style scoped>.overview-panel{display:grid;min-width:0;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--cc-border);background:#fff}.overview-panel>section{min-width:0;padding:16px 18px 18px;border-right:1px solid var(--cc-border)}.overview-panel>section:last-child{border-right:0}.overview-panel h2,.ledger-section h2{margin:0 0 12px;color:var(--cc-text);font-size:15px}.detail-fields{margin:0;border-top:1px solid var(--cc-border);border-left:1px solid var(--cc-border)}.detail-fields>div{display:grid;min-width:0;min-height:46px;grid-template-columns:120px minmax(0,1fr);border-right:1px solid var(--cc-border);border-bottom:1px solid var(--cc-border)}.detail-fields dt,.detail-fields dd{display:flex;min-width:0;align-items:center;padding:8px 10px}.detail-fields dt{background:#f8fafc;color:var(--cc-muted);font-size:12px}.detail-fields dd{margin:0;color:var(--cc-text)}.ledger-section{min-width:0;overflow:hidden;padding-top:16px;border:1px solid var(--cc-border);background:#fff}.ledger-section h2,.ledger-note{margin-right:18px;margin-left:18px}.ledger-note{color:var(--cc-muted);font-size:12px}</style> <style scoped>.overview-panel{display:grid;min-width:0;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--cc-border);background:#fff}.overview-panel>section{min-width:0;padding:16px 18px 18px;border-right:1px solid var(--cc-border)}.overview-panel>section:last-child{border-right:0}.overview-panel h2,.ledger-section h2{margin:0 0 12px;color:var(--cc-text);font-size:15px}.detail-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--cc-border);border-left:1px solid var(--cc-border)}.detail-fields>div{display:grid;min-width:0;min-height:46px;grid-template-columns:110px minmax(0,1fr);border-right:1px solid var(--cc-border);border-bottom:1px solid var(--cc-border)}.detail-fields dt,.detail-fields dd{display:flex;min-width:0;align-items:center;padding:8px 10px}.detail-fields dt{background:#f8fafc;color:var(--cc-muted);font-size:12px}.detail-fields dd{margin:0;color:var(--cc-text);overflow-wrap:anywhere}.ledger-section{min-width:0;overflow:hidden;padding-top:16px;border:1px solid var(--cc-border);background:#fff}.ledger-section h2,.ledger-note{margin-right:18px;margin-left:18px}.ledger-note{color:var(--cc-muted);font-size:12px}@media(max-width:1100px){.overview-panel,.detail-fields{grid-template-columns:1fr}.overview-panel>section{border-right:0;border-bottom:1px solid var(--cc-border)}}</style>
@@ -29,13 +29,13 @@ const pageSize = ref(10)
const total = ref(0) const total = ref(0)
const filters = ref<QueryModel>({ warehouseId: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' }) const filters = ref<QueryModel>({ warehouseId: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' })
const fields = computed<QueryField[]>(() => [ const fields = computed<QueryField[]>(() => [
...(access.can(overseasWarehousePermissions.warehouseList) ? [{ key: 'warehouseId', type: 'select' as const, label: '海外仓', width: 230, placeholder: '全部海外仓', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, disabled: contextLoading.value } }] : []), ...(access.can(overseasWarehousePermissions.warehouseList) ? [{ key: 'warehouseId', type: 'select' as const, label: '海外仓', width: 200, placeholder: '全部海外仓', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, disabled: contextLoading.value } }] : []),
{ key: 'owner', type: 'input', label: '客户/货主', placeholder: '请输入客户或货主' }, { key: 'owner', type: 'input', label: '客户/货主', width: 190, placeholder: '请输入客户或货主' },
{ key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码' }, { key: 'sku', type: 'input', label: 'SKU编码', width: 180, placeholder: '请输入SKU编码' },
{ key: 'productName', type: 'input', label: '中文品名', placeholder: '请输入中文品名' }, { key: 'productName', type: 'input', label: '中文品名', width: 190, placeholder: '请输入中文品名' },
{ key: 'batchNo', type: 'input', label: '批次号', placeholder: '请输入批次号' }, { key: 'batchNo', type: 'input', label: '批次号', width: 170, placeholder: '请输入批次号' },
{ key: 'location', type: 'input', label: '库位', placeholder: '请输入库位(可空)' }, { key: 'location', type: 'input', label: '库位', width: 200, placeholder: '请输入库位(可空)' },
{ key: 'status', type: 'select', label: '库存状态', placeholder: '全部库存状态', options: [{ label: '有可用库存', value: 'AVAILABLE' }, { label: '存在暂扣', value: 'HOLD' }, { label: '已分配', value: 'ALLOCATED' }, { label: '在途', value: 'IN_TRANSIT' }, { label: '收货中', value: 'RECEIVING' }, { label: '采购库存', value: 'PROCUREMENT' }] }, { key: 'status', type: 'select', label: '库存状态', width: 170, placeholder: '全部库存状态', options: [{ label: '有可用库存', value: 'AVAILABLE' }, { label: '存在暂扣', value: 'HOLD' }, { label: '已分配', value: 'ALLOCATED' }, { label: '在途', value: 'IN_TRANSIT' }, { label: '收货中', value: 'RECEIVING' }, { label: '采购库存', value: 'PROCUREMENT' }] },
]) ])
const metrics = computed(() => [ const metrics = computed(() => [
['在库总量', summary.value?.totalPackages ?? 0, 'primary'], ['可用库存', summary.value?.availablePackages ?? 0, 'success'], ['暂扣库存', summary.value?.holdPackages ?? 0, 'danger'], ['在库总量', summary.value?.totalPackages ?? 0, 'primary'], ['可用库存', summary.value?.availablePackages ?? 0, 'success'], ['暂扣库存', summary.value?.holdPackages ?? 0, 'danger'],
@@ -175,42 +175,48 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(wrapper.text()).toContain(`trace-${code}`) expect(wrapper.text()).toContain(`trace-${code}`)
}) })
it('loads the cabinet tab from server progress, shows the total and preserves server arrival ordering', async () => { it('renders pending cabinets as an arrival-ordered workbench with the remaining inbound-code count', async () => {
const nullArrival = { ...summary, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NULL', plannedArrivalAt: null, planCount: 0, pendingPlanCount: 15 } const nullArrival = { ...summary, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NULL', plannedArrivalAt: null, planCount: 0, pendingPlanCount: 15 }
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [summary, nullArrival], page: 1, size: 10, total: 2, pages: 1 }) const completed = { ...summary, sourceContainerBookingId: '2092873463568597003', containerNo: 'CONT-DONE', plannedInboundCodeCount: 15, pendingPlanCount: 0, allPlansReady: true }
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [completed, summary, nullArrival], page: 1, size: 10, total: 3, pages: 1 })
const wrapper = await mountView() const wrapper = await mountView()
const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子')) const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))
await containerTab!.trigger('click') await containerTab!.trigger('click')
await flushPromises() await flushPromises()
expect(listContainerPlanProgress).toHaveBeenLastCalledWith({ page: 1, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined }) expect(listContainerPlanProgress).toHaveBeenLastCalledWith({ page: 1, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined })
expect(wrapper.text()).toContain('柜子(2)') expect(wrapper.text()).toContain('柜子工作台(2)')
expect(wrapper.text()).toContain('计划到仓时间') expect(wrapper.text()).toContain('待处理柜工作台')
expect(wrapper.text()).toContain('已有 15 / 缺 0') expect(wrapper.text()).toContain('按计划到仓时间由近到远排列')
expect(wrapper.text()).toContain('已生成10个计划,剩余5个未操作') expect(wrapper.text()).toContain('5个入仓码待生成计划')
expect(wrapper.text()).toContain('未就绪') expect(wrapper.text()).toContain('共 15 个入仓码')
expect(wrapper.text()).toContain('存在5票尚未生成有效入库计划') expect(wrapper.text()).not.toContain('待补入仓码')
expect(wrapper.text()).not.toContain('缺入仓码')
expect(wrapper.text()).not.toContain('已生成 10 个计划')
expect(wrapper.text()).not.toContain('存在5票尚未生成有效入库计划')
expect(wrapper.text()).not.toContain('CONT-DONE')
expect(wrapper.text()).toContain('2 个柜待处理')
expect(wrapper.text().indexOf('CONT-15')).toBeLessThan(wrapper.text().indexOf('CONT-NULL')) expect(wrapper.text().indexOf('CONT-15')).toBeLessThan(wrapper.text().indexOf('CONT-NULL'))
}) })
it('uses independent cabinet pagination and loads a compact long-id detail drawer', async () => { it('uses independent cabinet pagination and opens the complete long-id detail in a dialog', async () => {
vi.mocked(listContainerPlanProgress).mockResolvedValue({ records: [summary], page: 1, size: 10, total: 21, pages: 3 }) vi.mocked(listContainerPlanProgress).mockResolvedValue({ records: [summary], page: 1, size: 10, total: 21, pages: 3 })
const wrapper = await mountView() const wrapper = await mountView()
const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子')) const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))
await containerTab!.trigger('click') await containerTab!.trigger('click')
await flushPromises() await flushPromises()
const pageTwo = wrapper.find('button[aria-label="第 2 页"]') const pagination = wrapper.findComponent({ name: 'ElPagination' })
await pageTwo.trigger('click') pagination.vm.$emit('current-change', 2)
await flushPromises() await flushPromises()
expect(listContainerPlanProgress).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 10 })) expect(listContainerPlanProgress).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 10 }))
const viewButtons = wrapper.findAll('button').filter((item) => item.text() === '查看') await wrapper.get('.container-workbench-card').trigger('click')
const viewButton = viewButtons[viewButtons.length - 1]
await viewButton!.trigger('click')
await flushPromises() await flushPromises()
expect(getContainerPlanProgress).toHaveBeenCalledWith(bookingId) expect(getContainerPlanProgress).toHaveBeenCalledWith(bookingId)
expect(document.body.textContent).toContain('柜级入库计划详情') expect(document.body.textContent).toContain('柜子详情 · CONT-15')
expect(document.body.textContent).toContain('LMO-1') expect(document.body.textContent).toContain('LMO-1')
expect(document.body.textContent).toContain('IN-1') expect(document.body.textContent).toContain('IN-1')
expect(document.body.textContent).toContain('LIP-1') expect(document.body.textContent).toContain('LIP-1')
expect(document.body.querySelector('.el-dialog')).not.toBeNull()
expect(document.body.querySelector('.el-drawer')).toBeNull()
}) })
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => { it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
@@ -7,7 +7,6 @@ import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -52,18 +51,18 @@ let containerOptionRequest = 0
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部') const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] }) const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const allFields: QueryField[] = [ const allFields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' }, { key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' }, { key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '全部类型', options: [ { key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '全部类型', options: [
{ label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' }, { label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' }, { label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' },
{ label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' }, { label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
] }, ] },
{ key: 'warehouse', type: 'input', label: '海外仓', placeholder: '请输入仓库名称或编码' }, { key: 'warehouse', type: 'input', label: '海外仓', width: 210, placeholder: '请输入仓库名称或编码' },
{ key: 'sourceOrderNo', type: 'input', label: '来源订单号', placeholder: '请输入来源订单号' }, { key: 'sourceOrderNo', type: 'input', label: '来源订单号', width: 190, placeholder: '请输入来源订单号' },
{ key: 'containerNo', type: 'input', label: '集装箱号', placeholder: '请输入集装箱号' }, { key: 'containerNo', type: 'input', label: '集装箱号', width: 180, placeholder: '请输入集装箱号' },
{ key: 'transferWarehouseCode', type: 'input', label: '转仓代码', placeholder: '请输入转仓代码' }, { key: 'transferWarehouseCode', type: 'input', label: '转仓代码', width: 170, placeholder: '请输入转仓代码' },
{ key: 'status', type: 'select', label: '当前状态', placeholder: '全部状态', options: [ { key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '全部状态', options: [
{ label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '待分配', value: 'PENDING_ASSIGNMENT' },
{ label: '待入库', value: 'PENDING_INBOUND' }, { label: '待入库', value: 'PENDING_INBOUND' },
{ label: '入库完成', value: 'INBOUND_COMPLETED' }, { label: '入库完成', value: 'INBOUND_COMPLETED' },
@@ -71,9 +70,9 @@ const allFields: QueryField[] = [
{ label: '派送中', value: 'DELIVERING' }, { label: '派送中', value: 'DELIVERING' },
{ label: '派送完成', value: 'DELIVERED' }, { label: '派送完成', value: 'DELIVERED' },
] }, ] },
{ key: 'abnormal', type: 'select', label: '是否异常', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] }, { key: 'abnormal', type: 'select', label: '是否异常', width: 140, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'detained', type: 'select', label: '是否暂扣', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] }, { key: 'detained', type: 'select', label: '是否暂扣', width: 140, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'createdAt', type: 'daterange', label: '创建时间' }, { key: 'createdAt', type: 'daterange', label: '创建时间', width: 280 },
] ]
const fields = computed<QueryField[]>(() => statusTab.value === '柜子' const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key)) ? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
@@ -144,15 +143,6 @@ const columns: DataTableColumn[] = [
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 }, { key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 }, { key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
] ]
const containerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'containerNo', prop: 'containerNo', label: '柜号', minWidth: 170 },
{ key: 'plannedArrivalAt', prop: 'plannedArrivalAt', label: '计划到仓时间', minWidth: 170, slotName: 'plannedArrivalAt' },
{ key: 'totalOrderCount', prop: 'totalOrderCount', label: '订单总数', width: 100, align: 'right' },
{ key: 'inboundCodes', label: '入仓码', minWidth: 170, slotName: 'inboundCodes' },
{ key: 'planProgress', label: '入库计划进度', minWidth: 270, slotName: 'planProgress' },
{ key: 'readiness', label: '整柜就绪状态', minWidth: 240, slotName: 'readiness' },
]
const containerDetailColumns: DataTableColumn[] = [ const containerDetailColumns: DataTableColumn[] = [
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 }, { key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
@@ -166,7 +156,19 @@ function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 19) : '—' return value ? value.replace('T', ' ').slice(0, 19) : '—'
} }
function canGenerateContainerPlans(container: ContainerPlanProgressSummaryDto) { function canGenerateContainerPlans(container: ContainerPlanProgressSummaryDto) {
return Boolean(container.containerNo) && container.missingInboundCodeCount === 0 && container.pendingPlanCount > 0 return Boolean(container.containerNo) && container.pendingPlanCount > 0
}
function pendingInboundCodeCount(container: ContainerPlanProgressSummaryDto) {
return Math.max(0, container.inboundCodeCount - container.plannedInboundCodeCount)
}
function changeContainerPage(value: number) {
page.value = value
void load()
}
function changeContainerPageSize(value: number) {
pageSize.value = value
page.value = 1
void load()
} }
async function load(model: QueryModel = filters.value) { async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model) Object.assign(filters.value, model)
@@ -182,8 +184,9 @@ async function load(model: QueryModel = filters.value) {
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined, createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined, createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
}) })
containerRows.value = result.records const pendingRecords = result.records.filter((container) => pendingInboundCodeCount(container) > 0)
containerTotal.value = result.total containerRows.value = pendingRecords
containerTotal.value = Math.max(0, result.total - (result.records.length - pendingRecords.length))
} catch (error) { } catch (error) {
containerRows.value = [] containerRows.value = []
containerTotal.value = 0 containerTotal.value = 0
@@ -380,7 +383,7 @@ onMounted(() => { void load() })
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab"> <el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
<el-radio-button value="全部">全部</el-radio-button> <el-radio-button value="全部">全部</el-radio-button>
<el-radio-button value="待分配">待分配</el-radio-button> <el-radio-button value="待分配">待分配</el-radio-button>
<el-radio-button value="柜子">柜子({{ containerTotal }})</el-radio-button> <el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button>
</el-radio-group> </el-radio-group>
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="search" @reset="reset" /> <QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="search" @reset="reset" />
</section> </section>
@@ -393,15 +396,28 @@ onMounted(() => { void load() })
<LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact> <LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
<template #details><ApiErrorMeta :details="actionError" /></template> <template #details><ApiErrorMeta :details="actionError" /></template>
</LoadErrorState> </LoadErrorState>
<section v-if="!loadError && statusTab === '柜子'" class="surface-panel table-panel"> <section v-if="!loadError && statusTab === '柜子'" class="surface-panel container-workbench" :aria-busy="loading">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="containerRows" :columns="containerColumns" :loading="loading" :total="containerTotal" preference-key="overseas-last-mile-container-progress" row-key="sourceContainerBookingId" empty-text="暂无柜级入库计划进度" @update:page="load()" @update:page-size="page = 1; load()"> <header class="container-workbench__header">
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openContainerDetail(row as ContainerPlanProgressSummaryDto)">查看</el-button></template> <div><strong>待处理柜工作台</strong><span>按计划到仓时间由近到远排列,已全部生成计划的柜不再展示</span></div>
<template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressSummaryDto).plannedArrivalAt) }}</template> <el-tag type="warning" effect="plain">{{ containerTotal }} 个柜待处理</el-tag>
<template #inboundCodes="{ row }">已有 {{ (row as ContainerPlanProgressSummaryDto).inboundCodeCount }} / 缺 {{ (row as ContainerPlanProgressSummaryDto).missingInboundCodeCount }}</template> </header>
<template #planProgress="{ row }">已生成{{ (row as ContainerPlanProgressSummaryDto).planCount }}个计划,剩余{{ (row as ContainerPlanProgressSummaryDto).pendingPlanCount }}个未操作</template> <div v-if="containerRows.length" class="container-workbench__grid">
<template #readiness="{ row }"><el-tag :type="(row as ContainerPlanProgressSummaryDto).allPlansReady ? 'success' : 'warning'" effect="plain">{{ (row as ContainerPlanProgressSummaryDto).allPlansReady ? '整柜已就绪' : '未就绪' }}</el-tag><span v-if="!(row as ContainerPlanProgressSummaryDto).allPlansReady" class="readiness-reason">{{ (row as ContainerPlanProgressSummaryDto).blockingReason || '尚未满足全部独立计划条件,不可视为 PDA 可作业' }}</span></template> <button v-for="container in containerRows" :key="container.sourceContainerBookingId" type="button" class="container-workbench-card" @click="openContainerDetail(container)">
<template #pagination-left>共 {{ containerTotal }} 个柜</template> <span class="container-workbench-card__top"><strong>{{ container.containerNo || '柜号待补充' }}</strong><span>查看详情</span></span>
</DataTable> <span class="container-workbench-card__arrival"><small>计划到仓</small><strong>{{ formatDateTime(container.plannedArrivalAt) }}</strong></span>
<span class="container-workbench-card__pending">
<strong>{{ pendingInboundCodeCount(container) }}</strong><span>个入仓码待生成计划</span>
</span>
<span class="container-workbench-card__facts">
<span>共 {{ container.inboundCodeCount }} 个入仓码</span>
</span>
</button>
</div>
<el-empty v-else-if="!loading" description="暂无待处理柜,所有柜均已完成计划分配" />
<div v-if="containerTotal" class="container-workbench__pagination">
<span>共 {{ containerTotal }} 个待处理柜</span>
<el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 20, 50]" :total="containerTotal" layout="sizes, prev, pager, next" background @current-change="changeContainerPage" @size-change="changeContainerPageSize" />
</div>
</section> </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()"> <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.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="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template> <template #toolbar-actions><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="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
@@ -438,7 +454,7 @@ onMounted(() => { void load() })
<el-alert v-if="selectedContainer" :title="canGenerateContainerPlans(selectedContainer) ? `确认后将为该柜剩余 ${selectedContainer.pendingPlanCount} 票分别生成独立入库计划` : selectedContainer.blockingReason || '所选柜不可生成'" :type="canGenerateContainerPlans(selectedContainer) ? 'info' : 'error'" :closable="false" show-icon /> <el-alert v-if="selectedContainer" :title="canGenerateContainerPlans(selectedContainer) ? `确认后将为该柜剩余 ${selectedContainer.pendingPlanCount} 票分别生成独立入库计划` : selectedContainer.blockingReason || '所选柜不可生成'" :type="canGenerateContainerPlans(selectedContainer) ? 'info' : 'error'" :closable="false" show-icon />
</el-form> </el-form>
</AppDialog> </AppDialog>
<AppDrawer v-model="containerDetailOpen" title="柜级入库计划详情" size="min(1040px, 96vw)" :loading="containerDetailLoading" :show-footer="false"> <AppDialog v-model="containerDetailOpen" :title="`柜子详情 · ${containerDetail?.summary.containerNo || '加载中'}`" width="min(1120px, calc(100vw - 40px))" :loading="containerDetailLoading" :show-footer="false">
<LoadErrorState v-if="containerDetailError" title="柜级详情加载失败" :message="containerDetailError.message" compact> <LoadErrorState v-if="containerDetailError" title="柜级详情加载失败" :message="containerDetailError.message" compact>
<template #details><ApiErrorMeta :details="containerDetailError" /></template> <template #details><ApiErrorMeta :details="containerDetailError" /></template>
</LoadErrorState> </LoadErrorState>
@@ -447,20 +463,41 @@ onMounted(() => { void load() })
<span>柜号:{{ containerDetail.summary.containerNo || '—' }}</span> <span>柜号:{{ containerDetail.summary.containerNo || '—' }}</span>
<span>计划到仓:{{ formatDateTime(containerDetail.summary.plannedArrivalAt) }}</span> <span>计划到仓:{{ formatDateTime(containerDetail.summary.plannedArrivalAt) }}</span>
<span>订单:{{ containerDetail.summary.totalOrderCount }}</span> <span>订单:{{ containerDetail.summary.totalOrderCount }}</span>
<span>入仓码:{{ containerDetail.summary.inboundCodeCount }},缺 {{ containerDetail.summary.missingInboundCodeCount }}</span> <span>入仓码:{{ containerDetail.summary.inboundCodeCount }}</span>
<span>计划:{{ containerDetail.summary.planCount }},未操作 {{ containerDetail.summary.pendingPlanCount }}</span> <span>入库计划:{{ containerDetail.summary.planCount }},待生成 {{ pendingInboundCodeCount(containerDetail.summary) }}</span>
</div> </div>
<DataTable :data="containerDetail.orders" :columns="containerDetailColumns" :loading="containerDetailLoading" preference-key="overseas-last-mile-container-detail" row-key="orderId" :total="undefined" empty-text="该柜暂无尾程订单"> <DataTable :data="containerDetail.orders" :columns="containerDetailColumns" :loading="containerDetailLoading" preference-key="overseas-last-mile-container-detail" row-key="orderId" :total="undefined" empty-text="该柜暂无尾程订单">
<template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressDetailDto['orders'][number]).plannedArrivalAt) }}</template> <template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressDetailDto['orders'][number]).plannedArrivalAt) }}</template>
</DataTable> </DataTable>
</template> </template>
</AppDrawer> </AppDialog>
</template> </template>
<style scoped> <style scoped>
.last-mile-toolbar { padding: 14px 16px 0; } .last-mile-toolbar { padding: 14px 16px 0; }
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; } .last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; } .container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
.readiness-reason { margin-left: 8px; color: var(--cc-muted); font-size: 12px; }
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; } .container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
.container-workbench { padding: 18px; }
.container-workbench__header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.container-workbench__header > div { display: grid; gap: 4px; }
.container-workbench__header strong { color: var(--cc-text); font-size: 16px; }
.container-workbench__header span { color: var(--cc-muted); font-size: 13px; }
.container-workbench__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.container-workbench-card { display: grid; min-width: 0; gap: 14px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: inherit; font: inherit; text-align: left; cursor: pointer; transition: border-color .16s ease, box-shadow .16s ease; }
.container-workbench-card:hover { border-color: var(--cc-primary); box-shadow: 0 6px 18px rgb(15 23 42 / 8%); }
.container-workbench-card:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
.container-workbench-card__top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.container-workbench-card__top strong { min-width: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 16px; }
.container-workbench-card__top > span { flex: 0 0 auto; color: var(--cc-primary); font-size: 13px; }
.container-workbench-card__arrival { display: grid; gap: 4px; }
.container-workbench-card__arrival small { color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__arrival strong { color: var(--cc-text); font-size: 15px; }
.container-workbench-card__pending { display: flex; align-items: baseline; gap: 8px; padding: 12px 14px; border-left: 3px solid #f59e0b; background: #fffaf0; }
.container-workbench-card__pending strong { color: #b45309; font-size: 28px; line-height: 1; }
.container-workbench-card__pending span { color: #92400e; font-size: 13px; }
.container-workbench-card__facts { display: flex; flex-wrap: wrap; gap: 6px 12px; color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__facts .is-warning { color: #c2410c; }
.container-workbench__pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 13px; }
@media (max-width: 1380px) { .container-workbench__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style> </style>
@@ -16,7 +16,7 @@ import { listInventoryBalances, listInventoryPositions, type InventoryBalanceDto
const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null) const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ documentNo: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const inventoryLoading = ref(false) const filters = ref<QueryModel>({ documentNo: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const inventoryLoading = ref(false)
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }] const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号' }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓' }, { key: 'status', type: 'select', label: '状态', options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }] const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', width: 190, placeholder: '请输入出库单号' }, { key: 'warehouseId', type: 'input', label: '目的仓', width: 180, placeholder: '请输入目的仓' }, { key: 'status', type: 'select', label: '状态', width: 150, options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }]
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } } async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
async function openCreate() { createOpen.value = true; selectedPositionId.value = ''; quantity.value = undefined; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '可用库存加载失败').message) } finally { inventoryLoading.value = false } } async function openCreate() { createOpen.value = true; selectedPositionId.value = ''; quantity.value = undefined; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '可用库存加载失败').message) } finally { inventoryLoading.value = false } }
@@ -36,5 +36,9 @@ onMounted(load)
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template> <template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template>
</DataTable></section> </DataTable></section>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
<AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" filterable placeholder="请选择有可用库存的托盘"><el-option v-for="item in positionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || '托盘号待补充'} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog> <AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form class="create-outbound-form" label-position="top"><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" filterable placeholder="请选择有可用库存的托盘"><el-option v-for="item in positionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || '托盘号待补充'} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog>
</template> </template>
<style scoped>
.create-outbound-form :deep(.el-input), .create-outbound-form :deep(.el-select), .create-outbound-form :deep(.el-input-number) { width: 100%; }
</style>
@@ -145,3 +145,8 @@ onMounted(loadWarehouses)
</AppDialog> </AppDialog>
</AdminPage> </AdminPage>
</template> </template>
<style scoped>
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number) { width: 100%; }
</style>
@@ -208,4 +208,6 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
<style scoped> <style scoped>
h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 14px; } h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 14px; }
h3:not(:first-child) { margin-top: 4px; padding-top: 14px; border-top: 1px solid var(--cc-border); } h3:not(:first-child) { margin-top: 4px; padding-top: 14px; border-top: 1px solid var(--cc-border); }
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number), .el-form :deep(.el-date-editor) { width: 100%; }
</style> </style>
@@ -215,13 +215,12 @@ describe('InboundCodeRelationshipDialog contract', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('removes the retired Drawer component and all Admin consumer references', () => { it('removes the retired Drawer component and keeps the Dialog only in confirmed Admin consumers', () => {
const root = process.cwd() const root = process.cwd()
expect(existsSync(resolve(root, 'src/shared/components/InboundCodeRelationshipDrawer.vue'))).toBe(false) expect(existsSync(resolve(root, 'src/shared/components/InboundCodeRelationshipDrawer.vue'))).toBe(false)
expect(existsSync(resolve(root, 'src/shared/components/InboundCodeRelationshipDrawer.test.ts'))).toBe(false) expect(existsSync(resolve(root, 'src/shared/components/InboundCodeRelationshipDrawer.test.ts'))).toBe(false)
const dialogConsumerFiles = [ const dialogConsumerFiles = [
'src/modules/inbounds/pages/InboundOrdersView.vue', 'src/modules/inbounds/pages/InboundOrdersView.vue',
'src/modules/inbounds/components/InboundTaskList.vue',
'src/modules/overseas-warehouse/pages/InventoryTransactionsView.vue', 'src/modules/overseas-warehouse/pages/InventoryTransactionsView.vue',
] ]
const consumerSources = dialogConsumerFiles.map((file) => readFileSync(resolve(root, file), 'utf8')) const consumerSources = dialogConsumerFiles.map((file) => readFileSync(resolve(root, file), 'utf8'))
@@ -233,6 +232,10 @@ describe('InboundCodeRelationshipDialog contract', () => {
expect(consumer).toContain('<InboundCodeRelationshipDialog') expect(consumer).toContain('<InboundCodeRelationshipDialog')
}) })
const inboundTasks = readFileSync(resolve(root, 'src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
expect(inboundTasks).not.toContain('InboundCodeRelationshipDrawer')
expect(inboundTasks).not.toContain('InboundCodeRelationshipDialog')
const unifiedOrders = readFileSync(resolve(root, 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'), 'utf8') const unifiedOrders = readFileSync(resolve(root, 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'), 'utf8')
expect(unifiedOrders).not.toContain('InboundCodeRelationshipDrawer') expect(unifiedOrders).not.toContain('InboundCodeRelationshipDrawer')
expect(unifiedOrders).not.toContain('InboundCodeRelationshipDialog') expect(unifiedOrders).not.toContain('InboundCodeRelationshipDialog')