From 12dc5dfd82ac88980623a0b0dce24eafa12a325d Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 21 Sep 2026 23:19:17 +0800
Subject: [PATCH 01/36] feat: add mojia sku sync admin page
---
src/core/navigation/adminNavigation.ts | 1 +
.../overseas-warehouse/api/mojiaSkuSync.ts | 67 +++++++
.../pages/MojiaSkuSyncView.vue | 167 ++++++++++++++++++
src/modules/overseas-warehouse/permissions.ts | 3 +
src/modules/overseas-warehouse/routes.ts | 6 +
5 files changed, 244 insertions(+)
create mode 100644 src/modules/overseas-warehouse/api/mojiaSkuSync.ts
create mode 100644 src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts
index 34c738c8..63f677a5 100644
--- a/src/core/navigation/adminNavigation.ts
+++ b/src/core/navigation/adminNavigation.ts
@@ -170,6 +170,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
+ { key: 'mojia-sku-sync', title: '一件代发 SKU 同步', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list' },
],
},
{
diff --git a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
new file mode 100644
index 00000000..81aa0ef2
--- /dev/null
+++ b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
@@ -0,0 +1,67 @@
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+
+export type MojiaSkuSyncStatus = 'PENDING' | 'SYNCING' | 'SUCCESS' | 'FAILED'
+
+export interface MojiaSkuSyncRecord {
+ id: string
+ skuCode: string
+ lengthCm: number
+ widthCm: number
+ heightCm: number
+ weightKg: number
+ imageFileId?: string | null
+ imageUrl?: string | null
+ measuredAt?: string | null
+ receivedAt?: string | null
+ syncStatus: MojiaSkuSyncStatus
+ syncFailureCode?: string | null
+ syncFailureReason?: string | null
+ lingxingSkuId?: string | null
+ revision: number
+ updatedAt?: string | null
+}
+
+export interface MojiaSkuSyncPage {
+ records: MojiaSkuSyncRecord[]
+ page: number
+ size: number
+ total: number
+ pages: number
+}
+
+export interface MojiaSkuSyncQuery {
+ page: number
+ size: number
+ skuCode?: string
+ syncStatus?: MojiaSkuSyncStatus
+}
+
+const basePath = '/platform/v1/firstleg/mojia-sku-sync'
+
+export async function listMojiaSkuSync(query: MojiaSkuSyncQuery) {
+ const response = await http.get, ApiEnvelope>(
+ basePath, { params: query },
+ )
+ return unwrapApiResponse(response)
+}
+
+export async function updateMojiaSkuSync(id: string, data: {
+ skuCode: string
+ lengthCm: number
+ widthCm: number
+ heightCm: number
+ weightKg: number
+ revision: number
+}) {
+ const response = await http.put, ApiEnvelope>(
+ `${basePath}/${encodeURIComponent(id)}`, data,
+ )
+ return unwrapApiResponse(response)
+}
+
+export async function retryMojiaSkuSync(id: string, revision: number) {
+ const response = await http.post, ApiEnvelope>(
+ `${basePath}/${encodeURIComponent(id)}/retry`, { revision },
+ )
+ return unwrapApiResponse(response)
+}
diff --git a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
new file mode 100644
index 00000000..44140a25
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+
+
+ 刷新
+
+
+ 编辑
+ 重新推送
+
+
+ —
+ {{ row.lengthCm }} × {{ row.widthCm }} × {{ row.heightCm }}
+ {{ syncLabel(row) }}
+ {{ row.lingxingSkuId || '未匹配' }}
+ {{ formatTime(row.measuredAt) }}
+ {{ row.syncFailureReason || '—' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/permissions.ts b/src/modules/overseas-warehouse/permissions.ts
index 4879b9e0..e73a8a7c 100644
--- a/src/modules/overseas-warehouse/permissions.ts
+++ b/src/modules/overseas-warehouse/permissions.ts
@@ -49,4 +49,7 @@ export const overseasWarehousePermissions = {
productList: 'platform:overseas-warehouse:product:list',
productCreate: 'platform:overseas-warehouse:product:create',
productEdit: 'platform:overseas-warehouse:product:edit',
+ mojiaSkuSyncList: 'platform:mojia-sku-sync:list',
+ mojiaSkuSyncEdit: 'platform:mojia-sku-sync:edit',
+ mojiaSkuSyncRetry: 'platform:mojia-sku-sync:retry',
} as const
diff --git a/src/modules/overseas-warehouse/routes.ts b/src/modules/overseas-warehouse/routes.ts
index 4394c76d..7155dc5a 100644
--- a/src/modules/overseas-warehouse/routes.ts
+++ b/src/modules/overseas-warehouse/routes.ts
@@ -130,4 +130,10 @@ export const overseasWarehouseRoutes = [
component: () => import('./pages/PalletDetailView.vue'),
meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false },
},
+ {
+ path: 'general/mojia-sku-sync',
+ name: 'mojia-sku-sync',
+ component: () => import('./pages/MojiaSkuSyncView.vue'),
+ meta: { title: '一件代发 SKU 同步', description: '查看墨家测量数据并同步领星 SKU 尺寸重量', permission: permission.mojiaSkuSyncList },
+ },
] satisfies RouteRecordRaw[]
--
2.54.0
From 3cb8b5b70effaca2939268334867176b480e3fc3 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 21 Sep 2026 23:32:12 +0800
Subject: [PATCH 02/36] fix: keep mojia sku sync statuses to three states
---
src/modules/overseas-warehouse/api/mojiaSkuSync.ts | 2 +-
src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
index 81aa0ef2..e9e68004 100644
--- a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
+++ b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
@@ -1,6 +1,6 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
-export type MojiaSkuSyncStatus = 'PENDING' | 'SYNCING' | 'SUCCESS' | 'FAILED'
+export type MojiaSkuSyncStatus = 'PENDING' | 'SUCCESS' | 'FAILED'
export interface MojiaSkuSyncRecord {
id: string
diff --git a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
index 44140a25..025d1417 100644
--- a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
+++ b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
@@ -47,10 +47,10 @@ const columns: DataTableColumn[] = [
]
const statusLabel: Record = {
- PENDING: '待同步', SYNCING: '同步中', SUCCESS: '同步成功', FAILED: '同步失败',
+ PENDING: '待同步', SUCCESS: '同步成功', FAILED: '同步失败',
}
const statusType: Record = {
- PENDING: 'warning', SYNCING: 'info', SUCCESS: 'success', FAILED: 'danger',
+ PENDING: 'warning', SUCCESS: 'success', FAILED: 'danger',
}
async function load() {
--
2.54.0
From 2e2f3ed2d2ac139cb5b777a291b057295a7e2ca0 Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Tue, 22 Sep 2026 08:47:43 +0800
Subject: [PATCH 03/36] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=8F=90=E4=BA=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../overseas-warehouse/api/lastMileOrders.ts | 1 +
.../WarehouseTransferReviewDialog.test.ts | 96 +++++++++++++++++++
.../WarehouseTransferReviewDialog.vue | 11 ++-
.../lastMilePresentation.test.ts | 13 +++
.../lastMilePresentation.ts | 8 +-
.../pages/LastMileOrderDetailView.test.ts | 26 +++++
.../pages/LastMileOrderDetailView.vue | 12 ++-
.../pages/LastMileOrdersView.vue | 7 +-
src/modules/overseas-warehouse/types.ts | 1 +
9 files changed, 166 insertions(+), 9 deletions(-)
create mode 100644 src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.test.ts
diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts
index c13e50c0..76c1161e 100644
--- a/src/modules/overseas-warehouse/api/lastMileOrders.ts
+++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts
@@ -87,6 +87,7 @@ export interface LastMileOrderSummaryDto {
deliveryOrderNo?: string | null
outboundOrderNo?: string | null
relabelOrderNo?: string | null
+ relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; createdAt: string }>
relabelStatus?: string | null
palletMergeFlag?: string | null
/** Optional grouping facts returned by newer last-leg services. */
diff --git a/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.test.ts b/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.test.ts
new file mode 100644
index 00000000..4a6c6991
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.test.ts
@@ -0,0 +1,96 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent, h } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import WarehouseTransferReviewDialog from './WarehouseTransferReviewDialog.vue'
+import { getWarehouseTransfer, reviewWarehouseTransfer } from '../api/warehouseTransfers'
+
+vi.mock('../api/warehouseTransfers', async importOriginal => ({
+ ...await importOriginal(),
+ getWarehouseTransfer: vi.fn(),
+ reviewWarehouseTransfer: vi.fn(),
+}))
+
+const AppDialogStub = defineComponent({
+ name: 'AppDialog',
+ props: { modelValue: Boolean },
+ emits: ['confirm', 'update:modelValue'],
+ setup(props, { slots }) {
+ return () => props.modelValue ? h('section', { class: 'dialog' }, slots.default?.()) : null
+ },
+})
+
+const DataTableStub = defineComponent({
+ name: 'DataTable',
+ props: { data: { type: Array, default: () => [] } },
+ setup(props) {
+ return () => h('div', { class: 'data-table' }, JSON.stringify(props.data))
+ },
+})
+
+function detail(transferNo: string) {
+ return {
+ id: `id-${transferNo}`,
+ transferNo,
+ packageCount: 2,
+ needRelabel: false,
+ status: 'PENDING_REVIEW',
+ items: [{ id: 'item-1', skuCode: 'SKU-1', productNameZh: '测试货物', packageCount: 2 }],
+ cartons: [],
+ pallets: [],
+ relabelBindings: [],
+ }
+}
+
+function mountDialog(props: { modelValue: boolean; customerId: string; customerName: string; transferNo: string }) {
+ return mount(WarehouseTransferReviewDialog, {
+ props,
+ global: {
+ stubs: {
+ AppDialog: AppDialogStub,
+ DataTable: DataTableStub,
+ LoadErrorState: true,
+ ApiErrorMeta: true,
+ ElForm: { template: '' },
+ ElFormItem: { template: '' },
+ ElRadioGroup: { template: '
' },
+ ElRadioButton: { template: '' },
+ ElInput: { template: '' },
+ },
+ },
+ })
+}
+
+describe('WarehouseTransferReviewDialog', () => {
+ beforeEach(() => {
+ vi.mocked(getWarehouseTransfer).mockReset()
+ vi.mocked(reviewWarehouseTransfer).mockReset()
+ })
+
+ it('loads and renders review details when first mounted already open', async () => {
+ vi.mocked(getWarehouseTransfer).mockResolvedValue(detail('WT-1'))
+
+ const wrapper = mountDialog({ modelValue: true, customerId: '20001', customerName: '测试客户', transferNo: 'WT-1' })
+ await flushPromises()
+
+ expect(getWarehouseTransfer).toHaveBeenCalledTimes(1)
+ expect(getWarehouseTransfer).toHaveBeenCalledWith('20001', 'WT-1')
+ expect(wrapper.text()).toContain('WT-1')
+ expect(wrapper.text()).toContain('测试货物')
+ wrapper.unmount()
+ })
+
+ it('reloads details when an open dialog switches to another transfer', async () => {
+ vi.mocked(getWarehouseTransfer)
+ .mockResolvedValueOnce(detail('WT-1'))
+ .mockResolvedValueOnce(detail('WT-2'))
+
+ const wrapper = mountDialog({ modelValue: true, customerId: '20001', customerName: '测试客户', transferNo: 'WT-1' })
+ await flushPromises()
+ await wrapper.setProps({ customerId: '20002', transferNo: 'WT-2' })
+ await flushPromises()
+
+ expect(getWarehouseTransfer).toHaveBeenLastCalledWith('20002', 'WT-2')
+ expect(wrapper.text()).toContain('WT-2')
+ wrapper.unmount()
+ })
+})
diff --git a/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue b/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue
index 305a2675..303bcf6b 100644
--- a/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue
+++ b/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue
@@ -51,7 +51,16 @@ async function submit() {
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '审核提交失败,请重试').message) }
finally { saving.value = false }
}
-watch(() => props.modelValue, (value) => { if (value) { form.decision = 'APPROVE'; form.remark = ''; void load() } })
+watch(
+ () => [props.modelValue, props.customerId, props.transferNo] as const,
+ ([value]) => {
+ if (!value) return
+ form.decision = 'APPROVE'
+ form.remark = ''
+ void load()
+ },
+ { immediate: true },
+)
diff --git a/src/modules/overseas-warehouse/lastMilePresentation.test.ts b/src/modules/overseas-warehouse/lastMilePresentation.test.ts
index efec5c3a..700a1f5c 100644
--- a/src/modules/overseas-warehouse/lastMilePresentation.test.ts
+++ b/src/modules/overseas-warehouse/lastMilePresentation.test.ts
@@ -2,6 +2,19 @@ import { describe, expect, it } from 'vitest'
import { formatOverseasDateTime, inboundPlanStatusLabel, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => {
+ it('keeps completed and processing relabel applications visible together', () => {
+ const row = toLastMileOrderListItem({
+ id: '1', customerId: 'customer-1', orderNo: 'RT-001', currentStatus: 'INBOUND_COMPLETED',
+ relabelApplications: [
+ { relabelNo: 'RL-FIRST', status: 'COMPLETED', quantity: 10, createdAt: '2026-09-21T10:00:00' },
+ { relabelNo: 'RL-SECOND', status: 'PROCESSING', quantity: 5, createdAt: '2026-09-21T11:00:00' },
+ ],
+ })
+ expect(row.relabelApplications?.map(item => [item.relabelNo, item.statusLabel, item.quantity])).toEqual([
+ ['RL-FIRST', '已完成', 10], ['RL-SECOND', '处理中', 5],
+ ])
+ })
+
it('formats overseas timestamps with zero-padded seconds', () => {
expect(formatOverseasDateTime('2026-08-27T16:19:46')).toBe('2026-08-27 16:19:46')
expect(formatOverseasDateTime('2026-08-07T06:09:04')).toBe('2026-08-07 06:09:04')
diff --git a/src/modules/overseas-warehouse/lastMilePresentation.ts b/src/modules/overseas-warehouse/lastMilePresentation.ts
index 5efd50d8..8376253c 100644
--- a/src/modules/overseas-warehouse/lastMilePresentation.ts
+++ b/src/modules/overseas-warehouse/lastMilePresentation.ts
@@ -96,7 +96,12 @@ export function packageDifferenceText(value: number | '—' | null | undefined)
return value > 0 ? `+${value}` : String(value)
}
-// 认领后显示处理中,可出库节点统一显示换标完成。
+export function relabelApplicationStatusLabel(value: unknown) {
+ const code = String(value ?? '').trim()
+ return ({ PENDING_REVIEW: '待审核', PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认', READY_FOR_OUTBOUND: '待出库', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' } as Record)[code] || text(code)
+}
+
+// 兼容原有单张换标单的汇总展示;子单使用完整业务状态。
export function relabelBusinessStatusLabel(value: unknown) {
const code = String(value ?? '').trim()
if (code === 'PROCESSING') return '处理中'
@@ -185,6 +190,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
outboundOrderNo: text(dto.outboundOrderNo),
dispatchOrderNo: text(dto.deliveryOrderNo),
relabelOrderNo: text(dto.relabelOrderNo),
+ relabelApplications: (dto.relabelApplications || []).map(application => ({ ...application, statusLabel: relabelApplicationStatusLabel(application.status) })),
relabelStatusCode: String(dto.relabelStatus ?? '').trim(),
relabelStatus: relabelBusinessStatusLabel(dto.relabelStatus),
consolidated: palletMergeLabel(dto.palletMergeFlag),
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts
index d398f90a..3ef22c02 100644
--- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts
+++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts
@@ -87,4 +87,30 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
expect(overview.text()).toContain('客户预报总箱数10 箱')
expect(overview.text()).not.toContain('实收总箱数')
})
+
+ it('renders both return relabel documents with independent states and quantities', async () => {
+ vi.mocked(getLastMileOrder).mockResolvedValue({
+ order: {
+ id: 'return-id', customerId: 'customer-1', orderNo: 'RT-001', orderType: 'RETURN_RELABEL', currentStatus: 'INBOUND_COMPLETED',
+ relabelOrderNo: 'RL-SECOND', relabelStatus: 'PROCESSING',
+ relabelApplications: [
+ { relabelNo: 'RL-FIRST', status: 'COMPLETED', quantity: 10, createdAt: '2026-09-21T10:00:00' },
+ { relabelNo: 'RL-SECOND', status: 'PROCESSING', quantity: 5, createdAt: '2026-09-21T11:00:00' },
+ ],
+ },
+ cargo: [], fees: [], statusHistory: [], operationHistory: [],
+ })
+ const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
+ await flushPromises()
+ await wrapper.get('#tab-plan').trigger('click')
+ await flushPromises()
+ const rows = wrapper.findAll('.related-panel .el-table__body tbody tr')
+ expect(rows).toHaveLength(2)
+ expect(rows[0].text()).toContain('RL-FIRST')
+ expect(rows[0].text()).toContain('已完成')
+ expect(rows[0].text()).toContain('10')
+ expect(rows[1].text()).toContain('RL-SECOND')
+ expect(rows[1].text()).toContain('处理中')
+ expect(rows[1].text()).toContain('5')
+ })
})
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue
index 3bfb06e0..4ba3a7d6 100644
--- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue
+++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue
@@ -13,14 +13,14 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto } from '../api/lastMileOrders'
-import { formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
+import { relabelApplicationStatusLabel, formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import OrderReferenceText from '../components/OrderReferenceText.vue'
interface DetailField { label: string; value: string | number; wide?: boolean }
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
-interface DocumentRow { type: string; number: string; createdAt: string; quote: string }
+interface DocumentRow { status?: string; quantity?: number; type: string; number: string; createdAt: string; quote: string }
interface FeeRow { id: string; item: string; currency: string; amount: string; status: string; description: string }
interface LogRow { id: string; occurredAt: string; node: string; operator: string; result: string; sortAt: string }
@@ -117,14 +117,15 @@ const planFields = computed(() => {
{ label: '合托批次', value: text(order?.palletMergeFlag) },
{ label: '计划托数', value: Number(order?.palletCount || 0) },
{ label: '入库单号', value: text(order?.inboundOrderNo) },
- { label: '换标状态', value: lastMileStatusLabel(order?.relabelStatus) },
+ { label: '换标子单', value: order?.relabelApplications?.length ? `${order.relabelApplications.length} 张(详见关联单据)` : lastMileStatusLabel(order?.relabelStatus) },
]
})
const documents = computed(() => {
const order = detail.value?.order
if (!order) return []
- return [['入库单', order.inboundOrderNo], ['出库单(只读关联)', order.outboundOrderNo], ['派送单(只读关联)', order.deliveryOrderNo], ['换标单', order.relabelOrderNo]]
- .filter((item) => item[1]).map(([type, number]) => ({ type: String(type), number: String(number), createdAt: '—', quote: '—' }))
+ return [['入库单', order.inboundOrderNo], ['出库单(只读关联)', order.outboundOrderNo], ['派送单(只读关联)', order.deliveryOrderNo], ['换标单', order.relabelApplications?.length ? undefined : order.relabelOrderNo]]
+ .filter((item) => item[1]).map(([type, number]) => ({ type: String(type), number: String(number), createdAt: '—', quote: '—' } as DocumentRow))
+ .concat((order.relabelApplications || []).map(application => ({ type: '换标单', number: application.relabelNo, createdAt: formatOverseasDateTime(application.createdAt), quote: '—', status: relabelApplicationStatusLabel(application.status), quantity: application.quantity })))
})
const cargo = computed(() => (detail.value?.cargo || []).map((item) => ({
id: String(item.id), sku: text(item.skuCode), productName: text(item.productNameZh), pallets: Number(item.palletCount || 0),
@@ -152,6 +153,7 @@ const cargoColumns: DataTableColumn[] = [
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 120, align: 'right' },
]
const documentColumns: DataTableColumn[] = [
+ { key: 'status', prop: 'status', label: '状态', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '换标数量(件)', minWidth: 130 },
{ key: 'type', prop: 'type', label: '单据类型', minWidth: 150 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 200 },
{ key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 170 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 150 },
]
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
index 3e985e36..194fffbf 100644
--- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
+++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
@@ -234,8 +234,8 @@ const columns: DataTableColumn[] = [
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
{ key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 150 },
{ key: 'dispatchOrderNo', prop: 'dispatchOrderNo', label: '派送单号', minWidth: 150 },
- { key: 'relabelOrderNo', prop: 'relabelOrderNo', label: '换标单号', minWidth: 150 },
- { key: 'relabelStatus', prop: 'relabelStatus', label: '换标状态', width: 110 },
+ { key: 'relabelOrderNo', slotName: 'relabelOrderNo', label: '换标单号', minWidth: 220 },
+ { key: 'relabelStatus', slotName: 'relabelStatus', label: '换标子状态', minWidth: 180 },
{ key: 'status', prop: 'status', label: '当前状态', width: 110 },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(仅自己)', minWidth: 160 },
@@ -926,6 +926,8 @@ onMounted(() => { void load() })
导出订单转仓生成入库计划按柜批量生成
+ {{ application.relabelNo }}
{{ row.relabelOrderNo }}
+ {{ application.statusLabel }} · {{ application.quantity }} 件
{{ row.relabelStatus }}
{{ packageDifferenceText(row.cartonDifference) }}
@@ -1066,6 +1068,7 @@ onMounted(() => { void load() })
diff --git a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts
index 6057670b..66f1bfc5 100644
--- a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts
+++ b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts
@@ -37,6 +37,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
['PalletsView.vue', 'overseas-pallets-query'],
['CartonsView.vue', 'overseas-cartons-query'],
['ProductsView.vue', 'overseas-products-query'],
+ ['LocationsView.vue', 'overseas-locations-query'],
])
const keys = [...expected.entries()].map(([fileName, preferenceKey]) => {
const value = source(fileName)
@@ -127,6 +128,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('@back="back"')
expect(value).not.toContain('AppDrawer')
if (fileName === 'LastMileOrderDetailView.vue') expect(value).toContain('title="新增费用"')
+ else if (fileName === 'InboundOrderDetailView.vue') expect(value).toContain('移库')
else expect(value).not.toContain('AppDialog')
}
})
@@ -342,10 +344,17 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('(?:)?查看<\/el-button>/)
}
}
+ const locations = source('LocationsView.vue')
+ expect(locations).toContain('海外仓库位管理')
+ expect(locations).toContain('库位编码')
})
it('connects the dispatch prototype detail to the real overseas API', () => {
@@ -387,12 +396,12 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(columnKeys('LastMileOrdersView.vue')).toEqual([
'actions', 'selection', 'orderNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse',
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
- 'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
+ 'cartons', 'cartonDifference', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
'createdAt', 'internalRemark', 'customerRemark', 'warehouseRemark',
])
expect(columnKeys('InboundOrdersView.vue')).toEqual([
- 'actions', 'selection', 'orderNo', 'inboundCode', 'lxCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'volume',
+ 'actions', 'selection', 'orderNo', 'inboundCode', 'lxCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'grossWeight', 'netWeight', 'volume',
'pallets', 'assigned', 'mergeAllowed', 'urgent', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt',
])
expect(columnKeys('InventoryView.vue')).toEqual([
diff --git a/src/modules/overseas-warehouse/routes.ts b/src/modules/overseas-warehouse/routes.ts
index 7155dc5a..9357f8d2 100644
--- a/src/modules/overseas-warehouse/routes.ts
+++ b/src/modules/overseas-warehouse/routes.ts
@@ -130,6 +130,12 @@ export const overseasWarehouseRoutes = [
component: () => import('./pages/PalletDetailView.vue'),
meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false },
},
+ {
+ path: 'overseas-warehouse/locations',
+ name: 'overseas-locations',
+ component: () => import('./pages/LocationsView.vue'),
+ meta: { title: '库位管理', description: '维护海外仓普通库位与 A0 共享溢出位', permission: permission.locationList },
+ },
{
path: 'general/mojia-sku-sync',
name: 'mojia-sku-sync',
--
2.54.0
From 4391a69c7d2635cb86c8314aa502073d9a230d4d Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Tue, 22 Sep 2026 11:33:08 +0800
Subject: [PATCH 05/36] feat: show lingxing sync switch status
---
.../overseas-warehouse/api/mojiaSkuSync.ts | 11 ++++++++++
.../pages/MojiaSkuSyncView.vue | 22 ++++++++++++++++---
2 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
index e9e68004..0ba0d4f8 100644
--- a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
+++ b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
@@ -36,6 +36,10 @@ export interface MojiaSkuSyncQuery {
syncStatus?: MojiaSkuSyncStatus
}
+export interface MojiaSkuSyncConfig {
+ autoSyncEnabled: boolean
+}
+
const basePath = '/platform/v1/firstleg/mojia-sku-sync'
export async function listMojiaSkuSync(query: MojiaSkuSyncQuery) {
@@ -45,6 +49,13 @@ export async function listMojiaSkuSync(query: MojiaSkuSyncQuery) {
return unwrapApiResponse(response)
}
+export async function getMojiaSkuSyncConfig() {
+ const response = await http.get, ApiEnvelope>(
+ `${basePath}/config`,
+ )
+ return unwrapApiResponse(response)
+}
+
export async function updateMojiaSkuSync(id: string, data: {
skuCode: string
lengthCm: number
diff --git a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
index 025d1417..7dba08a0 100644
--- a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
+++ b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
@@ -10,7 +10,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorMessage } from '@/shared/api/http'
import {
- listMojiaSkuSync, retryMojiaSkuSync, updateMojiaSkuSync,
+ getMojiaSkuSyncConfig, listMojiaSkuSync, retryMojiaSkuSync, updateMojiaSkuSync,
type MojiaSkuSyncRecord, type MojiaSkuSyncStatus,
} from '../api/mojiaSkuSync'
@@ -25,6 +25,7 @@ const canRetry = computed(() => access.can(permission.retry))
const rows = ref([])
const total = ref(0)
const loading = ref(false)
+const autoSyncEnabled = ref(false)
const error = ref('')
const actionId = ref('')
const page = ref(1)
@@ -65,6 +66,14 @@ async function load() {
} finally { loading.value = false }
}
+async function loadConfig() {
+ try {
+ autoSyncEnabled.value = (await getMojiaSkuSyncConfig()).autoSyncEnabled
+ } catch (cause) {
+ error.value = getApiErrorMessage(cause, '自动同步配置加载失败')
+ }
+}
+
function search() { page.value = 1; void load() }
function reset() { form.skuCode = ''; form.syncStatus = ''; search() }
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 19) : '—' }
@@ -104,7 +113,7 @@ async function retry(row: MojiaSkuSyncRecord) {
finally { actionId.value = '' }
}
-onMounted(() => { void load() })
+onMounted(() => { void Promise.all([loadConfig(), load()]) })
@@ -112,6 +121,13 @@ onMounted(() => { void load() })
+
@@ -127,7 +143,7 @@ onMounted(() => { void load() })
编辑
- 重新推送
+ 人工推送
—
--
2.54.0
From 33fc30911ef5d4b8f90547858e3308691d308d0e Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Tue, 22 Sep 2026 11:44:07 +0800
Subject: [PATCH 06/36] feat: add lingxing connection test action
---
.../overseas-warehouse/api/mojiaSkuSync.ts | 26 ++++++++++++++
.../pages/MojiaSkuSyncView.vue | 35 +++++++++++++++++--
2 files changed, 58 insertions(+), 3 deletions(-)
diff --git a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
index 0ba0d4f8..31db04d6 100644
--- a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
+++ b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
@@ -40,6 +40,25 @@ export interface MojiaSkuSyncConfig {
autoSyncEnabled: boolean
}
+export interface LingxingWarehouseTestResult {
+ whCode: string
+ reachable: boolean
+ matched: boolean
+ maintained: boolean
+ productSku?: string | null
+ barcode?: string | null
+ length?: number | null
+ width?: number | null
+ height?: number | null
+ weight?: number | null
+ message: string
+}
+
+export interface LingxingConnectionTestResult {
+ skuCode: string
+ warehouses: LingxingWarehouseTestResult[]
+}
+
const basePath = '/platform/v1/firstleg/mojia-sku-sync'
export async function listMojiaSkuSync(query: MojiaSkuSyncQuery) {
@@ -56,6 +75,13 @@ export async function getMojiaSkuSyncConfig() {
return unwrapApiResponse(response)
}
+export async function testLingxingConnection(skuCode: string) {
+ const response = await http.post
, ApiEnvelope>(
+ `${basePath}/test-connection`, { skuCode },
+ )
+ return unwrapApiResponse(response)
+}
+
export async function updateMojiaSkuSync(id: string, data: {
skuCode: string
lengthCm: number
diff --git a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
index 7dba08a0..eecd5f33 100644
--- a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
+++ b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
@@ -2,7 +2,7 @@
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
-import { Edit3, RefreshCw, RotateCw } from '@lucide/vue'
+import { Cable, Edit3, RefreshCw, RotateCw } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -10,8 +10,8 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorMessage } from '@/shared/api/http'
import {
- getMojiaSkuSyncConfig, listMojiaSkuSync, retryMojiaSkuSync, updateMojiaSkuSync,
- type MojiaSkuSyncRecord, type MojiaSkuSyncStatus,
+ getMojiaSkuSyncConfig, listMojiaSkuSync, retryMojiaSkuSync, testLingxingConnection, updateMojiaSkuSync,
+ type LingxingConnectionTestResult, type MojiaSkuSyncRecord, type MojiaSkuSyncStatus,
} from '../api/mojiaSkuSync'
const permission = {
@@ -28,6 +28,9 @@ const loading = ref(false)
const autoSyncEnabled = ref(false)
const error = ref('')
const actionId = ref('')
+const testingConnection = ref(false)
+const testOpen = ref(false)
+const testResult = ref(null)
const page = ref(1)
const size = ref(20)
const form = reactive({ skuCode: '', syncStatus: '' as '' | MojiaSkuSyncStatus })
@@ -81,6 +84,17 @@ function asRecord(row: unknown) { return row as MojiaSkuSyncRecord }
function syncLabel(row: unknown) { return statusLabel[asRecord(row).syncStatus] }
function syncTagType(row: unknown) { return statusType[asRecord(row).syncStatus] }
+async function testConnection() {
+ const skuCode = form.skuCode.trim()
+ if (!skuCode) { ElMessage.warning('请先输入要查询的 SKU'); return }
+ testingConnection.value = true
+ try {
+ testResult.value = await testLingxingConnection(skuCode)
+ testOpen.value = true
+ } catch (cause) { ElMessage.error(getApiErrorMessage(cause, '领星连接测试失败')) }
+ finally { testingConnection.value = false }
+}
+
function openEdit(row: MojiaSkuSyncRecord) {
editing.value = row
Object.assign(editForm, { skuCode: row.skuCode, lengthCm: row.lengthCm, widthCm: row.widthCm, heightCm: row.heightCm, weightKg: row.weightKg, revision: row.revision })
@@ -137,6 +151,7 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
查询
重置
+ 测试连接
刷新
@@ -168,6 +183,17 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
+
+
+
查询 SKU:{{ testResult.skuCode }}
+
+
+ {{ row.matched ? '已命中' : '未命中' }}
+ {{ row.matched ? (row.maintained ? '已维护' : '待维护') : '—' }}
+ {{ row.matched ? `${row.length ?? 0} × ${row.width ?? 0} × ${row.height ?? 0} cm / ${row.weight ?? 0} kg` : row.message }}
+
+
+
@@ -179,5 +205,8 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
.sync-pagination { display: flex; justify-content: flex-end; padding: var(--admin-space-4); }
.sync-edit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.sync-edit-grid :deep(.el-input-number) { width: 100%; }
+.connection-result { display: grid; gap: var(--admin-space-4); }
+.connection-result__sku { color: var(--cc-muted); }
+.connection-result__sku strong { color: var(--cc-text); }
@media (max-width: 900px) { .sync-filters .el-input { width: 180px; } }
--
2.54.0
From fed257a72070a0c44d120d3b3236ec58d59af5eb Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Tue, 22 Sep 2026 13:51:58 +0800
Subject: [PATCH 07/36] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/modules/iam/api/platformIam.ts | 3 +
src/modules/iam/pages/InternalUsersView.vue | 50 +++++++++++++
.../api/platformLoadingLists.test.ts | 4 +-
.../loading-lists/api/platformLoadingLists.ts | 16 ++++-
.../components/LoadingListDetailDrawer.vue | 40 +++++++----
.../components/LoadingListsView.test.ts | 6 +-
.../pages/PreloadPlanDetailView.test.ts | 28 ++++++--
.../pages/PreloadPlanDetailView.vue | 72 ++++++++++++++-----
8 files changed, 174 insertions(+), 45 deletions(-)
diff --git a/src/modules/iam/api/platformIam.ts b/src/modules/iam/api/platformIam.ts
index e6f4cf31..29ab981e 100644
--- a/src/modules/iam/api/platformIam.ts
+++ b/src/modules/iam/api/platformIam.ts
@@ -267,6 +267,7 @@ export interface PlatformManagedUser {
lastLoginAt: string | null
protectedAccount: boolean
overseasWarehouseId: number | string | null
+ domesticWarehouseId?: number | string | null
}
export interface PlatformManagedUserPage {
@@ -309,6 +310,7 @@ export interface CreatePlatformManagedUserRequest {
tenantId: number | string
roleIds: Array
overseasWarehouseId: number | string | null
+ domesticWarehouseId?: number | string | null
password: string
status: 'ACTIVE' | 'DISABLED'
}
@@ -320,6 +322,7 @@ export interface UpdatePlatformManagedUserRequest {
tenantId: number | string
roleIds: Array
overseasWarehouseId: number | string | null
+ domesticWarehouseId?: number | string | null
}
async function getData(path: string): Promise {
diff --git a/src/modules/iam/pages/InternalUsersView.vue b/src/modules/iam/pages/InternalUsersView.vue
index dbb66ecd..6506d2e7 100644
--- a/src/modules/iam/pages/InternalUsersView.vue
+++ b/src/modules/iam/pages/InternalUsersView.vue
@@ -29,6 +29,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
+import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
const access = useAccessPreviewStore()
const auth = useAuthStore()
@@ -37,6 +38,9 @@ const options = ref({ tenants: [], roles: [] })
const overseasWarehouses = ref([])
const overseasWarehousesLoading = ref(false)
const overseasWarehousesError = ref(null)
+const domesticWarehouses = ref([])
+const domesticWarehousesLoading = ref(false)
+const domesticWarehousesError = ref(null)
const loading = ref(false)
const saving = ref(false)
const loadError = ref(null)
@@ -81,6 +85,7 @@ const emptyForm = () => ({
tenantId: '0',
roleIds: [] as string[],
overseasWarehouseId: '',
+ domesticWarehouseId: '',
password: '',
confirmPassword: '',
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED',
@@ -111,6 +116,10 @@ const overseasPdaRoleIds = computed(() => new Set(
availableRoles.value.filter((role) => role.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR').map((role) => String(role.id)),
))
const hasOverseasPdaRole = computed(() => form.roleIds.some((roleId) => overseasPdaRoleIds.value.has(roleId)))
+const domesticPdaRoleIds = computed(() => new Set(
+ availableRoles.value.filter((role) => role.roleCode === 'CN_FIRSTLEG_PDA_OPERATOR').map((role) => String(role.id)),
+))
+const hasDomesticPdaRole = computed(() => form.roleIds.some((roleId) => domesticPdaRoleIds.value.has(roleId)))
const formRules = computed(() => ({
username: editingUser.value ? [] : [
@@ -127,6 +136,9 @@ const formRules = computed(() => ({
overseasWarehouseId: !hasOverseasPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
{ required: true, message: '请选择实际海外收货仓', trigger: 'change' },
],
+ domesticWarehouseId: !hasDomesticPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
+ { required: true, message: '请选择国内作业仓', trigger: 'change' },
+ ],
password: editingUser.value && !form.resetPassword ? [] : [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码为 8-64 位,且必须包含字母和数字', trigger: 'blur' },
@@ -217,6 +229,7 @@ function openEdit(row: PlatformManagedUser) {
tenantId: '0',
roleIds: row.roleIds.map(String),
overseasWarehouseId: row.overseasWarehouseId == null ? '' : String(row.overseasWarehouseId),
+ domesticWarehouseId: row.domesticWarehouseId == null ? '' : String(row.domesticWarehouseId),
password: '',
confirmPassword: '',
status: row.status,
@@ -243,6 +256,19 @@ async function loadOverseasWarehouses() {
}
}
+async function loadDomesticWarehouses() {
+ domesticWarehousesLoading.value = true
+ domesticWarehousesError.value = null
+ try {
+ domesticWarehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })
+ } catch (error) {
+ domesticWarehouses.value = []
+ domesticWarehousesError.value = getApiErrorDetails(error, '国内作业仓加载失败,请重试')
+ } finally {
+ domesticWarehousesLoading.value = false
+ }
+}
+
function isCurrentUser(row: PlatformManagedUser | null) {
if (!row) return false
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
@@ -266,6 +292,7 @@ async function saveUser() {
tenantId: '0',
roleIds: form.roleIds,
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
+ ...(hasDomesticPdaRole.value ? { domesticWarehouseId: form.domesticWarehouseId } : {}),
password: form.password,
status: form.status,
})
@@ -280,6 +307,7 @@ async function saveUser() {
tenantId: '0',
roleIds: form.roleIds,
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
+ ...(hasDomesticPdaRole.value ? { domesticWarehouseId: form.domesticWarehouseId } : {}),
})
}
if (canChangeStatus.value && form.status !== row.status) {
@@ -340,6 +368,14 @@ watch(hasOverseasPdaRole, (selected) => {
}
void loadOverseasWarehouses()
})
+watch(hasDomesticPdaRole, (selected) => {
+ if (!selected) {
+ form.domesticWarehouseId = ''
+ domesticWarehousesError.value = null
+ return
+ }
+ void loadDomesticWarehouses()
+})
onMounted(loadInitialData)
@@ -435,6 +471,20 @@ onMounted(loadInitialData)
仓库将写入登录 Token,海外 PDA 不提供仓库切换。
+
+
+
+
+
+ {{ domesticWarehousesError.message }}
+ 重新加载
+
+ 该人员只会出现在此仓库的国内头程 PDA 任务候选中。
+
diff --git a/src/modules/loading-lists/api/platformLoadingLists.test.ts b/src/modules/loading-lists/api/platformLoadingLists.test.ts
index fd6a5f27..83037d75 100644
--- a/src/modules/loading-lists/api/platformLoadingLists.test.ts
+++ b/src/modules/loading-lists/api/platformLoadingLists.test.ts
@@ -146,11 +146,11 @@ describe('platform loading lists API', () => {
},
})
- const result = await confirmPreloadPlan('1900000000000000001', 3, 'confirm-operation-1')
+ const result = await confirmPreloadPlan('1900000000000000001', 3, '1900000000000000007', 'confirm-operation-1')
expect(post).toHaveBeenCalledWith(
'/platform/v1/first-leg/loading-plans/1900000000000000001/confirm',
- { expectedVersion: 3 },
+ { expectedVersion: 3, assigneeId: '1900000000000000007' },
{ headers: { 'Idempotency-Key': 'confirm-operation-1' } },
)
expect(result).toMatchObject({
diff --git a/src/modules/loading-lists/api/platformLoadingLists.ts b/src/modules/loading-lists/api/platformLoadingLists.ts
index 6248fe26..8c41c447 100644
--- a/src/modules/loading-lists/api/platformLoadingLists.ts
+++ b/src/modules/loading-lists/api/platformLoadingLists.ts
@@ -186,6 +186,11 @@ export interface PreloadConfirmationResponse {
readiness: PreloadReadiness
}
+export interface DomesticPdaOperator {
+ userId: string
+ displayName: string
+}
+
type RawId = string | number | null | undefined
type RawPage = Omit, 'page' | 'size' | 'total' | 'pages'> & {
page: string | number
@@ -359,10 +364,17 @@ export async function updateLoadingItemRemarks(planId: string, itemId: string, r
}
}
-export async function confirmPreloadPlan(planId: string, expectedVersion: number, operationId: string) {
+export async function listDomesticPdaOperators(warehouseId: string, keyword?: string): Promise {
+ const response = await http.get, ApiEnvelope>(
+ '/platform/v1/iam/users/domestic-pda-operators', { params: { warehouseId, keyword } },
+ )
+ return unwrapApiResponse(response).map((operator) => ({ ...operator, userId: requiredId(operator.userId) }))
+}
+
+export async function confirmPreloadPlan(planId: string, expectedVersion: number, assigneeId: string, operationId: string) {
const response = await http.post, ApiEnvelope>(
`${basePath}/loading-plans/${encodeURIComponent(planId)}/confirm`,
- { expectedVersion },
+ { expectedVersion, assigneeId },
{ headers: { 'Idempotency-Key': operationId } },
)
const result = unwrapApiResponse(response)
diff --git a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue
index 00c9f868..7278b72d 100644
--- a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue
+++ b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue
@@ -1,7 +1,7 @@
-
+
+
+
+
+
+
+
+
+
+
@@ -278,7 +310,7 @@ watch([planId, warehouseId], () => void loadDetail())
-
+
@@ -286,6 +318,8 @@ watch([planId, warehouseId], () => void loadDetail())
{{ detail.summary.cabinetNo || '—' }}
{{ detail.summary.containerNo || '待补录' }}
+ {{ selectedAssignee?.displayName || '未选择' }}
+
@@ -296,6 +330,10 @@ watch([planId, warehouseId], () => void loadDetail())
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6, 16px); }
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.confirm-content { display: grid; gap: var(--admin-space-5, 12px); }
+.pda-operator-picker { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; }
+.pda-operator-picker label { flex: 0 0 auto; font-size: var(--admin-font-body, 13px); white-space: nowrap; }
+.pda-operator-picker label span { color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption, 12px); }
+.pda-operator-picker :deep(.el-select) { width: 180px; }
.remarks-form { margin-top: 14px; }
.remarks-form small { display: block; margin-top: var(--admin-space-1, 4px); color: var(--cc-muted); }
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
--
2.54.0
From 4dd1b83a6b26fe059bb42fa27d8c7deb9fc5fca6 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Tue, 22 Sep 2026 14:26:49 +0800
Subject: [PATCH 08/36] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/core/navigation/adminNavigation.ts | 2 +-
src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue | 2 +-
src/modules/overseas-warehouse/routes.ts | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts
index 63c0bb50..f2789290 100644
--- a/src/core/navigation/adminNavigation.ts
+++ b/src/core/navigation/adminNavigation.ts
@@ -171,7 +171,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
- { key: 'mojia-sku-sync', title: '一件代发 SKU 同步', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list' },
+ { key: 'mojia-sku-sync', title: 'SKU同步', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list' },
],
},
{
diff --git a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
index eecd5f33..9ae2a013 100644
--- a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
+++ b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
@@ -132,7 +132,7 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
-
+
import('./pages/MojiaSkuSyncView.vue'),
- meta: { title: '一件代发 SKU 同步', description: '查看墨家测量数据并同步领星 SKU 尺寸重量', permission: permission.mojiaSkuSyncList },
+ meta: { title: 'SKU同步', description: '查看墨家测量数据并同步领星 SKU 尺寸重量', permission: permission.mojiaSkuSyncList },
},
] satisfies RouteRecordRaw[]
--
2.54.0
From db92d1dfe14804014086883389b5f6ed11f722e8 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 23 Sep 2026 08:59:17 +0800
Subject: [PATCH 09/36] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../overseas-warehouse/api/mojiaSkuSync.ts | 7 ++
.../pages/MojiaSkuSyncView.vue | 65 ++++++++++++++++---
2 files changed, 64 insertions(+), 8 deletions(-)
diff --git a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
index 31db04d6..f0ed8c96 100644
--- a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
+++ b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
@@ -82,6 +82,13 @@ export async function testLingxingConnection(skuCode: string) {
return unwrapApiResponse(response)
}
+export async function submitLingxingVerificationCode(code: string) {
+ const response = await http.post, ApiEnvelope>(
+ `${basePath}/verification-code`, { code },
+ )
+ return unwrapApiResponse(response)
+}
+
export async function updateMojiaSkuSync(id: string, data: {
skuCode: string
lengthCm: number
diff --git a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
index 9ae2a013..119c5c81 100644
--- a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
+++ b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
@@ -10,7 +10,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorMessage } from '@/shared/api/http'
import {
- getMojiaSkuSyncConfig, listMojiaSkuSync, retryMojiaSkuSync, testLingxingConnection, updateMojiaSkuSync,
+ getMojiaSkuSyncConfig, listMojiaSkuSync, retryMojiaSkuSync, submitLingxingVerificationCode, testLingxingConnection, updateMojiaSkuSync,
type LingxingConnectionTestResult, type MojiaSkuSyncRecord, type MojiaSkuSyncStatus,
} from '../api/mojiaSkuSync'
@@ -31,6 +31,10 @@ const actionId = ref('')
const testingConnection = ref(false)
const testOpen = ref(false)
const testResult = ref(null)
+const verificationOpen = ref(false)
+const verificationCode = ref('')
+const submittingVerification = ref(false)
+const pendingVerificationAction = ref<{ type: 'test' } | { type: 'retry', id: string, revision: number } | null>(null)
const page = ref(1)
const size = ref(20)
const form = reactive({ skuCode: '', syncStatus: '' as '' | MojiaSkuSyncStatus })
@@ -91,10 +95,38 @@ async function testConnection() {
try {
testResult.value = await testLingxingConnection(skuCode)
testOpen.value = true
- } catch (cause) { ElMessage.error(getApiErrorMessage(cause, '领星连接测试失败')) }
+ } catch (cause) {
+ const message = getApiErrorMessage(cause, '领星连接测试失败')
+ if (/二次验证码|验证码|安全验证/.test(message)) {
+ pendingVerificationAction.value = { type: 'test' }
+ verificationOpen.value = true
+ }
+ ElMessage.error(message)
+ }
finally { testingConnection.value = false }
}
+async function submitVerification() {
+ if (!verificationCode.value.trim()) { ElMessage.warning('请输入验证码'); return }
+ submittingVerification.value = true
+ try {
+ await submitLingxingVerificationCode(verificationCode.value.trim())
+ const pending = pendingVerificationAction.value
+ verificationCode.value = ''
+ verificationOpen.value = false
+ pendingVerificationAction.value = null
+ await load()
+ ElMessage.success('验证码已提交,正在继续原操作')
+ if (pending?.type === 'test') {
+ await testConnection()
+ } else if (pending?.type === 'retry') {
+ const row = rows.value.find((item) => item.id === pending.id)
+ if (row) await retry(row, false)
+ }
+ } catch (cause) { ElMessage.error(getApiErrorMessage(cause, '验证码提交失败')) }
+ finally { submittingVerification.value = false }
+}
+
function openEdit(row: MojiaSkuSyncRecord) {
editing.value = row
Object.assign(editForm, { skuCode: row.skuCode, lengthCm: row.lengthCm, widthCm: row.widthCm, heightCm: row.heightCm, weightKg: row.weightKg, revision: row.revision })
@@ -117,13 +149,26 @@ async function saveEdit() {
finally { actionId.value = '' }
}
-async function retry(row: MojiaSkuSyncRecord) {
- try {
- await ElMessageBox.confirm(`确认重新推送 SKU「${row.skuCode}」的最新测量值?`, '重新推送领星', { type: 'warning', confirmButtonText: '重新推送', cancelButtonText: '取消', customClass: 'admin-density-confirm' })
- } catch { return }
+async function retry(row: MojiaSkuSyncRecord, confirm = true) {
+ if (confirm) {
+ try {
+ await ElMessageBox.confirm(`确认重新推送 SKU「${row.skuCode}」的最新测量值?`, '重新推送领星', { type: 'warning', confirmButtonText: '重新推送', cancelButtonText: '取消', customClass: 'admin-density-confirm' })
+ } catch { return }
+ }
actionId.value = row.id
- try { await retryMojiaSkuSync(row.id, row.revision); ElMessage.success('已进入待同步队列'); await load() }
- catch (cause) { ElMessage.error(getApiErrorMessage(cause, '重新推送失败')) }
+ try {
+ const result = await retryMojiaSkuSync(row.id, row.revision)
+ ElMessage.success(result.syncStatus === 'SUCCESS' ? '已成功推送到领星' : '推送失败,详情已记录')
+ await load()
+ }
+ catch (cause) {
+ const message = getApiErrorMessage(cause, '重新推送失败')
+ if (/二次验证码|验证码|安全验证/.test(message)) {
+ pendingVerificationAction.value = { type: 'retry', id: row.id, revision: row.revision }
+ verificationOpen.value = true
+ }
+ ElMessage.error(message)
+ }
finally { actionId.value = '' }
}
@@ -194,6 +239,10 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
+
+
+
+
--
2.54.0
From 10e20f896abe3a7f475a3276d592c93423f2fe06 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 23 Sep 2026 09:59:46 +0800
Subject: [PATCH 10/36] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/modules/overseas-warehouse/api/locations.ts | 6 ++++++
src/modules/overseas-warehouse/pages/LocationsView.vue | 9 +++++----
2 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/modules/overseas-warehouse/api/locations.ts b/src/modules/overseas-warehouse/api/locations.ts
index c575f255..81c0a4c7 100644
--- a/src/modules/overseas-warehouse/api/locations.ts
+++ b/src/modules/overseas-warehouse/api/locations.ts
@@ -43,6 +43,12 @@ export async function listOverseasLocations(warehouseId: string, query: Overseas
return normalizeOverseasPage(unwrapApiResponse(response))
}
+export async function listAllOverseasLocations(warehouseIds: string[], query: OverseasLocationQuery = {}) {
+ const results = await Promise.all(warehouseIds.map((warehouseId) => listOverseasLocations(warehouseId, { ...query, page: 1, size: 200 })))
+ const records = results.flatMap((result) => result.records)
+ return { records, total: records.length }
+}
+
export async function createOverseasLocation(warehouseId: string, payload: SaveOverseasLocationPayload) {
const response = await overseasHttp.post, ApiEnvelope>(base(warehouseId), payload)
return unwrapApiResponse(response)
diff --git a/src/modules/overseas-warehouse/pages/LocationsView.vue b/src/modules/overseas-warehouse/pages/LocationsView.vue
index cb366570..e9d407bf 100644
--- a/src/modules/overseas-warehouse/pages/LocationsView.vue
+++ b/src/modules/overseas-warehouse/pages/LocationsView.vue
@@ -12,7 +12,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import { getApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
-import { changeOverseasLocationStatus, createOverseasLocation, deleteOverseasLocation, listOverseasLocations, updateOverseasLocation, type OverseasLocationDto, type OverseasLocationStatus, type OverseasLocationType } from '../api/locations'
+import { changeOverseasLocationStatus, createOverseasLocation, deleteOverseasLocation, listAllOverseasLocations, listOverseasLocations, updateOverseasLocation, type OverseasLocationDto, type OverseasLocationStatus, type OverseasLocationType } from '../api/locations'
import { overseasWarehousePermissions as permission } from '../permissions'
const access = useAccessPreviewStore(); const warehouses = ref([]); const rows = ref([])
@@ -28,13 +28,14 @@ const queryFields = computed(() => [
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' }, { key: 'locationCode', prop: 'locationCode', label: '库位编码', minWidth: 180 }, { key: 'warehouse', label: '海外仓', minWidth: 190, slotName: 'warehouse' }, { key: 'zone', label: '库区', minWidth: 150, slotName: 'zone' }, { key: 'locationType', label: '类型', width: 130, slotName: 'locationType' }, { key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'occupiedPalletCount', prop: 'occupiedPalletCount', label: '托盘数', width: 100, align: 'right' }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
const typeLabels: Record = { VIRTUAL: '虚拟收货位', NORMAL: '普通库位', SHARED_OVERFLOW: 'A0共享溢出位' }; const statusLabels: Record = { ACTIVE: '可用', IDLE: '空闲', OCCUPIED: '已占用', INACTIVE: '已停用', DISABLED: '已停用' }
const selectedWarehouse = computed(() => warehouses.value.find(item => String(item.id) === String(form.warehouseId)))
+const warehouseNameById = computed(() => new Map(warehouses.value.map(item => [String(item.id), item.warehouseName])))
function isA0(row: OverseasLocationDto) { return row.sharedOverflow || row.locationType === 'SHARED_OVERFLOW' || row.locationCode.toUpperCase() === 'A0' }
function canEdit(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
function canStatus(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
function canDelete(row: OverseasLocationDto) { return access.can(permission.locationDelete) && !isA0(row) && row.status === 'DISABLED' && Number(row.occupiedPalletCount || 0) === 0 }
function actionError(error: unknown, fallback: string) { ElMessage.error(getApiErrorDetails(error, fallback).message) }
-async function loadWarehouses() { contextLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { actionError(error, '海外仓列表加载失败') } finally { contextLoading.value = false } }
-async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const warehouseId = String(filters.value.warehouseId || ''); if (!warehouseId) { rows.value = []; total.value = 0; return }; loading.value = true; loadError.value = null; try { const result = await listOverseasLocations(warehouseId, { page: page.value, size: pageSize.value, zoneCode: String(filters.value.zoneCode || '').trim() || undefined, keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: (String(filters.value.status || '') === 'DISABLED' ? 'INACTIVE' : String(filters.value.status || '')) as OverseasLocationStatus | '' }); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外库位加载失败') } finally { loading.value = false } }
+async function loadWarehouses() { contextLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records; await load() } catch (error) { actionError(error, '海外仓列表加载失败') } finally { contextLoading.value = false } }
+async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); if (!warehouses.value.length) { rows.value = []; total.value = 0; return }; loading.value = true; loadError.value = null; try { const selectedWarehouseId = String(filters.value.warehouseId || ''); const result = await (selectedWarehouseId ? listOverseasLocations(selectedWarehouseId, { page: 1, size: 200, zoneCode: String(filters.value.zoneCode || '').trim() || undefined, keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: (String(filters.value.status || '') === 'DISABLED' ? 'INACTIVE' : String(filters.value.status || '')) as OverseasLocationStatus | '' }) : listAllOverseasLocations(warehouses.value.map(item => String(item.id)), { zoneCode: String(filters.value.zoneCode || '').trim() || undefined, keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: (String(filters.value.status || '') === 'DISABLED' ? 'INACTIVE' : String(filters.value.status || '')) as OverseasLocationStatus | '' })); const records = result.records.map(row => ({ ...row, warehouseName: row.warehouseName || warehouseNameById.value.get(String(row.warehouseId)) || null })); total.value = records.length; const start = (page.value - 1) * pageSize.value; rows.value = records.slice(start, start + pageSize.value) } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外库位加载失败') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function resetForm() { Object.assign(form, { warehouseId: String(filters.value.warehouseId || ''), zoneCode: '', zoneName: '', locationCode: '', locationName: '', locationType: 'NORMAL', maxWeightKg: null, status: 'ACTIVE', remark: '' }); editing.value = null }
function openCreate() { resetForm(); editorOpen.value = true }
@@ -45,6 +46,6 @@ async function remove(row: OverseasLocationDto) { if (!canDelete(row)) return; t
onMounted(() => { void loadWarehouses() })
-新增库位编辑{{ (row as OverseasLocationDto).status === 'DISABLED' ? '启用' : '停用' }}删除
{{ (row as OverseasLocationDto).warehouseName || selectedWarehouse?.warehouseName || '—' }}{{ (row as OverseasLocationDto).zoneName || '—' }}({{ (row as OverseasLocationDto).zoneCode }}){{ typeLabels[(row as OverseasLocationDto).locationType] }}{{ statusLabels[(row as OverseasLocationDto).status] }}共 {{ total }} 个库位
取消保存库位
+新增库位编辑{{ (row as OverseasLocationDto).status === 'DISABLED' || (row as OverseasLocationDto).status === 'INACTIVE' ? '启用' : '停用' }}删除
{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}{{ (row as OverseasLocationDto).zoneName || '—' }}({{ (row as OverseasLocationDto).zoneCode }}){{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}共 {{ total }} 个库位
取消保存库位
--
2.54.0
From 409d8b3f57d48f81440f9b834a1d9f1b72639f40 Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Wed, 23 Sep 2026 11:25:23 +0800
Subject: [PATCH 11/36] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E4=BC=98=E5=8C=960923?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../overseas-warehouse/api/lastMileOrders.ts | 9 ++++++++
.../overseas-warehouse/api/relabelOrders.ts | 6 ++++-
.../pages/LastMileOrdersView.vue | 23 ++++++++++++++++++-
.../pages/RelabelOrderDetailView.test.ts | 9 ++++++++
.../pages/RelabelOrderDetailView.vue | 18 ++++++++++++---
.../pages/RelabelOrdersView.vue | 6 +++--
src/modules/overseas-warehouse/types.ts | 1 +
7 files changed, 65 insertions(+), 7 deletions(-)
diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts
index 76c1161e..d5136bee 100644
--- a/src/modules/overseas-warehouse/api/lastMileOrders.ts
+++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts
@@ -374,6 +374,15 @@ export async function reviewReturnOrder(returnNo: string, customerId: string, de
return unwrapApiResponse(response)
}
+export async function reviewDirectRelabelOrder(orderId: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) {
+ const response = await overseasHttp.put, ApiEnvelope>(
+ `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/relabel-review`,
+ { status: decision === 'APPROVE' ? 'PENDING_PROCESSING' : 'REJECTED', remark: remark?.trim() || undefined },
+ { params: { customerId } },
+ )
+ return unwrapApiResponse(response)
+}
+
export async function downloadLastMileOrders(query: Partial) {
const { page: _page, size: _size, ...filters } = query
const params = Object.fromEntries(
diff --git a/src/modules/overseas-warehouse/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts
index 1a5b747b..a79aa178 100644
--- a/src/modules/overseas-warehouse/api/relabelOrders.ts
+++ b/src/modules/overseas-warehouse/api/relabelOrders.ts
@@ -30,7 +30,9 @@ export interface CreateRelabelItemPayload {
export interface CreateRelabelPayload {
customerOrderNo: string
- overseasWarehouseId: string
+ operationWarehouseId?: string
+ /** @deprecated compatibility alias for older platform fixtures */
+ overseasWarehouseId?: string
labelDeadline: string
contactName: string
contactPhone: string
@@ -78,6 +80,8 @@ export interface RelabelSummaryDto {
fulfillmentStatus?: string | null
outboundOrderNo?: string | null
customerConfirmedAt?: string | null
+ reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER' | null
+ reviewRemark?: string | null
createdAt?: string | null
}
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
index d231dcea..71c1b291 100644
--- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
+++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
@@ -15,7 +15,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
-import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
+import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
@@ -806,6 +806,25 @@ async function reviewReturn(source: object) {
} else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '退货预报审核失败').message)
}
}
+async function reviewDirectRelabel(source: object) {
+ const row = source as LastMileOrderListItem
+ if (!row.id || !row.customerId) return
+ try {
+ await ElMessageBox.confirm('审核通过后,贴标单将进入海外仓换标作业。', '贴标单审核', { confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
+ await reviewDirectRelabelOrder(row.id, row.customerId, 'APPROVE')
+ ElMessage.success('贴标单已审核通过')
+ await load()
+ } catch (error) {
+ if (error === 'cancel') {
+ try {
+ const { value } = await ElMessageBox.prompt('请填写驳回原因,客户可在贴标单详情查看并修改后重新提交。', '驳回贴标单', { inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
+ await reviewDirectRelabelOrder(row.id, row.customerId, 'REJECT', value)
+ ElMessage.success('贴标单已驳回')
+ await load()
+ } catch (rejectError) { if (rejectError !== 'cancel' && rejectError !== 'close') ElMessage.error(getApiErrorDetails(rejectError, '贴标单审核失败').message) }
+ } else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '贴标单审核失败').message)
+ }
+}
function canConfirmRelabel(row: { relabelStatusCode?: string }) { return row.relabelStatusCode === 'CUSTOMER_CONFIRM_PENDING' }
function canReworkRelabel(row: { relabelStatusCode?: string }) { return ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(row.relabelStatusCode ?? '') }
function hasRelabelActions(row: LastMileOrderListItem) {
@@ -830,6 +849,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
const inboundPlanCreated = hasInboundPlan(row)
return [
{ key: 'detail', label: '查看', priority: 500 },
+ { key: 'review-direct-relabel', label: '审核', priority: 470, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && row.orderTypeCode === 'RELABEL_APPLICATION' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'inbound-plan', label: '生成入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) && !inboundPlanCreated },
@@ -841,6 +861,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
}
function handleLastMileRowAction(command: string, row: LastMileOrderListItem) {
if (command === 'detail') openDetail(row)
+ else if (command === 'review-direct-relabel') void reviewDirectRelabel(row)
else if (command === 'review-return') void reviewReturn(row)
else if (command === 'review') openTransferReview(row)
else if (command === 'inbound-plan') openInboundPlan(row)
diff --git a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts
index 595a3caa..b4d8dd92 100644
--- a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts
+++ b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts
@@ -19,4 +19,13 @@ describe('RelabelOrderDetailView', () => {
expect(source).toContain('value?.application.customerOrderNo || value?.application.lastlegOrderNo')
expect(source).toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId')
})
+
+ it('lets platform operators confirm completion or return PDA work for rework', () => {
+ expect(source).toContain('@click="confirmCompletion"')
+ expect(source).toContain('确认换标完成')
+ expect(source).toContain('@click="returnForRework"')
+ expect(source).toContain('退回重新贴标')
+ expect(source).toContain('await confirmRelabelCompletion(orderId.value)')
+ expect(source).toContain('await returnRelabelForRework(orderId.value')
+ })
})
diff --git a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue
index 7ff83847..1cbd6500 100644
--- a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue
+++ b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue
@@ -1,11 +1,11 @@
-新增库位编辑{{ (row as OverseasLocationDto).status === 'DISABLED' || (row as OverseasLocationDto).status === 'INACTIVE' ? '启用' : '停用' }}删除
{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}{{ (row as OverseasLocationDto).zoneName || '—' }}({{ (row as OverseasLocationDto).zoneCode }}){{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}共 {{ total }} 个库位
取消保存库位
+新增库位编辑{{ (row as OverseasLocationDto).status === 'DISABLED' || (row as OverseasLocationDto).status === 'INACTIVE' ? '启用' : '停用' }}删除
{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}{{ (row as OverseasLocationDto).zoneName || '—' }}({{ (row as OverseasLocationDto).zoneCode }}){{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}共 {{ total }} 个库位
取消保存库位
--
2.54.0
From 2934a795ad52cd3907d7daf2ac527073a5f6df27 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 24 Sep 2026 08:54:28 +0800
Subject: [PATCH 15/36] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../overseas-warehouse/api/locations.ts | 5 +----
.../pages/InboundOrderDetailView.vue | 2 +-
.../pages/LocationsView.vue | 20 +++++++++----------
3 files changed, 12 insertions(+), 15 deletions(-)
diff --git a/src/modules/overseas-warehouse/api/locations.ts b/src/modules/overseas-warehouse/api/locations.ts
index 81c0a4c7..04e46be4 100644
--- a/src/modules/overseas-warehouse/api/locations.ts
+++ b/src/modules/overseas-warehouse/api/locations.ts
@@ -10,8 +10,6 @@ export interface OverseasLocationDto {
warehouseId: string
warehouseCode?: string | null
warehouseName?: string | null
- zoneCode: string
- zoneName?: string | null
locationCode: string
locationName?: string | null
locationType: OverseasLocationType
@@ -26,7 +24,6 @@ export interface OverseasLocationDto {
}
export interface SaveOverseasLocationPayload {
- zoneCode: string
locationCode: string
locationType: 'NORMAL'
maxLoadKg: number
@@ -34,7 +31,7 @@ export interface SaveOverseasLocationPayload {
remark?: string | null
}
-export interface OverseasLocationQuery { page?: number; size?: number; zoneCode?: string; keyword?: string; locationType?: OverseasLocationType | ''; status?: OverseasLocationStatus | '' }
+export interface OverseasLocationQuery { page?: number; size?: number; keyword?: string; locationType?: OverseasLocationType | ''; status?: OverseasLocationStatus | '' }
function base(warehouseId: string) { return `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations` }
diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue
index 5a95845c..58b7462e 100644
--- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue
+++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue
@@ -88,7 +88,7 @@ onMounted(load)
入库单托盘明细
PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。
{{ palletRows.length }} 条移库
预约与排车
- {{ label }}
- {{ fieldValues[label] }}
-
+
--
2.54.0
From 4e37dc3330e78df1d9568340f64a744bdcf24993 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 24 Sep 2026 14:07:48 +0800
Subject: [PATCH 16/36] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/modules/overseas-warehouse/pages/LocationsView.vue | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/modules/overseas-warehouse/pages/LocationsView.vue b/src/modules/overseas-warehouse/pages/LocationsView.vue
index f8fb96ea..10ffe7d3 100644
--- a/src/modules/overseas-warehouse/pages/LocationsView.vue
+++ b/src/modules/overseas-warehouse/pages/LocationsView.vue
@@ -41,11 +41,11 @@ function resetForm() { Object.assign(form, { warehouseId: String(filters.value.w
function openCreate() { resetForm(); editorOpen.value = true }
function openEdit(row: OverseasLocationDto) { if (!canEdit(row)) return; Object.assign(form, { warehouseId: row.warehouseId, locationCode: row.locationCode, locationName: row.locationName || '', locationType: 'NORMAL', maxWeightKg: row.maxLoadKg == null ? null : Number(row.maxLoadKg), status: row.status === 'DISABLED' ? 'INACTIVE' : 'ACTIVE', remark: row.remark || '' }); editing.value = row; editorOpen.value = true }
async function save() { if (!form.warehouseId || !form.locationCode.trim() || form.maxWeightKg == null) { ElMessage.warning('请填写海外仓、库位编码和最大承重'); return }; saving.value = true; try { const payload = { locationCode: form.locationCode.trim(), locationType: 'NORMAL' as const, maxLoadKg: form.maxWeightKg, status: form.status, remark: form.remark.trim() || null }; if (editing.value) await updateOverseasLocation(form.warehouseId, editing.value.id, { ...payload, version: editing.value.version }); else await createOverseasLocation(form.warehouseId, payload); editorOpen.value = false; await load() } catch (error) { actionError(error, '保存库位失败') } finally { saving.value = false } }
-async function toggleStatus(row: OverseasLocationDto) { if (!canStatus(row)) return; const next = row.status === 'DISABLED' ? 'ACTIVE' : 'INACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'}库位“${row.locationCode}”?`, '请确认'); await changeOverseasLocationStatus(row.warehouseId, row.id, { locationCode: row.locationCode, locationType: 'NORMAL', maxLoadKg: Number(row.maxLoadKg || 0), status: next, remark: row.remark || null, version: row.version }); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '更新库位状态失败') } }
+async function toggleStatus(row: OverseasLocationDto) { if (!canStatus(row)) return; const next = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'}库位“${row.locationCode}”?`, '请确认'); await changeOverseasLocationStatus(row.warehouseId, row.id, { locationCode: row.locationCode, locationType: 'NORMAL', maxLoadKg: Number(row.maxLoadKg || 0), status: next, remark: row.remark || null, version: row.version }); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '更新库位状态失败') } }
async function remove(row: OverseasLocationDto) { if (!canDelete(row)) return; try { await ElMessageBox.confirm(`确认删除库位“${row.locationCode}”?`, '请确认', { type: 'warning' }); await deleteOverseasLocation(row.warehouseId, row.id, row.version); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '删除库位失败') } }
onMounted(() => { void loadWarehouses() })
-新增库位编辑{{ (row as OverseasLocationDto).status === 'DISABLED' || (row as OverseasLocationDto).status === 'INACTIVE' ? '启用' : '停用' }}删除
{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}{{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}共 {{ total }} 个库位
取消保存库位
+新增库位编辑{{ (row as OverseasLocationDto).status === 'ACTIVE' ? '停用' : '启用' }}删除
{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}{{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}共 {{ total }} 个库位
取消保存库位
--
2.54.0
From 945a3d3230893a56ca4fee50818076af59b8c8e7 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 24 Sep 2026 16:29:31 +0800
Subject: [PATCH 17/36] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=80=80=E7=A8=8E?=
=?UTF-8?q?=E6=8A=A5=E5=85=B3=E5=9B=9E=E6=89=A7=E4=BA=A4=E4=BA=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../api/platformContainerCustoms.test.ts | 8 +++---
.../api/platformContainerCustoms.ts | 7 ++++--
.../components/ContainerCustomsPanel.test.ts | 25 ++++++++++---------
.../components/ContainerCustomsPanel.vue | 23 ++++++-----------
4 files changed, 30 insertions(+), 33 deletions(-)
diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts
index 199831eb..2cfc01a1 100644
--- a/src/modules/container-loading/api/platformContainerCustoms.test.ts
+++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts
@@ -4,7 +4,7 @@ import { completeAllBuyContainerCustoms, downloadContainerCustomsCompletion, dow
const customs = {
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
- planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', hasActualCargo: true, version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1,
+ planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', hasActualCargo: true, version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12,
inbounds: [{ inboundId: 2092438843630391404n, orderId: 2092438843630391405n, orderNo: 'O-1', inboundCode: 'RK-1', tenantId: 2092438843630391406n, customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, completion: null }],
startedAt: null, completedAt: null,
@@ -39,14 +39,14 @@ describe('platform container customs API', () => {
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
})
- it('uploads one completion file with final-confirmation query parameters', async () => {
+ it('uploads one receipt file without completing the container', async () => {
const document = { ...customs.documents[0], documentId: '2092438843630391407', tenantId: '2092438843630391406' }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: document })
const completion = new File(['done'], 'done.pdf')
- await uploadContainerCustomsCompletion('2092438843630391407', completion, 0, true)
+ await uploadContainerCustomsCompletion('2092438843630391407', completion, 0)
expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/upload')
expect((post.mock.calls[0]?.[1] as FormData).get('file')).toBe(completion)
- expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0, finalConfirmation: true }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
+ expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0 }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
})
it('keeps source and completion downloads isolated on the long document id', async () => {
diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts
index ccfaa157..e1d7bad2 100644
--- a/src/modules/container-loading/api/platformContainerCustoms.ts
+++ b/src/modules/container-loading/api/platformContainerCustoms.ts
@@ -31,6 +31,8 @@ export interface ContainerCustomsDocument {
inboundCodes: string[]
frozen: boolean
completion: ContainerCustomsFile | null
+ receiptStatus?: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null
+ receiptRejectReason?: string | null
}
export interface ContainerCustomsView {
@@ -45,6 +47,7 @@ export interface ContainerCustomsView {
version: number
taxRefundCount: number
coveredTaxRefundCount: number
+ actualBoxCount: number
inbounds: ContainerCustomsInbound[]
documents: ContainerCustomsDocument[]
startedAt: string | null
@@ -105,13 +108,13 @@ export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVe
return action(loadingPlanId, 'complete-all-buy', expectedVersion)
}
-export async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number, finalConfirmation: boolean) {
+export async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number) {
const body = new FormData()
body.append('file', fileValue)
const response = await http.post, ApiEnvelope>(
`${basePath}/documents/${encodeURIComponent(documentId)}/completion/upload`,
body,
- { params: { expectedVersion, finalConfirmation }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-customs-completion') } },
+ { params: { expectedVersion }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-customs-completion') } },
)
const result = unwrapApiResponse(response)
return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, completion: file(result.completion) }
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
index 2e63b4d2..ef943649 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
@@ -28,7 +28,7 @@ import ContainerCustomsPanel from './ContainerCustomsPanel.vue'
function customs(status: ContainerCustomsStatus, hasCompletion = false): ContainerCustomsView {
return {
aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403',
- planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, hasActualCargo: true, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1,
+ planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, hasActualCargo: true, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12,
inbounds: [{ inboundId: '2092438843630391404', orderId: '2092438843630391405', orderNo: 'O-1', inboundCode: 'RK-1', tenantId: '2092438843630391406', customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
documents: [{
documentId: '2092438843630391407', tenantId: '2092438843630391406',
@@ -68,18 +68,19 @@ describe('ContainerCustomsPanel', () => {
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
const wrapper = await render('READY_TO_START')
expect(wrapper.text()).toContain('开始报关')
+ expect(wrapper.text()).toContain('实际箱数 12')
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('客户退税资料')
- expect(wrapper.text()).toContain('报关完成文件')
+ expect(wrapper.text()).toContain('报关行回执')
expect(wrapper.text()).not.toContain('客户报关资料')
expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true)
- expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件')
- expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件')
+ expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
+ expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('shows a successful empty state and hides customs actions when the completed plan has no actual cargo', async () => {
- api.get.mockResolvedValue({ ...customs('READY_TO_START'), hasActualCargo: false, taxRefundCount: 0, coveredTaxRefundCount: 0, inbounds: [], documents: [] })
+ api.get.mockResolvedValue({ ...customs('READY_TO_START'), hasActualCargo: false, taxRefundCount: 0, coveredTaxRefundCount: 0, actualBoxCount: 0, inbounds: [], documents: [] })
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
@@ -106,19 +107,19 @@ describe('ContainerCustomsPanel', () => {
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
let wrapper = await render('DECLARING', true, false)
- expect(wrapper.text()).toContain('上传报关完成文件')
+ expect(wrapper.text()).toContain('上传报关行回执')
expect(wrapper.text()).toContain('提交')
wrapper.unmount()
wrapper = await render('DECLARING', true, true)
- expect(wrapper.text()).toContain('覆盖报关完成文件')
+ expect(wrapper.text()).toContain('覆盖报关行回执')
expect(wrapper.text()).toContain('报关完成文件.pdf')
wrapper.unmount()
wrapper = await render('DECLARING', false, true)
expect(wrapper.text()).toContain('只读')
- expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件')
- expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件')
+ expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
+ expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -127,8 +128,8 @@ describe('ContainerCustomsPanel', () => {
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('报关完成文件.pdf')
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
- expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件')
- expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件')
+ expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
+ expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -139,7 +140,7 @@ describe('ContainerCustomsPanel', () => {
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
expect(api.downloadCompletion).not.toHaveBeenCalled()
- await wrapper.get('[aria-label="下载报关完成文件 报关完成文件.pdf"]').trigger('click')
+ await wrapper.get('[aria-label="下载报关行回执 报关完成文件.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407')
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2)
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue
index 312ceb4b..6002af16 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.vue
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue
@@ -38,7 +38,6 @@ const completionFiles = reactive>({})
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
-const missingCompletionCount = computed(() => customs.value?.documents.filter((item) => !item.completion).length || 0)
const statusText: Record = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
}
@@ -76,19 +75,13 @@ async function uploadCompletion(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
const selected = completionFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择对应的报关完成文件'); return }
- const finalConfirmation = !document.completion && missingCompletionCount.value === 1
- if (finalConfirmation) {
- try {
- await ElMessageBox.confirm('这是最后一份待上传的报关完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认上传并完成' })
- } catch { return }
- }
actionLoading.value = true; actionError.value = null
try {
- await uploadContainerCustomsCompletion(document.documentId, selected, document.completion?.version || 0, finalConfirmation)
+ await uploadContainerCustomsCompletion(document.documentId, selected, document.completion?.version || 0)
completionFiles[document.documentId] = null
await load()
emit('refreshed')
- ElMessage.success(finalConfirmation ? '报关完成文件已上传,整柜报关已完成' : '报关完成文件已上传')
+ ElMessage.success('报关行回执已上传,等待客户确认')
} catch (error) { actionError.value = getApiErrorDetails(error, '报关完成文件上传失败') }
finally { actionLoading.value = false }
}
@@ -168,19 +161,19 @@ onMounted(load)
- 退税总票 {{ customs.taxRefundCount }}已覆盖 {{ customs.coveredTaxRefundCount }}缺失 {{ missingCoverageCount }}
+ 实际箱数 {{ customs.actualBoxCount }}退税总票 {{ customs.taxRefundCount }}已覆盖 {{ customs.coveredTaxRefundCount }}缺失 {{ missingCoverageCount }}
缺退税资料清单
| 客户 | 订单号 | 入仓码 |
|---|
| {{ item.customerName }} | {{ item.orderNo }} | {{ item.inboundCode }} |
-
已冻结退税资料与报关完成文件
-
| 操作 | 客户退税资料 | 报关完成文件 | 关联入仓码 | 版本 | 客户 | 完成情况 |
+ 已冻结退税资料与报关行回执
+ | 操作 | 客户退税资料 | 报关行回执 | 关联入仓码 | 版本 | 客户 | 回执情况 |
- {{ document.completion ? '覆盖报关完成文件' : '上传报关完成文件' }}提交 只读 |
+ {{ document.completion ? '覆盖报关行回执' : '上传报关行回执' }}提交 只读 |
{{ document.source.fileName }}下载 |
- {{ document.completion.fileName }}下载 未上传 |
- {{ document.inboundCodes.join('、') || '—' }} | 退税资料 v{{ document.source.version }} / 报关完成 v{{ document.completion.version }} | {{ documentCustomers(document) }} | {{ document.completion ? '已上传报关完成文件' : '待上传报关完成文件' }} |
+ {{ document.completion.fileName }}下载 未上传 |
+ {{ document.inboundCodes.join('、') || '—' }} | 退税资料 v{{ document.source.version }} / 回执 v{{ document.completion.version }} | {{ documentCustomers(document) }} | {{ document.completion ? '已上传报关行回执' : '待上传报关行回执' }} |
--
2.54.0
From 391369a3d02a18f8682dd0ef6e5b3b20ab1d6dd2 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 24 Sep 2026 17:02:36 +0800
Subject: [PATCH 18/36] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/core/navigation/adminNavigation.ts | 2 +-
.../overseas-warehouse/api/mojiaSkuSync.ts | 11 +++--
.../pages/MojiaSkuSyncView.vue | 41 ++++++++++++-------
src/modules/overseas-warehouse/routes.ts | 2 +-
4 files changed, 37 insertions(+), 19 deletions(-)
diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts
index f2789290..7f18d240 100644
--- a/src/core/navigation/adminNavigation.ts
+++ b/src/core/navigation/adminNavigation.ts
@@ -171,7 +171,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
- { key: 'mojia-sku-sync', title: 'SKU同步', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list' },
+ { key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list' },
],
},
{
diff --git a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
index f0ed8c96..a43de7d6 100644
--- a/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
+++ b/src/modules/overseas-warehouse/api/mojiaSkuSync.ts
@@ -1,10 +1,14 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type MojiaSkuSyncStatus = 'PENDING' | 'SUCCESS' | 'FAILED'
+export type MojiaMeasurementKind = 'BOX_LABEL' | 'SKU'
export interface MojiaSkuSyncRecord {
id: string
- skuCode: string
+ measurementKind: MojiaMeasurementKind
+ boxCode: string
+ inboundCode?: string | null
+ skuCode?: string | null
lengthCm: number
widthCm: number
heightCm: number
@@ -13,7 +17,7 @@ export interface MojiaSkuSyncRecord {
imageUrl?: string | null
measuredAt?: string | null
receivedAt?: string | null
- syncStatus: MojiaSkuSyncStatus
+ syncStatus?: MojiaSkuSyncStatus | null
syncFailureCode?: string | null
syncFailureReason?: string | null
lingxingSkuId?: string | null
@@ -32,7 +36,8 @@ export interface MojiaSkuSyncPage {
export interface MojiaSkuSyncQuery {
page: number
size: number
- skuCode?: string
+ code?: string
+ measurementKind?: MojiaMeasurementKind
syncStatus?: MojiaSkuSyncStatus
}
diff --git a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
index 119c5c81..26415562 100644
--- a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
+++ b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue
@@ -11,7 +11,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorMessage } from '@/shared/api/http'
import {
getMojiaSkuSyncConfig, listMojiaSkuSync, retryMojiaSkuSync, submitLingxingVerificationCode, testLingxingConnection, updateMojiaSkuSync,
- type LingxingConnectionTestResult, type MojiaSkuSyncRecord, type MojiaSkuSyncStatus,
+ type LingxingConnectionTestResult, type MojiaSkuSyncRecord, type MojiaSkuSyncStatus, type MojiaMeasurementKind,
} from '../api/mojiaSkuSync'
const permission = {
@@ -37,14 +37,16 @@ const submittingVerification = ref(false)
const pendingVerificationAction = ref<{ type: 'test' } | { type: 'retry', id: string, revision: number } | null>(null)
const page = ref(1)
const size = ref(20)
-const form = reactive({ skuCode: '', syncStatus: '' as '' | MojiaSkuSyncStatus })
+const form = reactive({ code: '', measurementKind: '' as '' | MojiaMeasurementKind, syncStatus: '' as '' | MojiaSkuSyncStatus })
const editOpen = ref(false)
const editing = ref(null)
const editForm = reactive({ skuCode: '', lengthCm: 0, widthCm: 0, heightCm: 0, weightKg: 0, revision: 0 })
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
- { key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 180, showOverflowTooltip: true },
+ { key: 'sourceCode', label: '上传标识', minWidth: 180, showOverflowTooltip: true, slotName: 'sourceCode' },
+ { key: 'kind', label: '类型', width: 110, slotName: 'kind' },
+ { key: 'inboundCode', prop: 'inboundCode', label: '命中入仓码', minWidth: 150, showOverflowTooltip: true },
{ key: 'image', label: '测量图片', width: 110, slotName: 'image' },
{ key: 'dimensions', label: '长 × 宽 × 高(cm)', width: 170, slotName: 'dimensions' },
{ key: 'weightKg', prop: 'weightKg', label: '重量(kg)', width: 110 },
@@ -65,11 +67,11 @@ async function load() {
loading.value = true
error.value = ''
try {
- const result = await listMojiaSkuSync({ page: page.value, size: size.value, skuCode: form.skuCode || undefined, syncStatus: form.syncStatus || undefined })
+ const result = await listMojiaSkuSync({ page: page.value, size: size.value, code: form.code || undefined, measurementKind: form.measurementKind || undefined, syncStatus: form.syncStatus || undefined })
rows.value = result.records
total.value = result.total
} catch (cause) {
- error.value = getApiErrorMessage(cause, 'SKU同步记录加载失败')
+ error.value = getApiErrorMessage(cause, '称重机器上传记录加载失败')
} finally { loading.value = false }
}
@@ -82,14 +84,22 @@ async function loadConfig() {
}
function search() { page.value = 1; void load() }
-function reset() { form.skuCode = ''; form.syncStatus = ''; search() }
+function reset() { form.code = ''; form.measurementKind = ''; form.syncStatus = ''; search() }
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 19) : '—' }
function asRecord(row: unknown) { return row as MojiaSkuSyncRecord }
-function syncLabel(row: unknown) { return statusLabel[asRecord(row).syncStatus] }
-function syncTagType(row: unknown) { return statusType[asRecord(row).syncStatus] }
+function syncLabel(row: unknown) {
+ const value = asRecord(row)
+ if (value.measurementKind === 'BOX_LABEL') return value.inboundCode ? '已命中入仓码' : '未命中'
+ return value.syncStatus ? statusLabel[value.syncStatus] : '—'
+}
+function syncTagType(row: unknown) {
+ const value = asRecord(row)
+ if (value.measurementKind === 'BOX_LABEL') return value.inboundCode ? 'success' : 'warning'
+ return value.syncStatus ? statusType[value.syncStatus] : 'info'
+}
async function testConnection() {
- const skuCode = form.skuCode.trim()
+ const skuCode = form.code.trim()
if (!skuCode) { ElMessage.warning('请先输入要查询的 SKU'); return }
testingConnection.value = true
try {
@@ -129,7 +139,7 @@ async function submitVerification() {
function openEdit(row: MojiaSkuSyncRecord) {
editing.value = row
- Object.assign(editForm, { skuCode: row.skuCode, lengthCm: row.lengthCm, widthCm: row.widthCm, heightCm: row.heightCm, weightKg: row.weightKg, revision: row.revision })
+ Object.assign(editForm, { skuCode: row.skuCode || '', lengthCm: row.lengthCm, widthCm: row.widthCm, heightCm: row.heightCm, weightKg: row.weightKg, revision: row.revision })
editOpen.value = true
}
@@ -177,7 +187,7 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
-
+
{ void Promise.all([loadConfig(), load()]) })
show-icon
:closable="false"
/>
-
+
-
+
+
@@ -203,9 +214,11 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
编辑
- 人工推送
+ 人工推送
+
{{ row.measurementKind === 'SKU' ? row.skuCode : row.boxCode }}
+
{{ row.measurementKind === 'SKU' ? 'SKU测量' : '箱码测量' }}
—
{{ row.lengthCm }} × {{ row.widthCm }} × {{ row.heightCm }}
{{ syncLabel(row) }}
diff --git a/src/modules/overseas-warehouse/routes.ts b/src/modules/overseas-warehouse/routes.ts
index aeb544fa..ec079384 100644
--- a/src/modules/overseas-warehouse/routes.ts
+++ b/src/modules/overseas-warehouse/routes.ts
@@ -140,6 +140,6 @@ export const overseasWarehouseRoutes = [
path: 'general/mojia-sku-sync',
name: 'mojia-sku-sync',
component: () => import('./pages/MojiaSkuSyncView.vue'),
- meta: { title: 'SKU同步', description: '查看墨家测量数据并同步领星 SKU 尺寸重量', permission: permission.mojiaSkuSyncList },
+ meta: { title: '称重机器', description: '查看墨家机器上传的箱码和 SKU 测量记录', permission: permission.mojiaSkuSyncList },
},
] satisfies RouteRecordRaw[]
--
2.54.0
From d2e063ea3f53e700ec76d1b59e25697cce9e21ff Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sat, 26 Sep 2026 13:28:48 +0800
Subject: [PATCH 19/36] feat(admin): manage customs receipt and final files
---
.../api/platformContainerCustoms.test.ts | 12 +--
.../api/platformContainerCustoms.ts | 37 +++++++---
.../components/ContainerCustomsPanel.test.ts | 36 +++++----
.../components/ContainerCustomsPanel.vue | 73 ++++++++++++++-----
4 files changed, 109 insertions(+), 49 deletions(-)
diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts
index 2cfc01a1..7e1a5eb7 100644
--- a/src/modules/container-loading/api/platformContainerCustoms.test.ts
+++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts
@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
-import { completeAllBuyContainerCustoms, downloadContainerCustomsCompletion, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, uploadContainerCustomsCompletion } from './platformContainerCustoms'
+import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
const customs = {
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', hasActualCargo: true, version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12,
inbounds: [{ inboundId: 2092438843630391404n, orderId: 2092438843630391405n, orderNo: 'O-1', inboundCode: 'RK-1', tenantId: 2092438843630391406n, customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
- documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, completion: null }],
+ documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, receipt: null, finalDocument: null }],
startedAt: null, completedAt: null,
}
@@ -43,8 +43,8 @@ describe('platform container customs API', () => {
const document = { ...customs.documents[0], documentId: '2092438843630391407', tenantId: '2092438843630391406' }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: document })
const completion = new File(['done'], 'done.pdf')
- await uploadContainerCustomsCompletion('2092438843630391407', completion, 0)
- expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/upload')
+ await uploadContainerCustomsReceipt('2092438843630391407', completion, 0)
+ expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/receipt/upload')
expect((post.mock.calls[0]?.[1] as FormData).get('file')).toBe(completion)
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0 }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
})
@@ -56,11 +56,11 @@ describe('platform container customs API', () => {
const documentId = '2092438843630391407'
await expect(downloadContainerCustomsDocument(documentId)).resolves.toBe(source)
- await expect(downloadContainerCustomsCompletion(documentId)).resolves.toBe(completion)
+ await expect(downloadContainerCustomsReceipt(documentId)).resolves.toBe(completion)
expect(get.mock.calls).toEqual([
[`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }],
- [`/platform/v1/customs/loading-plans/documents/${documentId}/completion/download`, { responseType: 'blob' }],
+ [`/platform/v1/customs/loading-plans/documents/${documentId}/receipt/download`, { responseType: 'blob' }],
])
})
})
diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts
index e1d7bad2..594621db 100644
--- a/src/modules/container-loading/api/platformContainerCustoms.ts
+++ b/src/modules/container-loading/api/platformContainerCustoms.ts
@@ -30,9 +30,10 @@ export interface ContainerCustomsDocument {
inboundIds: string[]
inboundCodes: string[]
frozen: boolean
- completion: ContainerCustomsFile | null
+ receipt: ContainerCustomsFile | null
receiptStatus?: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null
receiptRejectReason?: string | null
+ finalDocument: ContainerCustomsFile | null
}
export interface ContainerCustomsView {
@@ -60,7 +61,7 @@ interface RawContainerCustomsView extends Omit
& { inboundId: string | number; orderId: string | number; tenantId: string | number }>
- documents: Array & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; completion: ContainerCustomsFile | null }>
+ documents: Array & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; receipt: ContainerCustomsFile | null; finalDocument: ContainerCustomsFile | null }>
}
const basePath = '/platform/v1/customs/loading-plans'
@@ -73,7 +74,7 @@ function normalize(value: RawContainerCustomsView): ContainerCustomsView {
...value,
aggregateId: String(value.aggregateId), loadingPlanId: String(value.loadingPlanId), containerBookingId: String(value.containerBookingId), version: Number(value.version),
inbounds: value.inbounds.map((item) => ({ ...item, inboundId: String(item.inboundId), orderId: String(item.orderId), tenantId: String(item.tenantId) })),
- documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, completion: file(item.completion) })),
+ documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, receipt: file(item.receipt), finalDocument: file(item.finalDocument) })),
}
}
@@ -87,11 +88,15 @@ export function downloadContainerCustomsDocument(documentId: string) {
return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
}
-export function downloadContainerCustomsCompletion(documentId: string) {
- return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`, { responseType: 'blob' })
+export function downloadContainerCustomsReceipt(documentId: string) {
+ return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' })
}
-async function action(loadingPlanId: string, name: 'start' | 'complete-all-buy', expectedVersion: number, remark?: string) {
+export function downloadContainerCustomsFinal(documentId: string) {
+ return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' })
+}
+
+async function action(loadingPlanId: string, name: 'start' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) {
const response = await http.post, ApiEnvelope>(
`${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`,
{ expectedVersion, confirm: true, remark: remark?.trim() || null },
@@ -108,14 +113,26 @@ export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVe
return action(loadingPlanId, 'complete-all-buy', expectedVersion)
}
-export async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number) {
+export function submitContainerCustomsToBroker(loadingPlanId: string, expectedVersion: number) {
+ return action(loadingPlanId, 'submit-to-broker', expectedVersion, '已将客户确认回执发送给报关行')
+}
+
+async function uploadFile(documentId: string, fileValue: File, expectedVersion: number, kind: 'receipt' | 'final') {
const body = new FormData()
body.append('file', fileValue)
const response = await http.post, ApiEnvelope>(
- `${basePath}/documents/${encodeURIComponent(documentId)}/completion/upload`,
+ `${basePath}/documents/${encodeURIComponent(documentId)}/${kind}/upload`,
body,
- { params: { expectedVersion }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-customs-completion') } },
+ { params: { expectedVersion }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId(`container-customs-${kind}`) } },
)
const result = unwrapApiResponse(response)
- return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, completion: file(result.completion) }
+ return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, receipt: file(result.receipt), finalDocument: file(result.finalDocument) }
+}
+
+export function uploadContainerCustomsReceipt(documentId: string, fileValue: File, expectedVersion: number) {
+ return uploadFile(documentId, fileValue, expectedVersion, 'receipt')
+}
+
+export function uploadContainerCustomsFinal(documentId: string, fileValue: File, expectedVersion: number) {
+ return uploadFile(documentId, fileValue, expectedVersion, 'final')
}
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
index ef943649..86fce58b 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
@@ -10,16 +10,19 @@ const api = vi.hoisted(() => ({
completeAllBuy: vi.fn(),
upload: vi.fn(),
downloadSource: vi.fn(),
- downloadCompletion: vi.fn(),
+ downloadReceipt: vi.fn(),
+ downloadFinal: vi.fn(),
}))
vi.mock('../api/platformContainerCustoms', () => ({
getContainerCustoms: api.get,
startContainerCustoms: api.start,
completeAllBuyContainerCustoms: api.completeAllBuy,
- uploadContainerCustomsCompletion: api.upload,
+ uploadContainerCustomsReceipt: api.upload,
downloadContainerCustomsDocument: api.downloadSource,
- downloadContainerCustomsCompletion: api.downloadCompletion,
+ downloadContainerCustomsReceipt: api.downloadReceipt,
+ downloadContainerCustomsFinal: api.downloadFinal,
+ submitContainerCustomsToBroker: vi.fn(),
}))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
@@ -34,7 +37,8 @@ function customs(status: ContainerCustomsStatus, hasCompletion = false): Contain
documentId: '2092438843630391407', tenantId: '2092438843630391406',
source: { fileId: '2092438843630391408', fileName: '客户退税资料.pdf', fileHash: 'source-hash', sizeBytes: 12, version: 2, uploadedAt: '2026-08-29T10:00:00' },
inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true,
- completion: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关完成文件.pdf', fileHash: 'completion-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null,
+ receipt: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关行回执.pdf', fileHash: 'receipt-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null,
+ finalDocument: hasCompletion ? { fileId: '2092438843630391410', fileName: '真实报关完税文件.pdf', fileHash: 'final-hash', sizeBytes: 14, version: 1, uploadedAt: '2026-08-29T12:00:00' } : null,
}],
startedAt: status === 'DECLARING' || status === 'COMPLETED' ? '2026-08-29T10:30:00' : null,
completedAt: status === 'COMPLETED' ? '2026-08-29T11:30:00' : null,
@@ -59,7 +63,8 @@ describe('ContainerCustomsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
api.downloadSource.mockResolvedValue(new Blob(['source']))
- api.downloadCompletion.mockResolvedValue(new Blob(['completion']))
+ api.downloadReceipt.mockResolvedValue(new Blob(['receipt']))
+ api.downloadFinal.mockResolvedValue(new Blob(['final']))
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:customs-file') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
@@ -113,7 +118,8 @@ describe('ContainerCustomsPanel', () => {
wrapper = await render('DECLARING', true, true)
expect(wrapper.text()).toContain('覆盖报关行回执')
- expect(wrapper.text()).toContain('报关完成文件.pdf')
+ expect(wrapper.text()).toContain('报关行回执.pdf')
+ expect(wrapper.text()).toContain('真实报关完税文件.pdf')
wrapper.unmount()
wrapper = await render('DECLARING', false, true)
@@ -126,7 +132,8 @@ describe('ContainerCustomsPanel', () => {
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
const wrapper = await render('COMPLETED', true, true)
expect(wrapper.text()).toContain('客户退税资料.pdf')
- expect(wrapper.text()).toContain('报关完成文件.pdf')
+ expect(wrapper.text()).toContain('报关行回执.pdf')
+ expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
@@ -138,13 +145,16 @@ describe('ContainerCustomsPanel', () => {
await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
- expect(api.downloadCompletion).not.toHaveBeenCalled()
+ expect(api.downloadReceipt).not.toHaveBeenCalled()
- await wrapper.get('[aria-label="下载报关行回执 报关完成文件.pdf"]').trigger('click')
+ await wrapper.get('[aria-label="下载报关行回执 报关行回执.pdf"]').trigger('click')
await flushPromises()
- expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407')
- expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2)
- expect(URL.createObjectURL).toHaveBeenCalledTimes(2)
+ expect(api.downloadReceipt).toHaveBeenCalledWith('2092438843630391407')
+ await wrapper.get('[aria-label="下载报关完税文件 真实报关完税文件.pdf"]').trigger('click')
+ await flushPromises()
+ expect(api.downloadFinal).toHaveBeenCalledWith('2092438843630391407')
+ expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(3)
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(3)
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:customs-file')
})
@@ -159,6 +169,6 @@ describe('ContainerCustomsPanel', () => {
expect(wrapper.text()).toContain('CUSTOMS_FILE_404')
expect(wrapper.text()).toContain('request-customs-download')
expect(wrapper.text()).toContain('trace-customs-download')
- expect(api.downloadCompletion).not.toHaveBeenCalled()
+ expect(api.downloadReceipt).not.toHaveBeenCalled()
})
})
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue
index 6002af16..b041e8f9 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.vue
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue
@@ -9,11 +9,14 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import {
completeAllBuyContainerCustoms,
- downloadContainerCustomsCompletion,
+ downloadContainerCustomsReceipt,
+ downloadContainerCustomsFinal,
downloadContainerCustomsDocument,
getContainerCustoms,
startContainerCustoms,
- uploadContainerCustomsCompletion,
+ submitContainerCustomsToBroker,
+ uploadContainerCustomsReceipt,
+ uploadContainerCustomsFinal,
type ContainerCustomsDocument,
type ContainerCustomsStatus,
type ContainerCustomsView,
@@ -33,7 +36,8 @@ const actionLoading = ref(false)
const loadError = ref(null)
const actionError = ref(null)
const downloadingKey = ref('')
-const completionFiles = reactive>({})
+const receiptFiles = reactive>({})
+const finalFiles = reactive>({})
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
@@ -45,9 +49,14 @@ const statusText: Record = {
function documentCustomers(document: ContainerCustomsDocument) {
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
}
-function selectCompletion(documentId: string, upload: UploadFile) {
+function selectReceipt(documentId: string, upload: UploadFile) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
- completionFiles[documentId] = upload.raw || null
+ receiptFiles[documentId] = upload.raw || null
+ actionError.value = null
+}
+function selectFinal(documentId: string, upload: UploadFile) {
+ if (!props.canManage || customs.value?.status !== 'DECLARING') return
+ finalFiles[documentId] = upload.raw || null
actionError.value = null
}
@@ -71,18 +80,31 @@ async function start() {
finally { actionLoading.value = false }
}
-async function uploadCompletion(document: ContainerCustomsDocument) {
+async function uploadReceipt(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
- const selected = completionFiles[document.documentId]
- if (!selected) { ElMessage.warning('请选择对应的报关完成文件'); return }
+ const selected = receiptFiles[document.documentId]
+ if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return }
actionLoading.value = true; actionError.value = null
try {
- await uploadContainerCustomsCompletion(document.documentId, selected, document.completion?.version || 0)
- completionFiles[document.documentId] = null
+ await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0)
+ receiptFiles[document.documentId] = null
await load()
emit('refreshed')
ElMessage.success('报关行回执已上传,等待客户确认')
- } catch (error) { actionError.value = getApiErrorDetails(error, '报关完成文件上传失败') }
+ } catch (error) { actionError.value = getApiErrorDetails(error, '报关行回执上传失败') }
+ finally { actionLoading.value = false }
+}
+
+async function uploadFinal(document: ContainerCustomsDocument) {
+ if (!props.canManage || customs.value?.status !== 'DECLARING') return
+ const selected = finalFiles[document.documentId]
+ if (!selected) { ElMessage.warning('请选择真实报关完税文件'); return }
+ actionLoading.value = true; actionError.value = null
+ try {
+ await uploadContainerCustomsFinal(document.documentId, selected, document.finalDocument?.version || 0)
+ finalFiles[document.documentId] = null
+ await load(); emit('refreshed'); ElMessage.success('真实报关完税文件已上传')
+ } catch (error) { actionError.value = getApiErrorDetails(error, '真实报关完税文件上传失败') }
finally { actionLoading.value = false }
}
@@ -95,8 +117,8 @@ function saveBlob(blob: Blob, fileName: string) {
URL.revokeObjectURL(url)
}
-async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | 'completion') {
- const target = kind === 'source' ? document.source : document.completion
+async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | 'receipt' | 'final') {
+ const target = kind === 'source' ? document.source : kind === 'receipt' ? document.receipt : document.finalDocument
if (!target) return
const key = `${kind}:${document.documentId}`
downloadingKey.value = key
@@ -104,9 +126,9 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
try {
const blob = kind === 'source'
? await downloadContainerCustomsDocument(document.documentId)
- : await downloadContainerCustomsCompletion(document.documentId)
+ : kind === 'receipt' ? await downloadContainerCustomsReceipt(document.documentId) : await downloadContainerCustomsFinal(document.documentId)
saveBlob(blob, target.fileName)
- ElMessage.success(`${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载已开始`)
+ ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '报关行回执' : '报关完税文件'}下载已开始`)
} catch (error) {
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`)
} finally {
@@ -114,6 +136,15 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
}
}
+async function submitToBroker() {
+ if (!customs.value || customs.value.status !== 'DECLARING') return
+ try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', '发送报关行执行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
+ actionLoading.value = true; actionError.value = null
+ try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') }
+ catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') }
+ finally { actionLoading.value = false }
+}
+
async function completeAllBuy() {
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
@@ -152,6 +183,7 @@ onMounted(load)
{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }}
导出买单 XLSX
开始报关
+ 发送报关行执行
确认报关完成
@@ -167,13 +199,14 @@ onMounted(load)
| 客户 | 订单号 | 入仓码 |
|---|
| {{ item.customerName }} | {{ item.orderNo }} | {{ item.inboundCode }} |
-
已冻结退税资料与报关行回执
-
| 操作 | 客户退税资料 | 报关行回执 | 关联入仓码 | 版本 | 客户 | 回执情况 |
+ 已冻结退税资料、报关行回执与完税文件
+ | 操作 | 客户退税资料 | 报关行回执 | 真实报关完税文件 | 关联入仓码 | 版本 | 客户 | 回执情况 |
- {{ document.completion ? '覆盖报关行回执' : '上传报关行回执' }}提交 只读 |
+ {{ document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}提交{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}提交 只读 |
{{ document.source.fileName }}下载 |
- {{ document.completion.fileName }}下载 未上传 |
- {{ document.inboundCodes.join('、') || '—' }} | 退税资料 v{{ document.source.version }} / 回执 v{{ document.completion.version }} | {{ documentCustomers(document) }} | {{ document.completion ? '已上传报关行回执' : '待上传报关行回执' }} |
+ {{ document.receipt.fileName }}下载 未上传 |
+ {{ document.finalDocument.fileName }}下载 未上传 |
+ {{ document.inboundCodes.join('、') || '—' }} | 退税资料 v{{ document.source.version }} / 回执 v{{ document.receipt.version }} / 完税 v{{ document.finalDocument.version }} | {{ documentCustomers(document) }} | {{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : '已上传回执') : '待上传报关行回执' }} |
--
2.54.0
From 47bd39d2d507b1c941a4317f3fb27b14b47f4f19 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sat, 26 Sep 2026 14:22:45 +0800
Subject: [PATCH 20/36] feat(admin): gate final customs upload on broker
submission
---
.../container-loading/api/platformContainerCustoms.ts | 1 +
.../container-loading/components/ContainerCustomsPanel.vue | 5 +++--
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts
index 594621db..37b7dc0d 100644
--- a/src/modules/container-loading/api/platformContainerCustoms.ts
+++ b/src/modules/container-loading/api/platformContainerCustoms.ts
@@ -53,6 +53,7 @@ export interface ContainerCustomsView {
documents: ContainerCustomsDocument[]
startedAt: string | null
completedAt: string | null
+ brokerSubmittedAt?: string | null
}
interface RawContainerCustomsView extends Omit {
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue
index b041e8f9..a72dcbc4 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.vue
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue
@@ -42,6 +42,7 @@ const finalFiles = reactive>({})
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
+const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
const statusText: Record = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
}
@@ -202,11 +203,11 @@ onMounted(load)
已冻结退税资料、报关行回执与完税文件
| 操作 | 客户退税资料 | 报关行回执 | 真实报关完税文件 | 关联入仓码 | 版本 | 客户 | 回执情况 |
- {{ document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}提交{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}提交 只读 |
+ {{ document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}提交{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}提交 只读 |
{{ document.source.fileName }}下载 |
{{ document.receipt.fileName }}下载 未上传 |
{{ document.finalDocument.fileName }}下载 未上传 |
- {{ document.inboundCodes.join('、') || '—' }} | 退税资料 v{{ document.source.version }} / 回执 v{{ document.receipt.version }} / 完税 v{{ document.finalDocument.version }} | {{ documentCustomers(document) }} | {{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : '已上传回执') : '待上传报关行回执' }} |
+ {{ document.inboundCodes.join('、') || '—' }} | {{ brokerSubmitted ? '已发送报关行执行' : '待发送报关行执行' }} | 退税资料 v{{ document.source.version }} / 回执 v{{ document.receipt.version }} / 完税 v{{ document.finalDocument.version }} | {{ documentCustomers(document) }} | {{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : '已上传回执') : '待上传报关行回执' }} |
--
2.54.0
From 4a0dd3b59779f8b6ed7d402689e0cfe3fe6c6694 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sat, 26 Sep 2026 17:31:29 +0800
Subject: [PATCH 21/36] fix(admin): align customs document workflow status
---
.../components/ContainerCustomsPanel.vue | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue
index a72dcbc4..23c434b2 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.vue
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue
@@ -82,7 +82,7 @@ async function start() {
}
async function uploadReceipt(document: ContainerCustomsDocument) {
- if (!props.canManage || customs.value?.status !== 'DECLARING') return
+ if (!props.canManage || customs.value?.status !== 'DECLARING' || brokerSubmitted.value) return
const selected = receiptFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return }
actionLoading.value = true; actionError.value = null
@@ -184,7 +184,7 @@ onMounted(load)
{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }}
导出买单 XLSX
开始报关
- 发送报关行执行
+ 发送报关行执行
确认报关完成
@@ -201,13 +201,13 @@ onMounted(load)
已冻结退税资料、报关行回执与完税文件
-
| 操作 | 客户退税资料 | 报关行回执 | 真实报关完税文件 | 关联入仓码 | 版本 | 客户 | 回执情况 |
+ | 操作 | 客户退税资料 | 报关行回执 | 真实报关完税文件 | 关联入仓码 | 报关行状态 | 版本 | 客户 | 回执情况 |
- {{ document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}提交{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}提交 只读 |
+ {{ document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}提交{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}提交 只读 |
{{ document.source.fileName }}下载 |
{{ document.receipt.fileName }}下载 未上传 |
{{ document.finalDocument.fileName }}下载 未上传 |
- {{ document.inboundCodes.join('、') || '—' }} | {{ brokerSubmitted ? '已发送报关行执行' : '待发送报关行执行' }} | 退税资料 v{{ document.source.version }} / 回执 v{{ document.receipt.version }} / 完税 v{{ document.finalDocument.version }} | {{ documentCustomers(document) }} | {{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : '已上传回执') : '待上传报关行回执' }} |
+ {{ document.inboundCodes.join('、') || '—' }} | {{ brokerSubmitted ? '已发送报关行执行' : '待发送报关行执行' }} | 退税资料 v{{ document.source.version }} / 回执 v{{ document.receipt.version }} / 完税 v{{ document.finalDocument.version }} | {{ documentCustomers(document) }} | {{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }} |
--
2.54.0
From ec6de70e670fb92238b2b321cb358fbfc3083677 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 27 Sep 2026 18:55:19 +0800
Subject: [PATCH 22/36] feat(admin): add sailing calendar management
---
src/app/router/index.ts | 2 +
src/core/navigation/adminNavigation.test.ts | 4 +
src/core/navigation/adminNavigation.ts | 2 +
.../api/platformSailingCalendar.ts | 199 ++++++++++
src/modules/sailing-calendar/index.ts | 2 +
.../pages/SailingCalendarView.vue | 361 ++++++++++++++++++
src/modules/sailing-calendar/routes.ts | 8 +
src/modules/sailing-calendar/types.ts | 295 ++++++++++++++
8 files changed, 873 insertions(+)
create mode 100644 src/modules/sailing-calendar/api/platformSailingCalendar.ts
create mode 100644 src/modules/sailing-calendar/index.ts
create mode 100644 src/modules/sailing-calendar/pages/SailingCalendarView.vue
create mode 100644 src/modules/sailing-calendar/routes.ts
create mode 100644 src/modules/sailing-calendar/types.ts
diff --git a/src/app/router/index.ts b/src/app/router/index.ts
index e64799da..de5e877a 100644
--- a/src/app/router/index.ts
+++ b/src/app/router/index.ts
@@ -32,6 +32,7 @@ import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes'
import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
import { pdaReleaseRoutes } from '@/modules/pda-releases/routes'
+import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes'
const router = createRouter({
history: createWebHistory(),
@@ -77,6 +78,7 @@ const router = createRouter({
...downloadRoutes,
...iamRoutes,
...pdaReleaseRoutes,
+ ...sailingCalendarRoutes,
{
path: '403',
name: 'forbidden',
diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts
index 79180a7d..ec14da23 100644
--- a/src/core/navigation/adminNavigation.test.ts
+++ b/src/core/navigation/adminNavigation.test.ts
@@ -35,6 +35,7 @@ describe('adminNavigation', () => {
'库位管理',
'托盘管理',
'整柜业务',
+ '船期日历',
])
expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访'])
expect(adminNavigation.find((item) => item.key === 'supplier-center')).toBeUndefined()
@@ -47,6 +48,7 @@ describe('adminNavigation', () => {
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
'尾程订单',
'入库单',
+ '库位管理',
'排车列表',
'出库单',
'派送单列表',
@@ -68,6 +70,7 @@ describe('adminNavigation', () => {
'船期价格表',
'车辆列表',
'下载中心',
+ '称重机器',
])
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
@@ -76,6 +79,7 @@ describe('adminNavigation', () => {
'租户管理',
'品牌设置',
'菜单权限',
+ 'PDA版本',
])
})
diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts
index 7f18d240..91e825cd 100644
--- a/src/core/navigation/adminNavigation.ts
+++ b/src/core/navigation/adminNavigation.ts
@@ -1,6 +1,7 @@
import type { Component } from 'vue'
import {
Building2,
+ CalendarDays,
ContactRound,
ClipboardList,
CircleDollarSign,
@@ -70,6 +71,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
+ { key: 'sailing-calendar', title: '船期日历', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' },
],
},
{
diff --git a/src/modules/sailing-calendar/api/platformSailingCalendar.ts b/src/modules/sailing-calendar/api/platformSailingCalendar.ts
new file mode 100644
index 00000000..010b2f78
--- /dev/null
+++ b/src/modules/sailing-calendar/api/platformSailingCalendar.ts
@@ -0,0 +1,199 @@
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import type { SailingAssignment, SailingBookingAdvice, SailingBookingLinkRequest, SailingCalendarEntry, SailingCalendarQuery, SailingCalendarResponse, SailingChannelResource, SailingChannelSaveRequest, SailingDetail, SailingExceptionPage, SailingExceptionQuery, SailingExceptionRecord, SailingExceptionReassignRequest, SailingExceptionReassignResult, SailingGenerationPreview, SailingGenerationResult, SailingSaveRequest, SailingTemplateResource, SailingTemplateSaveRequest, SailingVolumeDetail } from '../types'
+
+const basePath = '/platform/v1/sailings'
+
+function number(value: unknown, fallback = 0) {
+ const parsed = Number(value)
+ return Number.isFinite(parsed) ? parsed : fallback
+}
+
+function entry(raw: SailingCalendarEntry): SailingCalendarEntry {
+ return {
+ ...raw,
+ id: String(raw.id),
+ sailingNo: String(raw.sailingNo ?? ''),
+ warehouseId: String(raw.warehouseId ?? ''),
+ channelCode: String(raw.channelCode ?? ''),
+ channelName: String(raw.channelName ?? raw.channelCode ?? ''),
+ volumeSummary: summary(raw.volumeSummary),
+ version: number(raw.version),
+ }
+}
+
+function summary(raw?: Partial | null) {
+ return {
+ actualReceivedCbm: number(raw?.actualReceivedCbm),
+ catchableForecastCbm: number(raw?.catchableForecastCbm),
+ riskUnmatchedCbm: number(raw?.riskUnmatchedCbm),
+ bookedContainerCount: number(raw?.bookedContainerCount),
+ recommendedContainerCount: number(raw?.recommendedContainerCount),
+ pendingContainerCount: number(raw?.pendingContainerCount),
+ lateBatchCount: number(raw?.lateBatchCount),
+ overCapacity: Boolean(raw?.overCapacity),
+ planningCapacityCbm: raw?.planningCapacityCbm == null ? null : number(raw.planningCapacityCbm),
+ bookedPlanningCapacityCbm: raw?.bookedPlanningCapacityCbm == null ? null : number(raw.bookedPlanningCapacityCbm),
+ catchableReceivedCbm: raw?.catchableReceivedCbm == null ? null : number(raw.catchableReceivedCbm),
+ lateReceivedCbm: raw?.lateReceivedCbm == null ? null : number(raw.lateReceivedCbm),
+ snapshotAt: raw?.snapshotAt ?? null,
+ }
+}
+
+function assignment(raw: SailingAssignment): SailingAssignment {
+ return {
+ ...raw,
+ id: String(raw.id),
+ volumeCbm: number(raw.volumeCbm),
+ assignmentVersion: raw.assignmentVersion == null ? null : number(raw.assignmentVersion),
+ }
+}
+
+function history(raw: SailingDetail['histories'][number]): SailingDetail['histories'][number] {
+ return { ...raw, id: String(raw.id) }
+}
+
+function calendar(raw: SailingCalendarResponse): SailingCalendarResponse {
+ const source = raw || {} as SailingCalendarResponse
+ const rawSummary = source.summary || {} as SailingCalendarResponse['summary']
+ return {
+ ...source,
+ records: (source.records || []).map(entry),
+ summary: { ...summary(rawSummary), sailingCount: number(rawSummary.sailingCount) },
+ filterOptions: {
+ warehouses: source.filterOptions?.warehouses || [],
+ channels: source.filterOptions?.channels || [],
+ departurePorts: source.filterOptions?.departurePorts || [],
+ destinationPorts: source.filterOptions?.destinationPorts || [],
+ },
+ }
+}
+
+export async function listSailingCalendar(query: SailingCalendarQuery) {
+ const response = await http.get, ApiEnvelope>(`${basePath}/calendar`, { params: query })
+ return calendar(unwrapApiResponse(response))
+}
+
+export async function getSailing(id: string) {
+ const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`)
+ const result = unwrapApiResponse(response)
+ return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
+}
+
+function idempotencyKey(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
+
+export async function createSailing(payload: SailingSaveRequest) {
+ const response = await http.post, ApiEnvelope>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing') } })
+ const result = unwrapApiResponse(response)
+ return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
+}
+
+export async function updateSailing(id: string, payload: SailingSaveRequest) {
+ const response = await http.put, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing') } })
+ const result = unwrapApiResponse(response)
+ return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
+}
+
+export async function cancelSailing(id: string, expectedVersion: number, reason: string) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/cancel`, { expectedVersion, reason }, { headers: { 'Idempotency-Key': idempotencyKey('sailing-cancel') } })
+ const result = unwrapApiResponse(response)
+ return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
+}
+
+export async function copySailing(id: string, payload: { etdAt: string; cutoffAt: string }) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/copy`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-copy') } })
+ const result = unwrapApiResponse(response)
+ return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
+}
+
+export async function getSailingVolumeDetail(id: string) {
+ const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/volume-summary`)
+ const result = unwrapApiResponse(response)
+ return { ...result, summary: summary(result.summary), assignments: (result.assignments || []).map(assignment) }
+}
+
+export async function getSailingBookingAdvice(id: string) {
+ const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/booking-advice`)
+ const result = unwrapApiResponse(response)
+ return {
+ ...result,
+ actualReceivedCbm: number(result.actualReceivedCbm),
+ catchableTotalCbm: number(result.catchableTotalCbm),
+ planningCapacityCbm: result.planningCapacityCbm == null ? null : number(result.planningCapacityCbm),
+ bookedContainerCount: number(result.bookedContainerCount),
+ recommendedContainerCount: number(result.recommendedContainerCount),
+ pendingContainerCount: number(result.pendingContainerCount),
+ catchableReceivedCbm: result.catchableReceivedCbm == null ? null : number(result.catchableReceivedCbm),
+ lateReceivedCbm: result.lateReceivedCbm == null ? null : number(result.lateReceivedCbm),
+ scenarios: (result.scenarios || []).map((scenario) => ({ ...scenario, capacityCbm: number(scenario.capacityCbm), recommendedContainerCount: number(scenario.recommendedContainerCount), pendingContainerCount: number(scenario.pendingContainerCount) })),
+ }
+}
+
+export async function createSailingBookingLink(id: string, payload: SailingBookingLinkRequest) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/booking-links`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-booking-link') } })
+ return unwrapApiResponse(response)
+}
+
+const channelPath = '/platform/v1/sailing-channels'
+const templatePath = '/platform/v1/sailing-templates'
+const exceptionPath = '/platform/v1/sailing-assignments/exceptions'
+
+function channel(raw: SailingChannelResource): SailingChannelResource { return { ...raw, id: String(raw.id), defaultCutoffDays: number(raw.defaultCutoffDays), version: number(raw.version) } }
+function template(raw: SailingTemplateResource): SailingTemplateResource { return { ...raw, id: String(raw.id), channelId: String(raw.channelId), warehouseId: String(raw.warehouseId), weekdays: (raw.weekdays || []).map((value) => number(value)), cutoffDays: number(raw.cutoffDays), version: number(raw.version) } }
+
+export async function listSailingChannels() {
+ const response = await http.get, ApiEnvelope>(channelPath)
+ const result = unwrapApiResponse(response) as SailingChannelResource[] | { records?: SailingChannelResource[] } | null
+ return (Array.isArray(result) ? result : result?.records || []).map(channel)
+}
+export async function createSailingChannel(payload: SailingChannelSaveRequest) {
+ const response = await http.post, ApiEnvelope>(channelPath, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-channel') } })
+ return channel(unwrapApiResponse(response))
+}
+export async function updateSailingChannel(id: string, payload: SailingChannelSaveRequest) {
+ const response = await http.put, ApiEnvelope>(`${channelPath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-channel') } })
+ return channel(unwrapApiResponse(response))
+}
+export async function listSailingTemplates() {
+ const response = await http.get, ApiEnvelope>(templatePath)
+ const result = unwrapApiResponse(response) as SailingTemplateResource[] | { records?: SailingTemplateResource[] } | null
+ return (Array.isArray(result) ? result : result?.records || []).map(template)
+}
+export async function createSailingTemplate(payload: SailingTemplateSaveRequest) {
+ const response = await http.post, ApiEnvelope>(templatePath, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-template') } })
+ return template(unwrapApiResponse(response))
+}
+export async function updateSailingTemplate(id: string, payload: SailingTemplateSaveRequest) {
+ const response = await http.put, ApiEnvelope>(`${templatePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-template') } })
+ return template(unwrapApiResponse(response))
+}
+export async function previewSailingTemplateGeneration(id: string, payload: { from: string; to: string }) {
+ const response = await http.post, ApiEnvelope>(`${templatePath}/${encodeURIComponent(id)}/generation-preview`, payload)
+ return unwrapApiResponse(response)
+}
+export async function generateSailingTemplate(id: string, payload: { from: string; to: string }) {
+ const response = await http.post, ApiEnvelope>(`${templatePath}/${encodeURIComponent(id)}/generate`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-template-generate') } })
+ return unwrapApiResponse(response)
+}
+
+function exceptionRecord(raw: SailingExceptionRecord): SailingExceptionRecord {
+ return {
+ ...raw,
+ assignmentId: String(raw.assignmentId),
+ volumeCbm: number(raw.volumeCbm),
+ waitingDays: raw.waitingDays == null ? null : number(raw.waitingDays),
+ assignmentVersion: raw.assignmentVersion == null ? null : number(raw.assignmentVersion),
+ }
+}
+
+export async function listSailingExceptions(query: SailingExceptionQuery = {}) {
+ const response = await http.get, ApiEnvelope>(exceptionPath, { params: query })
+ const raw = unwrapApiResponse(response) as SailingExceptionPage | SailingExceptionRecord[] | null
+ if (Array.isArray(raw)) return { records: raw.map(exceptionRecord), page: 1, size: raw.length, total: raw.length, pages: raw.length ? 1 : 0 }
+ return { ...(raw || { records: [], page: 1, size: 20, total: 0, pages: 0 }), records: (raw?.records || []).map(exceptionRecord) }
+}
+
+/** Restricted exception reassignment; the server validates cutoff, route and warehouse scope. */
+export async function reassignSailingAssignment(id: string, payload: SailingExceptionReassignRequest) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/exception-reassign`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-exception-reassign') } })
+ return unwrapApiResponse(response)
+}
diff --git a/src/modules/sailing-calendar/index.ts b/src/modules/sailing-calendar/index.ts
new file mode 100644
index 00000000..d46da374
--- /dev/null
+++ b/src/modules/sailing-calendar/index.ts
@@ -0,0 +1,2 @@
+export * from './types'
+export * from './api/platformSailingCalendar'
diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
new file mode 100644
index 00000000..3e62ef19
--- /dev/null
+++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
@@ -0,0 +1,361 @@
+
+
+
+
+
+ {{ card.label }}{{ card.value }}
+
+
+
+ {{ month.replace('-', '年') }}月今天
时区:{{ data?.timezone || 'Asia/Shanghai' }} · 共 {{ data?.summary.sailingCount || 0 }} 个船期
+ {{ day }}
+
+
+
+
+
+ 取消保存
+ 此清单不受当前月份限制,只展示当前权限和筛选范围内仍待处理的未安排、晚到货量。
{{ row.status === 'LATE' ? '晚到' : '未安排' }}{{ decimal(row.volumeCbm) }}{{ dateTime(row.receivedAt || row.expectedArrivalAt) }}
+ 已收实测 {{ decimal(volumeDetail.summary.actualReceivedCbm) }}准时可赶实收 {{ decimal(volumeDetail.summary.catchableReceivedCbm || 0) }}晚到实收 {{ decimal(volumeDetail.summary.lateReceivedCbm || 0) }}预计可赶 {{ decimal(volumeDetail.summary.catchableForecastCbm) }}风险 / 未安排 {{ decimal(volumeDetail.summary.riskUnmatchedCbm) }}
{{ decimal(row.volumeCbm) }}{{ dateTime(row.receivedAt) }}重新安排—
+ {{ row.fromStatus || '—' }} → {{ row.toStatus || '—' }}{{ dateTime(row.createdAt) }}
+ 当前月没有可选目标,请先切换月份后再打开详情。取消提交准时可赶实收 {{ decimal(bookingAdvice.catchableReceivedCbm || 0) }}晚到实收 {{ decimal(bookingAdvice.lateReceivedCbm || 0) }}可赶总量 {{ decimal(bookingAdvice.catchableTotalCbm) }}已订柜 {{ bookingAdvice.bookedContainerCount }} 柜建议补柜 {{ bookingAdvice.pendingContainerCount }} 柜
晚到实收保留在历史统计,不增加原船订柜建议;建议按现有统一柜型容量初算,最终以智能排柜和人工确认结果为准。
{{ decimal(row.capacityCbm) }}
+ {{ row.status === 'ACTIVE' ? '启用' : '停用' }}{{ row.status === 'ACTIVE' ? '停用' : '启用' }}{{ formatWeekdays(row.weekdays) }}{{ row.departureTime }} / {{ row.cutoffDays }}天编辑预览生成确认生成
+ 取消保存渠道
+ 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}生成预览({{ templatePreview.length }} 条)
{{ row.conflict ? (row.conflictReason || "有冲突") : "可生成" }}取消保存模板预览生成
+
+
+
+
diff --git a/src/modules/sailing-calendar/routes.ts b/src/modules/sailing-calendar/routes.ts
new file mode 100644
index 00000000..39dbbc1b
--- /dev/null
+++ b/src/modules/sailing-calendar/routes.ts
@@ -0,0 +1,8 @@
+import type { RouteRecordRaw } from 'vue-router'
+
+export const sailingCalendarRoutes = [{
+ path: 'sailing-calendar',
+ name: 'sailing-calendar',
+ component: () => import('./pages/SailingCalendarView.vue'),
+ meta: { title: '船期日历', description: '维护计划船期、查看收货体积和订柜建议', permission: 'platform:firstleg:sailing-calendar:list' },
+}] satisfies RouteRecordRaw[]
diff --git a/src/modules/sailing-calendar/types.ts b/src/modules/sailing-calendar/types.ts
new file mode 100644
index 00000000..a47a1fda
--- /dev/null
+++ b/src/modules/sailing-calendar/types.ts
@@ -0,0 +1,295 @@
+export type SailingStatus = 'DRAFT' | 'OPEN' | 'CLOSED' | 'DEPARTED' | 'CANCELLED' | 'COMPLETED'
+
+export type SailingAssignmentStatus = 'FORECAST_MATCHED' | 'RECEIVED' | 'LATE' | 'UNMATCHED' | 'SUPERSEDED'
+
+export interface SailingVolumeSummary {
+ actualReceivedCbm: number
+ catchableForecastCbm: number
+ riskUnmatchedCbm: number
+ bookedContainerCount: number
+ recommendedContainerCount: number
+ pendingContainerCount: number
+ lateBatchCount: number
+ overCapacity: boolean
+ planningCapacityCbm?: number | null
+ bookedPlanningCapacityCbm?: number | null
+ catchableReceivedCbm?: number | null
+ lateReceivedCbm?: number | null
+ snapshotAt?: string | null
+}
+
+export interface SailingCalendarEntry {
+ id: string
+ sailingNo: string
+ warehouseId: string
+ warehouseName: string
+ channelCode: string
+ channelName: string
+ vesselName: string
+ voyageNo: string
+ departurePortCode: string
+ departurePortName?: string | null
+ destinationPortCode: string
+ destinationPortName?: string | null
+ etdAt: string
+ cutoffAt: string
+ status: SailingStatus
+ volumeSummary: SailingVolumeSummary
+ version: number
+ remark?: string | null
+}
+
+export interface SailingFilterOption {
+ value: string
+ label: string
+}
+
+export interface SailingCalendarFilterOptions {
+ warehouses: SailingFilterOption[]
+ channels: SailingFilterOption[]
+ departurePorts: SailingFilterOption[]
+ destinationPorts: SailingFilterOption[]
+}
+
+export interface SailingCalendarSummary extends SailingVolumeSummary {
+ sailingCount: number
+}
+
+export interface SailingCalendarResponse {
+ month: string
+ timezone: string
+ records: SailingCalendarEntry[]
+ summary: SailingCalendarSummary
+ filterOptions: SailingCalendarFilterOptions
+}
+
+export interface SailingCalendarQuery {
+ month: string
+ warehouseId?: string
+ channelCode?: string
+ departurePortCode?: string
+ destinationPortCode?: string
+ keyword?: string
+ status?: SailingStatus
+}
+
+export interface SailingSaveRequest {
+ templateId?: string | null
+ channelId?: string | null
+ warehouseId: string
+ warehouseName?: string | null
+ channelCode: string
+ channelName: string
+ vesselName: string
+ voyageNo: string
+ departurePortCode: string
+ departurePortName?: string | null
+ destinationPortCode: string
+ destinationPortName?: string | null
+ etdAt: string
+ etaAt?: string | null
+ cutoffAt: string
+ sourceType?: string | null
+ plannedCapacityCbm?: number | null
+ remark?: string | null
+ expectedVersion?: number
+}
+
+export interface SailingDetail {
+ sailing: SailingCalendarEntry
+ histories: SailingHistory[]
+}
+
+export interface SailingHistory {
+ id: string
+ action: string
+ fromStatus?: string | null
+ toStatus?: string | null
+ reason?: string | null
+ operatorName?: string | null
+ createdAt: string
+}
+
+export interface SailingVolumeDetail {
+ summary: SailingVolumeSummary
+ assignments: SailingAssignment[]
+}
+
+export interface SailingAssignment {
+ id: string
+ receiptBatchId?: string | null
+ forecastId?: string | null
+ inboundCode?: string | null
+ receivedAt?: string | null
+ volumeCbm: number
+ status: SailingAssignmentStatus
+ assignmentSource: 'AUTO' | 'MANUAL' | 'ROLLOVER'
+ assignmentVersion?: number | null
+ reason?: string | null
+}
+
+export interface SailingExceptionReassignRequest {
+ assignmentId: string
+ targetSailingId: string
+ expectedAssignmentVersion: number
+ expectedTargetVersion: number
+ reason: string
+}
+
+export interface SailingExceptionReassignResult {
+ oldAssignmentId: string
+ newAssignmentId: string
+ sourceSailingId: string
+ targetSailingId: string
+ assignmentVersion: number
+ matchStatus: SailingAssignmentStatus
+ volumeCbm?: number | null
+ forecastVolumeCbm?: number | null
+ reason?: string | null
+}
+
+export type SailingExceptionStatus = Extract
+
+export interface SailingExceptionRecord {
+ assignmentId: string
+ inboundCode?: string | null
+ receiptBatchId?: string | null
+ forecastId?: string | null
+ warehouseId: string
+ warehouseName?: string | null
+ channelCode?: string | null
+ channelName?: string | null
+ sourceSailingNo?: string | null
+ sourceVoyageNo?: string | null
+ status: SailingExceptionStatus
+ volumeCbm: number
+ receivedAt?: string | null
+ expectedArrivalAt?: string | null
+ waitingDays?: number | null
+ reason?: string | null
+ assignmentVersion?: number | null
+}
+
+export interface SailingExceptionPage {
+ records: SailingExceptionRecord[]
+ page: number
+ size: number
+ total: number
+ pages: number
+}
+
+export interface SailingExceptionQuery {
+ warehouseId?: string
+ channelCode?: string
+ status?: SailingExceptionStatus
+ page?: number
+ size?: number
+}
+
+export interface SailingBookingAdvice {
+ actualReceivedCbm: number
+ catchableTotalCbm: number
+ planningCapacityCbm?: number | null
+ bookedContainerCount: number
+ recommendedContainerCount: number
+ pendingContainerCount: number
+ scenarios: Array<{
+ containerTypeCode: string
+ containerTypeName: string
+ capacityCbm: number
+ recommendedContainerCount: number
+ pendingContainerCount: number
+ }>
+ calculatedAt: string
+ catchableReceivedCbm?: number | null
+ lateReceivedCbm?: number | null
+}
+
+export interface SailingBookingLinkRequest {
+ oceanBookingId: string
+ expectedVersion?: number
+}
+
+export type SailingChannelStatus = 'ACTIVE' | 'INACTIVE'
+export type SailingChannelSourceType = 'CATALOG' | 'SCHEDULE_ONLY'
+
+export interface SailingChannelResource {
+ id: string
+ channelCode: string
+ channelName: string
+ sourceType: SailingChannelSourceType
+ mappedChannelCode?: string | null
+ defaultDeparturePortCode?: string | null
+ defaultDestinationPortCode?: string | null
+ defaultCutoffDays: number
+ status: SailingChannelStatus
+ remark?: string | null
+ version: number
+}
+
+export interface SailingChannelSaveRequest {
+ channelCode: string
+ channelName: string
+ sourceType: SailingChannelSourceType
+ mappedChannelCode?: string | null
+ defaultDeparturePortCode?: string | null
+ defaultDestinationPortCode?: string | null
+ defaultCutoffDays: number
+ remark?: string | null
+ expectedVersion?: number
+ status?: SailingChannelStatus
+}
+
+export type SailingTemplateStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE'
+
+export interface SailingTemplateResource {
+ id: string
+ templateNo: string
+ channelId: string
+ channelName: string
+ warehouseId: string
+ warehouseName?: string | null
+ weekdays: number[]
+ templateName?: string | null
+ effectiveFrom: string
+ effectiveTo?: string | null
+ departureTime: string
+ cutoffDays: number
+ cutoffLocalTime: string
+ voyageRule?: string | null
+ vesselName?: string | null
+ departurePortCode?: string | null
+ destinationPortCode?: string | null
+ remark?: string | null
+ status: SailingTemplateStatus
+ version: number
+}
+
+export interface SailingTemplateSaveRequest {
+ templateName?: string | null
+ channelId: string
+ warehouseId: string
+ warehouseName?: string | null
+ weekdays: number[]
+ effectiveFrom: string
+ effectiveTo?: string | null
+ departureTime: string
+ cutoffDays: number
+ cutoffLocalTime: string
+ voyageRule?: string | null
+ vesselName?: string | null
+ departurePortCode?: string | null
+ destinationPortCode?: string | null
+ remark?: string | null
+ expectedVersion?: number
+}
+
+export interface SailingGenerationPreview {
+ templateId: string
+ records: Array<{ date?: string; etdAt: string; cutoffAt: string; voyageNo?: string | null; sailingNo?: string | null; conflict?: boolean; conflictReason?: string | null }>
+}
+
+export interface SailingGenerationResult extends SailingGenerationPreview {
+ generatedCount: number
+ skippedCount: number
+}
--
2.54.0
From 45e1e9b50944ce35681d599d80e72136099341ff Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 27 Sep 2026 19:12:17 +0800
Subject: [PATCH 23/36] fix(admin): send template pagination params
---
src/modules/sailing-calendar/api/platformSailingCalendar.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/modules/sailing-calendar/api/platformSailingCalendar.ts b/src/modules/sailing-calendar/api/platformSailingCalendar.ts
index 010b2f78..5b92c1f2 100644
--- a/src/modules/sailing-calendar/api/platformSailingCalendar.ts
+++ b/src/modules/sailing-calendar/api/platformSailingCalendar.ts
@@ -154,7 +154,7 @@ export async function updateSailingChannel(id: string, payload: SailingChannelSa
return channel(unwrapApiResponse(response))
}
export async function listSailingTemplates() {
- const response = await http.get, ApiEnvelope>(templatePath)
+ const response = await http.get, ApiEnvelope>(templatePath, { params: { page: 1, size: 200 } })
const result = unwrapApiResponse(response) as SailingTemplateResource[] | { records?: SailingTemplateResource[] } | null
return (Array.isArray(result) ? result : result?.records || []).map(template)
}
--
2.54.0
From 29cb0bd57a42d3071f7389cc6eb82499d4852080 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 27 Sep 2026 20:41:39 +0800
Subject: [PATCH 24/36] feat(admin): add formal customs result actions
---
.../api/platformContainerCustoms.ts | 69 ++++++++++++++++
.../components/ContainerCustomsPanel.vue | 79 ++++++++++++++++++-
2 files changed, 146 insertions(+), 2 deletions(-)
diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts
index 37b7dc0d..4f5a374b 100644
--- a/src/modules/container-loading/api/platformContainerCustoms.ts
+++ b/src/modules/container-loading/api/platformContainerCustoms.ts
@@ -56,6 +56,42 @@ export interface ContainerCustomsView {
brokerSubmittedAt?: string | null
}
+export type DeclarationBatchStatus = 'READY' | 'SUBMITTED' | 'ACKNOWLEDGED' | 'RESULT_PENDING' | 'CLOSED' | 'CANCELLED'
+export type ClearanceResultType = 'INSPECTION' | 'SUPPLEMENT_REQUIRED' | 'RELEASED' | 'DETAINED' | 'OTHER'
+export type ClearanceResultStatus = 'OPEN' | 'RESOLVED' | 'CANCELLED'
+
+export interface ClearanceResult {
+ id: string
+ declarationBatchId: string
+ customsContainerId: string
+ resultType: ClearanceResultType
+ resultStatus: ClearanceResultStatus
+ customsEntryNo: string | null
+ occurredAt: string
+ resolution: string | null
+ evidenceFileId: string | null
+ recordedByName: string
+ recordedAt: string
+ previousResultId: string | null
+}
+
+export interface DeclarationBatch {
+ id: string
+ customsContainerId: string
+ loadingPlanId: string
+ batchNo: string
+ status: DeclarationBatchStatus
+ sourceVersion: number
+ customsEntryNo: string | null
+ version: number
+ submittedAt: string | null
+ submittedByName: string | null
+ acknowledgedAt: string | null
+ acknowledgedByName: string | null
+ remark: string | null
+ results: ClearanceResult[]
+}
+
interface RawContainerCustomsView extends Omit {
aggregateId: string | number
loadingPlanId: string | number
@@ -79,6 +115,39 @@ function normalize(value: RawContainerCustomsView): ContainerCustomsView {
}
}
+function normalizeClearanceResult(value: ClearanceResult): ClearanceResult {
+ return { ...value, id: String(value.id), declarationBatchId: String(value.declarationBatchId), customsContainerId: String(value.customsContainerId), evidenceFileId: value.evidenceFileId == null ? null : String(value.evidenceFileId), previousResultId: value.previousResultId == null ? null : String(value.previousResultId) }
+}
+
+function normalizeDeclarationBatch(value: DeclarationBatch): DeclarationBatch {
+ return { ...value, id: String(value.id), customsContainerId: String(value.customsContainerId), loadingPlanId: String(value.loadingPlanId), sourceVersion: Number(value.sourceVersion), version: Number(value.version), results: (value.results || []).map(normalizeClearanceResult) }
+}
+
+export async function getDeclarationBatches(loadingPlanId: string) {
+ const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`)
+ return (unwrapApiResponse(response) || []).map(normalizeDeclarationBatch)
+}
+
+export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number, batchNo?: string) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, batchNo: batchNo?.trim() || null, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } })
+ return normalizeDeclarationBatch(unwrapApiResponse(response))
+}
+
+export async function submitDeclarationBatch(batch: DeclarationBatch, customsEntryNo: string) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, batchNo: batch.batchNo, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } })
+ return normalizeDeclarationBatch(unwrapApiResponse(response))
+}
+
+export async function recordClearanceResult(batch: DeclarationBatch, resultType: ClearanceResultType, resolution?: string) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/results`, { expectedVersion: batch.version, resultType, occurredAt: new Date().toISOString(), resolution: resolution?.trim() || null, evidenceFileId: null, resolved: false }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-record') } })
+ return normalizeClearanceResult(unwrapApiResponse(response))
+}
+
+export async function resolveClearanceResult(result: ClearanceResult, batchVersion: number, resolution: string) {
+ const response = await http.post, ApiEnvelope>(`${basePath}/clearance-results/${encodeURIComponent(result.id)}/resolve`, { expectedVersion: batchVersion, resolution: resolution.trim() }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-resolve') } })
+ return normalizeClearanceResult(unwrapApiResponse(response))
+}
+
export async function getContainerCustoms(loadingPlanId: string) {
const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(loadingPlanId)}`)
const value = unwrapApiResponse(response)
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue
index 23c434b2..8d78aff2 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.vue
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue
@@ -9,10 +9,15 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import {
completeAllBuyContainerCustoms,
+ createDeclarationBatch,
downloadContainerCustomsReceipt,
downloadContainerCustomsFinal,
downloadContainerCustomsDocument,
getContainerCustoms,
+ getDeclarationBatches,
+ recordClearanceResult,
+ resolveClearanceResult,
+ submitDeclarationBatch,
startContainerCustoms,
submitContainerCustomsToBroker,
uploadContainerCustomsReceipt,
@@ -20,6 +25,7 @@ import {
type ContainerCustomsDocument,
type ContainerCustomsStatus,
type ContainerCustomsView,
+ type DeclarationBatch,
} from '../api/platformContainerCustoms'
const props = defineProps<{
@@ -31,6 +37,7 @@ const props = defineProps<{
const emit = defineEmits<{ refreshed: [] }>()
const customs = ref(null)
+const declarationBatches = ref([])
const loading = ref(false)
const actionLoading = ref(false)
const loadError = ref(null)
@@ -43,6 +50,8 @@ const missingInbounds = computed(() => customs.value?.inbounds.filter((item) =>
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
+const latestDeclarationBatch = computed(() => declarationBatches.value[0] || null)
+const openDeclarationBatch = computed(() => declarationBatches.value.find((item) => ['READY', 'SUBMITTED', 'ACKNOWLEDGED', 'RESULT_PENDING'].includes(item.status)) || null)
const statusText: Record = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
}
@@ -65,11 +74,65 @@ async function load() {
if (!props.canList || !props.loadingPlanId) return
loading.value = true
loadError.value = null
- try { customs.value = await getContainerCustoms(props.loadingPlanId) }
+ try {
+ customs.value = await getContainerCustoms(props.loadingPlanId)
+ if (customs.value?.status === 'COMPLETED') {
+ try { declarationBatches.value = await getDeclarationBatches(props.loadingPlanId) } catch { declarationBatches.value = [] }
+ } else declarationBatches.value = []
+ }
catch (error) { customs.value = null; loadError.value = getApiErrorDetails(error, '整柜报关状态加载失败') }
finally { loading.value = false }
}
+function declarationStatusLabel(value: DeclarationBatch['status']) {
+ return ({ READY: '待提交', SUBMITTED: '已提交', ACKNOWLEDGED: '已受理', RESULT_PENDING: '待处理结果', CLOSED: '已关闭', CANCELLED: '已取消' } as Record)[value] || value
+}
+
+async function createFormalDeclaration() {
+ if (!customs.value || customs.value.status !== 'COMPLETED' || openDeclarationBatch.value) return
+ try { await ElMessageBox.confirm('当前柜的资料、回执和真实报关完税文件已完成,确认创建正式申报批次?', '创建正式申报批次', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
+ actionLoading.value = true; actionError.value = null
+ try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
+ catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
+ finally { actionLoading.value = false }
+}
+
+async function submitFormalDeclaration(batch: DeclarationBatch) {
+ try {
+ const answer = await ElMessageBox.prompt('请输入报关单号', '提交正式申报', { inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
+ actionLoading.value = true; actionError.value = null
+ await submitDeclarationBatch(batch, answer.value)
+ await load(); ElMessage.success('正式申报已提交')
+ } catch (error) {
+ if (error === 'cancel' || error === 'close') return
+ actionError.value = getApiErrorDetails(error, '正式申报提交失败')
+ } finally { actionLoading.value = false }
+}
+
+async function recordFormalResult(batch: DeclarationBatch) {
+ try {
+ const answer = await ElMessageBox.prompt('可填写查验处理说明,没有说明可直接确认', '录入海关查验结果', { inputPlaceholder: '处理说明(可选)', confirmButtonText: '确认录入' })
+ actionLoading.value = true; actionError.value = null
+ await recordClearanceResult(batch, 'INSPECTION', answer.value)
+ await load(); ElMessage.success('海关结果已录入')
+ } catch (error) {
+ if (error === 'cancel' || error === 'close') return
+ actionError.value = getApiErrorDetails(error, '海关结果录入失败')
+ } finally { actionLoading.value = false }
+}
+
+async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationBatch['results'][number]) {
+ try {
+ const answer = await ElMessageBox.prompt('请输入处理说明', '处理海关结果', { inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
+ actionLoading.value = true; actionError.value = null
+ await resolveClearanceResult(result, batch.version, answer.value)
+ await load(); ElMessage.success('海关结果已处理')
+ } catch (error) {
+ if (error === 'cancel' || error === 'close') return
+ actionError.value = getApiErrorDetails(error, '海关结果处理失败')
+ } finally { actionLoading.value = false }
+}
+
async function start() {
if (!customs.value || customs.value.status !== 'READY_TO_START') return
try {
@@ -211,6 +274,18 @@ onMounted(load)
+
+
正式申报与海关结果
正式申报批次建立在退税资料、回执确认和真实完税文件完成之后,与船期和 PDA 节点独立。
创建正式申报批次
+
+
+
+ {{ batch.batchNo }}报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}
+ {{ declarationStatusLabel(batch.status) }}
+ 提交正式申报录入查验结果
+ {{ result.resultType === 'INSPECTION' ? '查验' : result.resultType === 'SUPPLEMENT_REQUIRED' ? '补充资料' : result.resultType === 'RELEASED' ? '放行' : result.resultType }}{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}{{ result.resolution || '—' }}处理
+
+
+
国内离仓状态:{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}
@@ -219,5 +294,5 @@ onMounted(load)
--
2.54.0
From 855d8c0d5df41a77615e6646aae1d57dbed1590e Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 27 Sep 2026 20:48:19 +0800
Subject: [PATCH 25/36] fix(admin): create declaration batch after broker
handoff
---
.../components/ContainerCustomsPanel.vue | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue
index 8d78aff2..5de21e11 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.vue
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue
@@ -50,7 +50,6 @@ const missingInbounds = computed(() => customs.value?.inbounds.filter((item) =>
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
-const latestDeclarationBatch = computed(() => declarationBatches.value[0] || null)
const openDeclarationBatch = computed(() => declarationBatches.value.find((item) => ['READY', 'SUBMITTED', 'ACKNOWLEDGED', 'RESULT_PENDING'].includes(item.status)) || null)
const statusText: Record = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
@@ -89,8 +88,8 @@ function declarationStatusLabel(value: DeclarationBatch['status']) {
}
async function createFormalDeclaration() {
- if (!customs.value || customs.value.status !== 'COMPLETED' || openDeclarationBatch.value) return
- try { await ElMessageBox.confirm('当前柜的资料、回执和真实报关完税文件已完成,确认创建正式申报批次?', '创建正式申报批次', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
+ if (!customs.value || !['DECLARING', 'COMPLETED'].includes(customs.value.status) || !brokerSubmitted.value || openDeclarationBatch.value) return
+ try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', '创建正式申报批次', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
@@ -274,8 +273,8 @@ onMounted(load)
-
-
正式申报与海关结果
正式申报批次建立在退税资料、回执确认和真实完税文件完成之后,与船期和 PDA 节点独立。
创建正式申报批次
+
+
正式申报与海关结果
回执确认并发送报关行执行后建立正式申报批次;完税文件、船期和 PDA 节点保持独立。
创建正式申报批次
--
2.54.0
From a34feddd4a4f0ee848b3d8670d65e5cdbfd0f26b Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 27 Sep 2026 22:07:54 +0800
Subject: [PATCH 26/36] feat(admin): narrow sailing calendar to read-only
display
---
.../pages/SailingCalendarView.vue | 59 +++----------------
1 file changed, 9 insertions(+), 50 deletions(-)
diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
index 3e62ef19..5b809f3d 100644
--- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue
+++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
@@ -20,15 +20,13 @@ import {
getSailingVolumeDetail,
listSailingChannels,
listSailingCalendar,
- listSailingExceptions,
listSailingTemplates,
previewSailingTemplateGeneration,
- reassignSailingAssignment,
updateSailingChannel,
updateSailingTemplate,
updateSailing,
} from '../api/platformSailingCalendar'
-import type { SailingAssignment, SailingBookingAdvice, SailingCalendarEntry, SailingCalendarFilterOptions, SailingCalendarResponse, SailingChannelResource, SailingChannelSaveRequest, SailingChannelSourceType, SailingExceptionPage, SailingHistory, SailingSaveRequest, SailingStatus, SailingTemplateResource, SailingTemplateSaveRequest, SailingVolumeDetail } from '../types'
+import type { SailingBookingAdvice, SailingCalendarEntry, SailingCalendarFilterOptions, SailingCalendarResponse, SailingChannelResource, SailingChannelSaveRequest, SailingChannelSourceType, SailingHistory, SailingSaveRequest, SailingStatus, SailingTemplateResource, SailingTemplateSaveRequest, SailingVolumeDetail } from '../types'
interface CalendarCell { date: string; day: number; currentMonth: boolean; records: SailingCalendarEntry[] }
@@ -40,10 +38,6 @@ const data = ref(null)
const loading = ref(false)
const error = ref(null)
const query = reactive({ warehouseId: '', channelCode: '', departurePortCode: '', destinationPortCode: '', keyword: '', status: '' as '' | SailingStatus })
-const exceptionsOpen = ref(false)
-const exceptionsLoading = ref(false)
-const exceptionsError = ref(null)
-const exceptions = ref({ records: [], page: 1, size: 20, total: 0, pages: 0 })
const selectedSailing = ref(null)
const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
@@ -57,10 +51,6 @@ const volumeDetail = ref(null)
const adviceOpen = ref(false)
const adviceLoading = ref(false)
const bookingAdvice = ref(null)
-const reassignmentOpen = ref(false)
-const reassignmentSaving = ref(false)
-const reassignmentAssignment = ref(null)
-const reassignmentForm = reactive({ targetSailingId: '', targetSailingVersion: 0, reason: '' })
const form = reactive(blankForm())
const managementOpen = ref(false)
const managementTab = ref<'channels' | 'templates'>('channels')
@@ -125,10 +115,10 @@ const summaryCards = computed(() => {
const summary = data.value?.summary
return [
{ label: '已收实测', value: decimal(summary?.actualReceivedCbm || 0), tone: 'blue' },
- { label: '准时可赶实收', value: decimal(summary?.catchableReceivedCbm || 0), tone: 'green' },
- { label: '晚到实收', value: decimal(summary?.lateReceivedCbm || 0), tone: 'orange' },
- { label: '预计可赶', value: decimal(summary?.catchableForecastCbm || 0), tone: 'green' },
- { label: '风险 / 未安排', value: decimal(summary?.riskUnmatchedCbm || 0), tone: 'orange' },
+ { label: '截止前参考实收', value: decimal(summary?.catchableReceivedCbm || 0), tone: 'green' },
+ { label: '截止后 / 风险实收', value: decimal(summary?.lateReceivedCbm || 0), tone: 'orange' },
+ { label: '预计参考量', value: decimal(summary?.catchableForecastCbm || 0), tone: 'green' },
+ { label: '待确认货量', value: decimal(summary?.riskUnmatchedCbm || 0), tone: 'orange' },
{ label: '已订柜 / 建议', value: `${summary?.bookedContainerCount || 0} / ${summary?.recommendedContainerCount || 0}`, tone: 'purple' },
{ label: '待补柜', value: String(summary?.pendingContainerCount || 0), tone: 'red' },
]
@@ -136,11 +126,6 @@ const summaryCards = computed(() => {
const canCreate = computed(() => access.can('platform:firstleg:sailing-calendar:create'))
const canEdit = computed(() => access.can('platform:firstleg:sailing-calendar:edit'))
const canManageTemplates = computed(() => access.can('platform:firstleg:sailing-template:manage'))
-const canReassign = computed(() => access.can('platform:firstleg:sailing-volume:exception-reassign'))
-const reassignmentTargets = computed(() => (data.value?.records || []).filter((record) => {
- const source = selectedSailing.value
- return record.id !== source?.id && record.status === 'OPEN' && record.warehouseId === source?.warehouseId && record.channelCode === source?.channelCode && record.departurePortCode === source?.departurePortCode && record.destinationPortCode === source?.destinationPortCode
-}))
async function load() {
loading.value = true
@@ -156,18 +141,6 @@ async function load() {
loading.value = false
}
}
-async function openExceptions() {
- exceptionsOpen.value = true
- exceptionsLoading.value = true
- exceptionsError.value = null
- try {
- exceptions.value = await listSailingExceptions({ warehouseId: query.warehouseId || undefined, channelCode: query.channelCode || undefined, page: 1, size: 50 })
- } catch (cause) {
- exceptionsError.value = getApiErrorDetails(cause, '异常待办加载失败')
- } finally {
- exceptionsLoading.value = false
- }
-}
function changeMonth(delta: number) {
const next = parseMonth(month.value)
next.setMonth(next.getMonth() + delta)
@@ -251,19 +224,6 @@ async function generateTemplate(row: unknown) {
try { await ElMessageBox.confirm(`确认按模板“${templateRow.templateNo}”生成选定有效期内的船期?重复日期会由服务端跳过。`, '批量生成船期', { type: 'warning', confirmButtonText: '确认生成', cancelButtonText: '返回' }); templateGenerateLoading.value = true; const result = await generateSailingTemplate(templateRow.id, { from: templateRow.effectiveFrom, to: templateRow.effectiveTo || `${month.value}-28` }); ElMessage.success(`生成完成:${result.generatedCount} 个,跳过 ${result.skippedCount} 个`); await load(); await openManagement('templates') } catch (cause) { if (cause !== 'cancel') ElMessage.error(getApiErrorDetails(cause, '批量生成失败').message) } finally { templateGenerateLoading.value = false }
}
function formatWeekdays(value: unknown) { return Array.isArray(value) ? value.map((day: unknown) => ['一', '二', '三', '四', '五', '六', '日'][Number(day) - 1]).join('、') : '—' }
-function openReassignment(row: unknown) { reassignmentAssignment.value = row as SailingAssignment; Object.assign(reassignmentForm, { targetSailingId: '', targetSailingVersion: 0, reason: '' }); reassignmentOpen.value = true }
-async function saveReassignment() {
- const target = reassignmentTargets.value.find((record) => record.id === reassignmentForm.targetSailingId)
- if (!selectedSailing.value || !reassignmentAssignment.value || !target || !reassignmentForm.reason.trim()) { ElMessage.error('请选择符合仓库、渠道和航线的目标船期,并填写重新安排原因'); return }
- reassignmentSaving.value = true
- try {
- await reassignSailingAssignment(selectedSailing.value.id, { assignmentId: reassignmentAssignment.value.id, targetSailingId: target.id, expectedAssignmentVersion: reassignmentAssignment.value.assignmentVersion ?? 1, expectedTargetVersion: target.version, reason: reassignmentForm.reason.trim() })
- ElMessage.success('已提交重新安排')
- reassignmentOpen.value = false
- await openVolumeDetail()
- await load()
- } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '重新安排提交失败').message) } finally { reassignmentSaving.value = false }
-}
function openCreate() { dialogMode.value = 'create'; Object.assign(form, blankForm()); dialogOpen.value = true }
function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; Object.assign(form, { templateId: null, channelId: null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: 'MANUAL', plannedCapacityCbm: null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true }
function onEtdChanged() { if (dialogMode.value === 'create') form.cutoffAt = cutoffFor(form.etdAt) }
@@ -329,7 +289,6 @@ onMounted(() => void load())
重置
- 待安排 / 异常
渠道与周模板
新增船期
@@ -346,10 +305,8 @@ onMounted(() => void load())
取消保存
-
此清单不受当前月份限制,只展示当前权限和筛选范围内仍待处理的未安排、晚到货量。
{{ row.status === 'LATE' ? '晚到' : '未安排' }}{{ decimal(row.volumeCbm) }}{{ dateTime(row.receivedAt || row.expectedArrivalAt) }}
-
已收实测 {{ decimal(volumeDetail.summary.actualReceivedCbm) }}准时可赶实收 {{ decimal(volumeDetail.summary.catchableReceivedCbm || 0) }}晚到实收 {{ decimal(volumeDetail.summary.lateReceivedCbm || 0) }}预计可赶 {{ decimal(volumeDetail.summary.catchableForecastCbm) }}风险 / 未安排 {{ decimal(volumeDetail.summary.riskUnmatchedCbm) }}
{{ decimal(row.volumeCbm) }}{{ dateTime(row.receivedAt) }}重新安排—
-
{{ row.fromStatus || '—' }} → {{ row.toStatus || '—' }}{{ dateTime(row.createdAt) }}
-
当前月没有可选目标,请先切换月份后再打开详情。取消提交准时可赶实收 {{ decimal(bookingAdvice.catchableReceivedCbm || 0) }}晚到实收 {{ decimal(bookingAdvice.lateReceivedCbm || 0) }}可赶总量 {{ decimal(bookingAdvice.catchableTotalCbm) }}已订柜 {{ bookingAdvice.bookedContainerCount }} 柜建议补柜 {{ bookingAdvice.pendingContainerCount }} 柜
晚到实收保留在历史统计,不增加原船订柜建议;建议按现有统一柜型容量初算,最终以智能排柜和人工确认结果为准。
{{ decimal(row.capacityCbm) }}
+
已收实测 {{ decimal(volumeDetail.summary.actualReceivedCbm) }}截止前参考 {{ decimal(volumeDetail.summary.catchableReceivedCbm || 0) }}晚到 / 风险 {{ decimal(volumeDetail.summary.lateReceivedCbm || 0) }}预计参考 {{ decimal(volumeDetail.summary.catchableForecastCbm) }}待确认 {{ decimal(volumeDetail.summary.riskUnmatchedCbm) }}
{{ decimal(row.volumeCbm) }}{{ dateTime(row.receivedAt) }}
+
{{ row.fromStatus || '—' }} → {{ row.toStatus || '—' }}{{ dateTime(row.createdAt) }}
已收实测 {{ decimal(bookingAdvice.actualReceivedCbm || 0) }}截止前参考 {{ decimal(bookingAdvice.catchableTotalCbm) }}晚到 / 风险 {{ decimal(bookingAdvice.lateReceivedCbm || 0) }}已订柜 {{ bookingAdvice.bookedContainerCount }} 柜建议补柜 {{ bookingAdvice.pendingContainerCount }} 柜
以上为现有数据的只读初算,不会自动归班、合船或创建订舱;最终以智能排柜和人工确认结果为准。
{{ decimal(row.capacityCbm) }}
{{ row.status === 'ACTIVE' ? '启用' : '停用' }}{{ row.status === 'ACTIVE' ? '停用' : '启用' }}{{ formatWeekdays(row.weekdays) }}{{ row.departureTime }} / {{ row.cutoffDays }}天编辑预览生成确认生成
取消保存渠道
周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}生成预览({{ templatePreview.length }} 条)
{{ row.conflict ? (row.conflictReason || "有冲突") : "可生成" }}取消保存模板预览生成
@@ -359,3 +316,5 @@ onMounted(() => void load())
+
+
--
2.54.0
From 517aad5e5e87ab6132da9ecb4ddd953b4c978548 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 27 Sep 2026 22:18:55 +0800
Subject: [PATCH 27/36] fix(admin): clarify sailing display data sources
---
src/modules/sailing-calendar/pages/SailingCalendarView.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
index 5b809f3d..3c94dbd7 100644
--- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue
+++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
@@ -305,7 +305,7 @@ onMounted(() => void load())
取消保存
- 已收实测 {{ decimal(volumeDetail.summary.actualReceivedCbm) }}截止前参考 {{ decimal(volumeDetail.summary.catchableReceivedCbm || 0) }}晚到 / 风险 {{ decimal(volumeDetail.summary.lateReceivedCbm || 0) }}预计参考 {{ decimal(volumeDetail.summary.catchableForecastCbm) }}待确认 {{ decimal(volumeDetail.summary.riskUnmatchedCbm) }}
{{ decimal(row.volumeCbm) }}{{ dateTime(row.receivedAt) }}
+ 已收实测 {{ decimal(volumeDetail.summary.actualReceivedCbm) }}截止前参考 {{ decimal(volumeDetail.summary.catchableReceivedCbm || 0) }}晚到 / 风险 {{ decimal(volumeDetail.summary.lateReceivedCbm || 0) }}预计参考 {{ decimal(volumeDetail.summary.catchableForecastCbm) }}待确认 {{ decimal(volumeDetail.summary.riskUnmatchedCbm) }}
{{ decimal(row.volumeCbm) }}{{ dateTime(row.receivedAt) }}
{{ row.fromStatus || '—' }} → {{ row.toStatus || '—' }}{{ dateTime(row.createdAt) }}
已收实测 {{ decimal(bookingAdvice.actualReceivedCbm || 0) }}截止前参考 {{ decimal(bookingAdvice.catchableTotalCbm) }}晚到 / 风险 {{ decimal(bookingAdvice.lateReceivedCbm || 0) }}已订柜 {{ bookingAdvice.bookedContainerCount }} 柜建议补柜 {{ bookingAdvice.pendingContainerCount }} 柜
以上为现有数据的只读初算,不会自动归班、合船或创建订舱;最终以智能排柜和人工确认结果为准。
{{ decimal(row.capacityCbm) }}
{{ row.status === 'ACTIVE' ? '启用' : '停用' }}{{ row.status === 'ACTIVE' ? '停用' : '启用' }}{{ formatWeekdays(row.weekdays) }}{{ row.departureTime }} / {{ row.cutoffDays }}天编辑预览生成确认生成
取消保存渠道
--
2.54.0
From 524a23b4882eee734696794207d96276212577b2 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 27 Sep 2026 22:27:28 +0800
Subject: [PATCH 28/36] feat(admin): show adjacent sailing merge reference
---
.../pages/SailingCalendarView.vue | 35 +++++++++++++++++--
1 file changed, 33 insertions(+), 2 deletions(-)
diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
index 3c94dbd7..6faccef2 100644
--- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue
+++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
@@ -111,6 +111,37 @@ const cells = computed(() => {
})
})
const selectedDateRecords = computed(() => recordsByDate.value.get(selectedDate.value) || [])
+const adjacentSailings = computed(() => {
+ const current = selectedSailing.value
+ if (!current) return []
+ const currentTime = new Date(current.etdAt).getTime()
+ return (data.value?.records || [])
+ .filter((record) => record.id !== current.id
+ && record.status !== 'CANCELLED'
+ && record.warehouseId === current.warehouseId
+ && record.channelCode === current.channelCode
+ && record.departurePortCode === current.departurePortCode
+ && record.destinationPortCode === current.destinationPortCode)
+ .sort((left, right) => Math.abs(new Date(left.etdAt).getTime() - currentTime) - Math.abs(new Date(right.etdAt).getTime() - currentTime))
+ .slice(0, 3)
+})
+function referenceVolume(record: unknown) {
+ const sailing = record as SailingCalendarEntry
+ return Number(sailing.volumeSummary.catchableReceivedCbm || 0) + Number(sailing.volumeSummary.catchableForecastCbm || 0)
+}
+function remainingCapacity(record: unknown) {
+ const sailing = record as SailingCalendarEntry
+ const capacity = Number(sailing.volumeSummary.planningCapacityCbm || 0)
+ return Math.max(capacity - referenceVolume(record), 0)
+}
+const currentRemainingCapacity = computed(() => selectedSailing.value ? remainingCapacity(selectedSailing.value) : 0)
+function mergeAdvice(record: unknown) {
+ const current = selectedSailing.value
+ const sailing = record as SailingCalendarEntry
+ if (!current || current.status !== 'OPEN' || sailing.status !== 'OPEN') return '仅供对比'
+ const currentCapacity = Number(current.volumeSummary.planningCapacityCbm || 0)
+ return currentCapacity > 0 && referenceVolume(current) < currentCapacity && remainingCapacity(sailing) > 0 ? '可人工评估合船' : '仅供对比'
+}
const summaryCards = computed(() => {
const summary = data.value?.summary
return [
@@ -306,7 +337,7 @@ onMounted(() => void load())
取消保存
已收实测 {{ decimal(volumeDetail.summary.actualReceivedCbm) }}截止前参考 {{ decimal(volumeDetail.summary.catchableReceivedCbm || 0) }}晚到 / 风险 {{ decimal(volumeDetail.summary.lateReceivedCbm || 0) }}预计参考 {{ decimal(volumeDetail.summary.catchableForecastCbm) }}待确认 {{ decimal(volumeDetail.summary.riskUnmatchedCbm) }}
{{ decimal(row.volumeCbm) }}{{ dateTime(row.receivedAt) }}
- {{ row.fromStatus || '—' }} → {{ row.toStatus || '—' }}{{ dateTime(row.createdAt) }}
已收实测 {{ decimal(bookingAdvice.actualReceivedCbm || 0) }}截止前参考 {{ decimal(bookingAdvice.catchableTotalCbm) }}晚到 / 风险 {{ decimal(bookingAdvice.lateReceivedCbm || 0) }}已订柜 {{ bookingAdvice.bookedContainerCount }} 柜建议补柜 {{ bookingAdvice.pendingContainerCount }} 柜
以上为现有数据的只读初算,不会自动归班、合船或创建订舱;最终以智能排柜和人工确认结果为准。
{{ decimal(row.capacityCbm) }}
+ {{ row.fromStatus || '—' }} → {{ row.toStatus || '—' }}{{ dateTime(row.createdAt) }}
已收实测 {{ decimal(bookingAdvice.actualReceivedCbm || 0) }}截止前参考 {{ decimal(bookingAdvice.catchableTotalCbm) }}晚到 / 风险 {{ decimal(bookingAdvice.lateReceivedCbm || 0) }}已订柜 {{ bookingAdvice.bookedContainerCount }} 柜建议补柜 {{ bookingAdvice.pendingContainerCount }} 柜
以上为现有数据的只读初算,不会自动归班、合船或创建订舱;最终以智能排柜和人工确认结果为准。
{{ decimal(row.capacityCbm) }}相邻船期合船参考只做展示,不自动合并
本船参考量 {{ decimal(bookingAdvice.catchableTotalCbm) }}规划容量 {{ decimal(bookingAdvice.planningCapacityCbm || 0) }}剩余容量 {{ decimal(currentRemainingCapacity) }}
{{ row.vesselName }}{{ row.voyageNo }} · {{ dateTime(row.etdAt) }}
{{ decimal(referenceVolume(row)) }}{{ decimal(remainingCapacity(row)) }}{{ mergeAdvice(row) }}仅展示同仓、同渠道、同起运港和目的港的相邻船期;实际是否合船仍回到智能排柜和人工确认。
{{ row.status === 'ACTIVE' ? '启用' : '停用' }}{{ row.status === 'ACTIVE' ? '停用' : '启用' }}{{ formatWeekdays(row.weekdays) }}{{ row.departureTime }} / {{ row.cutoffDays }}天编辑预览生成确认生成
取消保存渠道
周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}生成预览({{ templatePreview.length }} 条)
{{ row.conflict ? (row.conflictReason || "有冲突") : "可生成" }}取消保存模板预览生成
@@ -314,7 +345,7 @@ onMounted(() => void load())
--
2.54.0
From 62c9eb58be2e491302b74ff6529b4db037dd29bc Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 27 Sep 2026 22:45:41 +0800
Subject: [PATCH 29/36] feat(admin): improve sailing cutoff and weekly
generation
---
.../pages/SailingCalendarView.vue | 50 +++++++++++++------
1 file changed, 36 insertions(+), 14 deletions(-)
diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
index 6faccef2..b01f1c90 100644
--- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue
+++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
@@ -51,6 +51,9 @@ const volumeDetail = ref(null)
const adviceOpen = ref(false)
const adviceLoading = ref(false)
const bookingAdvice = ref(null)
+const cutoffMode = ref<'rule' | 'custom'>('rule')
+const cutoffDays = ref(3)
+const cutoffLocalTime = ref('18:00:00')
const form = reactive(blankForm())
const managementOpen = ref(false)
const managementTab = ref<'channels' | 'templates'>('channels')
@@ -68,9 +71,14 @@ const templateGenerateLoading = ref(false)
const templatePreview = ref>([])
const editingTemplateId = ref('')
const templateForm = reactive({ templateName: '', channelId: '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '', departurePortCode: '', destinationPortCode: '', remark: '' })
+const generationFrom = ref(formatDate(today))
+const generationTo = ref(addDays(formatDate(today), 56))
function formatMonth(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}` }
function formatDate(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}` }
+function addDays(value: string, days: number) { const date = new Date(`${value}T00:00:00`); date.setDate(date.getDate() + days); return formatDate(date) }
+function resetGenerationRange(from = formatDate(today)) { generationFrom.value = from || formatDate(today); generationTo.value = addDays(generationFrom.value, 56) }
+function setGenerationWeeks(weeks: number) { generationTo.value = addDays(generationFrom.value || formatDate(today), weeks * 7) }
function parseMonth(value: string) { const [year, monthNumber] = value.split('-').map(Number); return new Date(year, monthNumber - 1, 1) }
function dateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function decimal(value: number) { return `${Number(value || 0).toFixed(2)} m³` }
@@ -83,10 +91,15 @@ function blankForm(): SailingSaveRequest {
}
function cutoffFor(etdAt: string) {
const value = new Date(etdAt)
- value.setDate(value.getDate() - 3)
- value.setHours(18, 0, 0, 0)
- return `${formatDate(value)}T18:00:00`
+ value.setDate(value.getDate() - cutoffDays.value)
+ const [hours, minutes, seconds] = cutoffLocalTime.value.split(':').map(Number)
+ value.setHours(hours || 0, minutes || 0, seconds || 0, 0)
+ return `${formatDate(value)}T${cutoffLocalTime.value}`
}
+function syncCutoffFromRule() { if (cutoffMode.value === 'rule' && form.etdAt) form.cutoffAt = cutoffFor(form.etdAt) }
+function onCutoffModeChanged() { syncCutoffFromRule() }
+function onCutoffRuleChanged() { syncCutoffFromRule() }
+function onCustomCutoffChanged() { cutoffMode.value = 'custom' }
const filters = computed(() => data.value?.filterOptions || { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] })
const recordsByDate = computed(() => {
@@ -214,8 +227,10 @@ async function saveChannel() {
}
function openTemplateCreate() {
editingTemplateId.value = ''
- Object.assign(templateForm, { templateName: '', channelId: channels.value.find((item) => item.status === 'ACTIVE')?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '', departurePortCode: '', destinationPortCode: '', remark: '', expectedVersion: undefined })
+ const activeChannel = channels.value.find((item) => item.status === 'ACTIVE')
+ Object.assign(templateForm, { templateName: '', channelId: activeChannel?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: activeChannel?.defaultCutoffDays ?? 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '', departurePortCode: '', destinationPortCode: '', remark: '', expectedVersion: undefined })
templatePreview.value = []
+ resetGenerationRange(templateForm.effectiveFrom)
templateDialogOpen.value = true
}
function openTemplateEdit(row: unknown) {
@@ -223,6 +238,7 @@ function openTemplateEdit(row: unknown) {
editingTemplateId.value = templateRow.id
Object.assign(templateForm, { templateName: templateRow.templateName || '', channelId: templateRow.channelId, warehouseId: templateRow.warehouseId, warehouseName: templateRow.warehouseName || '', weekdays: [...templateRow.weekdays], effectiveFrom: templateRow.effectiveFrom, effectiveTo: templateRow.effectiveTo || '', departureTime: templateRow.departureTime, cutoffDays: templateRow.cutoffDays, cutoffLocalTime: templateRow.cutoffLocalTime, voyageRule: templateRow.voyageRule || '', vesselName: templateRow.vesselName || '', departurePortCode: templateRow.departurePortCode || '', destinationPortCode: templateRow.destinationPortCode || '', remark: templateRow.remark || '', expectedVersion: templateRow.version })
templatePreview.value = []
+ resetGenerationRange(templateForm.effectiveFrom)
templateDialogOpen.value = true
}
function templatePayload() { return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b) } }
@@ -240,11 +256,12 @@ async function saveTemplate() {
await openManagement('templates')
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '周模板保存失败').message) } finally { templateSaving.value = false }
}
-function generationRange() { const from = templateForm.effectiveFrom; const to = templateForm.effectiveTo || `${month.value}-28`; return { from, to } }
+function generationRange() { return { from: generationFrom.value, to: generationTo.value } }
async function previewTemplate(row?: unknown) {
const templateRow = row as SailingTemplateResource | undefined
if (templateRow) openTemplateEdit(templateRow)
if (!validateTemplate()) return
+ if (!generationFrom.value || !generationTo.value || generationFrom.value > generationTo.value) { ElMessage.error('请填写有效的本次生成起止日期'); return }
const templateId = templateRow?.id || templates.value.find((item) => item.channelId === templateForm.channelId && item.warehouseId === templateForm.warehouseId && item.version === templateForm.expectedVersion)?.id
if (!templateId) { ElMessage.info('请先保存周模板,再进行生成预览'); return }
templatePreviewLoading.value = true
@@ -252,12 +269,12 @@ async function previewTemplate(row?: unknown) {
}
async function generateTemplate(row: unknown) {
const templateRow = row as SailingTemplateResource
- try { await ElMessageBox.confirm(`确认按模板“${templateRow.templateNo}”生成选定有效期内的船期?重复日期会由服务端跳过。`, '批量生成船期', { type: 'warning', confirmButtonText: '确认生成', cancelButtonText: '返回' }); templateGenerateLoading.value = true; const result = await generateSailingTemplate(templateRow.id, { from: templateRow.effectiveFrom, to: templateRow.effectiveTo || `${month.value}-28` }); ElMessage.success(`生成完成:${result.generatedCount} 个,跳过 ${result.skippedCount} 个`); await load(); await openManagement('templates') } catch (cause) { if (cause !== 'cancel') ElMessage.error(getApiErrorDetails(cause, '批量生成失败').message) } finally { templateGenerateLoading.value = false }
+ try { openTemplateEdit(templateRow); if (!generationFrom.value || !generationTo.value || generationFrom.value > generationTo.value) { ElMessage.error('请填写有效的本次生成起止日期'); return } await ElMessageBox.confirm(`确认按模板“${templateRow.templateNo}”生成 ${generationFrom.value} 至 ${generationTo.value} 的船期?重复日期会由服务端跳过。`, '批量生成船期', { type: 'warning', confirmButtonText: '确认生成', cancelButtonText: '返回' }); templateGenerateLoading.value = true; const result = await generateSailingTemplate(templateRow.id, generationRange()); ElMessage.success(`生成完成:${result.generatedCount} 个,跳过 ${result.skippedCount} 个`); await load(); await openManagement('templates') } catch (cause) { if (cause !== 'cancel') ElMessage.error(getApiErrorDetails(cause, '批量生成失败').message) } finally { templateGenerateLoading.value = false }
}
function formatWeekdays(value: unknown) { return Array.isArray(value) ? value.map((day: unknown) => ['一', '二', '三', '四', '五', '六', '日'][Number(day) - 1]).join('、') : '—' }
-function openCreate() { dialogMode.value = 'create'; Object.assign(form, blankForm()); dialogOpen.value = true }
-function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; Object.assign(form, { templateId: null, channelId: null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: 'MANUAL', plannedCapacityCbm: null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true }
-function onEtdChanged() { if (dialogMode.value === 'create') form.cutoffAt = cutoffFor(form.etdAt) }
+function openCreate() { dialogMode.value = 'create'; cutoffMode.value = 'rule'; cutoffDays.value = 3; cutoffLocalTime.value = '18:00:00'; Object.assign(form, blankForm()); dialogOpen.value = true }
+function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: null, channelId: null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: 'MANUAL', plannedCapacityCbm: null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true }
+function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() }
function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道编码'], ['channelName', '渠道名称'], ['vesselName', '船名'], ['voyageNo', '航次'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } return true }
async function save() {
if (!validateForm()) return
@@ -285,7 +302,11 @@ async function copySelected() {
const record = selectedSailing.value
if (!record || !canEdit.value) return
const etd = new Date(record.etdAt); etd.setDate(etd.getDate() + 7)
- try { await copySailing(record.id, { etdAt: `${formatDate(etd)}T${record.etdAt.slice(11, 19)}`, cutoffAt: cutoffFor(`${formatDate(etd)}T${record.etdAt.slice(11, 19)}`) }); ElMessage.success('已复制为下一班'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '复制船期失败').message) }
+ const etdAt = `${formatDate(etd)}T${record.etdAt.slice(11, 19)}`
+ const offsetMs = new Date(record.etdAt).getTime() - new Date(record.cutoffAt).getTime()
+ const copiedCutoff = new Date(new Date(etdAt).getTime() - offsetMs)
+ const cutoffAt = `${formatDate(copiedCutoff)}T${String(copiedCutoff.getHours()).padStart(2, '0')}:${String(copiedCutoff.getMinutes()).padStart(2, '0')}:${String(copiedCutoff.getSeconds()).padStart(2, '0')}`
+ try { await copySailing(record.id, { etdAt, cutoffAt }); ElMessage.success('已复制为下一班'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '复制船期失败').message) }
}
async function openVolumeDetail() {
if (!selectedSailing.value) return
@@ -321,7 +342,8 @@ onMounted(() => void load())
重置
渠道与周模板
- 新增船期
+ 按周批量生成
+ 新增单个船次
{{ card.label }}{{ card.value }}
@@ -335,17 +357,17 @@ onMounted(() => void load())
- 取消保存
+ 按开船前 N 天自定义时间提前 2 天提前 3 天提前 4 天
建议截止:{{ dateTime(form.cutoffAt) }}(按开船本地日期倒推自然日)
取消保存
已收实测 {{ decimal(volumeDetail.summary.actualReceivedCbm) }}截止前参考 {{ decimal(volumeDetail.summary.catchableReceivedCbm || 0) }}晚到 / 风险 {{ decimal(volumeDetail.summary.lateReceivedCbm || 0) }}预计参考 {{ decimal(volumeDetail.summary.catchableForecastCbm) }}待确认 {{ decimal(volumeDetail.summary.riskUnmatchedCbm) }}
{{ decimal(row.volumeCbm) }}{{ dateTime(row.receivedAt) }}
{{ row.fromStatus || '—' }} → {{ row.toStatus || '—' }}{{ dateTime(row.createdAt) }}
已收实测 {{ decimal(bookingAdvice.actualReceivedCbm || 0) }}截止前参考 {{ decimal(bookingAdvice.catchableTotalCbm) }}晚到 / 风险 {{ decimal(bookingAdvice.lateReceivedCbm || 0) }}已订柜 {{ bookingAdvice.bookedContainerCount }} 柜建议补柜 {{ bookingAdvice.pendingContainerCount }} 柜
以上为现有数据的只读初算,不会自动归班、合船或创建订舱;最终以智能排柜和人工确认结果为准。
{{ decimal(row.capacityCbm) }}相邻船期合船参考只做展示,不自动合并
本船参考量 {{ decimal(bookingAdvice.catchableTotalCbm) }}规划容量 {{ decimal(bookingAdvice.planningCapacityCbm || 0) }}剩余容量 {{ decimal(currentRemainingCapacity) }}
{{ row.vesselName }}{{ row.voyageNo }} · {{ dateTime(row.etdAt) }}
{{ decimal(referenceVolume(row)) }}{{ decimal(remainingCapacity(row)) }}{{ mergeAdvice(row) }}仅展示同仓、同渠道、同起运港和目的港的相邻船期;实际是否合船仍回到智能排柜和人工确认。
{{ row.status === 'ACTIVE' ? '启用' : '停用' }}{{ row.status === 'ACTIVE' ? '停用' : '启用' }}{{ formatWeekdays(row.weekdays) }}{{ row.departureTime }} / {{ row.cutoffDays }}天编辑预览生成确认生成
取消保存渠道
- 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}生成预览({{ templatePreview.length }} 条)
{{ row.conflict ? (row.conflictReason || "有冲突") : "可生成" }}取消保存模板预览生成
+ 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周
生成预览({{ templatePreview.length }} 条)
{{ row.conflict ? (row.conflictReason || "有冲突") : "可生成" }}取消保存模板预览生成
--
2.54.0
From 5a90503542b5852d5e78fa02aa9646195d5d2583 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 27 Sep 2026 22:50:58 +0800
Subject: [PATCH 30/36] fix(admin): start weekly generation from today
---
src/modules/sailing-calendar/pages/SailingCalendarView.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
index b01f1c90..aee5ddeb 100644
--- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue
+++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
@@ -77,7 +77,7 @@ const generationTo = ref(addDays(formatDate(today), 56))
function formatMonth(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}` }
function formatDate(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}` }
function addDays(value: string, days: number) { const date = new Date(`${value}T00:00:00`); date.setDate(date.getDate() + days); return formatDate(date) }
-function resetGenerationRange(from = formatDate(today)) { generationFrom.value = from || formatDate(today); generationTo.value = addDays(generationFrom.value, 56) }
+function resetGenerationRange(from = formatDate(today)) { const todayKey = formatDate(today); generationFrom.value = from && from > todayKey ? from : todayKey; generationTo.value = addDays(generationFrom.value, 56) }
function setGenerationWeeks(weeks: number) { generationTo.value = addDays(generationFrom.value || formatDate(today), weeks * 7) }
function parseMonth(value: string) { const [year, monthNumber] = value.split('-').map(Number); return new Date(year, monthNumber - 1, 1) }
function dateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
--
2.54.0
From 3cf676cbbdc06ba80efbaeb2e355e6e1473e57d5 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 27 Sep 2026 22:58:22 +0800
Subject: [PATCH 31/36] fix(admin): clarify weekly sailing defaults
---
src/modules/sailing-calendar/pages/SailingCalendarView.vue | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
index aee5ddeb..0adb2226 100644
--- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue
+++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
@@ -242,7 +242,7 @@ function openTemplateEdit(row: unknown) {
templateDialogOpen.value = true
}
function templatePayload() { return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b) } }
-function validateTemplate() { if (!templateForm.channelId || !templateForm.warehouseId || !templateForm.weekdays.length || !templateForm.effectiveFrom || !templateForm.departureTime || templateForm.cutoffDays < 0 || !templateForm.vesselName?.trim() || !templateForm.departurePortCode?.trim() || !templateForm.destinationPortCode?.trim()) { ElMessage.error('请补全渠道、收货仓、周几、船名、起运港、目的港、有效期和截止天数'); return false } return true }
+function validateTemplate() { if (!templateForm.channelId || !templateForm.warehouseId || !templateForm.weekdays.length || !templateForm.effectiveFrom || !templateForm.departureTime || templateForm.cutoffDays < 0 || !templateForm.vesselName?.trim() || !templateForm.departurePortCode?.trim() || !templateForm.destinationPortCode?.trim()) { ElMessage.error('请补全渠道、收货仓、周几、默认船名、起运港、目的港、有效期和截止天数'); return false } if (templateForm.cutoffDays === 0 && templateForm.cutoffLocalTime >= templateForm.departureTime) { ElMessage.error('同日收货截止时刻必须早于开船时间'); return false } return true }
async function saveTemplate() {
if (!validateTemplate()) return
templateSaving.value = true
@@ -275,7 +275,7 @@ function formatWeekdays(value: unknown) { return Array.isArray(value) ? value.ma
function openCreate() { dialogMode.value = 'create'; cutoffMode.value = 'rule'; cutoffDays.value = 3; cutoffLocalTime.value = '18:00:00'; Object.assign(form, blankForm()); dialogOpen.value = true }
function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: null, channelId: null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: 'MANUAL', plannedCapacityCbm: null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true }
function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() }
-function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道编码'], ['channelName', '渠道名称'], ['vesselName', '船名'], ['voyageNo', '航次'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } return true }
+function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道编码'], ['channelName', '渠道名称'], ['vesselName', '船名'], ['voyageNo', '航次'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true }
async function save() {
if (!validateForm()) return
saving.value = true
@@ -362,7 +362,7 @@ onMounted(() => void load())
{{ row.fromStatus || '—' }} → {{ row.toStatus || '—' }}{{ dateTime(row.createdAt) }}
已收实测 {{ decimal(bookingAdvice.actualReceivedCbm || 0) }}截止前参考 {{ decimal(bookingAdvice.catchableTotalCbm) }}晚到 / 风险 {{ decimal(bookingAdvice.lateReceivedCbm || 0) }}已订柜 {{ bookingAdvice.bookedContainerCount }} 柜建议补柜 {{ bookingAdvice.pendingContainerCount }} 柜
以上为现有数据的只读初算,不会自动归班、合船或创建订舱;最终以智能排柜和人工确认结果为准。
{{ decimal(row.capacityCbm) }}相邻船期合船参考只做展示,不自动合并
本船参考量 {{ decimal(bookingAdvice.catchableTotalCbm) }}规划容量 {{ decimal(bookingAdvice.planningCapacityCbm || 0) }}剩余容量 {{ decimal(currentRemainingCapacity) }}
{{ row.vesselName }}{{ row.voyageNo }} · {{ dateTime(row.etdAt) }}
{{ decimal(referenceVolume(row)) }}{{ decimal(remainingCapacity(row)) }}{{ mergeAdvice(row) }}仅展示同仓、同渠道、同起运港和目的港的相邻船期;实际是否合船仍回到智能排柜和人工确认。
{{ row.status === 'ACTIVE' ? '启用' : '停用' }}{{ row.status === 'ACTIVE' ? '停用' : '启用' }}{{ formatWeekdays(row.weekdays) }}{{ row.departureTime }} / {{ row.cutoffDays }}天编辑预览生成确认生成
取消保存渠道
- 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周
生成预览({{ templatePreview.length }} 条)
{{ row.conflict ? (row.conflictReason || "有冲突") : "可生成" }}取消保存模板预览生成
+ 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周
生成预览({{ templatePreview.length }} 条)
{{ row.conflict ? (row.conflictReason || "有冲突") : "可生成" }}取消保存模板预览生成
--
2.54.0
From 5cc1512a2808b1df5a36ff1a23925cbe476d2d5a Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 27 Sep 2026 23:41:28 +0800
Subject: [PATCH 32/36] fix(admin): advance sailing copy selection
---
.../sailing-calendar/pages/SailingCalendarView.vue | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
index 0adb2226..6233aea3 100644
--- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue
+++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
@@ -306,7 +306,16 @@ async function copySelected() {
const offsetMs = new Date(record.etdAt).getTime() - new Date(record.cutoffAt).getTime()
const copiedCutoff = new Date(new Date(etdAt).getTime() - offsetMs)
const cutoffAt = `${formatDate(copiedCutoff)}T${String(copiedCutoff.getHours()).padStart(2, '0')}:${String(copiedCutoff.getMinutes()).padStart(2, '0')}:${String(copiedCutoff.getSeconds()).padStart(2, '0')}`
- try { await copySailing(record.id, { etdAt, cutoffAt }); ElMessage.success('已复制为下一班'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '复制船期失败').message) }
+ try {
+ const result = await copySailing(record.id, { etdAt, cutoffAt })
+ const copied = result.sailing
+ const targetMonth = copied.etdAt.slice(0, 7)
+ month.value = targetMonth
+ selectedDate.value = copied.etdAt.slice(0, 10)
+ selectedSailing.value = copied
+ if (targetMonth === data.value?.month) await load()
+ ElMessage.success(`已定位到下一班:${copied.etdAt.slice(0, 16).replace('T', ' ')}`)
+ } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '复制船期失败').message) }
}
async function openVolumeDetail() {
if (!selectedSailing.value) return
--
2.54.0
From ffe91a1936f3d6416bd7dcdd8ab07a6793b38613 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 28 Sep 2026 09:49:53 +0800
Subject: [PATCH 33/36] feat(admin): show customs receipt history
---
.../container-loading/api/platformContainerCustoms.ts | 10 +++++++++-
.../components/ContainerCustomsPanel.vue | 5 ++++-
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts
index 4f5a374b..e1a839d5 100644
--- a/src/modules/container-loading/api/platformContainerCustoms.ts
+++ b/src/modules/container-loading/api/platformContainerCustoms.ts
@@ -12,6 +12,13 @@ export interface ContainerCustomsFile {
uploadedAt: string
}
+export interface ContainerCustomsReceiptVersion {
+ version: number
+ file: ContainerCustomsFile
+ operatorName?: string | null
+ createdAt: string
+}
+
export interface ContainerCustomsInbound {
inboundId: string
orderId: string
@@ -34,6 +41,7 @@ export interface ContainerCustomsDocument {
receiptStatus?: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null
receiptRejectReason?: string | null
finalDocument: ContainerCustomsFile | null
+ receiptHistory?: ContainerCustomsReceiptVersion[]
}
export interface ContainerCustomsView {
@@ -111,7 +119,7 @@ function normalize(value: RawContainerCustomsView): ContainerCustomsView {
...value,
aggregateId: String(value.aggregateId), loadingPlanId: String(value.loadingPlanId), containerBookingId: String(value.containerBookingId), version: Number(value.version),
inbounds: value.inbounds.map((item) => ({ ...item, inboundId: String(item.inboundId), orderId: String(item.orderId), tenantId: String(item.tenantId) })),
- documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, receipt: file(item.receipt), finalDocument: file(item.finalDocument) })),
+ documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, receipt: file(item.receipt), finalDocument: file(item.finalDocument), receiptHistory: (item.receiptHistory || []).map((version) => ({ ...version, version: Number(version.version), file: file(version.file)! })) })),
}
}
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue
index 5de21e11..a12d80aa 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.vue
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue
@@ -58,6 +58,9 @@ const statusText: Record = {
function documentCustomers(document: ContainerCustomsDocument) {
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
}
+function receiptHistoryLabel(document: ContainerCustomsDocument) {
+ return (document.receiptHistory || []).map((item) => `v${item.version}`).join('、') || '—'
+}
function selectReceipt(documentId: string, upload: UploadFile) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
receiptFiles[documentId] = upload.raw || null
@@ -269,7 +272,7 @@ onMounted(load)
{{ document.source.fileName }}下载 |
{{ document.receipt.fileName }}下载 未上传 |
{{ document.finalDocument.fileName }}下载 未上传 |
- {{ document.inboundCodes.join('、') || '—' }} | {{ brokerSubmitted ? '已发送报关行执行' : '待发送报关行执行' }} | 退税资料 v{{ document.source.version }} / 回执 v{{ document.receipt.version }} / 完税 v{{ document.finalDocument.version }} | {{ documentCustomers(document) }} | {{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }} |
+ {{ document.inboundCodes.join('、') || '—' }} | {{ brokerSubmitted ? '已发送报关行执行' : '待发送报关行执行' }} | 退税资料 v{{ document.source.version }} / 回执 v{{ document.receipt.version }} / 完税 v{{ document.finalDocument.version }}回执历史:{{ receiptHistoryLabel(document) }} | {{ documentCustomers(document) }} | {{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }} |
--
2.54.0
From fad49a07a8cc39ff1759c0f159f5533feb5f9738 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 28 Sep 2026 12:30:23 +0800
Subject: [PATCH 34/36] fix(admin): preserve sailing metadata and generation
choices
---
.../api/platformSailingCalendar.ts | 5 ++
.../pages/SailingCalendarView.vue | 47 +++++++++++++------
src/modules/sailing-calendar/types.ts | 5 ++
3 files changed, 43 insertions(+), 14 deletions(-)
diff --git a/src/modules/sailing-calendar/api/platformSailingCalendar.ts b/src/modules/sailing-calendar/api/platformSailingCalendar.ts
index 5b92c1f2..c120a55f 100644
--- a/src/modules/sailing-calendar/api/platformSailingCalendar.ts
+++ b/src/modules/sailing-calendar/api/platformSailingCalendar.ts
@@ -16,6 +16,11 @@ function entry(raw: SailingCalendarEntry): SailingCalendarEntry {
warehouseId: String(raw.warehouseId ?? ''),
channelCode: String(raw.channelCode ?? ''),
channelName: String(raw.channelName ?? raw.channelCode ?? ''),
+ templateId: raw.templateId == null ? null : String(raw.templateId),
+ channelId: raw.channelId == null ? null : String(raw.channelId),
+ etaAt: raw.etaAt ?? null,
+ sourceType: raw.sourceType ?? null,
+ plannedCapacityCbm: raw.plannedCapacityCbm == null ? null : number(raw.plannedCapacityCbm),
volumeSummary: summary(raw.volumeSummary),
version: number(raw.version),
}
diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
index 6233aea3..38ab71a3 100644
--- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue
+++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue
@@ -62,6 +62,7 @@ const managementError = ref(null)
const channels = ref([])
const channelDialogOpen = ref(false)
const channelSaving = ref(false)
+const editingChannelId = ref('')
const channelForm = reactive({ channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY', mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '' })
const templates = ref([])
const templateDialogOpen = ref(false)
@@ -169,6 +170,7 @@ const summaryCards = computed(() => {
})
const canCreate = computed(() => access.can('platform:firstleg:sailing-calendar:create'))
const canEdit = computed(() => access.can('platform:firstleg:sailing-calendar:edit'))
+const canCopy = computed(() => access.can('platform:firstleg:sailing-calendar:create'))
const canManageTemplates = computed(() => access.can('platform:firstleg:sailing-template:manage'))
async function load() {
@@ -214,16 +216,19 @@ async function toggleChannel(row: unknown) {
ElMessage.success(channelRow.status === 'ACTIVE' ? '渠道已启用' : '渠道已停用')
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '渠道状态更新失败').message) }
}
-function openChannelCreate() { Object.assign(channelForm, { channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY' as SailingChannelSourceType, mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '', expectedVersion: undefined }); channelDialogOpen.value = true }
+function openChannelCreate() { editingChannelId.value = ''; Object.assign(channelForm, { channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY' as SailingChannelSourceType, mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '', expectedVersion: undefined, status: undefined }); channelDialogOpen.value = true }
+function openChannelEdit(row: unknown) { const channelRow = row as SailingChannelResource; editingChannelId.value = channelRow.id; Object.assign(channelForm, { channelCode: channelRow.channelCode, channelName: channelRow.channelName, sourceType: channelRow.sourceType, mappedChannelCode: channelRow.mappedChannelCode || '', defaultDeparturePortCode: channelRow.defaultDeparturePortCode || '', defaultDestinationPortCode: channelRow.defaultDestinationPortCode || '', defaultCutoffDays: channelRow.defaultCutoffDays, remark: channelRow.remark || '', expectedVersion: channelRow.version, status: channelRow.status }); channelDialogOpen.value = true }
async function saveChannel() {
if (!channelForm.channelCode.trim() || !channelForm.channelName.trim() || channelForm.defaultCutoffDays < 0) { ElMessage.error('请填写渠道编码、名称和有效的提前截止天数'); return }
channelSaving.value = true
try {
- const response = await createSailingChannel(channelForm)
- channels.value = [...channels.value, response]
+ const response = editingChannelId.value ? await updateSailingChannel(editingChannelId.value, channelForm) : await createSailingChannel(channelForm)
+ if (editingChannelId.value) channels.value = channels.value.map((item) => item.id === response.id ? response : item)
+ else channels.value = [...channels.value, response]
channelDialogOpen.value = false
- ElMessage.success('渠道已创建')
- } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '渠道创建失败').message) } finally { channelSaving.value = false }
+ ElMessage.success(editingChannelId.value ? '渠道已更新' : '渠道已创建')
+ editingChannelId.value = ''
+ } catch (cause) { ElMessage.error(getApiErrorDetails(cause, editingChannelId.value ? '渠道更新失败' : '渠道创建失败').message) } finally { channelSaving.value = false }
}
function openTemplateCreate() {
editingTemplateId.value = ''
@@ -233,12 +238,12 @@ function openTemplateCreate() {
resetGenerationRange(templateForm.effectiveFrom)
templateDialogOpen.value = true
}
-function openTemplateEdit(row: unknown) {
+function openTemplateEdit(row: unknown, options: { preserveGenerationRange?: boolean } = {}) {
const templateRow = row as SailingTemplateResource
editingTemplateId.value = templateRow.id
Object.assign(templateForm, { templateName: templateRow.templateName || '', channelId: templateRow.channelId, warehouseId: templateRow.warehouseId, warehouseName: templateRow.warehouseName || '', weekdays: [...templateRow.weekdays], effectiveFrom: templateRow.effectiveFrom, effectiveTo: templateRow.effectiveTo || '', departureTime: templateRow.departureTime, cutoffDays: templateRow.cutoffDays, cutoffLocalTime: templateRow.cutoffLocalTime, voyageRule: templateRow.voyageRule || '', vesselName: templateRow.vesselName || '', departurePortCode: templateRow.departurePortCode || '', destinationPortCode: templateRow.destinationPortCode || '', remark: templateRow.remark || '', expectedVersion: templateRow.version })
templatePreview.value = []
- resetGenerationRange(templateForm.effectiveFrom)
+ if (!options.preserveGenerationRange) resetGenerationRange(templateForm.effectiveFrom)
templateDialogOpen.value = true
}
function templatePayload() { return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b) } }
@@ -269,11 +274,25 @@ async function previewTemplate(row?: unknown) {
}
async function generateTemplate(row: unknown) {
const templateRow = row as SailingTemplateResource
- try { openTemplateEdit(templateRow); if (!generationFrom.value || !generationTo.value || generationFrom.value > generationTo.value) { ElMessage.error('请填写有效的本次生成起止日期'); return } await ElMessageBox.confirm(`确认按模板“${templateRow.templateNo}”生成 ${generationFrom.value} 至 ${generationTo.value} 的船期?重复日期会由服务端跳过。`, '批量生成船期', { type: 'warning', confirmButtonText: '确认生成', cancelButtonText: '返回' }); templateGenerateLoading.value = true; const result = await generateSailingTemplate(templateRow.id, generationRange()); ElMessage.success(`生成完成:${result.generatedCount} 个,跳过 ${result.skippedCount} 个`); await load(); await openManagement('templates') } catch (cause) { if (cause !== 'cancel') ElMessage.error(getApiErrorDetails(cause, '批量生成失败').message) } finally { templateGenerateLoading.value = false }
+ openTemplateEdit(templateRow)
+}
+async function confirmTemplateGeneration() {
+ const templateId = editingTemplateId.value
+ if (!templateId) { ElMessage.info('请先保存周模板,再生成船期'); return }
+ if (!generationFrom.value || !generationTo.value || generationFrom.value > generationTo.value) { ElMessage.error('请填写有效的本次生成起止日期'); return }
+ await ElMessageBox.confirm(`确认按当前模板生成 ${generationFrom.value} 至 ${generationTo.value} 的船期?重复日期会由服务端跳过。`, '批量生成船期', { type: 'warning', confirmButtonText: '确认生成', cancelButtonText: '返回' })
+ templateGenerateLoading.value = true
+ try {
+ const result = await generateSailingTemplate(templateId, generationRange())
+ ElMessage.success(`生成完成:${result.generatedCount} 个,跳过 ${result.skippedCount} 个`)
+ templateDialogOpen.value = false
+ await load()
+ await openManagement('templates')
+ } finally { templateGenerateLoading.value = false }
}
function formatWeekdays(value: unknown) { return Array.isArray(value) ? value.map((day: unknown) => ['一', '二', '三', '四', '五', '六', '日'][Number(day) - 1]).join('、') : '—' }
function openCreate() { dialogMode.value = 'create'; cutoffMode.value = 'rule'; cutoffDays.value = 3; cutoffLocalTime.value = '18:00:00'; Object.assign(form, blankForm()); dialogOpen.value = true }
-function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: null, channelId: null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: 'MANUAL', plannedCapacityCbm: null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true }
+function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true }
function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() }
function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道编码'], ['channelName', '渠道名称'], ['vesselName', '船名'], ['voyageNo', '航次'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true }
async function save() {
@@ -300,7 +319,7 @@ async function cancelSelected() {
}
async function copySelected() {
const record = selectedSailing.value
- if (!record || !canEdit.value) return
+ if (!record || !canCopy.value) return
const etd = new Date(record.etdAt); etd.setDate(etd.getDate() + 7)
const etdAt = `${formatDate(etd)}T${record.etdAt.slice(11, 19)}`
const offsetMs = new Date(record.etdAt).getTime() - new Date(record.cutoffAt).getTime()
@@ -363,15 +382,15 @@ onMounted(() => void load())
{{ day }}
-
+
按开船前 N 天自定义时间提前 2 天提前 3 天提前 4 天
建议截止:{{ dateTime(form.cutoffAt) }}(按开船本地日期倒推自然日)
取消保存
已收实测 {{ decimal(volumeDetail.summary.actualReceivedCbm) }}截止前参考 {{ decimal(volumeDetail.summary.catchableReceivedCbm || 0) }}晚到 / 风险 {{ decimal(volumeDetail.summary.lateReceivedCbm || 0) }}预计参考 {{ decimal(volumeDetail.summary.catchableForecastCbm) }}待确认 {{ decimal(volumeDetail.summary.riskUnmatchedCbm) }}
{{ decimal(row.volumeCbm) }}{{ dateTime(row.receivedAt) }}
{{ row.fromStatus || '—' }} → {{ row.toStatus || '—' }}{{ dateTime(row.createdAt) }}
已收实测 {{ decimal(bookingAdvice.actualReceivedCbm || 0) }}截止前参考 {{ decimal(bookingAdvice.catchableTotalCbm) }}晚到 / 风险 {{ decimal(bookingAdvice.lateReceivedCbm || 0) }}已订柜 {{ bookingAdvice.bookedContainerCount }} 柜建议补柜 {{ bookingAdvice.pendingContainerCount }} 柜
以上为现有数据的只读初算,不会自动归班、合船或创建订舱;最终以智能排柜和人工确认结果为准。
{{ decimal(row.capacityCbm) }}相邻船期合船参考只做展示,不自动合并
本船参考量 {{ decimal(bookingAdvice.catchableTotalCbm) }}规划容量 {{ decimal(bookingAdvice.planningCapacityCbm || 0) }}剩余容量 {{ decimal(currentRemainingCapacity) }}
{{ row.vesselName }}{{ row.voyageNo }} · {{ dateTime(row.etdAt) }}
{{ decimal(referenceVolume(row)) }}{{ decimal(remainingCapacity(row)) }}{{ mergeAdvice(row) }}仅展示同仓、同渠道、同起运港和目的港的相邻船期;实际是否合船仍回到智能排柜和人工确认。
- {{ row.status === 'ACTIVE' ? '启用' : '停用' }}{{ row.status === 'ACTIVE' ? '停用' : '启用' }}{{ formatWeekdays(row.weekdays) }}{{ row.departureTime }} / {{ row.cutoffDays }}天编辑预览生成确认生成
- 取消保存渠道
- 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周
生成预览({{ templatePreview.length }} 条)
{{ row.conflict ? (row.conflictReason || "有冲突") : "可生成" }}取消保存模板预览生成
+ {{ row.status === 'ACTIVE' ? '启用' : '停用' }}编辑{{ row.status === 'ACTIVE' ? '停用' : '启用' }}{{ formatWeekdays(row.weekdays) }}{{ row.departureTime }} / {{ row.cutoffDays }}天编辑预览生成设置并生成
+ 取消{{ editingChannelId ? '保存修改' : '保存渠道' }}
+ 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周
生成预览({{ templatePreview.length }} 条)
{{ row.conflict ? (row.conflictReason || "有冲突") : "可生成" }}取消保存模板预览生成生成船期
diff --git a/src/modules/sailing-calendar/types.ts b/src/modules/sailing-calendar/types.ts
index a47a1fda..0e582108 100644
--- a/src/modules/sailing-calendar/types.ts
+++ b/src/modules/sailing-calendar/types.ts
@@ -36,6 +36,11 @@ export interface SailingCalendarEntry {
status: SailingStatus
volumeSummary: SailingVolumeSummary
version: number
+ templateId?: string | null
+ channelId?: string | null
+ etaAt?: string | null
+ sourceType?: string | null
+ plannedCapacityCbm?: number | null
remark?: string | null
}
--
2.54.0
From 730defabca6bc6750f478829c8894d1865d6a7d8 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 28 Sep 2026 13:21:59 +0800
Subject: [PATCH 35/36] feat(customs): show broker submission state in admin
---
.../api/platformContainerCustoms.test.ts | 12 ++++-
.../api/platformContainerCustoms.ts | 50 ++++++++++++++++++-
.../components/ContainerCustomsPanel.test.ts | 26 +++++++++-
.../components/ContainerCustomsPanel.vue | 36 ++++++++++---
4 files changed, 111 insertions(+), 13 deletions(-)
diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts
index 7e1a5eb7..3aae0473 100644
--- a/src/modules/container-loading/api/platformContainerCustoms.test.ts
+++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts
@@ -1,12 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
-import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
+import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
const customs = {
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', hasActualCargo: true, version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12,
inbounds: [{ inboundId: 2092438843630391404n, orderId: 2092438843630391405n, orderNo: 'O-1', inboundCode: 'RK-1', tenantId: 2092438843630391406n, customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
- documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, receipt: null, finalDocument: null }],
+ documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, receipt: null, finalDocument: null, confirmationHistory: [] }],
+ brokerSubmission: null,
startedAt: null, completedAt: null,
}
@@ -39,6 +40,13 @@ describe('platform container customs API', () => {
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
})
+ it('sends the frozen source documents to the broker through the first-send action', async () => {
+ const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: customs })
+ await sendContainerCustomsToBroker('2092438843630391402', 3)
+ expect(post.mock.calls[0]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/send-to-broker', { expectedVersion: 3, confirm: true, remark: '已将冻结退税报关资料首次发送给报关行' }])
+ expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
+ })
+
it('uploads one receipt file without completing the container', async () => {
const document = { ...customs.documents[0], documentId: '2092438843630391407', tenantId: '2092438843630391406' }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: document })
diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts
index e1a839d5..a8bd6067 100644
--- a/src/modules/container-loading/api/platformContainerCustoms.ts
+++ b/src/modules/container-loading/api/platformContainerCustoms.ts
@@ -19,6 +19,29 @@ export interface ContainerCustomsReceiptVersion {
createdAt: string
}
+export type ContainerCustomsConfirmationAction = 'CONFIRM' | 'REJECT'
+
+export interface ContainerCustomsConfirmationHistory {
+ id: string
+ receiptVersion: number
+ action: ContainerCustomsConfirmationAction
+ reason: string | null
+ operatorName: string
+ createdAt: string
+}
+
+export interface ContainerCustomsBrokerSubmission {
+ id: string
+ customsContainerId: string
+ loadingPlanId: string
+ batchNo: string
+ status: 'SENT'
+ sourceVersion: number
+ sentByName: string
+ sentAt: string
+ version: number
+}
+
export interface ContainerCustomsInbound {
inboundId: string
orderId: string
@@ -42,6 +65,7 @@ export interface ContainerCustomsDocument {
receiptRejectReason?: string | null
finalDocument: ContainerCustomsFile | null
receiptHistory?: ContainerCustomsReceiptVersion[]
+ confirmationHistory: ContainerCustomsConfirmationHistory[]
}
export interface ContainerCustomsView {
@@ -59,6 +83,7 @@ export interface ContainerCustomsView {
actualBoxCount: number
inbounds: ContainerCustomsInbound[]
documents: ContainerCustomsDocument[]
+ brokerSubmission: ContainerCustomsBrokerSubmission | null
startedAt: string | null
completedAt: string | null
brokerSubmittedAt?: string | null
@@ -119,7 +144,24 @@ function normalize(value: RawContainerCustomsView): ContainerCustomsView {
...value,
aggregateId: String(value.aggregateId), loadingPlanId: String(value.loadingPlanId), containerBookingId: String(value.containerBookingId), version: Number(value.version),
inbounds: value.inbounds.map((item) => ({ ...item, inboundId: String(item.inboundId), orderId: String(item.orderId), tenantId: String(item.tenantId) })),
- documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, receipt: file(item.receipt), finalDocument: file(item.finalDocument), receiptHistory: (item.receiptHistory || []).map((version) => ({ ...version, version: Number(version.version), file: file(version.file)! })) })),
+ brokerSubmission: value.brokerSubmission ? {
+ ...value.brokerSubmission,
+ id: String(value.brokerSubmission.id),
+ customsContainerId: String(value.brokerSubmission.customsContainerId),
+ loadingPlanId: String(value.brokerSubmission.loadingPlanId),
+ sourceVersion: Number(value.brokerSubmission.sourceVersion),
+ version: Number(value.brokerSubmission.version),
+ } : null,
+ documents: value.documents.map((item) => ({
+ ...item,
+ documentId: String(item.documentId),
+ tenantId: String(item.tenantId),
+ source: file(item.source)!,
+ receipt: file(item.receipt),
+ finalDocument: file(item.finalDocument),
+ receiptHistory: (item.receiptHistory || []).map((version) => ({ ...version, version: Number(version.version), file: file(version.file)! })),
+ confirmationHistory: (item.confirmationHistory || []).map((history) => ({ ...history, id: String(history.id), receiptVersion: Number(history.receiptVersion), reason: history.reason ?? null })),
+ })),
}
}
@@ -174,7 +216,7 @@ export function downloadContainerCustomsFinal(documentId: string) {
return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' })
}
-async function action(loadingPlanId: string, name: 'start' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) {
+async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) {
const response = await http.post, ApiEnvelope>(
`${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`,
{ expectedVersion, confirm: true, remark: remark?.trim() || null },
@@ -187,6 +229,10 @@ export function startContainerCustoms(loadingPlanId: string, expectedVersion: nu
return action(loadingPlanId, 'start', expectedVersion)
}
+export function sendContainerCustomsToBroker(loadingPlanId: string, expectedVersion: number) {
+ return action(loadingPlanId, 'send-to-broker', expectedVersion, '已将冻结退税报关资料首次发送给报关行')
+}
+
export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVersion: number) {
return action(loadingPlanId, 'complete-all-buy', expectedVersion)
}
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
index 86fce58b..f5fd91b7 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts
@@ -8,6 +8,8 @@ const api = vi.hoisted(() => ({
get: vi.fn(),
start: vi.fn(),
completeAllBuy: vi.fn(),
+ sendToBroker: vi.fn(),
+ getDeclarationBatches: vi.fn(),
upload: vi.fn(),
downloadSource: vi.fn(),
downloadReceipt: vi.fn(),
@@ -23,6 +25,8 @@ vi.mock('../api/platformContainerCustoms', () => ({
downloadContainerCustomsReceipt: api.downloadReceipt,
downloadContainerCustomsFinal: api.downloadFinal,
submitContainerCustomsToBroker: vi.fn(),
+ sendContainerCustomsToBroker: api.sendToBroker,
+ getDeclarationBatches: api.getDeclarationBatches,
}))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
@@ -39,7 +43,9 @@ function customs(status: ContainerCustomsStatus, hasCompletion = false): Contain
inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true,
receipt: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关行回执.pdf', fileHash: 'receipt-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null,
finalDocument: hasCompletion ? { fileId: '2092438843630391410', fileName: '真实报关完税文件.pdf', fileHash: 'final-hash', sizeBytes: 14, version: 1, uploadedAt: '2026-08-29T12:00:00' } : null,
+ confirmationHistory: hasCompletion ? [{ id: '2092438843630391411', receiptVersion: 1, action: 'CONFIRM', reason: null, operatorName: '客户一', createdAt: '2026-08-29T11:15:00' }] : [],
}],
+ brokerSubmission: hasCompletion ? { id: '2092438843630391412', customsContainerId: '2092438843630391401', loadingPlanId: '2092438843630391402', batchNo: 'BS-20260829-001', status: 'SENT', sourceVersion: 3, sentByName: '平台员', sentAt: '2026-08-29T10:45:00', version: 1 } : null,
startedAt: status === 'DECLARING' || status === 'COMPLETED' ? '2026-08-29T10:30:00' : null,
completedAt: status === 'COMPLETED' ? '2026-08-29T11:30:00' : null,
}
@@ -62,6 +68,7 @@ function buttonTexts(wrapper: Awaited>) {
describe('ContainerCustomsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
+ api.getDeclarationBatches.mockResolvedValue([])
api.downloadSource.mockResolvedValue(new Blob(['source']))
api.downloadReceipt.mockResolvedValue(new Blob(['receipt']))
api.downloadFinal.mockResolvedValue(new Blob(['final']))
@@ -112,14 +119,17 @@ describe('ContainerCustomsPanel', () => {
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
let wrapper = await render('DECLARING', true, false)
- expect(wrapper.text()).toContain('上传报关行回执')
- expect(wrapper.text()).toContain('提交')
+ expect(wrapper.text()).toContain('首次发送报关行')
+ expect(wrapper.text()).toContain('首次发送后上传回执')
+ expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
wrapper.unmount()
wrapper = await render('DECLARING', true, true)
+ expect(wrapper.text()).toContain('发送报关行执行')
expect(wrapper.text()).toContain('覆盖报关行回执')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
+ expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
wrapper.unmount()
wrapper = await render('DECLARING', false, true)
@@ -129,6 +139,18 @@ describe('ContainerCustomsPanel', () => {
expect(buttonTexts(wrapper)).not.toContain('提交')
})
+ it('loads declaration batches during DECLARING after broker execution was sent', async () => {
+ const value = { ...customs('DECLARING', true), brokerSubmittedAt: '2026-08-29T11:20:00' }
+ api.get.mockResolvedValue(value)
+ const wrapper = mount(ContainerCustomsPanel, {
+ props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
+ global: { plugins: [ElementPlus] },
+ })
+ await flushPromises()
+ expect(api.getDeclarationBatches).toHaveBeenCalledWith('2092438843630391402')
+ wrapper.unmount()
+ })
+
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
const wrapper = await render('COMPLETED', true, true)
expect(wrapper.text()).toContain('客户退税资料.pdf')
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue
index a12d80aa..d02b4a41 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.vue
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue
@@ -19,6 +19,7 @@ import {
resolveClearanceResult,
submitDeclarationBatch,
startContainerCustoms,
+ sendContainerCustomsToBroker,
submitContainerCustomsToBroker,
uploadContainerCustomsReceipt,
uploadContainerCustomsFinal,
@@ -49,7 +50,9 @@ const finalFiles = reactive>({})
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
+const brokerSubmission = computed(() => customs.value?.brokerSubmission || null)
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
+const allReceiptsConfirmed = computed(() => Boolean(customs.value?.documents.length) && customs.value?.documents.every((document) => document.receipt && document.receiptStatus === 'CONFIRMED') === true)
const openDeclarationBatch = computed(() => declarationBatches.value.find((item) => ['READY', 'SUBMITTED', 'ACKNOWLEDGED', 'RESULT_PENDING'].includes(item.status)) || null)
const statusText: Record = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
@@ -61,8 +64,16 @@ function documentCustomers(document: ContainerCustomsDocument) {
function receiptHistoryLabel(document: ContainerCustomsDocument) {
return (document.receiptHistory || []).map((item) => `v${item.version}`).join('、') || '—'
}
+function confirmationActionLabel(action: ContainerCustomsDocument['confirmationHistory'][number]['action']) {
+ return action === 'CONFIRM' ? '确认' : '打回'
+}
+function formatDateTime(value: string | null | undefined) {
+ if (!value) return '—'
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
+}
function selectReceipt(documentId: string, upload: UploadFile) {
- if (!props.canManage || customs.value?.status !== 'DECLARING') return
+ if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
receiptFiles[documentId] = upload.raw || null
actionError.value = null
}
@@ -78,7 +89,7 @@ async function load() {
loadError.value = null
try {
customs.value = await getContainerCustoms(props.loadingPlanId)
- if (customs.value?.status === 'COMPLETED') {
+ if (customs.value?.status === 'COMPLETED' || customs.value?.brokerSubmittedAt) {
try { declarationBatches.value = await getDeclarationBatches(props.loadingPlanId) } catch { declarationBatches.value = [] }
} else declarationBatches.value = []
}
@@ -147,7 +158,7 @@ async function start() {
}
async function uploadReceipt(document: ContainerCustomsDocument) {
- if (!props.canManage || customs.value?.status !== 'DECLARING' || brokerSubmitted.value) return
+ if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
const selected = receiptFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return }
actionLoading.value = true; actionError.value = null
@@ -203,7 +214,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
}
async function submitToBroker() {
- if (!customs.value || customs.value.status !== 'DECLARING') return
+ if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', '发送报关行执行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') }
@@ -211,6 +222,15 @@ async function submitToBroker() {
finally { actionLoading.value = false }
}
+async function sendToBroker() {
+ if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return
+ try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', '首次发送报关行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
+ actionLoading.value = true; actionError.value = null
+ try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') }
+ catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') }
+ finally { actionLoading.value = false }
+}
+
async function completeAllBuy() {
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
@@ -249,7 +269,8 @@ onMounted(load)
{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }}
导出买单 XLSX
开始报关
- 发送报关行执行
+ 首次发送报关行
+ 发送报关行执行
确认报关完成
@@ -260,6 +281,7 @@ onMounted(load)
实际箱数 {{ customs.actualBoxCount }}退税总票 {{ customs.taxRefundCount }}已覆盖 {{ customs.coveredTaxRefundCount }}缺失 {{ missingCoverageCount }}
+ 首次发送批次 {{ brokerSubmission.batchNo }}状态 {{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}发送人 {{ brokerSubmission.sentByName || '—' }}发送时间 {{ formatDateTime(brokerSubmission.sentAt) }}资料版本 v{{ brokerSubmission.sourceVersion }}
缺退税资料清单
| 客户 | 订单号 | 入仓码 |
|---|
| {{ item.customerName }} | {{ item.orderNo }} | {{ item.inboundCode }} |
@@ -268,11 +290,11 @@ onMounted(load)
已冻结退税资料、报关行回执与完税文件
| 操作 | 客户退税资料 | 报关行回执 | 真实报关完税文件 | 关联入仓码 | 报关行状态 | 版本 | 客户 | 回执情况 |
- {{ document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}提交{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}提交 只读 |
+ {{ !brokerSubmission ? '首次发送后上传回执' : document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}提交{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}提交 只读 |
{{ document.source.fileName }}下载 |
{{ document.receipt.fileName }}下载 未上传 |
{{ document.finalDocument.fileName }}下载 未上传 |
- {{ document.inboundCodes.join('、') || '—' }} | {{ brokerSubmitted ? '已发送报关行执行' : '待发送报关行执行' }} | 退税资料 v{{ document.source.version }} / 回执 v{{ document.receipt.version }} / 完税 v{{ document.finalDocument.version }}回执历史:{{ receiptHistoryLabel(document) }} | {{ documentCustomers(document) }} | {{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }} |
+ {{ document.inboundCodes.join('、') || '—' }} | {{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待回执确认' : '待首次发送报关行' }} | 退税资料 v{{ document.source.version }} / 回执 v{{ document.receipt.version }} / 完税 v{{ document.finalDocument.version }}回执历史:{{ receiptHistoryLabel(document) }}{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }} · {{ history.reason }} | {{ documentCustomers(document) }} | {{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }} |
--
2.54.0
From e962577eca0d2399e31b268f28b44a1048182e77 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 28 Sep 2026 15:24:33 +0800
Subject: [PATCH 36/36] fix(admin): align test contracts and sailing
integrations
---
AGENTS.md | 1 +
src/app/DomesticOutboundListLayout.test.ts | 2 +-
.../components/ContainerCustomsPanel.vue | 6 +-
src/modules/container-loading/index.ts | 1 +
.../pages/ContainerDetailView.test.ts | 3 +-
src/modules/customers/pages/CustomersView.vue | 2 +-
src/modules/fba-pricing/public.ts | 2 +
.../finance/pages/FinancePresentation.test.ts | 2 +-
.../measurement/InboundMeasurementDialog.vue | 2 +-
.../review/MeasurementReviewPanel.vue | 8 +--
.../pages/OceanBookingDetailView.vue | 2 +-
.../lastMilePresentation.test.ts | 2 +-
.../pages/InboundOrderDetailView.vue | 2 +-
.../pages/LastMileOrdersView.vue | 12 ++--
.../pages/MojiaSkuSyncView.vue | 12 ++--
.../pages/RelabelOrdersView.test.ts | 2 +-
src/modules/overseas-warehouse/routes.test.ts | 2 +
.../pages/SailingCalendarView.vue | 63 +++++++++----------
src/modules/sailing-calendar/types.ts | 8 +--
.../pages/UnifiedOrdersView.test.ts | 2 +-
.../pages/UnifiedOrdersView.vue | 2 +-
src/styles/adminDensity.test.ts | 9 +--
22 files changed, 74 insertions(+), 73 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index ba341794..b39a57b2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,6 +43,7 @@
| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
+| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
diff --git a/src/app/DomesticOutboundListLayout.test.ts b/src/app/DomesticOutboundListLayout.test.ts
index 56b5c209..9c55b44d 100644
--- a/src/app/DomesticOutboundListLayout.test.ts
+++ b/src/app/DomesticOutboundListLayout.test.ts
@@ -31,7 +31,7 @@ describe('domestic outbound list responsive contracts', () => {
const content = source(path)
const columns = [...content.matchAll(/\{[^{}]*(?:key|prop): '[^']+'[^{}]*label: '[^']+'[^{}]*\}/g)]
.map((match) => match[0])
- .filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'"))
+ .filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'") && !column.includes('priority:') && !column.includes("key: 'view'"))
expect(columns.length).toBeGreaterThan(1)
for (const column of columns) expect(column).toMatch(/\b(?:width|minWidth):\s*\d+/)
})
diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue
index d02b4a41..ed95c2d7 100644
--- a/src/modules/container-loading/components/ContainerCustomsPanel.vue
+++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue
@@ -112,7 +112,7 @@ async function createFormalDeclaration() {
async function submitFormalDeclaration(batch: DeclarationBatch) {
try {
- const answer = await ElMessageBox.prompt('请输入报关单号', '提交正式申报', { inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
+ const answer = await ElMessageBox.prompt('请输入报关单号', '提交正式申报', { customClass: 'admin-density-confirm', inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
actionLoading.value = true; actionError.value = null
await submitDeclarationBatch(batch, answer.value)
await load(); ElMessage.success('正式申报已提交')
@@ -124,7 +124,7 @@ async function submitFormalDeclaration(batch: DeclarationBatch) {
async function recordFormalResult(batch: DeclarationBatch) {
try {
- const answer = await ElMessageBox.prompt('可填写查验处理说明,没有说明可直接确认', '录入海关查验结果', { inputPlaceholder: '处理说明(可选)', confirmButtonText: '确认录入' })
+ const answer = await ElMessageBox.prompt('可填写查验处理说明,没有说明可直接确认', '录入海关查验结果', { customClass: 'admin-density-confirm', inputPlaceholder: '处理说明(可选)', confirmButtonText: '确认录入' })
actionLoading.value = true; actionError.value = null
await recordClearanceResult(batch, 'INSPECTION', answer.value)
await load(); ElMessage.success('海关结果已录入')
@@ -136,7 +136,7 @@ async function recordFormalResult(batch: DeclarationBatch) {
async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationBatch['results'][number]) {
try {
- const answer = await ElMessageBox.prompt('请输入处理说明', '处理海关结果', { inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
+ const answer = await ElMessageBox.prompt('请输入处理说明', '处理海关结果', { customClass: 'admin-density-confirm', inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
actionLoading.value = true; actionError.value = null
await resolveClearanceResult(result, batch.version, answer.value)
await load(); ElMessage.success('海关结果已处理')
diff --git a/src/modules/container-loading/index.ts b/src/modules/container-loading/index.ts
index da4036f4..b37f139a 100644
--- a/src/modules/container-loading/index.ts
+++ b/src/modules/container-loading/index.ts
@@ -1,4 +1,5 @@
export * from './api/platformContainerLoading'
export * from './types'
+export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts
index 0b5c40d0..a5a6ef3e 100644
--- a/src/modules/container-loading/pages/ContainerDetailView.test.ts
+++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts
@@ -47,7 +47,8 @@ describe('ContainerDetailView customs integration', () => {
expect(source).toContain(' { void loadCustomers() })
{{ reviewLoadError.message }}
重新加载待审核客户
导出Excel新增客户
-
+
{{ (row as PlatformTenantResource).tenantName }}
{{ statusMeta((row as PlatformTenantResource).status).label }}
{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}
diff --git a/src/modules/fba-pricing/public.ts b/src/modules/fba-pricing/public.ts
index e0e6fc2b..922fdac3 100644
--- a/src/modules/fba-pricing/public.ts
+++ b/src/modules/fba-pricing/public.ts
@@ -5,3 +5,5 @@ export {
setCustomerChargeRuleDefault,
} from './customer-rules/api'
export type { CustomerChargeRule, CreateCustomerChargeRuleRequest } from './customer-rules/api'
+export { getPlatformPriceBook, listPlatformPriceBooks } from './carrier-rates/api/platformPriceBooks'
+export type { PlatformPriceBookParsedChannel } from './carrier-rates/api/platformPriceBooks'
diff --git a/src/modules/finance/pages/FinancePresentation.test.ts b/src/modules/finance/pages/FinancePresentation.test.ts
index a3a262af..170aaff5 100644
--- a/src/modules/finance/pages/FinancePresentation.test.ts
+++ b/src/modules/finance/pages/FinancePresentation.test.ts
@@ -123,7 +123,7 @@ describe('finance presentation governance', () => {
expect(statements).toContain('createReconciliationStatement')
expect(statements).toContain('actionReconciliationStatement')
expect(statements).toContain('')
- expect(statements).toContain('aria-label="更多对账单操作"')
+ expect(statements).toContain(':more-label="`更多对账单操作 · ${(row as FinanceStatementReadModel).billNo}`"')
expect(statements).toContain('adjustmentLines: selected.map')
expect(statements).not.toContain('disabled title="接口尚未提供')
})
diff --git a/src/modules/inbounds/measurement/InboundMeasurementDialog.vue b/src/modules/inbounds/measurement/InboundMeasurementDialog.vue
index 64b29edd..ef30633b 100644
--- a/src/modules/inbounds/measurement/InboundMeasurementDialog.vue
+++ b/src/modules/inbounds/measurement/InboundMeasurementDialog.vue
@@ -148,7 +148,7 @@ function handleSubmitted() {
.inbound-measurement-workspace-dialog .measurement-panel__heading .measurement-panel__actions,
.inbound-measurement-workspace-dialog .measurement-panel__footer { display: none; }
.inbound-measurement-workspace-dialog .measurement-summary-section { margin: 0 -12px; padding: 10px 12px 13px; border: 1px solid #cce8df; border-left: 4px solid #20a477; border-radius: 8px; background: #f4fbf8; }
-.inbound-measurement-workspace-dialog .measurement-summary-context { min-height: 38px; }
+.inbound-measurement-workspace-dialog .measurement-summary-context { min-height: auto; }
.inbound-measurement-workspace-dialog .measurement-subheading { color: #18795d; font-size: 15px; }
.inbound-measurement-workspace-dialog .measurement-metrics { padding: 0; border: 1px solid #cce8df; border-radius: 8px; background: #fff; overflow: hidden; }
.inbound-measurement-workspace-dialog .measurement-metrics > div { min-height: 58px; padding: 9px 14px; border-left: 1px solid #e8ecf2; }
diff --git a/src/modules/inbounds/review/MeasurementReviewPanel.vue b/src/modules/inbounds/review/MeasurementReviewPanel.vue
index 137f1853..7f1b9993 100644
--- a/src/modules/inbounds/review/MeasurementReviewPanel.vue
+++ b/src/modules/inbounds/review/MeasurementReviewPanel.vue
@@ -352,13 +352,13 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
.review-comparison__row--declared { background: linear-gradient(90deg, #ddf5e9, #eaf9f1); box-shadow: inset 4px 0 #25a879; }
.review-comparison__row--declared > strong { color: #16845d; }
.review-comparison__row + .review-comparison__row { border-top: 1px dashed #dfe4ed; }
-.review-comparison__row > strong { font-size: 14px; }
-.review-declaration dd, .review-metrics dd { font-size: 16px; }
+.review-comparison__row > strong { font-size: var(--admin-font-body, 13px); }
+.review-declaration dd, .review-metrics dd { font-size: var(--admin-font-body, 13px); }
.review-metric--primary dd, .review-weight-value, .review-weight-totals strong { color: #138154 !important; font-weight: 750; }
.review-weight-items { padding-top: 0; }
-.review-weight-totals { display: flex; gap: 18px; color: var(--cc-muted); font-size: 12px; }
+.review-weight-totals { display: flex; gap: 18px; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.review-weight-totals span { padding: 4px 8px; border-radius: 7px; background: #f0faf5; }
-.review-weight-totals strong { margin-left: 6px; font-size: 16px; }
+.review-weight-totals strong { margin-left: 6px; font-size: var(--admin-font-body, 13px); }
.review-weight-table :deep(.el-table__header th) { background: linear-gradient(180deg, #f1f4fc, #e9eef8); }
.review-weight-table :deep(.el-table__cell) { padding: 9px 0; }
.review-location { display: inline-block; padding: 3px 7px; border: 1px solid #e2e8f2; border-radius: 5px; background: #f3f6fb; color: #53627a; white-space: nowrap; }
diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue
index 343c3c07..b51f17c4 100644
--- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue
+++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue
@@ -20,7 +20,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
-import { SMART_LOADING_CHANNEL_GROUPS } from '@/modules/container-loading/smartLoadingChannelGroups'
+import { SMART_LOADING_CHANNEL_GROUPS } from '@/modules/container-loading'
import {
createOceanBooking,
deleteOceanBookingDraft,
diff --git a/src/modules/overseas-warehouse/lastMilePresentation.test.ts b/src/modules/overseas-warehouse/lastMilePresentation.test.ts
index 700a1f5c..97f8d09f 100644
--- a/src/modules/overseas-warehouse/lastMilePresentation.test.ts
+++ b/src/modules/overseas-warehouse/lastMilePresentation.test.ts
@@ -117,7 +117,7 @@ describe('last-mile order presentation adapter', () => {
['PENDING_REVIEW', '待审核'],
['WAIT_PDA_RECEIVING', '入库测量中'],
['WAIT_CUSTOMER_CONFIRMATION', '待客户确认实测'],
- ['INBOUND_COMPLETED', '入库完成'],
+ ['INBOUND_COMPLETED', '全部入库'],
])('maps warehouse transfer status %s to %s', (status, expected) => {
expect(lastMileStatusLabel(status)).toBe(expected)
})
diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue
index 58b7462e..162c3817 100644
--- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue
+++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue
@@ -88,7 +88,7 @@ onMounted(load)
入库单托盘明细
PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。
{{ palletRows.length }} 条移库
预约与排车
- {{ label }}
- {{ fieldValues[label] }}
-
+
-
-
diff --git a/src/modules/sailing-calendar/types.ts b/src/modules/sailing-calendar/types.ts
index 0e582108..723249e2 100644
--- a/src/modules/sailing-calendar/types.ts
+++ b/src/modules/sailing-calendar/types.ts
@@ -25,8 +25,8 @@ export interface SailingCalendarEntry {
warehouseName: string
channelCode: string
channelName: string
- vesselName: string
- voyageNo: string
+ vesselName: string | null
+ voyageNo: string | null
departurePortCode: string
departurePortName?: string | null
destinationPortCode: string
@@ -85,8 +85,8 @@ export interface SailingSaveRequest {
warehouseName?: string | null
channelCode: string
channelName: string
- vesselName: string
- voyageNo: string
+ vesselName: string | null
+ voyageNo: string | null
departurePortCode: string
departurePortName?: string | null
destinationPortCode: string
diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
index 09f8a540..a815ebb7 100644
--- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
+++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
@@ -249,7 +249,7 @@ describe('UnifiedOrdersView contract', () => {
it('counts detail in the three directly visible row actions', () => {
expect(source).toContain("{ key: 'detail', label: '详情', priority: 500 }")
expect(source).toContain('...unifiedOrderSecondaryActions(row).map')
- expect(source).toContain(' {
打印箱唛({{ selectedCartonLabelOrders.length }})
智能装柜
-
+
{{ (row as UnifiedOrderResource).inboundCode || '待生成入仓码' }}
diff --git a/src/styles/adminDensity.test.ts b/src/styles/adminDensity.test.ts
index 0c0cac4e..ca4d498e 100644
--- a/src/styles/adminDensity.test.ts
+++ b/src/styles/adminDensity.test.ts
@@ -108,6 +108,7 @@ const domesticPages = [
{ title: '库位管理', path: '/master-data/locations', file: 'warehouse-resources/locations/pages/LocationsView.vue' },
{ title: '托盘管理', path: '/master-data/pallets', file: 'warehouse-resources/pallets/pages/PalletsView.vue' },
{ title: '整柜业务', path: '/domestic-containers', file: 'domestic-containers/pages/DomesticContainersView.vue' },
+ { title: '船期日历', path: '/sailing-calendar', file: 'sailing-calendar/pages/SailingCalendarView.vue' },
]
const customerPages = [
{ title: '客户列表', path: '/customers', file: 'customers/pages/CustomersView.vue' },
@@ -247,11 +248,11 @@ describe('approved Admin density ownership', () => {
expect(read('modules/overseas-warehouse/pages/LastMileOrderDetailView.vue')).toContain('density-detail-overview')
})
- it('covers all ten overseas warehouse menus, details and both return inventory modes', () => {
+ it('covers all eleven overseas warehouse menus, details and both return inventory modes', () => {
const navigation = read('core/navigation/adminNavigation.ts')
const section = navigation.slice(navigation.indexOf("key: 'overseas-inventory'"), navigation.indexOf("key: 'customer-service'"))
- for (const path of ['last-mile-orders', 'inbound-orders', 'dispatches', 'outbounds', 'delivery-orders', 'relabel-orders', 'pallets', 'inventory', 'return-inventory', 'return-cartons']) expect(section).toContain("path: '/overseas-warehouse/" + path + "'")
- expect([...section.matchAll(/path: '/g)]).toHaveLength(10)
+ for (const path of ['last-mile-orders', 'inbound-orders', 'locations', 'dispatches', 'outbounds', 'delivery-orders', 'relabel-orders', 'pallets', 'inventory', 'return-inventory', 'return-cartons']) expect(section).toContain("path: '/overseas-warehouse/" + path + "'")
+ expect([...section.matchAll(/path: '/g)]).toHaveLength(11)
const routes = read('modules/overseas-warehouse/routes.ts')
for (const mode of ['inventory', 'cartons']) expect(routes).toContain("mode: '" + mode + "'")
for (const file of overseasConsumers) {
@@ -376,7 +377,7 @@ describe('approved Admin density ownership', () => {
}
})
- it('covers all seven domestic menu entries and loads density CSS on direct entry', () => {
+ it('covers all eight domestic menu entries and loads density CSS on direct entry', () => {
const navigation = read('core/navigation/adminNavigation.ts')
const domestic = navigation.slice(navigation.indexOf("key: 'domestic-warehouse'"), navigation.indexOf("key: 'overseas-delivery'"))
expect([...domestic.matchAll(/path: '/g)]).toHaveLength(domesticPages.length)
--
2.54.0