Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test

This commit is contained in:
2026-09-28 17:26:28 +08:00
33 changed files with 1379 additions and 126 deletions
+1 -1
View File
@@ -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+/)
})
+2
View File
@@ -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',
@@ -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版本',
])
})
+3 -1
View File
@@ -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' },
],
},
{
@@ -171,7 +173,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' },
],
},
{
@@ -1,12 +1,13 @@
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, 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,
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, confirmationHistory: [] }],
brokerSubmission: null,
startedAt: null, completedAt: null,
}
@@ -39,14 +40,21 @@ 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('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 })
const completion = new File(['done'], 'done.pdf')
await uploadContainerCustomsCompletion('2092438843630391407', completion, 0, true)
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, 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 () => {
@@ -56,11 +64,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' }],
])
})
})
@@ -12,6 +12,36 @@ export interface ContainerCustomsFile {
uploadedAt: string
}
export interface ContainerCustomsReceiptVersion {
version: number
file: ContainerCustomsFile
operatorName?: string | null
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
@@ -30,7 +60,12 @@ 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
receiptHistory?: ContainerCustomsReceiptVersion[]
confirmationHistory: ContainerCustomsConfirmationHistory[]
}
export interface ContainerCustomsView {
@@ -45,10 +80,49 @@ export interface ContainerCustomsView {
version: number
taxRefundCount: number
coveredTaxRefundCount: number
actualBoxCount: number
inbounds: ContainerCustomsInbound[]
documents: ContainerCustomsDocument[]
brokerSubmission: ContainerCustomsBrokerSubmission | null
startedAt: string | null
completedAt: string | null
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<ContainerCustomsView, 'aggregateId' | 'loadingPlanId' | 'containerBookingId' | 'version' | 'inbounds' | 'documents'> {
@@ -57,7 +131,7 @@ interface RawContainerCustomsView extends Omit<ContainerCustomsView, 'aggregateI
containerBookingId: string | number
version: string | number
inbounds: Array<Omit<ContainerCustomsInbound, 'inboundId' | 'orderId' | 'tenantId'> & { inboundId: string | number; orderId: string | number; tenantId: string | number }>
documents: Array<Omit<ContainerCustomsDocument, 'documentId' | 'tenantId' | 'source' | 'completion'> & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; completion: ContainerCustomsFile | null }>
documents: Array<Omit<ContainerCustomsDocument, 'documentId' | 'tenantId' | 'source' | 'receipt' | 'finalDocument'> & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; receipt: ContainerCustomsFile | null; finalDocument: ContainerCustomsFile | null }>
}
const basePath = '/platform/v1/customs/loading-plans'
@@ -70,10 +144,60 @@ 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) })),
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 })),
})),
}
}
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<DeclarationBatch[]>, ApiEnvelope<DeclarationBatch[]>>(`${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<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${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<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${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<ClearanceResult>, ApiEnvelope<ClearanceResult>>(`${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<ClearanceResult>, ApiEnvelope<ClearanceResult>>(`${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<RawContainerCustomsView | null>, ApiEnvelope<RawContainerCustomsView | null>>(`${basePath}/${encodeURIComponent(loadingPlanId)}`)
const value = unwrapApiResponse(response)
@@ -84,11 +208,15 @@ export function downloadContainerCustomsDocument(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
}
export function downloadContainerCustomsCompletion(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`, { responseType: 'blob' })
export function downloadContainerCustomsReceipt(documentId: string) {
return http.get<Blob, Blob>(`${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<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' })
}
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<RawContainerCustomsView>, ApiEnvelope<RawContainerCustomsView>>(
`${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`,
{ expectedVersion, confirm: true, remark: remark?.trim() || null },
@@ -101,18 +229,34 @@ 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)
}
export async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number, finalConfirmation: boolean) {
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<ContainerCustomsDocument>, ApiEnvelope<ContainerCustomsDocument>>(
`${basePath}/documents/${encodeURIComponent(documentId)}/completion/upload`,
`${basePath}/documents/${encodeURIComponent(documentId)}/${kind}/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-${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')
}
@@ -8,18 +8,25 @@ 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(),
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(),
sendContainerCustomsToBroker: api.sendToBroker,
getDeclarationBatches: api.getDeclarationBatches,
}))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
@@ -28,14 +35,17 @@ 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',
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,
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,
}
@@ -58,8 +68,10 @@ function buttonTexts(wrapper: Awaited<ReturnType<typeof render>>) {
describe('ContainerCustomsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
api.getDeclarationBatches.mockResolvedValue([])
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)
@@ -68,18 +80,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,29 +119,46 @@ 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('报关完成文件.pdf')
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)
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('提交')
})
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')
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('覆盖报关完成文件')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -137,13 +167,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')
})
@@ -158,6 +191,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()
})
})
@@ -9,14 +9,24 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import {
completeAllBuyContainerCustoms,
downloadContainerCustomsCompletion,
createDeclarationBatch,
downloadContainerCustomsReceipt,
downloadContainerCustomsFinal,
downloadContainerCustomsDocument,
getContainerCustoms,
getDeclarationBatches,
recordClearanceResult,
resolveClearanceResult,
submitDeclarationBatch,
startContainerCustoms,
uploadContainerCustomsCompletion,
sendContainerCustomsToBroker,
submitContainerCustomsToBroker,
uploadContainerCustomsReceipt,
uploadContainerCustomsFinal,
type ContainerCustomsDocument,
type ContainerCustomsStatus,
type ContainerCustomsView,
type DeclarationBatch,
} from '../api/platformContainerCustoms'
const props = defineProps<{
@@ -28,17 +38,22 @@ const props = defineProps<{
const emit = defineEmits<{ refreshed: [] }>()
const customs = ref<ContainerCustomsView | null>(null)
const declarationBatches = ref<DeclarationBatch[]>([])
const loading = ref(false)
const actionLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const downloadingKey = ref('')
const completionFiles = reactive<Record<string, File | null>>({})
const receiptFiles = reactive<Record<string, File | null>>({})
const finalFiles = reactive<Record<string, File | null>>({})
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 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<ContainerCustomsStatus, string> = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
}
@@ -46,9 +61,25 @@ const statusText: Record<ContainerCustomsStatus, string> = {
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 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' || !brokerSubmission.value || brokerSubmitted.value) return
receiptFiles[documentId] = upload.raw || null
actionError.value = null
}
function selectFinal(documentId: string, upload: UploadFile) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
completionFiles[documentId] = upload.raw || null
finalFiles[documentId] = upload.raw || null
actionError.value = null
}
@@ -56,11 +87,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' || customs.value?.brokerSubmittedAt) {
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<string, string>)[value] || value
}
async function createFormalDeclaration() {
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, '正式申报批次创建失败') }
finally { actionLoading.value = false }
}
async function submitFormalDeclaration(batch: DeclarationBatch) {
try {
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('正式申报已提交')
} 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('可填写查验处理说明,没有说明可直接确认', '录入海关查验结果', { customClass: 'admin-density-confirm', 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('请输入处理说明', '处理海关结果', { 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('海关结果已处理')
} 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 {
@@ -72,24 +157,31 @@ async function start() {
finally { actionLoading.value = false }
}
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 }
}
async function uploadReceipt(document: ContainerCustomsDocument) {
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
try {
await uploadContainerCustomsCompletion(document.documentId, selected, document.completion?.version || 0, finalConfirmation)
completionFiles[document.documentId] = null
await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0)
receiptFiles[document.documentId] = null
await load()
emit('refreshed')
ElMessage.success(finalConfirmation ? '报关完成文件已上传,整柜报关已完成' : '报关完成文件已上传')
} catch (error) { actionError.value = getApiErrorDetails(error, '报关完成文件上传失败') }
ElMessage.success('报关行回执已上传,等待客户确认')
} 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 }
}
@@ -102,8 +194,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
@@ -111,9 +203,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 {
@@ -121,6 +213,24 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
}
}
async function submitToBroker() {
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('已发送给报关行执行') }
catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') }
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: '确认完成' }) }
@@ -159,6 +269,8 @@ onMounted(load)
<el-tag :type="!customs.hasActualCargo ? 'info' : customs.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }}</el-tag>
<el-button v-if="canManage && allBuy" plain :loading="actionLoading" @click="exportAllBuy">导出买单 XLSX</el-button>
<el-button v-if="canManage && customs.hasActualCargo && customs.status === 'READY_TO_START'" type="primary" :loading="actionLoading" @click="start">开始报关</el-button>
<el-button v-if="canManage && customs.hasActualCargo && customs.status === 'DECLARING' && !brokerSubmission" type="primary" :loading="actionLoading" @click="sendToBroker">首次发送报关行</el-button>
<el-button v-if="canManage && customs.hasActualCargo && customs.status === 'DECLARING' && brokerSubmission && !brokerSubmitted" type="primary" plain :disabled="!allReceiptsConfirmed" :loading="actionLoading" @click="submitToBroker">发送报关行执行</el-button>
<el-button v-if="canManage && allBuy && customs.status === 'DECLARING'" type="primary" :loading="actionLoading" @click="completeAllBuy">确认报关完成</el-button>
</div>
</header>
@@ -168,22 +280,36 @@ onMounted(load)
<template v-else-if="customs">
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
<template v-else>
<div class="customs-metrics"><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
<div class="customs-metrics"><span>实际箱数 <strong>{{ customs.actualBoxCount }}</strong></span><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
<div v-if="brokerSubmission" class="broker-submission-fact" aria-label="首次发送报关行批次"><span>首次发送批次 <strong>{{ brokerSubmission.batchNo }}</strong></span><span>状态 <strong>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</strong></span><span>发送人 <strong>{{ brokerSubmission.sentByName || '—' }}</strong></span><span>发送时间 <strong>{{ formatDateTime(brokerSubmission.sentAt) }}</strong></span><span>资料版本 <strong>v{{ brokerSubmission.sourceVersion }}</strong></span></div>
<div v-if="missingInbounds.length" class="compact-table-wrap">
<h3>缺退税资料清单</h3>
<table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table>
</div>
<div v-if="customs.documents.length" class="compact-table-wrap">
<h3>已冻结退税资料与报关完成文件</h3>
<table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关完成文件</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody>
<h3>已冻结退税资料、报关行回执与完税文件</h3>
<table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关行回执</th><th>真实报关完税文件</th><th>关联入仓码</th><th>报关行状态</th><th>版本</th><th>客户</th><th>回执情况</th></tr></thead><tbody>
<tr v-for="document in customs.documents" :key="document.documentId">
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button class="density-action" size="small">{{ document.completion ? '覆盖报关完成文件' : '上传报关完成文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!completionFiles[document.documentId]" :loading="actionLoading" @click="uploadCompletion(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传回执' : document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
<td><div v-if="document.completion" class="file-cell"><span :title="document.completion.fileName">{{ document.completion.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `completion:${document.documentId}`" :aria-label="`下载报关完成文件 ${document.completion.fileName}`" @click="downloadFile(document, 'completion')">下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.completion"> / 报关完成 v{{ document.completion.version }}</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.completion ? '已上传报关完成文件' : '待上传报关完成文件' }}</td>
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载报关行回执 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td>
<td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待回执确认' : '待首次发送报关行' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.receipt"> / 回执 v{{ document.receipt.version }}</span><span v-if="document.finalDocument"> / 完税 v{{ document.finalDocument.version }}</span><small v-if="document.receiptHistory?.length" class="receipt-history-note">回执历史:{{ receiptHistoryLabel(document) }}</small><small v-for="history in document.confirmationHistory" :key="history.id" class="confirmation-history-note">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }}</td>
</tr>
</tbody></table>
</div>
<div v-if="['DECLARING', 'COMPLETED'].includes(customs.status) && brokerSubmitted" class="compact-table-wrap declaration-batches-wrap">
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3><p>回执确认并发送报关行执行后建立正式申报批次;完税文件、船期和 PDA 节点保持独立。</p></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
<div v-else class="declaration-batch-list">
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
<div class="declaration-batch-main"><strong>{{ batch.batchNo }}</strong><span>报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}</span></div>
<el-tag size="small" :type="batch.status === 'CLOSED' ? 'success' : batch.status === 'CANCELLED' ? 'info' : 'warning'">{{ declarationStatusLabel(batch.status) }}</el-tag>
<div class="declaration-batch-actions" v-if="canManage"><el-button v-if="batch.status === 'READY'" size="small" type="primary" :loading="actionLoading" @click="submitFormalDeclaration(batch)">提交正式申报</el-button><el-button v-if="['SUBMITTED','ACKNOWLEDGED','RESULT_PENDING'].includes(batch.status)" size="small" plain :loading="actionLoading" @click="recordFormalResult(batch)">录入查验结果</el-button></div>
<div v-if="batch.results.length" class="declaration-result-list"><div v-for="result in batch.results" :key="result.id" class="declaration-result-row"><span>{{ result.resultType === 'INSPECTION' ? '查验' : result.resultType === 'SUPPLEMENT_REQUIRED' ? '补充资料' : result.resultType === 'RELEASED' ? '放行' : result.resultType }}</span><el-tag size="small" :type="result.resultStatus === 'RESOLVED' ? 'success' : 'warning'">{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}</el-tag><small>{{ result.resolution || '—' }}</small><el-button v-if="canManage && result.resultStatus === 'OPEN'" link type="primary" size="small" :loading="actionLoading" @click="resolveFormalResult(batch, result)">处理</el-button></div></div>
</article>
</div>
</div>
</template>
<div v-if="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}</el-tag></div>
</template>
@@ -192,5 +318,5 @@ onMounted(load)
</template>
<style scoped>
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:var(--admin-space-6);margin-top:var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);background:var(--cc-surface-muted);font-size:var(--admin-font-caption)}.customs-metrics strong{margin-left:var(--admin-space-1);font-size:var(--admin-font-body)}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:var(--admin-space-4)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:var(--admin-space-6);margin-top:var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);background:var(--cc-surface-muted);font-size:var(--admin-font-caption)}.customs-metrics strong{margin-left:var(--admin-space-1);font-size:var(--admin-font-body)}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:var(--admin-space-4)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}
</style>
+1
View File
@@ -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'
@@ -47,7 +47,8 @@ describe('ContainerDetailView customs integration', () => {
expect(source).toContain('<el-tabs v-model="activeTab"')
expect(source).toContain("v-if=\"isDomestic && activeTab === 'loading-list'\"")
expect(source).toContain("loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)")
expect(source).toContain("getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)")
expect(source).toContain('getContainer(containerId.value, props.workbench)')
expect(source).toContain('getContainerOverview(containerId.value)')
expect(source).toContain(':can-edit="false"')
})
@@ -1070,7 +1070,7 @@ onMounted(() => { void loadCustomers() })
<div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" :stripe="true" border>
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers">导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate">新增客户</el-button></div></template>
<template #actions="{ row }"><RowActions :actions="customerRowActions(row as PlatformTenantResource)" :more-label="customerMoreActionsLabel(row as PlatformTenantResource)" @action="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></template>
<template #actions="{ row }"><RowActions class="density-action" :actions="customerRowActions(row as PlatformTenantResource)" :more-label="customerMoreActionsLabel(row as PlatformTenantResource)" @action="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></template>
<template #company="{ row }"><strong class="customer-company-name">{{ (row as PlatformTenantResource).tenantName }}</strong></template>
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
<template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template>
+2
View File
@@ -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'
@@ -123,7 +123,7 @@ describe('finance presentation governance', () => {
expect(statements).toContain('createReconciliationStatement')
expect(statements).toContain('actionReconciliationStatement')
expect(statements).toContain('<template #toolbar-actions>')
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="接口尚未提供')
})
@@ -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; }
@@ -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; }
@@ -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,
@@ -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
}
@@ -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)
})
@@ -88,7 +88,7 @@ onMounted(load)
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!(row as typeof palletRows[number]).palletNo || !(row as typeof palletRows[number]).palletVersion" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></section>
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><div class="compact-section-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><span>费用结果以服务端为准</span></div><div class="detail-table-wrap"><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /></div><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog" title="托盘移库" width="min(560px, calc(100vw - 28px))" confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="!targetLocationCode" @confirm="submitMove"><el-form class="density-form" label-position="top"><el-form-item label="托盘号"><el-input :model-value="moveRow?.palletNo" readonly /></el-form-item><el-form-item label="目标库位" required><el-select v-model="targetLocationCode" filterable placeholder="请选择普通库位或 A0"><el-option v-for="item in availableLocations" :key="item.id" :value="item.locationCode" :label="`${item.locationCode}${item.sharedOverflow || item.locationType === 'SHARED_OVERFLOW' ? ' · A0共享溢出位' : ''}`" /></el-select></el-form-item><el-form-item label="操作原因" required><el-input v-model="moveReason" maxlength="200" /></el-form-item></el-form></AppDialog></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog" title="托盘移库" width="min(560px, calc(100vw - 28px))" confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="!targetLocationCode" @confirm="submitMove"><el-form class="density-form" label-position="top"><el-form-item label="托盘号"><el-input :model-value="moveRow?.palletNo" readonly /></el-form-item><el-form-item label="目标库位" required><el-select v-model="targetLocationCode" filterable placeholder="请选择普通库位或 A0"><el-option v-for="item in availableLocations" :key="item.id" :value="item.locationCode" :label="`${item.locationCode}${item.sharedOverflow || item.locationType === 'SHARED_OVERFLOW' ? ' · A0共享溢出位' : ''}`" /></el-select></el-form-item><el-form-item label="操作原因" required><el-input v-model="moveReason" maxlength="200" placeholder="请输入移库原因" /></el-form-item></el-form></AppDialog></template>
<style scoped>
.detail-section { min-width: 0; padding: 0; border: 0; background: #fff; }
@@ -798,14 +798,14 @@ async function reviewReturn(source: object) {
const returnNo = row.sourceOrderNo
if (!returnNo || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', '退货预报审核', { confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', '退货预报审核', { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await reviewReturnOrder(returnNo, 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: '取消' })
const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回退货预报', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
await reviewReturnOrder(returnNo, row.customerId, 'REJECT', value)
ElMessage.success('退货预报已驳回')
await load()
@@ -817,14 +817,14 @@ 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 ElMessageBox.confirm('审核通过后,贴标单将进入海外仓换标作业。', '贴标单审核', { customClass: 'admin-density-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: '取消' })
const { value } = await ElMessageBox.prompt('请填写驳回原因,客户可在贴标单详情查看并修改后重新提交。', '驳回贴标单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
await reviewDirectRelabelOrder(row.id, row.customerId, 'REJECT', value)
ElMessage.success('贴标单已驳回')
await load()
@@ -903,7 +903,7 @@ async function confirmRelabel(row: { relabelOrderNo?: string }) {
const relabelNo = row.relabelOrderNo
if (!relabelNo) return
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', '换标完成确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成')
await load()
@@ -914,7 +914,7 @@ async function reworkRelabel(row: { relabelOrderNo?: string }) {
if (!relabelNo) return
try {
const task = await getRelabelReviewTask(relabelNo)
const { value } = await ElMessageBox.prompt('填写退回原因,仓库将重新执行换标并上传照片。', '重新换标', { inputType: 'textarea', inputValidator: (value) => Boolean(value?.trim()) && value.trim().length <= 1000 || '请填写 1 至 1000 字原因', confirmButtonText: '确认重新换标', cancelButtonText: '取消' })
const { value } = await ElMessageBox.prompt('填写退回原因,仓库将重新执行换标并上传照片。', '重新换标', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (value) => Boolean(value?.trim()) && value.trim().length <= 1000 || '请填写 1 至 1000 字原因', confirmButtonText: '确认重新换标', cancelButtonText: '取消' })
await returnRelabelForRework(relabelNo, task.version, value.trim(), crypto.randomUUID())
ElMessage.success('已退回重新换标')
await load()
@@ -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<MojiaSkuSyncRecord | null>(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()]) })
<template>
<AdminPage class="admin-density mojia-sku-sync-page">
<ListPageLayout aria-label="SKU同步">
<ListPageLayout aria-label="称重机器">
<section class="surface-panel table-panel" :aria-busy="loading">
<el-alert v-if="error" type="error" :title="error" show-icon :closable="false" />
<el-alert
@@ -187,10 +197,11 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
show-icon
:closable="false"
/>
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无墨家 SKU 测量记录">
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无墨家机器上传记录">
<template #toolbar-start>
<div class="sync-filters">
<el-input v-model.trim="form.skuCode" clearable placeholder="搜索 SKU" @keyup.enter="search" />
<el-input v-model.trim="form.code" clearable placeholder="搜索箱码或 SKU" @keyup.enter="search" />
<el-select v-model="form.measurementKind" clearable placeholder="上传类型" style="width: 130px"><el-option label="箱码" value="BOX_LABEL" /><el-option label="SKU" value="SKU" /></el-select>
<el-select v-model="form.syncStatus" clearable placeholder="同步状态" style="width: 140px">
<el-option v-for="(label, value) in statusLabel" :key="value" :label="label" :value="value" />
</el-select>
@@ -203,9 +214,11 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
<template #actions="{ row }">
<div class="density-actions">
<el-button v-if="canEdit" class="density-action" @click="openEdit(asRecord(row))"><Edit3 :size="15" />编辑</el-button>
<el-button v-if="canRetry && row.syncStatus !== 'SUCCESS'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="retry(asRecord(row))"><RotateCw :size="15" />人工推送</el-button>
<el-button v-if="canRetry && row.measurementKind === 'SKU' && row.syncStatus !== 'SUCCESS'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="retry(asRecord(row))"><RotateCw :size="15" />人工推送</el-button>
</div>
</template>
<template #sourceCode="{ row }">{{ row.measurementKind === 'SKU' ? row.skuCode : row.boxCode }}</template>
<template #kind="{ row }">{{ row.measurementKind === 'SKU' ? 'SKU测量' : '箱码测量' }}</template>
<template #image="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="sync-image" preview-teleported /><span v-else>—</span></template>
<template #dimensions="{ row }">{{ row.lengthCm }} × {{ row.widthCm }} × {{ row.heightCm }}</template>
<template #syncStatus="{ row }"><el-tag size="small" :type="syncTagType(row)">{{ syncLabel(row) }}</el-tag></template>
@@ -218,12 +231,12 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
</ListPageLayout>
<AppDialog v-model="editOpen" class="admin-density-dialog" title="编辑 SKU 测量修正版" width="min(620px, calc(100vw - 28px))" confirm-text="保存并重新同步" :confirm-loading="Boolean(actionId)" @confirm="saveEdit">
<el-form label-position="top" class="sync-edit-form">
<el-form-item label="SKU"><el-input v-model.trim="editForm.skuCode" /></el-form-item>
<el-form-item label="SKU"><el-input v-model.trim="editForm.skuCode" placeholder="请输入 SKU" /></el-form-item>
<div class="sync-edit-grid">
<el-form-item label="长(cm)"><el-input-number v-model="editForm.lengthCm" :min="0.001" :controls="false" /></el-form-item>
<el-form-item label="宽(cm)"><el-input-number v-model="editForm.widthCm" :min="0.001" :controls="false" /></el-form-item>
<el-form-item label="高(cm)"><el-input-number v-model="editForm.heightCm" :min="0.001" :controls="false" /></el-form-item>
<el-form-item label="重量(kg)"><el-input-number v-model="editForm.weightKg" :min="0.001" :controls="false" /></el-form-item>
<el-form-item label="长(cm)"><el-input-number v-model="editForm.lengthCm" :min="0.001" :controls="false" placeholder="请输入长度" /></el-form-item>
<el-form-item label="宽(cm)"><el-input-number v-model="editForm.widthCm" :min="0.001" :controls="false" placeholder="请输入宽度" /></el-form-item>
<el-form-item label="高(cm)"><el-input-number v-model="editForm.heightCm" :min="0.001" :controls="false" placeholder="请输入高度" /></el-form-item>
<el-form-item label="重量(kg)"><el-input-number v-model="editForm.weightKg" :min="0.001" :controls="false" placeholder="请输入重量" /></el-form-item>
</div>
<el-alert type="info" :closable="false" title="原始墨家测量记录不可变,本次保存会生成新的人工修正版并进入同步队列。" />
</el-form>
@@ -241,7 +254,7 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
</AppDialog>
<AppDialog v-model="verificationOpen" class="admin-density-dialog" title="领星二次验证" width="min(460px, calc(100vw - 28px))" confirm-text="提交验证码" :confirm-loading="submittingVerification" @confirm="submitVerification">
<el-alert type="warning" :closable="false" title="领星要求二次验证,请在验证码发送到手机、邮箱或验证器后输入。" />
<el-form label-position="top" class="verification-form"><el-form-item label="验证码"><el-input v-model.trim="verificationCode" maxlength="16" autocomplete="one-time-code" @keyup.enter="submitVerification" /></el-form-item></el-form>
<el-form label-position="top" class="verification-form"><el-form-item label="验证码"><el-input v-model.trim="verificationCode" maxlength="16" autocomplete="one-time-code" placeholder="请输入验证码" @keyup.enter="submitVerification" /></el-form-item></el-form>
</AppDialog>
</AdminPage>
</template>
@@ -104,7 +104,7 @@ describe('RelabelOrdersView', () => {
expect(firstKey).toBeTruthy()
expect(retryKey).toBe(firstKey)
expect(createRelabelOrder).toHaveBeenLastCalledWith('10001', expect.objectContaining({
customerOrderNo: 'CO-1', overseasWarehouseId: 'WH-1', idempotencyKey: firstKey,
customerOrderNo: 'CO-1', operationWarehouseId: 'WH-1', chargeAmount: 20, contactName: '张三', contactPhone: '13800000000', labelDeadline: '2026-08-30T10:00:00', remark: undefined, idempotencyKey: firstKey,
items: [expect.objectContaining({ skuCode: 'SKU-1', newSkuCode: 'SKU-2', labelRequirement: '换SKU标' })],
}))
})
@@ -26,6 +26,8 @@ describe('overseas warehouse routes', () => {
['overseas-warehouse/relabel-orders/:orderId', permission.relabelOrderList],
['overseas-warehouse/pallets', permission.palletList],
['overseas-warehouse/pallets/:palletId', permission.palletList],
['overseas-warehouse/locations', permission.locationList],
['general/mojia-sku-sync', permission.mojiaSkuSyncList],
])
})
+1 -1
View File
@@ -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[]
@@ -0,0 +1,204 @@
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 ?? ''),
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),
}
}
function summary(raw?: Partial<SailingCalendarEntry['volumeSummary']> | 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<SailingCalendarResponse>, ApiEnvelope<SailingCalendarResponse>>(`${basePath}/calendar`, { params: query })
return calendar(unwrapApiResponse(response))
}
export async function getSailing(id: string) {
const response = await http.get<ApiEnvelope<SailingDetail>, ApiEnvelope<SailingDetail>>(`${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<SailingDetail>, ApiEnvelope<SailingDetail>>(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<SailingDetail>, ApiEnvelope<SailingDetail>>(`${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<SailingDetail>, ApiEnvelope<SailingDetail>>(`${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<SailingDetail>, ApiEnvelope<SailingDetail>>(`${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<SailingVolumeDetail>, ApiEnvelope<SailingVolumeDetail>>(`${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<SailingBookingAdvice>, ApiEnvelope<SailingBookingAdvice>>(`${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<unknown>, ApiEnvelope<unknown>>(`${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<SailingChannelResource[]>, ApiEnvelope<SailingChannelResource[]>>(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<SailingChannelResource>, ApiEnvelope<SailingChannelResource>>(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<SailingChannelResource>, ApiEnvelope<SailingChannelResource>>(`${channelPath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-channel') } })
return channel(unwrapApiResponse(response))
}
export async function listSailingTemplates() {
const response = await http.get<ApiEnvelope<SailingTemplateResource[]>, ApiEnvelope<SailingTemplateResource[]>>(templatePath, { params: { page: 1, size: 200 } })
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<SailingTemplateResource>, ApiEnvelope<SailingTemplateResource>>(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<SailingTemplateResource>, ApiEnvelope<SailingTemplateResource>>(`${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<SailingGenerationPreview>, ApiEnvelope<SailingGenerationPreview>>(`${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<SailingGenerationResult>, ApiEnvelope<SailingGenerationResult>>(`${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<SailingExceptionPage>, ApiEnvelope<SailingExceptionPage>>(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<SailingExceptionReassignResult>, ApiEnvelope<SailingExceptionReassignResult>>(`${basePath}/${encodeURIComponent(id)}/exception-reassign`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-exception-reassign') } })
return unwrapApiResponse(response)
}
+2
View File
@@ -0,0 +1,2 @@
export * from './types'
export * from './api/platformSailingCalendar'
File diff suppressed because one or more lines are too long
+8
View File
@@ -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[]
+300
View File
@@ -0,0 +1,300 @@
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 | null
voyageNo: string | null
departurePortCode: string
departurePortName?: string | null
destinationPortCode: string
destinationPortName?: string | null
etdAt: string
cutoffAt: string
status: SailingStatus
volumeSummary: SailingVolumeSummary
version: number
templateId?: string | null
channelId?: string | null
etaAt?: string | null
sourceType?: string | null
plannedCapacityCbm?: number | null
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 | null
voyageNo: string | null
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<SailingAssignmentStatus, 'LATE' | 'UNMATCHED'>
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
}
@@ -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('<RowActions :actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
expect(source).toContain('<RowActions class="density-action" :actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
expect(source).toContain("if (action === 'approve') return '审核'")
expect(source).not.toContain("if (action === 'approve') return '通过'")
})
@@ -2740,7 +2740,7 @@ onBeforeUnmount(() => {
<el-button v-if="can('platform:firstleg:inbound:view')" :loading="printingCartonLabels" :disabled="loading || printingCartonLabels || !selectedCartonLabelOrders.length" @click="printSelectedCartonLabels"><Printer :size="16" />打印箱唛<span v-if="selectedCartonLabelOrders.length">({{ selectedCartonLabelOrders.length }})</span></el-button>
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
</template>
<template #actions="{ row }"><RowActions :actions="unifiedOrderRowActions(row as UnifiedOrderResource)" :more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" @action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)" /></template>
<template #actions="{ row }"><RowActions class="density-action" :actions="unifiedOrderRowActions(row as UnifiedOrderResource)" :more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" @action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)" /></template>
<template #inboundCode="{ row }">
<div class="inbound-relation-cell order-code-cell">
<strong class="inbound-code-value" :title="(row as UnifiedOrderResource).inboundCode || '待生成入仓码'">{{ (row as UnifiedOrderResource).inboundCode || '待生成入仓码' }}</strong>
+5 -4
View File
@@ -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)