diff --git a/AGENTS.md b/AGENTS.md index 385d7d2c..b39a57b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,11 +3,13 @@ ## 快速读取顺序 1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。 -2. 页面、布局或交互任务同时读取 `../../docs/Admin-Client前端原型还原验收规则.md` 和后台原型 `../../docs/悦/货代SAAS平台后台/AGENTS.md`。 +2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。 Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。 3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。 4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/跨区域认证与服务通信契约.md`。 +普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。累计超过约 250 行时改用定向检索,不全文加载无关专题。 + ## 项目定位与技术基线 - 本项目是虫虫出海内部运营后台,仅服务平台员工;不承载企业客户自助、官网、小程序、独立 APP 或 PDA 作业。 @@ -41,6 +43,7 @@ | 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | | 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | +| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 | | FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 | | PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 | | 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 | @@ -104,7 +107,7 @@ ## 开发验收身份 - 开发环境人工验收身份为 `platform_admin`。密码从未提交的 `CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取;缺失时再向用户索取,不写入仓库、日志或快照。 -- 由人工操作真实页面并截图,AI 核对加载、空态、错误态、权限和桌面布局;缺少截图时标记“待人工截图确认”,不自行批量取证。 +- 视觉验收由人工操作真实页面并截图,AI 核对受影响的状态和桌面布局;缺少截图标记“待人工截图确认”,不自行批量取证。自动检查单独报告“自动检查通过/失败”;纯逻辑且无视觉变化标记“无需视觉验收”,不能据此声称真实页面验收通过。 ## 命令与质量门禁 diff --git a/src/app/DomesticOutboundListLayout.test.ts b/src/app/DomesticOutboundListLayout.test.ts index 56b5c209..9c55b44d 100644 --- a/src/app/DomesticOutboundListLayout.test.ts +++ b/src/app/DomesticOutboundListLayout.test.ts @@ -31,7 +31,7 @@ describe('domestic outbound list responsive contracts', () => { const content = source(path) const columns = [...content.matchAll(/\{[^{}]*(?:key|prop): '[^']+'[^{}]*label: '[^']+'[^{}]*\}/g)] .map((match) => match[0]) - .filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'")) + .filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'") && !column.includes('priority:') && !column.includes("key: 'view'")) expect(columns.length).toBeGreaterThan(1) for (const column of columns) expect(column).toMatch(/\b(?:width|minWidth):\s*\d+/) }) diff --git a/src/app/router/index.ts b/src/app/router/index.ts index e64799da..de5e877a 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -32,6 +32,7 @@ import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes' import { overseasBusinessRoutes } from '@/modules/overseas-business/routes' import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes' import { pdaReleaseRoutes } from '@/modules/pda-releases/routes' +import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes' const router = createRouter({ history: createWebHistory(), @@ -77,6 +78,7 @@ const router = createRouter({ ...downloadRoutes, ...iamRoutes, ...pdaReleaseRoutes, + ...sailingCalendarRoutes, { path: '403', name: 'forbidden', diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 79180a7d..ec14da23 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -35,6 +35,7 @@ describe('adminNavigation', () => { '库位管理', '托盘管理', '整柜业务', + '船期日历', ]) expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访']) expect(adminNavigation.find((item) => item.key === 'supplier-center')).toBeUndefined() @@ -47,6 +48,7 @@ describe('adminNavigation', () => { expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([ '尾程订单', '入库单', + '库位管理', '排车列表', '出库单', '派送单列表', @@ -68,6 +70,7 @@ describe('adminNavigation', () => { '船期价格表', '车辆列表', '下载中心', + '称重机器', ]) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([ @@ -76,6 +79,7 @@ describe('adminNavigation', () => { '租户管理', '品牌设置', '菜单权限', + 'PDA版本', ]) }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 34c738c8..91e825cd 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -1,6 +1,7 @@ import type { Component } from 'vue' import { Building2, + CalendarDays, ContactRound, ClipboardList, CircleDollarSign, @@ -70,6 +71,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' }, { key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' }, { key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' }, + { key: 'sailing-calendar', title: '船期日历', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' }, ], }, { @@ -112,6 +114,7 @@ export const adminNavigation: AdminNavigationNode[] = [ children: [ { key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' }, { key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' }, + { key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' }, { key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' }, { key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' }, { key: 'fba-delivery', title: '派送单列表', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' }, @@ -170,6 +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: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list' }, ], }, { diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts index 199831eb..3aae0473 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.test.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { completeAllBuyContainerCustoms, 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' }], ]) }) }) diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts index ccfaa157..a8bd6067 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.ts @@ -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 { @@ -57,7 +131,7 @@ interface RawContainerCustomsView extends Omit & { inboundId: string | number; orderId: string | number; tenantId: string | number }> - documents: Array & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; completion: ContainerCustomsFile | null }> + documents: Array & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; receipt: ContainerCustomsFile | null; finalDocument: ContainerCustomsFile | null }> } const basePath = '/platform/v1/customs/loading-plans' @@ -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>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`) + return (unwrapApiResponse(response) || []).map(normalizeDeclarationBatch) +} + +export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number, batchNo?: string) { + const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, batchNo: batchNo?.trim() || null, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } }) + return normalizeDeclarationBatch(unwrapApiResponse(response)) +} + +export async function submitDeclarationBatch(batch: DeclarationBatch, customsEntryNo: string) { + const response = await http.post, ApiEnvelope>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, batchNo: batch.batchNo, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } }) + return normalizeDeclarationBatch(unwrapApiResponse(response)) +} + +export async function recordClearanceResult(batch: DeclarationBatch, resultType: ClearanceResultType, resolution?: string) { + const response = await http.post, ApiEnvelope>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/results`, { expectedVersion: batch.version, resultType, occurredAt: new Date().toISOString(), resolution: resolution?.trim() || null, evidenceFileId: null, resolved: false }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-record') } }) + return normalizeClearanceResult(unwrapApiResponse(response)) +} + +export async function resolveClearanceResult(result: ClearanceResult, batchVersion: number, resolution: string) { + const response = await http.post, ApiEnvelope>(`${basePath}/clearance-results/${encodeURIComponent(result.id)}/resolve`, { expectedVersion: batchVersion, resolution: resolution.trim() }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-resolve') } }) + return normalizeClearanceResult(unwrapApiResponse(response)) +} + export async function getContainerCustoms(loadingPlanId: string) { const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(loadingPlanId)}`) const value = unwrapApiResponse(response) @@ -84,11 +208,15 @@ export function downloadContainerCustomsDocument(documentId: string) { return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' }) } -export function downloadContainerCustomsCompletion(documentId: string) { - return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`, { responseType: 'blob' }) +export function downloadContainerCustomsReceipt(documentId: string) { + return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' }) } -async function action(loadingPlanId: string, name: 'start' | 'complete-all-buy', expectedVersion: number, remark?: string) { +export function downloadContainerCustomsFinal(documentId: string) { + return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' }) +} + +async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) { const response = await http.post, ApiEnvelope>( `${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`, { expectedVersion, confirm: true, remark: remark?.trim() || null }, @@ -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>( - `${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') } diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts index 2e63b4d2..f5fd91b7 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -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>) { 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() }) }) diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index 312ceb4b..ed95c2d7 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -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(null) +const declarationBatches = ref([]) const loading = ref(false) const actionLoading = ref(false) const loadError = ref(null) const actionError = ref(null) const downloadingKey = ref('') -const completionFiles = reactive>({}) +const receiptFiles = reactive>({}) +const finalFiles = reactive>({}) const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || []) const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0))) 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 = { WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成', } @@ -46,9 +61,25 @@ const statusText: Record = { function documentCustomers(document: ContainerCustomsDocument) { return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—' } -function selectCompletion(documentId: string, upload: UploadFile) { +function 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)[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) {{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }} 导出买单 XLSX 开始报关 + 首次发送报关行 + 发送报关行执行 确认报关完成 @@ -168,22 +280,36 @@ onMounted(load) @@ -192,5 +318,5 @@ onMounted(load) diff --git a/src/modules/container-loading/index.ts b/src/modules/container-loading/index.ts index da4036f4..b37f139a 100644 --- a/src/modules/container-loading/index.ts +++ b/src/modules/container-loading/index.ts @@ -1,4 +1,5 @@ export * from './api/platformContainerLoading' export * from './types' +export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups' export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue' export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue' diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 0b5c40d0..a5a6ef3e 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -47,7 +47,8 @@ describe('ContainerDetailView customs integration', () => { expect(source).toContain(' { void loadCustomers() }) - + diff --git a/src/modules/fba-pricing/public.ts b/src/modules/fba-pricing/public.ts index e0e6fc2b..922fdac3 100644 --- a/src/modules/fba-pricing/public.ts +++ b/src/modules/fba-pricing/public.ts @@ -5,3 +5,5 @@ export { setCustomerChargeRuleDefault, } from './customer-rules/api' export type { CustomerChargeRule, CreateCustomerChargeRuleRequest } from './customer-rules/api' +export { getPlatformPriceBook, listPlatformPriceBooks } from './carrier-rates/api/platformPriceBooks' +export type { PlatformPriceBookParsedChannel } from './carrier-rates/api/platformPriceBooks' diff --git a/src/modules/finance/pages/FinancePresentation.test.ts b/src/modules/finance/pages/FinancePresentation.test.ts index a3a262af..170aaff5 100644 --- a/src/modules/finance/pages/FinancePresentation.test.ts +++ b/src/modules/finance/pages/FinancePresentation.test.ts @@ -123,7 +123,7 @@ describe('finance presentation governance', () => { expect(statements).toContain('createReconciliationStatement') expect(statements).toContain('actionReconciliationStatement') expect(statements).toContain(' @@ -306,7 +315,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading }) diff --git a/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue new file mode 100644 index 00000000..30d8d811 --- /dev/null +++ b/src/modules/overseas-warehouse/pages/MojiaSkuSyncView.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts index 595a3caa..b4d8dd92 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.test.ts @@ -19,4 +19,13 @@ describe('RelabelOrderDetailView', () => { expect(source).toContain('value?.application.customerOrderNo || value?.application.lastlegOrderNo') expect(source).toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId') }) + + it('lets platform operators confirm completion or return PDA work for rework', () => { + expect(source).toContain('@click="confirmCompletion"') + expect(source).toContain('确认换标完成') + expect(source).toContain('@click="returnForRework"') + expect(source).toContain('退回重新贴标') + expect(source).toContain('await confirmRelabelCompletion(orderId.value)') + expect(source).toContain('await returnRelabelForRework(orderId.value') + }) }) diff --git a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue index 7ff83847..1cbd6500 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue @@ -1,11 +1,11 @@ + + + + diff --git a/src/modules/sailing-calendar/routes.ts b/src/modules/sailing-calendar/routes.ts new file mode 100644 index 00000000..39dbbc1b --- /dev/null +++ b/src/modules/sailing-calendar/routes.ts @@ -0,0 +1,8 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const sailingCalendarRoutes = [{ + path: 'sailing-calendar', + name: 'sailing-calendar', + component: () => import('./pages/SailingCalendarView.vue'), + meta: { title: '船期日历', description: '维护计划船期、查看收货体积和订柜建议', permission: 'platform:firstleg:sailing-calendar:list' }, +}] satisfies RouteRecordRaw[] diff --git a/src/modules/sailing-calendar/types.ts b/src/modules/sailing-calendar/types.ts new file mode 100644 index 00000000..723249e2 --- /dev/null +++ b/src/modules/sailing-calendar/types.ts @@ -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 + +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 +} diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index 09f8a540..a815ebb7 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -249,7 +249,7 @@ describe('UnifiedOrdersView contract', () => { it('counts detail in the three directly visible row actions', () => { expect(source).toContain("{ key: 'detail', label: '详情', priority: 500 }") expect(source).toContain('...unifiedOrderSecondaryActions(row).map') - expect(source).toContain(' { 打印箱唛({{ selectedCartonLabelOrders.length }}) 智能装柜 - +