Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test
This commit is contained in:
@@ -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+/)
|
||||
})
|
||||
|
||||
@@ -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版本',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './api/platformSailingCalendar'
|
||||
File diff suppressed because one or more lines are too long
@@ -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[]
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user