Release/test #11

Merged
admin merged 43 commits from release/test into main 2026-09-28 15:59:28 +08:00
59 changed files with 2419 additions and 185 deletions
+5 -2
View File
@@ -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 核对受影响的状态和桌面布局;缺少截图标记“待人工截图确认”,不自行批量取证。自动检查单独报告“自动检查通过/失败”;纯逻辑且无视觉变化标记“无需视觉验收”,不能据此声称真实页面验收通过。
## 命令与质量门禁
+1 -1
View File
@@ -31,7 +31,7 @@ describe('domestic outbound list responsive contracts', () => {
const content = source(path)
const columns = [...content.matchAll(/\{[^{}]*(?:key|prop): '[^']+'[^{}]*label: '[^']+'[^{}]*\}/g)]
.map((match) => match[0])
.filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'"))
.filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'") && !column.includes('priority:') && !column.includes("key: 'view'"))
expect(columns.length).toBeGreaterThan(1)
for (const column of columns) expect(column).toMatch(/\b(?:width|minWidth):\s*\d+/)
})
+2
View File
@@ -32,6 +32,7 @@ import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes'
import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
import { pdaReleaseRoutes } from '@/modules/pda-releases/routes'
import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes'
const router = createRouter({
history: createWebHistory(),
@@ -77,6 +78,7 @@ const router = createRouter({
...downloadRoutes,
...iamRoutes,
...pdaReleaseRoutes,
...sailingCalendarRoutes,
{
path: '403',
name: 'forbidden',
@@ -35,6 +35,7 @@ describe('adminNavigation', () => {
'库位管理',
'托盘管理',
'整柜业务',
'船期日历',
])
expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访'])
expect(adminNavigation.find((item) => item.key === 'supplier-center')).toBeUndefined()
@@ -47,6 +48,7 @@ describe('adminNavigation', () => {
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
'尾程订单',
'入库单',
'库位管理',
'排车列表',
'出库单',
'派送单列表',
@@ -68,6 +70,7 @@ describe('adminNavigation', () => {
'船期价格表',
'车辆列表',
'下载中心',
'称重机器',
])
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
@@ -76,6 +79,7 @@ describe('adminNavigation', () => {
'租户管理',
'品牌设置',
'菜单权限',
'PDA版本',
])
})
+4
View File
@@ -1,6 +1,7 @@
import type { Component } from 'vue'
import {
Building2,
CalendarDays,
ContactRound,
ClipboardList,
CircleDollarSign,
@@ -70,6 +71,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
{ key: 'sailing-calendar', title: '船期日历', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' },
],
},
{
@@ -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' },
],
},
{
@@ -1,12 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { completeAllBuyContainerCustoms, downloadContainerCustomsCompletion, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, uploadContainerCustomsCompletion } from './platformContainerCustoms'
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
const customs = {
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', hasActualCargo: true, version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1,
planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', hasActualCargo: true, version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12,
inbounds: [{ inboundId: 2092438843630391404n, orderId: 2092438843630391405n, orderNo: 'O-1', inboundCode: 'RK-1', tenantId: 2092438843630391406n, customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, completion: null }],
documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, receipt: null, finalDocument: null, confirmationHistory: [] }],
brokerSubmission: null,
startedAt: null, completedAt: null,
}
@@ -39,14 +40,21 @@ describe('platform container customs API', () => {
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
})
it('uploads one completion file with final-confirmation query parameters', async () => {
it('sends the frozen source documents to the broker through the first-send action', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: customs })
await sendContainerCustomsToBroker('2092438843630391402', 3)
expect(post.mock.calls[0]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/send-to-broker', { expectedVersion: 3, confirm: true, remark: '已将冻结退税报关资料首次发送给报关行' }])
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
})
it('uploads one receipt file without completing the container', async () => {
const document = { ...customs.documents[0], documentId: '2092438843630391407', tenantId: '2092438843630391406' }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: document })
const completion = new File(['done'], 'done.pdf')
await uploadContainerCustomsCompletion('2092438843630391407', completion, 0, true)
expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/upload')
await uploadContainerCustomsReceipt('2092438843630391407', completion, 0)
expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/receipt/upload')
expect((post.mock.calls[0]?.[1] as FormData).get('file')).toBe(completion)
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0, finalConfirmation: true }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0 }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
})
it('keeps source and completion downloads isolated on the long document id', async () => {
@@ -56,11 +64,11 @@ describe('platform container customs API', () => {
const documentId = '2092438843630391407'
await expect(downloadContainerCustomsDocument(documentId)).resolves.toBe(source)
await expect(downloadContainerCustomsCompletion(documentId)).resolves.toBe(completion)
await expect(downloadContainerCustomsReceipt(documentId)).resolves.toBe(completion)
expect(get.mock.calls).toEqual([
[`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }],
[`/platform/v1/customs/loading-plans/documents/${documentId}/completion/download`, { responseType: 'blob' }],
[`/platform/v1/customs/loading-plans/documents/${documentId}/receipt/download`, { responseType: 'blob' }],
])
})
})
@@ -12,6 +12,36 @@ export interface ContainerCustomsFile {
uploadedAt: string
}
export interface ContainerCustomsReceiptVersion {
version: number
file: ContainerCustomsFile
operatorName?: string | null
createdAt: string
}
export type ContainerCustomsConfirmationAction = 'CONFIRM' | 'REJECT'
export interface ContainerCustomsConfirmationHistory {
id: string
receiptVersion: number
action: ContainerCustomsConfirmationAction
reason: string | null
operatorName: string
createdAt: string
}
export interface ContainerCustomsBrokerSubmission {
id: string
customsContainerId: string
loadingPlanId: string
batchNo: string
status: 'SENT'
sourceVersion: number
sentByName: string
sentAt: string
version: number
}
export interface ContainerCustomsInbound {
inboundId: string
orderId: string
@@ -30,7 +60,12 @@ export interface ContainerCustomsDocument {
inboundIds: string[]
inboundCodes: string[]
frozen: boolean
completion: ContainerCustomsFile | null
receipt: ContainerCustomsFile | null
receiptStatus?: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null
receiptRejectReason?: string | null
finalDocument: ContainerCustomsFile | null
receiptHistory?: ContainerCustomsReceiptVersion[]
confirmationHistory: ContainerCustomsConfirmationHistory[]
}
export interface ContainerCustomsView {
@@ -45,10 +80,49 @@ export interface ContainerCustomsView {
version: number
taxRefundCount: number
coveredTaxRefundCount: number
actualBoxCount: number
inbounds: ContainerCustomsInbound[]
documents: ContainerCustomsDocument[]
brokerSubmission: ContainerCustomsBrokerSubmission | null
startedAt: string | null
completedAt: string | null
brokerSubmittedAt?: string | null
}
export type DeclarationBatchStatus = 'READY' | 'SUBMITTED' | 'ACKNOWLEDGED' | 'RESULT_PENDING' | 'CLOSED' | 'CANCELLED'
export type ClearanceResultType = 'INSPECTION' | 'SUPPLEMENT_REQUIRED' | 'RELEASED' | 'DETAINED' | 'OTHER'
export type ClearanceResultStatus = 'OPEN' | 'RESOLVED' | 'CANCELLED'
export interface ClearanceResult {
id: string
declarationBatchId: string
customsContainerId: string
resultType: ClearanceResultType
resultStatus: ClearanceResultStatus
customsEntryNo: string | null
occurredAt: string
resolution: string | null
evidenceFileId: string | null
recordedByName: string
recordedAt: string
previousResultId: string | null
}
export interface DeclarationBatch {
id: string
customsContainerId: string
loadingPlanId: string
batchNo: string
status: DeclarationBatchStatus
sourceVersion: number
customsEntryNo: string | null
version: number
submittedAt: string | null
submittedByName: string | null
acknowledgedAt: string | null
acknowledgedByName: string | null
remark: string | null
results: ClearanceResult[]
}
interface RawContainerCustomsView extends Omit<ContainerCustomsView, 'aggregateId' | 'loadingPlanId' | 'containerBookingId' | 'version' | 'inbounds' | 'documents'> {
@@ -57,7 +131,7 @@ interface RawContainerCustomsView extends Omit<ContainerCustomsView, 'aggregateI
containerBookingId: string | number
version: string | number
inbounds: Array<Omit<ContainerCustomsInbound, 'inboundId' | 'orderId' | 'tenantId'> & { inboundId: string | number; orderId: string | number; tenantId: string | number }>
documents: Array<Omit<ContainerCustomsDocument, 'documentId' | 'tenantId' | 'source' | 'completion'> & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; completion: ContainerCustomsFile | null }>
documents: Array<Omit<ContainerCustomsDocument, 'documentId' | 'tenantId' | 'source' | 'receipt' | 'finalDocument'> & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; receipt: ContainerCustomsFile | null; finalDocument: ContainerCustomsFile | null }>
}
const basePath = '/platform/v1/customs/loading-plans'
@@ -70,10 +144,60 @@ function normalize(value: RawContainerCustomsView): ContainerCustomsView {
...value,
aggregateId: String(value.aggregateId), loadingPlanId: String(value.loadingPlanId), containerBookingId: String(value.containerBookingId), version: Number(value.version),
inbounds: value.inbounds.map((item) => ({ ...item, inboundId: String(item.inboundId), orderId: String(item.orderId), tenantId: String(item.tenantId) })),
documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, completion: file(item.completion) })),
brokerSubmission: value.brokerSubmission ? {
...value.brokerSubmission,
id: String(value.brokerSubmission.id),
customsContainerId: String(value.brokerSubmission.customsContainerId),
loadingPlanId: String(value.brokerSubmission.loadingPlanId),
sourceVersion: Number(value.brokerSubmission.sourceVersion),
version: Number(value.brokerSubmission.version),
} : null,
documents: value.documents.map((item) => ({
...item,
documentId: String(item.documentId),
tenantId: String(item.tenantId),
source: file(item.source)!,
receipt: file(item.receipt),
finalDocument: file(item.finalDocument),
receiptHistory: (item.receiptHistory || []).map((version) => ({ ...version, version: Number(version.version), file: file(version.file)! })),
confirmationHistory: (item.confirmationHistory || []).map((history) => ({ ...history, id: String(history.id), receiptVersion: Number(history.receiptVersion), reason: history.reason ?? null })),
})),
}
}
function normalizeClearanceResult(value: ClearanceResult): ClearanceResult {
return { ...value, id: String(value.id), declarationBatchId: String(value.declarationBatchId), customsContainerId: String(value.customsContainerId), evidenceFileId: value.evidenceFileId == null ? null : String(value.evidenceFileId), previousResultId: value.previousResultId == null ? null : String(value.previousResultId) }
}
function normalizeDeclarationBatch(value: DeclarationBatch): DeclarationBatch {
return { ...value, id: String(value.id), customsContainerId: String(value.customsContainerId), loadingPlanId: String(value.loadingPlanId), sourceVersion: Number(value.sourceVersion), version: Number(value.version), results: (value.results || []).map(normalizeClearanceResult) }
}
export async function getDeclarationBatches(loadingPlanId: string) {
const response = await http.get<ApiEnvelope<DeclarationBatch[]>, ApiEnvelope<DeclarationBatch[]>>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`)
return (unwrapApiResponse(response) || []).map(normalizeDeclarationBatch)
}
export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number, batchNo?: string) {
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, batchNo: batchNo?.trim() || null, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } })
return normalizeDeclarationBatch(unwrapApiResponse(response))
}
export async function submitDeclarationBatch(batch: DeclarationBatch, customsEntryNo: string) {
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, batchNo: batch.batchNo, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } })
return normalizeDeclarationBatch(unwrapApiResponse(response))
}
export async function recordClearanceResult(batch: DeclarationBatch, resultType: ClearanceResultType, resolution?: string) {
const response = await http.post<ApiEnvelope<ClearanceResult>, ApiEnvelope<ClearanceResult>>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/results`, { expectedVersion: batch.version, resultType, occurredAt: new Date().toISOString(), resolution: resolution?.trim() || null, evidenceFileId: null, resolved: false }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-record') } })
return normalizeClearanceResult(unwrapApiResponse(response))
}
export async function resolveClearanceResult(result: ClearanceResult, batchVersion: number, resolution: string) {
const response = await http.post<ApiEnvelope<ClearanceResult>, ApiEnvelope<ClearanceResult>>(`${basePath}/clearance-results/${encodeURIComponent(result.id)}/resolve`, { expectedVersion: batchVersion, resolution: resolution.trim() }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-resolve') } })
return normalizeClearanceResult(unwrapApiResponse(response))
}
export async function getContainerCustoms(loadingPlanId: string) {
const response = await http.get<ApiEnvelope<RawContainerCustomsView | null>, ApiEnvelope<RawContainerCustomsView | null>>(`${basePath}/${encodeURIComponent(loadingPlanId)}`)
const value = unwrapApiResponse(response)
@@ -84,11 +208,15 @@ export function downloadContainerCustomsDocument(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
}
export function downloadContainerCustomsCompletion(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`, { responseType: 'blob' })
export function downloadContainerCustomsReceipt(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' })
}
async function action(loadingPlanId: string, name: 'start' | 'complete-all-buy', expectedVersion: number, remark?: string) {
export function downloadContainerCustomsFinal(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' })
}
async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) {
const response = await http.post<ApiEnvelope<RawContainerCustomsView>, ApiEnvelope<RawContainerCustomsView>>(
`${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`,
{ expectedVersion, confirm: true, remark: remark?.trim() || null },
@@ -101,18 +229,34 @@ export function startContainerCustoms(loadingPlanId: string, expectedVersion: nu
return action(loadingPlanId, 'start', expectedVersion)
}
export function sendContainerCustomsToBroker(loadingPlanId: string, expectedVersion: number) {
return action(loadingPlanId, 'send-to-broker', expectedVersion, '已将冻结退税报关资料首次发送给报关行')
}
export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVersion: number) {
return action(loadingPlanId, 'complete-all-buy', expectedVersion)
}
export async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number, finalConfirmation: boolean) {
export function submitContainerCustomsToBroker(loadingPlanId: string, expectedVersion: number) {
return action(loadingPlanId, 'submit-to-broker', expectedVersion, '已将客户确认回执发送给报关行')
}
async function uploadFile(documentId: string, fileValue: File, expectedVersion: number, kind: 'receipt' | 'final') {
const body = new FormData()
body.append('file', fileValue)
const response = await http.post<ApiEnvelope<ContainerCustomsDocument>, ApiEnvelope<ContainerCustomsDocument>>(
`${basePath}/documents/${encodeURIComponent(documentId)}/completion/upload`,
`${basePath}/documents/${encodeURIComponent(documentId)}/${kind}/upload`,
body,
{ params: { expectedVersion, finalConfirmation }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-customs-completion') } },
{ params: { expectedVersion }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId(`container-customs-${kind}`) } },
)
const result = unwrapApiResponse(response)
return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, completion: file(result.completion) }
return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, receipt: file(result.receipt), finalDocument: file(result.finalDocument) }
}
export function uploadContainerCustomsReceipt(documentId: string, fileValue: File, expectedVersion: number) {
return uploadFile(documentId, fileValue, expectedVersion, 'receipt')
}
export function uploadContainerCustomsFinal(documentId: string, fileValue: File, expectedVersion: number) {
return uploadFile(documentId, fileValue, expectedVersion, 'final')
}
@@ -8,18 +8,25 @@ const api = vi.hoisted(() => ({
get: vi.fn(),
start: vi.fn(),
completeAllBuy: vi.fn(),
sendToBroker: vi.fn(),
getDeclarationBatches: vi.fn(),
upload: vi.fn(),
downloadSource: vi.fn(),
downloadCompletion: vi.fn(),
downloadReceipt: vi.fn(),
downloadFinal: vi.fn(),
}))
vi.mock('../api/platformContainerCustoms', () => ({
getContainerCustoms: api.get,
startContainerCustoms: api.start,
completeAllBuyContainerCustoms: api.completeAllBuy,
uploadContainerCustomsCompletion: api.upload,
uploadContainerCustomsReceipt: api.upload,
downloadContainerCustomsDocument: api.downloadSource,
downloadContainerCustomsCompletion: api.downloadCompletion,
downloadContainerCustomsReceipt: api.downloadReceipt,
downloadContainerCustomsFinal: api.downloadFinal,
submitContainerCustomsToBroker: vi.fn(),
sendContainerCustomsToBroker: api.sendToBroker,
getDeclarationBatches: api.getDeclarationBatches,
}))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
@@ -28,14 +35,17 @@ import ContainerCustomsPanel from './ContainerCustomsPanel.vue'
function customs(status: ContainerCustomsStatus, hasCompletion = false): ContainerCustomsView {
return {
aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403',
planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, hasActualCargo: true, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1,
planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, hasActualCargo: true, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12,
inbounds: [{ inboundId: '2092438843630391404', orderId: '2092438843630391405', orderNo: 'O-1', inboundCode: 'RK-1', tenantId: '2092438843630391406', customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
documents: [{
documentId: '2092438843630391407', tenantId: '2092438843630391406',
source: { fileId: '2092438843630391408', fileName: '客户退税资料.pdf', fileHash: 'source-hash', sizeBytes: 12, version: 2, uploadedAt: '2026-08-29T10:00:00' },
inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true,
completion: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关完成文件.pdf', fileHash: 'completion-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null,
receipt: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关行回执.pdf', fileHash: 'receipt-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null,
finalDocument: hasCompletion ? { fileId: '2092438843630391410', fileName: '真实报关完税文件.pdf', fileHash: 'final-hash', sizeBytes: 14, version: 1, uploadedAt: '2026-08-29T12:00:00' } : null,
confirmationHistory: hasCompletion ? [{ id: '2092438843630391411', receiptVersion: 1, action: 'CONFIRM', reason: null, operatorName: '客户一', createdAt: '2026-08-29T11:15:00' }] : [],
}],
brokerSubmission: hasCompletion ? { id: '2092438843630391412', customsContainerId: '2092438843630391401', loadingPlanId: '2092438843630391402', batchNo: 'BS-20260829-001', status: 'SENT', sourceVersion: 3, sentByName: '平台员', sentAt: '2026-08-29T10:45:00', version: 1 } : null,
startedAt: status === 'DECLARING' || status === 'COMPLETED' ? '2026-08-29T10:30:00' : null,
completedAt: status === 'COMPLETED' ? '2026-08-29T11:30:00' : null,
}
@@ -58,8 +68,10 @@ function buttonTexts(wrapper: Awaited<ReturnType<typeof render>>) {
describe('ContainerCustomsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
api.getDeclarationBatches.mockResolvedValue([])
api.downloadSource.mockResolvedValue(new Blob(['source']))
api.downloadCompletion.mockResolvedValue(new Blob(['completion']))
api.downloadReceipt.mockResolvedValue(new Blob(['receipt']))
api.downloadFinal.mockResolvedValue(new Blob(['final']))
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:customs-file') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
@@ -68,18 +80,19 @@ describe('ContainerCustomsPanel', () => {
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
const wrapper = await render('READY_TO_START')
expect(wrapper.text()).toContain('开始报关')
expect(wrapper.text()).toContain('实际箱数 12')
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('客户退税资料')
expect(wrapper.text()).toContain('报关完成文件')
expect(wrapper.text()).toContain('报关行回执')
expect(wrapper.text()).not.toContain('客户报关资料')
expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true)
expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('shows a successful empty state and hides customs actions when the completed plan has no actual cargo', async () => {
api.get.mockResolvedValue({ ...customs('READY_TO_START'), hasActualCargo: false, taxRefundCount: 0, coveredTaxRefundCount: 0, inbounds: [], documents: [] })
api.get.mockResolvedValue({ ...customs('READY_TO_START'), hasActualCargo: false, taxRefundCount: 0, coveredTaxRefundCount: 0, actualBoxCount: 0, inbounds: [], documents: [] })
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
@@ -106,29 +119,46 @@ describe('ContainerCustomsPanel', () => {
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
let wrapper = await render('DECLARING', true, false)
expect(wrapper.text()).toContain('上传报关完成文件')
expect(wrapper.text()).toContain('提交')
expect(wrapper.text()).toContain('首次发送报关行')
expect(wrapper.text()).toContain('首次发送后上传回执')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
wrapper.unmount()
wrapper = await render('DECLARING', true, true)
expect(wrapper.text()).toContain('覆盖报关完成文件')
expect(wrapper.text()).toContain('报关完成文件.pdf')
expect(wrapper.text()).toContain('发送报关行执行')
expect(wrapper.text()).toContain('覆盖报关行回执')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
wrapper.unmount()
wrapper = await render('DECLARING', false, true)
expect(wrapper.text()).toContain('只读')
expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('loads declaration batches during DECLARING after broker execution was sent', async () => {
const value = { ...customs('DECLARING', true), brokerSubmittedAt: '2026-08-29T11:20:00' }
api.get.mockResolvedValue(value)
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(api.getDeclarationBatches).toHaveBeenCalledWith('2092438843630391402')
wrapper.unmount()
})
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
const wrapper = await render('COMPLETED', true, true)
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('报关完成文件.pdf')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -137,13 +167,16 @@ describe('ContainerCustomsPanel', () => {
await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
expect(api.downloadCompletion).not.toHaveBeenCalled()
expect(api.downloadReceipt).not.toHaveBeenCalled()
await wrapper.get('[aria-label="下载报关完成文件 报关完成文件.pdf"]').trigger('click')
await wrapper.get('[aria-label="下载报关行回执 报关行回执.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407')
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2)
expect(URL.createObjectURL).toHaveBeenCalledTimes(2)
expect(api.downloadReceipt).toHaveBeenCalledWith('2092438843630391407')
await wrapper.get('[aria-label="下载报关完税文件 真实报关完税文件.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadFinal).toHaveBeenCalledWith('2092438843630391407')
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(3)
expect(URL.createObjectURL).toHaveBeenCalledTimes(3)
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:customs-file')
})
@@ -158,6 +191,6 @@ describe('ContainerCustomsPanel', () => {
expect(wrapper.text()).toContain('CUSTOMS_FILE_404')
expect(wrapper.text()).toContain('request-customs-download')
expect(wrapper.text()).toContain('trace-customs-download')
expect(api.downloadCompletion).not.toHaveBeenCalled()
expect(api.downloadReceipt).not.toHaveBeenCalled()
})
})
@@ -9,14 +9,24 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import {
completeAllBuyContainerCustoms,
downloadContainerCustomsCompletion,
createDeclarationBatch,
downloadContainerCustomsReceipt,
downloadContainerCustomsFinal,
downloadContainerCustomsDocument,
getContainerCustoms,
getDeclarationBatches,
recordClearanceResult,
resolveClearanceResult,
submitDeclarationBatch,
startContainerCustoms,
uploadContainerCustomsCompletion,
sendContainerCustomsToBroker,
submitContainerCustomsToBroker,
uploadContainerCustomsReceipt,
uploadContainerCustomsFinal,
type ContainerCustomsDocument,
type ContainerCustomsStatus,
type ContainerCustomsView,
type DeclarationBatch,
} from '../api/platformContainerCustoms'
const props = defineProps<{
@@ -28,17 +38,22 @@ const props = defineProps<{
const emit = defineEmits<{ refreshed: [] }>()
const customs = ref<ContainerCustomsView | null>(null)
const declarationBatches = ref<DeclarationBatch[]>([])
const loading = ref(false)
const actionLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const downloadingKey = ref('')
const completionFiles = reactive<Record<string, File | null>>({})
const receiptFiles = reactive<Record<string, File | null>>({})
const finalFiles = reactive<Record<string, File | null>>({})
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
const missingCompletionCount = computed(() => customs.value?.documents.filter((item) => !item.completion).length || 0)
const brokerSubmission = computed(() => customs.value?.brokerSubmission || null)
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
const allReceiptsConfirmed = computed(() => Boolean(customs.value?.documents.length) && customs.value?.documents.every((document) => document.receipt && document.receiptStatus === 'CONFIRMED') === true)
const openDeclarationBatch = computed(() => declarationBatches.value.find((item) => ['READY', 'SUBMITTED', 'ACKNOWLEDGED', 'RESULT_PENDING'].includes(item.status)) || null)
const statusText: Record<ContainerCustomsStatus, string> = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
}
@@ -46,9 +61,25 @@ const statusText: Record<ContainerCustomsStatus, string> = {
function documentCustomers(document: ContainerCustomsDocument) {
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
}
function selectCompletion(documentId: string, upload: UploadFile) {
function receiptHistoryLabel(document: ContainerCustomsDocument) {
return (document.receiptHistory || []).map((item) => `v${item.version}`).join('、') || '—'
}
function confirmationActionLabel(action: ContainerCustomsDocument['confirmationHistory'][number]['action']) {
return action === 'CONFIRM' ? '确认' : '打回'
}
function formatDateTime(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function selectReceipt(documentId: string, upload: UploadFile) {
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
receiptFiles[documentId] = upload.raw || null
actionError.value = null
}
function selectFinal(documentId: string, upload: UploadFile) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
completionFiles[documentId] = upload.raw || null
finalFiles[documentId] = upload.raw || null
actionError.value = null
}
@@ -56,11 +87,65 @@ async function load() {
if (!props.canList || !props.loadingPlanId) return
loading.value = true
loadError.value = null
try { customs.value = await getContainerCustoms(props.loadingPlanId) }
try {
customs.value = await getContainerCustoms(props.loadingPlanId)
if (customs.value?.status === 'COMPLETED' || customs.value?.brokerSubmittedAt) {
try { declarationBatches.value = await getDeclarationBatches(props.loadingPlanId) } catch { declarationBatches.value = [] }
} else declarationBatches.value = []
}
catch (error) { customs.value = null; loadError.value = getApiErrorDetails(error, '整柜报关状态加载失败') }
finally { loading.value = false }
}
function declarationStatusLabel(value: DeclarationBatch['status']) {
return ({ READY: '待提交', SUBMITTED: '已提交', ACKNOWLEDGED: '已受理', RESULT_PENDING: '待处理结果', CLOSED: '已关闭', CANCELLED: '已取消' } as Record<string, string>)[value] || value
}
async function createFormalDeclaration() {
if (!customs.value || !['DECLARING', 'COMPLETED'].includes(customs.value.status) || !brokerSubmitted.value || openDeclarationBatch.value) return
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', '创建正式申报批次', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
finally { actionLoading.value = false }
}
async function submitFormalDeclaration(batch: DeclarationBatch) {
try {
const answer = await ElMessageBox.prompt('请输入报关单号', '提交正式申报', { customClass: 'admin-density-confirm', inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
actionLoading.value = true; actionError.value = null
await submitDeclarationBatch(batch, answer.value)
await load(); ElMessage.success('正式申报已提交')
} catch (error) {
if (error === 'cancel' || error === 'close') return
actionError.value = getApiErrorDetails(error, '正式申报提交失败')
} finally { actionLoading.value = false }
}
async function recordFormalResult(batch: DeclarationBatch) {
try {
const answer = await ElMessageBox.prompt('可填写查验处理说明,没有说明可直接确认', '录入海关查验结果', { customClass: 'admin-density-confirm', inputPlaceholder: '处理说明(可选)', confirmButtonText: '确认录入' })
actionLoading.value = true; actionError.value = null
await recordClearanceResult(batch, 'INSPECTION', answer.value)
await load(); ElMessage.success('海关结果已录入')
} catch (error) {
if (error === 'cancel' || error === 'close') return
actionError.value = getApiErrorDetails(error, '海关结果录入失败')
} finally { actionLoading.value = false }
}
async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationBatch['results'][number]) {
try {
const answer = await ElMessageBox.prompt('请输入处理说明', '处理海关结果', { customClass: 'admin-density-confirm', inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
actionLoading.value = true; actionError.value = null
await resolveClearanceResult(result, batch.version, answer.value)
await load(); ElMessage.success('海关结果已处理')
} catch (error) {
if (error === 'cancel' || error === 'close') return
actionError.value = getApiErrorDetails(error, '海关结果处理失败')
} finally { actionLoading.value = false }
}
async function start() {
if (!customs.value || customs.value.status !== 'READY_TO_START') return
try {
@@ -72,24 +157,31 @@ async function start() {
finally { actionLoading.value = false }
}
async function uploadCompletion(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
const selected = completionFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择对应的报关完成文件'); return }
const finalConfirmation = !document.completion && missingCompletionCount.value === 1
if (finalConfirmation) {
try {
await ElMessageBox.confirm('这是最后一份待上传的报关完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认上传并完成' })
} catch { return }
}
async function uploadReceipt(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
const selected = receiptFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return }
actionLoading.value = true; actionError.value = null
try {
await uploadContainerCustomsCompletion(document.documentId, selected, document.completion?.version || 0, finalConfirmation)
completionFiles[document.documentId] = null
await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0)
receiptFiles[document.documentId] = null
await load()
emit('refreshed')
ElMessage.success(finalConfirmation ? '报关完成文件已上传,整柜报关已完成' : '报关完成文件已上传')
} catch (error) { actionError.value = getApiErrorDetails(error, '报关完成文件上传失败') }
ElMessage.success('报关行回执已上传,等待客户确认')
} catch (error) { actionError.value = getApiErrorDetails(error, '报关行回执上传失败') }
finally { actionLoading.value = false }
}
async function uploadFinal(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
const selected = finalFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择真实报关完税文件'); return }
actionLoading.value = true; actionError.value = null
try {
await uploadContainerCustomsFinal(document.documentId, selected, document.finalDocument?.version || 0)
finalFiles[document.documentId] = null
await load(); emit('refreshed'); ElMessage.success('真实报关完税文件已上传')
} catch (error) { actionError.value = getApiErrorDetails(error, '真实报关完税文件上传失败') }
finally { actionLoading.value = false }
}
@@ -102,8 +194,8 @@ function saveBlob(blob: Blob, fileName: string) {
URL.revokeObjectURL(url)
}
async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | 'completion') {
const target = kind === 'source' ? document.source : document.completion
async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | 'receipt' | 'final') {
const target = kind === 'source' ? document.source : kind === 'receipt' ? document.receipt : document.finalDocument
if (!target) return
const key = `${kind}:${document.documentId}`
downloadingKey.value = key
@@ -111,9 +203,9 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
try {
const blob = kind === 'source'
? await downloadContainerCustomsDocument(document.documentId)
: await downloadContainerCustomsCompletion(document.documentId)
: kind === 'receipt' ? await downloadContainerCustomsReceipt(document.documentId) : await downloadContainerCustomsFinal(document.documentId)
saveBlob(blob, target.fileName)
ElMessage.success(`${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载已开始`)
ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '报关行回执' : '报关完税文件'}下载已开始`)
} catch (error) {
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`)
} finally {
@@ -121,6 +213,24 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
}
}
async function submitToBroker() {
if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', '发送报关行执行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') }
catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') }
finally { actionLoading.value = false }
}
async function sendToBroker() {
if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', '首次发送报关行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') }
catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') }
finally { actionLoading.value = false }
}
async function completeAllBuy() {
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
@@ -159,6 +269,8 @@ onMounted(load)
<el-tag :type="!customs.hasActualCargo ? 'info' : customs.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }}</el-tag>
<el-button v-if="canManage && allBuy" plain :loading="actionLoading" @click="exportAllBuy">导出买单 XLSX</el-button>
<el-button v-if="canManage && customs.hasActualCargo && customs.status === 'READY_TO_START'" type="primary" :loading="actionLoading" @click="start">开始报关</el-button>
<el-button v-if="canManage && customs.hasActualCargo && customs.status === 'DECLARING' && !brokerSubmission" type="primary" :loading="actionLoading" @click="sendToBroker">首次发送报关行</el-button>
<el-button v-if="canManage && customs.hasActualCargo && customs.status === 'DECLARING' && brokerSubmission && !brokerSubmitted" type="primary" plain :disabled="!allReceiptsConfirmed" :loading="actionLoading" @click="submitToBroker">发送报关行执行</el-button>
<el-button v-if="canManage && allBuy && customs.status === 'DECLARING'" type="primary" :loading="actionLoading" @click="completeAllBuy">确认报关完成</el-button>
</div>
</header>
@@ -168,22 +280,36 @@ onMounted(load)
<template v-else-if="customs">
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
<template v-else>
<div class="customs-metrics"><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
<div class="customs-metrics"><span>实际箱数 <strong>{{ customs.actualBoxCount }}</strong></span><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
<div v-if="brokerSubmission" class="broker-submission-fact" aria-label="首次发送报关行批次"><span>首次发送批次 <strong>{{ brokerSubmission.batchNo }}</strong></span><span>状态 <strong>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</strong></span><span>发送人 <strong>{{ brokerSubmission.sentByName || '—' }}</strong></span><span>发送时间 <strong>{{ formatDateTime(brokerSubmission.sentAt) }}</strong></span><span>资料版本 <strong>v{{ brokerSubmission.sourceVersion }}</strong></span></div>
<div v-if="missingInbounds.length" class="compact-table-wrap">
<h3>缺退税资料清单</h3>
<table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table>
</div>
<div v-if="customs.documents.length" class="compact-table-wrap">
<h3>已冻结退税资料与报关完成文件</h3>
<table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关完成文件</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody>
<h3>已冻结退税资料、报关行回执与完税文件</h3>
<table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关行回执</th><th>真实报关完税文件</th><th>关联入仓码</th><th>报关行状态</th><th>版本</th><th>客户</th><th>回执情况</th></tr></thead><tbody>
<tr v-for="document in customs.documents" :key="document.documentId">
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button class="density-action" size="small">{{ document.completion ? '覆盖报关完成文件' : '上传报关完成文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!completionFiles[document.documentId]" :loading="actionLoading" @click="uploadCompletion(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传回执' : document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
<td><div v-if="document.completion" class="file-cell"><span :title="document.completion.fileName">{{ document.completion.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `completion:${document.documentId}`" :aria-label="`下载报关完成文件 ${document.completion.fileName}`" @click="downloadFile(document, 'completion')">下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.completion"> / 报关完成 v{{ document.completion.version }}</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.completion ? '已上传报关完成文件' : '待上传报关完成文件' }}</td>
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载报关行回执 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td>
<td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待回执确认' : '待首次发送报关行' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.receipt"> / 回执 v{{ document.receipt.version }}</span><span v-if="document.finalDocument"> / 完税 v{{ document.finalDocument.version }}</span><small v-if="document.receiptHistory?.length" class="receipt-history-note">回执历史:{{ receiptHistoryLabel(document) }}</small><small v-for="history in document.confirmationHistory" :key="history.id" class="confirmation-history-note">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }}</td>
</tr>
</tbody></table>
</div>
<div v-if="['DECLARING', 'COMPLETED'].includes(customs.status) && brokerSubmitted" class="compact-table-wrap declaration-batches-wrap">
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3><p>回执确认并发送报关行执行后建立正式申报批次;完税文件、船期和 PDA 节点保持独立。</p></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
<div v-else class="declaration-batch-list">
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
<div class="declaration-batch-main"><strong>{{ batch.batchNo }}</strong><span>报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}</span></div>
<el-tag size="small" :type="batch.status === 'CLOSED' ? 'success' : batch.status === 'CANCELLED' ? 'info' : 'warning'">{{ declarationStatusLabel(batch.status) }}</el-tag>
<div class="declaration-batch-actions" v-if="canManage"><el-button v-if="batch.status === 'READY'" size="small" type="primary" :loading="actionLoading" @click="submitFormalDeclaration(batch)">提交正式申报</el-button><el-button v-if="['SUBMITTED','ACKNOWLEDGED','RESULT_PENDING'].includes(batch.status)" size="small" plain :loading="actionLoading" @click="recordFormalResult(batch)">录入查验结果</el-button></div>
<div v-if="batch.results.length" class="declaration-result-list"><div v-for="result in batch.results" :key="result.id" class="declaration-result-row"><span>{{ result.resultType === 'INSPECTION' ? '查验' : result.resultType === 'SUPPLEMENT_REQUIRED' ? '补充资料' : result.resultType === 'RELEASED' ? '放行' : result.resultType }}</span><el-tag size="small" :type="result.resultStatus === 'RESOLVED' ? 'success' : 'warning'">{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}</el-tag><small>{{ result.resolution || '—' }}</small><el-button v-if="canManage && result.resultStatus === 'OPEN'" link type="primary" size="small" :loading="actionLoading" @click="resolveFormalResult(batch, result)">处理</el-button></div></div>
</article>
</div>
</div>
</template>
<div v-if="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}</el-tag></div>
</template>
@@ -192,5 +318,5 @@ onMounted(load)
</template>
<style scoped>
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:var(--admin-space-6);margin-top:var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);background:var(--cc-surface-muted);font-size:var(--admin-font-caption)}.customs-metrics strong{margin-left:var(--admin-space-1);font-size:var(--admin-font-body)}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:var(--admin-space-4)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:var(--admin-space-6);margin-top:var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);background:var(--cc-surface-muted);font-size:var(--admin-font-caption)}.customs-metrics strong{margin-left:var(--admin-space-1);font-size:var(--admin-font-body)}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:var(--admin-space-4)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}
</style>
+1
View File
@@ -1,4 +1,5 @@
export * from './api/platformContainerLoading'
export * from './types'
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
@@ -47,7 +47,8 @@ describe('ContainerDetailView customs integration', () => {
expect(source).toContain('<el-tabs v-model="activeTab"')
expect(source).toContain("v-if=\"isDomestic && activeTab === 'loading-list'\"")
expect(source).toContain("loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)")
expect(source).toContain("getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)")
expect(source).toContain('getContainer(containerId.value, props.workbench)')
expect(source).toContain('getContainerOverview(containerId.value)')
expect(source).toContain(':can-edit="false"')
})
@@ -1070,7 +1070,7 @@ onMounted(() => { void loadCustomers() })
<div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" :stripe="true" border>
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers">导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate">新增客户</el-button></div></template>
<template #actions="{ row }"><RowActions :actions="customerRowActions(row as PlatformTenantResource)" :more-label="customerMoreActionsLabel(row as PlatformTenantResource)" @action="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></template>
<template #actions="{ row }"><RowActions class="density-action" :actions="customerRowActions(row as PlatformTenantResource)" :more-label="customerMoreActionsLabel(row as PlatformTenantResource)" @action="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></template>
<template #company="{ row }"><strong class="customer-company-name">{{ (row as PlatformTenantResource).tenantName }}</strong></template>
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
<template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template>
+2
View File
@@ -5,3 +5,5 @@ export {
setCustomerChargeRuleDefault,
} from './customer-rules/api'
export type { CustomerChargeRule, CreateCustomerChargeRuleRequest } from './customer-rules/api'
export { getPlatformPriceBook, listPlatformPriceBooks } from './carrier-rates/api/platformPriceBooks'
export type { PlatformPriceBookParsedChannel } from './carrier-rates/api/platformPriceBooks'
@@ -123,7 +123,7 @@ describe('finance presentation governance', () => {
expect(statements).toContain('createReconciliationStatement')
expect(statements).toContain('actionReconciliationStatement')
expect(statements).toContain('<template #toolbar-actions>')
expect(statements).toContain('aria-label="更多对账单操作"')
expect(statements).toContain(':more-label="`更多对账单操作 · ${(row as FinanceStatementReadModel).billNo}`"')
expect(statements).toContain('adjustmentLines: selected.map')
expect(statements).not.toContain('disabled title="接口尚未提供')
})
+3
View File
@@ -267,6 +267,7 @@ export interface PlatformManagedUser {
lastLoginAt: string | null
protectedAccount: boolean
overseasWarehouseId: number | string | null
domesticWarehouseId?: number | string | null
}
export interface PlatformManagedUserPage {
@@ -309,6 +310,7 @@ export interface CreatePlatformManagedUserRequest {
tenantId: number | string
roleIds: Array<number | string>
overseasWarehouseId: number | string | null
domesticWarehouseId?: number | string | null
password: string
status: 'ACTIVE' | 'DISABLED'
}
@@ -320,6 +322,7 @@ export interface UpdatePlatformManagedUserRequest {
tenantId: number | string
roleIds: Array<number | string>
overseasWarehouseId: number | string | null
domesticWarehouseId?: number | string | null
}
async function getData<T>(path: string): Promise<T> {
@@ -29,6 +29,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
const access = useAccessPreviewStore()
const auth = useAuthStore()
@@ -37,6 +38,9 @@ const options = ref<PlatformUserOptions>({ tenants: [], roles: [] })
const overseasWarehouses = ref<SharedReceivingOverseasWarehouseOption[]>([])
const overseasWarehousesLoading = ref(false)
const overseasWarehousesError = ref<ApiErrorDetails | null>(null)
const domesticWarehouses = ref<PlatformWarehouseSummaryResource[]>([])
const domesticWarehousesLoading = ref(false)
const domesticWarehousesError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -81,6 +85,7 @@ const emptyForm = () => ({
tenantId: '0',
roleIds: [] as string[],
overseasWarehouseId: '',
domesticWarehouseId: '',
password: '',
confirmPassword: '',
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED',
@@ -111,6 +116,10 @@ const overseasPdaRoleIds = computed(() => new Set(
availableRoles.value.filter((role) => role.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR').map((role) => String(role.id)),
))
const hasOverseasPdaRole = computed(() => form.roleIds.some((roleId) => overseasPdaRoleIds.value.has(roleId)))
const domesticPdaRoleIds = computed(() => new Set(
availableRoles.value.filter((role) => role.roleCode === 'CN_FIRSTLEG_PDA_OPERATOR').map((role) => String(role.id)),
))
const hasDomesticPdaRole = computed(() => form.roleIds.some((roleId) => domesticPdaRoleIds.value.has(roleId)))
const formRules = computed<FormRules>(() => ({
username: editingUser.value ? [] : [
@@ -127,6 +136,9 @@ const formRules = computed<FormRules>(() => ({
overseasWarehouseId: !hasOverseasPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
{ required: true, message: '请选择实际海外收货仓', trigger: 'change' },
],
domesticWarehouseId: !hasDomesticPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
{ required: true, message: '请选择国内作业仓', trigger: 'change' },
],
password: editingUser.value && !form.resetPassword ? [] : [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码为 8-64 位,且必须包含字母和数字', trigger: 'blur' },
@@ -217,6 +229,7 @@ function openEdit(row: PlatformManagedUser) {
tenantId: '0',
roleIds: row.roleIds.map(String),
overseasWarehouseId: row.overseasWarehouseId == null ? '' : String(row.overseasWarehouseId),
domesticWarehouseId: row.domesticWarehouseId == null ? '' : String(row.domesticWarehouseId),
password: '',
confirmPassword: '',
status: row.status,
@@ -243,6 +256,19 @@ async function loadOverseasWarehouses() {
}
}
async function loadDomesticWarehouses() {
domesticWarehousesLoading.value = true
domesticWarehousesError.value = null
try {
domesticWarehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })
} catch (error) {
domesticWarehouses.value = []
domesticWarehousesError.value = getApiErrorDetails(error, '国内作业仓加载失败,请重试')
} finally {
domesticWarehousesLoading.value = false
}
}
function isCurrentUser(row: PlatformManagedUser | null) {
if (!row) return false
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
@@ -266,6 +292,7 @@ async function saveUser() {
tenantId: '0',
roleIds: form.roleIds,
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
...(hasDomesticPdaRole.value ? { domesticWarehouseId: form.domesticWarehouseId } : {}),
password: form.password,
status: form.status,
})
@@ -280,6 +307,7 @@ async function saveUser() {
tenantId: '0',
roleIds: form.roleIds,
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
...(hasDomesticPdaRole.value ? { domesticWarehouseId: form.domesticWarehouseId } : {}),
})
}
if (canChangeStatus.value && form.status !== row.status) {
@@ -340,6 +368,14 @@ watch(hasOverseasPdaRole, (selected) => {
}
void loadOverseasWarehouses()
})
watch(hasDomesticPdaRole, (selected) => {
if (!selected) {
form.domesticWarehouseId = ''
domesticWarehousesError.value = null
return
}
void loadDomesticWarehouses()
})
onMounted(loadInitialData)
</script>
@@ -435,6 +471,20 @@ onMounted(loadInitialData)
</p>
<p v-else class="field-help">仓库将写入登录 Token,海外 PDA 不提供仓库切换。</p>
</el-form-item>
<el-form-item v-if="hasDomesticPdaRole" label="国内作业仓" prop="domesticWarehouseId" required>
<el-select v-model="form.domesticWarehouseId" filterable
:loading="domesticWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
placeholder="请选择国内作业仓">
<el-option v-for="warehouse in domesticWarehouses" :key="String(warehouse.id)"
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
:value="String(warehouse.id)" />
</el-select>
<p v-if="domesticWarehousesError" class="field-error">
{{ domesticWarehousesError.message }}
<el-button link type="primary" :loading="domesticWarehousesLoading" @click="loadDomesticWarehouses">重新加载</el-button>
</p>
<p v-else class="field-help">该人员只会出现在此仓库的国内头程 PDA 任务候选中。</p>
</el-form-item>
<el-form-item v-if="editingUser" label="最后登录">
<el-input :model-value="formatLogin(editingUser.lastLoginAt)" disabled />
</el-form-item>
@@ -84,6 +84,7 @@ function handleSubmitted() {
<div class="measurement-dialog-workspace">
<section class="measurement-dialog-weight" aria-label="称重图片、OCR 与托盘核对">
<WeightOcrPanel
table-mode
:customer-id="customerId"
:inbound-id="inboundId"
@changed="handleUpdated"
@@ -146,6 +147,15 @@ function handleSubmitted() {
.inbound-measurement-workspace-dialog .measurement-panel { border: 0; border-radius: 0; box-shadow: none; }
.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: 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; }
.inbound-measurement-workspace-dialog .measurement-metrics > div:first-child { border-left: 0; }
.inbound-measurement-workspace-dialog .measurement-declaration { margin-top: 12px; padding: 10px 12px 0; border: 1px solid #d9e2fa; border-left: 4px solid #647fec; border-radius: 8px; background: #f7f9ff; }
.inbound-measurement-workspace-dialog .measurement-items-section { margin-top: 12px; padding: 10px 12px 13px; border: 1px solid #eadfc9; border-left: 4px solid #c79643; border-radius: 8px; background: #fffbf4; }
.inbound-measurement-workspace-dialog .measurement-items-section .measurement-section__heading { margin-bottom: 8px; }
@media (max-width: 720px) {
.inbound-measurement-workspace-dialog.el-dialog { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); }
.inbound-measurement-workspace-dialog .el-dialog__header { padding: 16px; }
@@ -52,13 +52,8 @@ const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTE
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
const displayCustomerName = computed(() => detail.value?.companyName?.trim() || props.customerName?.trim() || '客户未命名')
const displayChannelName = computed(() => detail.value?.channelName?.trim() || detail.value?.channelCode?.trim() || props.channelName?.trim() || props.channelCode?.trim() || '渠道未填写')
const sourceLabels: Record<string, string> = {
THIRD_PARTY: '第三方设备',
DEVICE: '第三方设备',
OPENAPI: '第三方设备',
MANUAL: '平台人工',
}
const sourceLabel = computed(() => sourceLabels[detail.value?.sourceType || ''] || (detail.value?.sourceType ? '未知来源' : '尚未记录'))
const measurementCount = computed(() => detail.value?.items.length || 0)
const weightCount = computed(() => detail.value?.weightResults.length || 0)
function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) {
if (value === null || value === undefined || !Number.isFinite(value)) return '—'
@@ -180,18 +175,18 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
<section class="review-panel" aria-labelledby="measurement-review-title">
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
<div class="review-toolbar" aria-label="测量审核操作">
<div class="review-toolbar__actions">
<el-button v-if="isPending && canReview" type="primary" plain @click="correctionMode = true"><Pencil :size="16" />修改重量</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : approvalButtonLabel }}</el-button>
</div>
<div class="review-toolbar__status">
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
<span v-if="isPending">通过后进入待分柜</span>
<span v-if="isPending">通过后进入{{ approvalTargetLabel }}</span>
<span v-else-if="isRejected">历史记录为审核驳回,仅供查看</span>
<span v-else>当前记录不可审核</span>
<span v-if="detail" class="review-toolbar__context">客户:{{ displayCustomerName }} · 渠道:{{ displayChannelName }}</span>
</div>
<div class="review-toolbar__actions">
<el-button v-if="isPending && canReview" type="primary" plain @click="correctionMode = true"><Pencil :size="16" />修改重量</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : approvalButtonLabel }}</el-button>
<el-button :loading="loading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div>
<el-button class="review-refresh" :loading="loading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div>
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
@@ -204,12 +199,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
<section class="review-data" aria-labelledby="review-data-title">
<header class="review-section-heading">
<div>
<h3 id="review-data-title">审核数据总览</h3>
</div>
<el-tag effect="plain">{{ sourceLabel }}</el-tag>
</header>
<header class="review-section-heading"><h3 id="review-data-title">审核数据总览</h3></header>
<div class="review-comparison">
<div class="review-comparison__row review-comparison__row--declared">
@@ -223,7 +213,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
<div class="review-comparison__row">
<strong>平台实测</strong>
<dl class="review-metrics" aria-label="平台实测结果汇总">
<div><dt>箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, 0) }} <small v-if="formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0)">{{ formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0) }}</small></dd></div>
<div><dt>箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, 0) }}{{ formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0) ? ` ${formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0)}` : '' }}</dd></div>
<div><dt>毛重</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>kg<span v-if="formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg')"> · {{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }}</span></small></dd></div>
<div class="review-metric--primary"><dt>货物净重</dt><dd>{{ formatNumber(detail.actualNetWeightKg) }} <small>kg</small></dd></div>
<div><dt>体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>m³<span v-if="formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³')"> · {{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³') }}</span></small></dd></div>
@@ -238,12 +228,12 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
<div>
<h3 id="review-items-title">箱规测量明细</h3>
</div>
<span>{{ detail.items.length }} 条</span>
<span>{{ measurementCount }} 条</span>
</header>
<div v-if="detail.items.length" class="review-table-scroll">
<el-table :data="detail.items" border stripe table-layout="fixed" class="review-table">
<el-table-column type="index" label="#" width="44" align="center" />
<el-table-column label="箱号" width="240" show-overflow-tooltip>
<el-table-column label="箱号" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
</el-table-column>
<el-table-column label="测量图片" width="98" align="center">
@@ -281,12 +271,31 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
<el-empty v-else description="暂无箱规测量明细" :image-size="48" />
</section>
<section class="review-items review-weight-items" aria-labelledby="review-weight-title">
<header class="review-section-heading"><h3 id="review-weight-title">⚖ 称重明细</h3><div class="review-weight-totals"><span>实测毛重 <strong>{{ formatNumber(detail.actualGrossWeightKg, 3) }} KG</strong></span><span>货物重量 <strong>{{ formatNumber(detail.actualNetWeightKg, 3) }} KG</strong></span></div></header>
<div v-if="weightCount" class="review-table-scroll">
<el-table :data="detail.weightResults" border stripe table-layout="fixed" class="review-table review-weight-table">
<el-table-column type="index" label="#" width="44" align="center" />
<el-table-column label="称重图片" width="112" align="center"><template #default="{ row }"><MeasurementEvidenceThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`${row.palletCode || '托盘'}称重图片`" empty-text="无图片" @open="openImagePreview(row.fileId, row.palletCode || '托盘')" /></template></el-table-column>
<el-table-column prop="palletCode" label="托盘编码" min-width="120" />
<el-table-column prop="palletType" label="托盘类型" min-width="120" />
<el-table-column label="库位" min-width="130"><template #default="{ row }"><span class="review-location">{{ row.locationCode || '—' }}<template v-if="row.locationCode"> · {{ row.slotStatus === 'BOUND' ? '待上架' : row.slotStatus || '待上架' }}</template></span></template></el-table-column>
<el-table-column label="毛重(KG)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.grossWeightKg, 3) }}</template></el-table-column>
<el-table-column label="托盘皮重(kg)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.palletTareWeightKg, 3) }}</template></el-table-column>
<el-table-column label="货物重量(kg)" min-width="120" align="right"><template #default="{ row }"><strong class="review-weight-value">{{ formatNumber(row.netWeightKg, 3) }}</strong></template></el-table-column>
<el-table-column label="称重时间" min-width="150"><template #default="{ row }">{{ formatDate(row.confirmedAt || row.adjustedAt) }}</template></el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无称重明细" :image-size="48" />
</section>
<WeightOcrPanel
review-mode
:correction-mode="correctionMode"
:customer-id="customerId"
:inbound-id="inboundId"
@changed="handleWeightChanged"
:class="{ 'review-weight-editor--hidden': !correctionMode }"
/>
</template>
@@ -306,7 +315,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
<style scoped>
.review-panel { display: grid; gap: var(--admin-space-5, 12px); color: var(--cc-text); }
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; position: sticky; top: -12px; z-index: 3; flex-wrap: wrap; margin: -var(--admin-space-5, 12px) -var(--admin-space-6, 16px) 0; padding: var(--admin-space-3, 8px) var(--admin-space-6, 16px); border-bottom: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; position: sticky; top: -12px; z-index: 3; flex-wrap: wrap; margin: -var(--admin-space-5, 12px) -var(--admin-space-6, 16px) 0; padding: var(--admin-space-3, 8px) var(--admin-space-6, 16px); border-bottom: 1px solid var(--cc-border); background: linear-gradient(100deg, #f3f6ff 0%, #fff 48%, #eefaf7 100%); }
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }
.review-toolbar__status > span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.review-toolbar__actions { justify-content: flex-end; }
@@ -337,6 +346,23 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
.review-table :deep(.el-table__cell) { padding: 7px 0; }
.review-table :deep(.review-time-cell .cell) { white-space: nowrap; }
.review-table :deep(.measurement-evidence-thumb) { min-width: 72px; border-radius: var(--admin-radius, 4px); }
.review-data { border: 1px solid #dfe5ef; border-radius: 8px; overflow: hidden; background: #fff; }
.review-data .review-section-heading { margin: 0; padding: 10px 14px 6px; border-bottom: 0; }
.review-comparison__row { padding: 10px 14px; }
.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: 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: 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: 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; }
.review-weight-editor--hidden { display: none; }
.review-image-preview { display: grid; min-height: 240px; place-items: center; }
.review-image-preview img { display: block; width: auto; max-width: 100%; max-height: 70vh; object-fit: contain; }
.review-image-preview__error { display: grid; justify-items: center; gap: var(--admin-space-3, 8px); color: var(--el-color-danger); }
@@ -346,6 +372,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
.review-toolbar__actions { display: grid; width: 100%; grid-template-columns: 1fr; justify-content: stretch; }
.review-toolbar__actions :deep(.el-button) { width: 100%; min-height: 44px; }
.review-section-heading { align-items: stretch; flex-direction: column; }
.review-weight-totals { flex-wrap: wrap; gap: 6px; }
.review-comparison__row { grid-template-columns: 78px minmax(0, 1fr); align-items: start; }
.review-metrics, .review-declaration { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@@ -36,9 +36,11 @@ const props = withDefaults(defineProps<{
inboundId: string
reviewMode?: boolean
correctionMode?: boolean
tableMode?: boolean
}>(), {
reviewMode: false,
correctionMode: false,
tableMode: false,
})
const emit = defineEmits<{
changed: []
@@ -577,6 +579,25 @@ defineExpose({ reload: loadWorkspace })
<strong>尚未上传称重图片</strong>
</div>
<div v-else-if="tableMode" class="weight-table-wrap">
<table class="weight-data-table">
<thead><tr><th>#</th><th>称重图片</th><th>托盘编码</th><th>托盘类型</th><th>库位</th><th>实测毛重(KG)</th><th>服务端确认毛重(KG)</th><th>托盘皮重(KG)</th><th>货物重量(KG)</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="(row, index) in rows" :key="row.id">
<td>{{ index + 1 }}</td>
<td><WeightImageThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`称重图片 ${index + 1}`" @open="openPreview(row)" /></td>
<td>{{ row.palletCode || '—' }}</td>
<td>{{ row.palletType || '—' }}</td>
<td><span class="weight-location">{{ row.locationCode || '未分配' }}<template v-if="row.locationCode"> · {{ putawayStatusLabel(row.slotStatus) }}</template></span></td>
<td class="weight-gross-value">{{ formatWeight(row.adjustedWeightValue ?? row.ocrWeightValue, row.adjustedWeightUnit || row.ocrWeightUnit || 'KG') }}</td>
<td>{{ formatWeight(row.grossWeightKg) }}</td>
<td>{{ formatWeight(row.palletTareWeightKg) }}</td>
<td class="weight-net-value">{{ formatWeight(row.netWeightKg) }}</td>
<td><el-tooltip v-if="canEdit && editable && !reviewMode" content="删除图片" placement="top"><el-button text type="danger" :aria-label="`删除称重图片 ${index + 1}`" :loading="isActionLoading('delete', row.id)" :disabled="Boolean(activeAction)" @click="removeResult(row)"><Trash2 :size="16" /></el-button></el-tooltip><span v-else>—</span></td>
</tr>
</tbody>
</table>
</div>
<div v-else class="weight-grid">
<article v-for="(row, index) in rows" :key="row.id" class="weight-card">
<WeightImageThumbnail
@@ -711,6 +732,17 @@ defineExpose({ reload: loadWorkspace })
.weight-card__facts dt { color: var(--cc-muted); }
.weight-card__facts dd { min-width: 0; margin: 0; color: #344054; overflow-wrap: anywhere; text-align: right; }
.weight-card__correct { width: 100%; margin: var(--admin-space-5, 12px) 0 0; }
.weight-table-wrap { overflow-x: auto; border: 1px solid #d7e0ef; border-radius: 8px; background: #fff; }
.weight-data-table { width: 100%; min-width: 1180px; border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: var(--admin-font-caption, 12px); }
.weight-data-table th { height: 42px; padding: 0 8px; border-bottom: 1px solid #d5dfee; background: linear-gradient(180deg, #f1f4fc, #e9eef8); color: #405473; font-weight: 700; white-space: nowrap; }
.weight-data-table td { height: 76px; padding: 6px 8px; border-top: 1px solid #ebeff5; color: #4b5870; text-align: center; vertical-align: middle; white-space: nowrap; }
.weight-data-table th + th, .weight-data-table td + td { border-left: 1px solid #edf0f5; }
.weight-data-table tbody tr:nth-child(even) td { background: #f8faff; }
.weight-data-table tbody tr:hover td { background: #edf3ff; }
.weight-data-table :deep(.weight-thumb) { width: 90px; height: 60px; margin: auto; border-radius: 8px; }
.weight-data-table .weight-gross-value { color: #33425f; font-weight: 750; }
.weight-data-table .weight-net-value { color: #16875b; font-weight: 750; }
.weight-location { display: inline-block; padding: 3px 7px; border: 1px solid #e2e8f2; border-radius: 5px; background: #f3f6fb; color: #53627a; }
.weight-preview { display: block; width: 100%; max-height: min(50vh, 460px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
.weight-key-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3, 8px) var(--admin-space-5, 12px); margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-3, 8px) 0; border-block: 1px solid var(--cc-border); }
.weight-key-facts div { display: grid; min-width: 0; gap: var(--admin-space-1, 4px); }
@@ -146,11 +146,11 @@ describe('platform loading lists API', () => {
},
})
const result = await confirmPreloadPlan('1900000000000000001', 3, 'confirm-operation-1')
const result = await confirmPreloadPlan('1900000000000000001', 3, '1900000000000000007', 'confirm-operation-1')
expect(post).toHaveBeenCalledWith(
'/platform/v1/first-leg/loading-plans/1900000000000000001/confirm',
{ expectedVersion: 3 },
{ expectedVersion: 3, assigneeId: '1900000000000000007' },
{ headers: { 'Idempotency-Key': 'confirm-operation-1' } },
)
expect(result).toMatchObject({
@@ -186,6 +186,11 @@ export interface PreloadConfirmationResponse {
readiness: PreloadReadiness
}
export interface DomesticPdaOperator {
userId: string
displayName: string
}
type RawId = string | number | null | undefined
type RawPage<T extends LoadingListSummary> = Omit<LoadingListPage<T>, 'page' | 'size' | 'total' | 'pages'> & {
page: string | number
@@ -359,10 +364,17 @@ export async function updateLoadingItemRemarks(planId: string, itemId: string, r
}
}
export async function confirmPreloadPlan(planId: string, expectedVersion: number, operationId: string) {
export async function listDomesticPdaOperators(warehouseId: string, keyword?: string): Promise<DomesticPdaOperator[]> {
const response = await http.get<ApiEnvelope<DomesticPdaOperator[]>, ApiEnvelope<DomesticPdaOperator[]>>(
'/platform/v1/iam/users/domestic-pda-operators', { params: { warehouseId, keyword } },
)
return unwrapApiResponse(response).map((operator) => ({ ...operator, userId: requiredId(operator.userId) }))
}
export async function confirmPreloadPlan(planId: string, expectedVersion: number, assigneeId: string, operationId: string) {
const response = await http.post<ApiEnvelope<PreloadConfirmationResponse>, ApiEnvelope<PreloadConfirmationResponse>>(
`${basePath}/loading-plans/${encodeURIComponent(planId)}/confirm`,
{ expectedVersion },
{ expectedVersion, assigneeId },
{ headers: { 'Idempotency-Key': operationId } },
)
const result = unwrapApiResponse(response)
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import { ArrowLeft, Check, Pencil } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
import { ArrowLeft, Check, ChevronRight, Pencil } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -18,10 +18,11 @@ const props = defineProps<{
detail: LoadingDetail | null
error: ApiErrorDetails | null
canEdit: boolean
canPushPda?: boolean
pdaAssigneeSelected?: boolean
dense?: boolean
embedded?: boolean
canEditTransport?: boolean
transportNote?: string
confirmSaving?: boolean
}>()
@@ -101,6 +102,13 @@ const visibleHistories = computed(() => {
return true
})
})
const historyExpanded = ref(props.mode !== 'preload')
let lastHistoryPlanId: string | number | undefined
watch(() => props.detail?.summary.planId, (id) => {
if (id == null) return
if (lastHistoryPlanId !== undefined && id !== lastHistoryPlanId) historyExpanded.value = props.mode !== 'preload'
lastHistoryPlanId = id
})
const blockingReasonLabels: Record<string, string> = {
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
PRELOAD_LINK_MISSING: '装柜明细尚未完整关联入库预装票据',
@@ -156,13 +164,13 @@ function formatNumber(value?: number | null, digits = 3) {
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
<div v-if="!embedded" class="detail-page-header__identity">
<h1>{{ title }}</h1>
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细和柜次费用' }}</p>
<p v-if="mode !== 'preload'">查看已完成装柜明细和柜次费用</p>
</div>
<div v-if="detail && summary" class="detail-page-header__actions">
<PalletLabelPrintDialog :dense="mode === 'preload' || dense" v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
<template v-if="canEditItemRemark">
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
<el-button v-if="canPushPda" type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm || !pdaAssigneeSelected" @click="emit('confirmPreload')"><Check :size="16" />确认预装并推送 PDA</el-button>
</template>
</div>
</header>
@@ -192,7 +200,6 @@ function formatNumber(value?: number | null, digits = 3) {
<strong>暂不能确认预装:</strong>
<span>{{ blockingReasonText || '请刷新详情获取最新确认条件' }}</span>
</div>
<div v-if="preloadSummary" class="readiness-note transport-note" role="status"><strong>真实集装箱号:</strong><span>{{ transportNote }}</span></div>
<section class="detail-section">
<DataTable
@@ -204,7 +211,7 @@ function formatNumber(value?: number | null, digits = 3) {
empty-text="暂无装柜货物明细"
aria-label="装柜货物明细"
>
<template #toolbar-start><div class="detail-section__heading"><div><h2>装柜货物明细 <span>{{ detail.items.length }} 条</span></h2><p>仓库备注将随装柜完成资料下发海外仓</p></div></div></template>
<template #toolbar-start><div class="detail-section__heading"><h2>装柜货物明细 <span>{{ detail.items.length }} 条</span></h2><slot v-if="mode === 'preload'" name="pda-operator" /></div></template>
<template #actions="{ row }">
<el-button :class="{ 'density-action': mode === 'preload' || dense }" size="small" :type="mode === 'preload' ? undefined : 'primary'" :plain="mode !== 'preload'" :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的备注`" @click="emit('editRemarks', row as LoadingItem)"><Pencil v-if="mode !== 'preload'" :size="14" />编辑备注</el-button>
</template>
@@ -222,9 +229,9 @@ function formatNumber(value?: number | null, digits = 3) {
</DataTable>
</section>
<section class="history-section" aria-label="操作历史">
<header><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span></header>
<el-table :data="visibleHistories" border stripe empty-text="暂无操作历史" aria-label="清单操作历史">
<section class="history-section" :class="{ 'is-expanded': historyExpanded }" aria-label="操作历史">
<header><button type="button" class="history-section__toggle" :aria-expanded="historyExpanded" aria-controls="loading-history-table" @click="historyExpanded = !historyExpanded"><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span><ChevronRight :size="16" class="history-section__arrow" :class="{ 'is-expanded': historyExpanded }" aria-hidden="true" /></button></header>
<el-table v-if="historyExpanded" id="loading-history-table" :data="visibleHistories" border stripe empty-text="暂无操作历史" aria-label="清单操作历史">
<el-table-column label="时间" width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
<el-table-column label="事件" width="140"><template #default="{ row }">{{ actionLabel(row.actionType) }}</template></el-table-column>
<el-table-column label="状态变化" min-width="200"><template #default="{ row }"><span v-if="row.fromStatus || row.toStatus">{{ loadingListStatusLabel(row.fromStatus) }} → {{ loadingListStatusLabel(row.toStatus) }}</span><span v-else>—</span></template></el-table-column>
@@ -262,16 +269,21 @@ function formatNumber(value?: number | null, digits = 3) {
.readiness-note strong { flex: 0 0 auto; color: var(--el-color-warning-dark-2); }
.readiness-note span { min-width: 0; color: var(--cc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-section, .history-section { min-width: 0; overflow: hidden; padding: var(--admin-space-6, 16px) 0 0; border-top: 1px solid var(--cc-border); }
.detail-section__heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; }
.detail-section__heading h2 { margin: 0; font-size: 15px; }
.detail-section__heading h2 span { margin-left: var(--admin-space-2, 6px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); font-weight: 400; }
.detail-section__heading p { margin: 2px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); line-height: 1.35; }
.detail-section :deep(.el-table) { width: 100%; }
.detail-section :deep(.el-table th.el-table__cell) { padding: 7px 0; }
.detail-section :deep(.el-table td.el-table__cell) { padding: var(--admin-space-2, 6px) 0; }
.detail-section :deep(.identifier-column .cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-section small { display: block; margin-top: 3px; color: var(--cc-muted); }
.remark-cell { min-width: 0; line-height: 1.45; overflow-wrap: anywhere; }
.history-section header { display: flex; align-items: baseline; gap: var(--admin-space-4, 10px); margin-bottom: 18px; }
.history-section header { margin: 0; }
.history-section.is-expanded header { margin-bottom: 18px; }
.history-section__toggle { display: inline-flex; align-items: center; gap: var(--admin-space-4, 10px); padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; }
.history-section__toggle:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 4px; border-radius: 2px; }
.history-section__arrow { color: var(--cc-muted); transition: transform 0.2s ease; }
.history-section__arrow.is-expanded { transform: rotate(90deg); }
.history-section h2 { margin: 0; font-size: var(--admin-font-title, 16px); }
.history-section header span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.loading-density { gap: var(--admin-space-5, 12px); padding: var(--admin-space-5, 12px); border-radius: var(--admin-radius, 4px); }
@@ -287,8 +299,8 @@ function formatNumber(value?: number | null, digits = 3) {
.loading-density .detail-layout :deep(.detail-page-layout__content) { gap: var(--admin-space-5, 12px); }
.loading-density .detail-section, .loading-density .history-section { padding-top: var(--admin-space-3, 8px); }
.loading-density .detail-section__heading h2, .loading-density .history-section h2 { font-size: var(--admin-font-section, 14px); }
.loading-density .history-section header { margin-bottom: var(--admin-space-3, 8px); }
.loading-density .detail-section__heading p, .loading-density small { font-size: var(--admin-font-caption, 12px); }
.loading-density .history-section.is-expanded header { margin-bottom: var(--admin-space-3, 8px); }
.loading-density small { font-size: var(--admin-font-caption, 12px); }
.loading-density .readiness-note { align-items: baseline; }
.loading-density .readiness-note span { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
@media (max-width: 720px) { .loading-density .detail-summary { grid-template-columns: 1fr; } .loading-density .detail-summary__wide { grid-column: auto; } }
@@ -116,11 +116,11 @@ describe('LoadingListsView', () => {
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
expect(confirmedDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
expect(confirmedDetailSource).toContain('if (sequence !== loadSequence) return')
expect(preloadDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
expect(preloadDetailSource).toContain('watch([planId, warehouseId], () => {')
})
it('uses backend readiness for whole-plan confirmation and translates blocking reasons', () => {
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm"')
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm || !pdaAssigneeSelected"')
expect(detailSource).toContain("OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱'")
expect(detailSource).toContain("PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认'")
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
@@ -185,7 +185,7 @@ describe('LoadingListsView', () => {
it('keeps customer remarks read-only while exposing three editable collaboration remarks', () => {
expect(detailSource).toContain('来源:客户下单')
expect(detailSource).toContain('来源:订单内部备注')
expect(detailSource).toContain('仓库备注将随装柜完成资料下发海外仓')
expect(detailSource).not.toContain('仓库备注将随装柜完成资料下发海外仓')
expect(detailSource).toContain("emit('editRemarks', row as LoadingItem)")
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
expect(detailSource).not.toContain("emit('editCustomerRemark'")
@@ -68,9 +68,9 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).toContain("['EDITING', 'LOADING'].includes(summary.status)")
expect(source).toContain('if (!summary.containerNo) return true')
expect(source).not.toContain('summary?.containerNo && summary.status')
expect(source).toContain('PDA 已领取或作业已开始,真实集装箱号只读。')
expect(source).toContain('该历史 PDA 任务缺少真实集装箱号;补录保存后即可进入任务。')
expect(source).toContain('确认预装前必须补录真实集装箱号。')
expect(source).not.toContain('transportCorrectionNote')
expect(detailSource).not.toContain('class="readiness-note transport-note"')
expect(detailSource).not.toContain('真实集装箱号:</strong>')
expect(source).toContain("isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次'")
expect(source).toContain("isContainerCorrection.value && !String(value || '').trim() ? new Error('纠错时请输入操作原因')")
expect(source).toContain("String(value || '').trim().toUpperCase()")
@@ -86,24 +86,38 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(detailSource).toContain('role="status"')
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
expect(detailSource).not.toContain('type="success" :closable="false" show-icon title="整份预装计划已确认"')
expect(detailSource).toContain('<el-button type="primary" :loading="confirmSaving"')
expect(detailSource).toContain('type="primary" :loading="confirmSaving"')
expect(detailSource).not.toContain('<el-button type="success" :loading="confirmSaving"')
})
it('keeps explanatory notes outside the item list hidden', () => {
expect(detailSource).not.toContain('核对运输信息、确认预装并维护逐条仓库备注')
expect(detailSource).not.toContain('仓库备注将随装柜完成资料下发海外仓')
expect(source).not.toContain('暂无候选,请先配置国内头程 PDA 作业员')
})
it('confirms the whole plan through readiness with stable idempotency and refreshes the detail', () => {
expect(source).toContain('confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)')
expect(source).toContain('confirmPreloadPlan(summary.planId, summary.version, assigneeId.value, confirmOperationId.value)')
expect(source).toContain('listDomesticPdaOperators(warehouseId.value)')
expect(source).toContain('v-model="assigneeId"')
expect(source).toContain('<template #pda-operator>')
expect(detailSource).toContain('<slot v-if="mode === \'preload\'" name="pda-operator" />')
expect(source).not.toContain('暂无候选,请先配置国内头程 PDA 作业员')
expect(detailSource).toContain('!pdaAssigneeSelected')
expect(source).toContain('selectedAssignee?.displayName')
expect(source).not.toContain('<el-form-item label="国内头程 PDA 作业员(必选)">')
expect(source).toContain('summary?.readiness.canConfirm')
expect(source).toContain('confirmSaving.value')
expect(source).toContain('await loadDetail()')
expect(source).toContain('本操作将确认整份预装计划')
expect(source).toContain(':confirm-disabled="!detail?.summary.readiness.canConfirm"')
expect(source).toContain(':confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading"')
expect(source).not.toContain('/inbounds/')
})
it('keeps backend confirmation errors visible and supports narrow dialogs', () => {
expect(source).toContain("confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')")
expect(source).toContain('<OperationErrorToast :details="remarkError || editError || confirmError" />')
expect(source).not.toContain(':message="confirmError.message"')
expect(source).toContain(':message="confirmError.message"')
expect(source).toContain('width="min(520px, calc(100vw - 28px))"')
expect(source).toContain("label=\"柜次号\"")
expect(source).toContain("label=\"集装箱号\"")
@@ -12,7 +12,7 @@ import { getApiErrorDetails, http, unwrapApiResponse, type ApiEnvelope, type Api
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, updateLoadingItemRemarks, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, listDomesticPdaOperators, updateLoadingItemRemarks, updatePreloadTransport, type DomesticPdaOperator, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
const route = useRoute()
const router = useRouter()
@@ -28,6 +28,10 @@ const confirmOpen = ref(false)
const confirmSaving = ref(false)
const confirmError = ref<ApiErrorDetails | null>(null)
const confirmOperationId = ref('')
const assigneeId = ref('')
const assigneeOptions = ref<DomesticPdaOperator[]>([])
const assigneeLoading = ref(false)
const selectedAssignee = computed(() => assigneeOptions.value.find((operator) => operator.userId === assigneeId.value))
const editFormRef = ref<FormInstance>()
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
const remarkOpen = ref(false)
@@ -37,6 +41,7 @@ const remarkTarget = ref<LoadingItem | null>(null)
const remarkForm = reactive({ internalRemark: '', warehouseRemark: '', customerServiceRemark: '' })
const remarkOperationId = ref('')
let loadSequence = 0
let assigneeLoadSequence = 0
const planId = computed(() => String(route.params.planId || ''))
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
type LoadingPlanLifecycle = { planId: string | number; planStatus: string; detailResource: 'PRELOAD' | 'LOADING_LIST' }
@@ -49,6 +54,7 @@ async function resolveLoadingPlanLifecycle() {
return unwrapApiResponse(response)
}
const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
const canPushPda = computed(() => canEdit.value && access.can('platform:firstleg:pda:assign'))
const canCorrectTransport = computed(() => {
const summary = detail.value?.summary
const pdaStatus = summary?.readiness.pdaTaskStatus
@@ -57,15 +63,6 @@ const canCorrectTransport = computed(() => {
return !pdaStatus || pdaStatus === 'PENDING'
})
const isContainerCorrection = computed(() => Boolean(detail.value?.summary.containerNo))
const transportCorrectionNote = computed(() => {
const summary = detail.value?.summary
if (!summary) return ''
if (!summary.containerNo && summary.readiness.pdaTaskId) return '该历史 PDA 任务缺少真实集装箱号;补录保存后即可进入任务。'
if (summary.readiness.pdaTaskStatus && summary.readiness.pdaTaskStatus !== 'PENDING') return 'PDA 已领取或作业已开始,真实集装箱号只读。'
if (!canCorrectTransport.value) return '当前服务端阶段不允许纠正真实集装箱号。'
if (!summary.containerNo) return '确认预装前必须补录真实集装箱号。'
return '真实集装箱号仅可在 PDA 领取前纠错,纠错保存时必须填写操作原因。'
})
const editDialogTitle = computed(() => isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次')
const editRules: FormRules = {
containerNo: [
@@ -117,6 +114,7 @@ async function loadDetail() {
return
}
detail.value = result
if (canPushPda.value && !result.summary.readiness.pdaTaskId) void loadAssigneeOptions()
} catch (cause) {
if (sequence !== loadSequence) return
error.value = getApiErrorDetails(cause, '预装清单详情加载失败,请稍后重试')
@@ -165,6 +163,25 @@ async function saveTransport() {
}
}
async function loadAssigneeOptions() {
const sequence = ++assigneeLoadSequence
assigneeLoading.value = true
try {
if (!warehouseId.value) return
const options = await listDomesticPdaOperators(warehouseId.value)
if (sequence !== assigneeLoadSequence) return
assigneeOptions.value = options
if (assigneeId.value && !assigneeOptions.value.some((operator) => operator.userId === assigneeId.value)) assigneeId.value = ''
confirmError.value = null
} catch (cause) {
if (sequence !== assigneeLoadSequence) return
confirmError.value = getApiErrorDetails(cause, '国内 PDA 作业员加载失败,请重试')
assigneeOptions.value = []
} finally {
if (sequence === assigneeLoadSequence) assigneeLoading.value = false
}
}
function openRemarks(item: LoadingItem) {
if (!canEdit.value || !detail.value || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
remarkTarget.value = item
@@ -224,7 +241,7 @@ async function saveRemarks() {
function openConfirmPreload() {
const summary = detail.value?.summary
if (!canEdit.value || !summary?.readiness.canConfirm) return
if (!canPushPda.value || !summary?.readiness.canConfirm || !assigneeId.value) return
confirmError.value = null
confirmOperationId.value = `admin-preload-confirm-${summary.planId}-${summary.version}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
confirmOpen.value = true
@@ -232,15 +249,15 @@ function openConfirmPreload() {
async function submitConfirmPreload() {
const summary = detail.value?.summary
if (!summary?.readiness.canConfirm || confirmSaving.value) return
if (!summary?.readiness.canConfirm || !canPushPda.value || !assigneeId.value || confirmSaving.value) return
confirmSaving.value = true
confirmError.value = null
try {
const result = await confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)
const result = await confirmPreloadPlan(summary.planId, summary.version, assigneeId.value, confirmOperationId.value)
await loadDetail()
confirmOpen.value = false
confirmOperationId.value = ''
ElMessage.success(result.readiness.readyForPda ? '预装已确认,PDA 任务已具备下发条件' : '预装已确认')
ElMessage.success(result.readiness.readyForPda ? '预装已确认,PDA 任务已推送给指定作业员' : '预装已确认')
} catch (cause) {
confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')
} finally {
@@ -249,14 +266,29 @@ async function submitConfirmPreload() {
}
onMounted(() => void loadDetail())
watch([planId, warehouseId], () => void loadDetail())
watch([planId, warehouseId], () => {
assigneeLoadSequence++
assigneeId.value = ''
assigneeOptions.value = []
assigneeLoading.value = false
void loadDetail()
})
</script>
<template>
<AdminPage>
<div class="preload-detail-page admin-density">
<OperationErrorToast :details="remarkError || editError || confirmError" />
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :can-edit-transport="canCorrectTransport" :transport-note="transportCorrectionNote" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-remarks="openRemarks" @confirm-preload="openConfirmPreload" />
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :can-push-pda="canPushPda" :pda-assignee-selected="Boolean(assigneeId)" :can-edit-transport="canCorrectTransport" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-remarks="openRemarks" @confirm-preload="openConfirmPreload">
<template #pda-operator>
<div v-if="canPushPda && !detail?.summary.readiness.pdaTaskId" class="pda-operator-picker">
<label for="preload-pda-operator">PDA 操作员 <span>(必选)</span></label>
<el-select id="preload-pda-operator" v-model="assigneeId" filterable placeholder="请选择操作员" :loading="assigneeLoading" :disabled="confirmSaving" aria-label="PDA 操作员">
<el-option v-for="operator in assigneeOptions" :key="operator.userId" :value="operator.userId" :label="operator.displayName" />
</el-select>
</div>
</template>
</LoadingListDetailDrawer>
</div>
<AppDialog v-model="editOpen" class="loading-transport-dialog admin-density-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
@@ -278,7 +310,7 @@ watch([planId, warehouseId], () => void loadDetail())
<LoadErrorState v-if="remarkError" compact title="备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog" title="确认预装并推送 PDA" width="min(520px, calc(100vw - 28px))" confirm-text="确认并推送" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading" @confirm="submitConfirmPreload">
<div class="confirm-content">
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" />
<el-descriptions v-if="detail" :column="1" border>
@@ -286,6 +318,8 @@ watch([planId, warehouseId], () => void loadDetail())
<el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待补录' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions :column="1" border><el-descriptions-item label="PDA 操作员">{{ selectedAssignee?.displayName || '未选择' }}</el-descriptions-item></el-descriptions>
<LoadErrorState v-if="confirmError" compact title="推送失败" :message="confirmError.message" :show-retry="false" />
</div>
</AppDialog>
</AdminPage>
@@ -296,6 +330,10 @@ watch([planId, warehouseId], () => void loadDetail())
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6, 16px); }
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.confirm-content { display: grid; gap: var(--admin-space-5, 12px); }
.pda-operator-picker { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; }
.pda-operator-picker label { flex: 0 0 auto; font-size: var(--admin-font-body, 13px); white-space: nowrap; }
.pda-operator-picker label span { color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption, 12px); }
.pda-operator-picker :deep(.el-select) { width: 180px; }
.remarks-form { margin-top: 14px; }
.remarks-form small { display: block; margin-top: var(--admin-space-1, 4px); color: var(--cc-muted); }
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
@@ -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
View File
@@ -27,6 +27,7 @@
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型,状态须覆盖 `WAIT_PICK/待拣货`、`PICKING/拣货中`、`PICKED/已拣货`、`WAIT_OUTBOUND/待出库`、`OUTBOUNDED/已出库` 和 `CANCELLED/已取消`;仅未开始的 `WAIT_PICK/WAIT_OUTBOUND` 允许取消。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
| 海外库位维护与状态 | `pages/LocationsView.vue`、`api/locations.ts` | 按海外仓分页或多仓查询库位,支持库区/库位编码、类型和状态筛选;`NORMAL` 库位可编辑、停用或删除,`A0/SHARED_OVERFLOW`、`VIRTUAL` 由服务端事实保护,不能按普通库位修改。接口经 `/platform/v1/warehouse-admin/{warehouseId}/locations`,权限沿 `permissions.ts` 的库位维护/删除入口 |
| 托盘、箱唛、产品与换标 | `pages/PalletsView.vue`、`pages/CartonsView.vue`、`pages/ProductsView.vue`、`pages/RelabelOrdersView.vue` 及详情页 | `api/pallets.ts`、`api/relabelOrders.ts`;换标详情展示 PDA 完工照片与复核人,状态区分待客户确认和待出库;路由是否公开以 `routes.ts` 为准 |
## 履约硬边界
@@ -53,6 +53,7 @@ export interface InboundPlanDetailDto {
customerName?: string | null
orderType?: string | null
containerNo?: string | null
overseasWarehouseId?: string | null
overseasWarehouseCode?: string | null
overseasWarehouseName?: string | null
status?: string | null
@@ -67,7 +68,7 @@ export interface InboundPlanDetailDto {
updatedAt?: string | null
remark?: string | null
lines: Array<{ id: string; orderId: string; inboundCode?: string | null; fbaWarehouseCode?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; grossWeightKg?: number | null; netWeightKg?: number | null; volumeCbm?: number | null; requiredPackageCount: number; plannedPalletCount: number }>
pallets: Array<{ id: string; palletNo?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; capacityCbm?: number | null; allocatedVolumeCbm?: number | null; sequenceNo: number; warehousePalletId?: string | null; warehouseLocationId?: string | null; warehouseResourceLockId?: string | null; assignmentStatus?: string | null; lockedAt?: string | null; actualVolumeCbm?: number | null; actualPackageCount: number }>
pallets: Array<{ id: string; palletNo?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; capacityCbm?: number | null; allocatedVolumeCbm?: number | null; sequenceNo: number; warehousePalletId?: string | null; warehouseLocationId?: string | null; warehouseLocationCode?: string | null; warehouseLocationType?: string | null; warehouseLocationStatus?: string | null; sharedOverflow?: boolean | null; assignmentStatus?: string | null; lockedAt?: string | null; actualVolumeCbm?: number | null; actualPackageCount: number; version?: number }>
allocations: Array<{ lineId: string; palletId: string; palletNo: string; palletOwnerPlanNo: string; palletOwnedByCurrentPlan: boolean; allocatedVolumeCbm?: number | null; plannedPackageCount: number; countedPackageCount: number; pickedPackageCount: number; actualVolumeCbm?: number | null; status?: string | null; version: number }>
}
@@ -79,3 +80,27 @@ export async function listInboundPlans(query: InboundPlanQuery) {
export async function getInboundPlan(planNo: string, customerId: string) {
return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } }))
}
export interface PlatformInventoryMoveRequest {
overseasWarehouseId: string
sourcePalletCode: string
targetLocationCode: string
expectedPalletVersion: number
reason: string
idempotencyKey: string
}
export interface PlatformInventoryMoveResult {
commandId: string
status: string
canMove?: boolean
blockReason?: string | null
sourceLocationCode?: string | null
targetLocationCode?: string | null
sharedOverflow?: boolean | null
}
export async function moveInboundPallet(payload: PlatformInventoryMoveRequest) {
const response = await overseasHttp.post<ApiEnvelope<PlatformInventoryMoveResult>, ApiEnvelope<PlatformInventoryMoveResult>>('/platform/v1/last-leg/inventory-moves', payload, { headers: { 'Idempotency-Key': payload.idempotencyKey } })
return unwrapApiResponse(response)
}
@@ -87,6 +87,7 @@ export interface LastMileOrderSummaryDto {
deliveryOrderNo?: string | null
outboundOrderNo?: string | null
relabelOrderNo?: string | null
relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; createdAt: string }>
relabelStatus?: string | null
palletMergeFlag?: string | null
/** Optional grouping facts returned by newer last-leg services. */
@@ -373,6 +374,15 @@ export async function reviewReturnOrder(returnNo: string, customerId: string, de
return unwrapApiResponse(response)
}
export async function reviewDirectRelabelOrder(orderId: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) {
const response = await overseasHttp.put<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/relabel-review`,
{ status: decision === 'APPROVE' ? 'PENDING_PROCESSING' : 'REJECTED', remark: remark?.trim() || undefined },
{ params: { customerId } },
)
return unwrapApiResponse(response)
}
export async function downloadLastMileOrders(query: Partial<LastMileOrderQuery>) {
const { page: _page, size: _size, ...filters } = query
const params = Object.fromEntries(
@@ -0,0 +1,67 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export type OverseasLocationType = 'VIRTUAL' | 'NORMAL' | 'SHARED_OVERFLOW'
export type OverseasLocationStatus = 'ACTIVE' | 'IDLE' | 'OCCUPIED' | 'INACTIVE' | 'DISABLED'
export interface OverseasLocationDto {
id: string
warehouseId: string
warehouseCode?: string | null
warehouseName?: string | null
locationCode: string
locationName?: string | null
locationType: OverseasLocationType
sharedOverflow: boolean
status: OverseasLocationStatus
maxLoadKg?: number | string | null
occupiedPalletCount?: number | null
version: number
remark?: string | null
createdAt?: string | null
updatedAt?: string | null
}
export interface SaveOverseasLocationPayload {
locationCode: string
locationType: 'NORMAL'
maxLoadKg: number
status: 'ACTIVE' | 'INACTIVE'
remark?: string | null
}
export interface OverseasLocationQuery { page?: number; size?: number; keyword?: string; locationType?: OverseasLocationType | ''; status?: OverseasLocationStatus | '' }
function base(warehouseId: string) { return `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations` }
export async function listOverseasLocations(warehouseId: string, query: OverseasLocationQuery = {}) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>, ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>>(base(warehouseId), { params: { page: query.page ?? 1, size: query.size ?? 20, ...query } })
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function listAllOverseasLocations(warehouseIds: string[], query: OverseasLocationQuery = {}) {
const results = await Promise.all(warehouseIds.map((warehouseId) => listOverseasLocations(warehouseId, { ...query, page: 1, size: 200 })))
const records = results.flatMap((result) => result.records)
return { records, total: records.length }
}
export async function createOverseasLocation(warehouseId: string, payload: SaveOverseasLocationPayload) {
const response = await overseasHttp.post<ApiEnvelope<string>, ApiEnvelope<string>>(base(warehouseId), payload)
return unwrapApiResponse(response)
}
export async function updateOverseasLocation(warehouseId: string, locationId: string, payload: SaveOverseasLocationPayload & { version: number }) {
const response = await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, payload)
return unwrapApiResponse(response)
}
export async function changeOverseasLocationStatus(warehouseId: string, locationId: string, payload: SaveOverseasLocationPayload & { version: number }) {
const response = await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, payload)
return unwrapApiResponse(response)
}
export async function deleteOverseasLocation(warehouseId: string, locationId: string, version: number) {
const response = await overseasHttp.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, { params: { version } })
return unwrapApiResponse(response)
}
@@ -0,0 +1,116 @@
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
measurementKind: MojiaMeasurementKind
boxCode: string
inboundCode?: string | null
skuCode?: string | null
lengthCm: number
widthCm: number
heightCm: number
weightKg: number
imageFileId?: string | null
imageUrl?: string | null
measuredAt?: string | null
receivedAt?: string | null
syncStatus?: MojiaSkuSyncStatus | null
syncFailureCode?: string | null
syncFailureReason?: string | null
lingxingSkuId?: string | null
revision: number
updatedAt?: string | null
}
export interface MojiaSkuSyncPage {
records: MojiaSkuSyncRecord[]
page: number
size: number
total: number
pages: number
}
export interface MojiaSkuSyncQuery {
page: number
size: number
code?: string
measurementKind?: MojiaMeasurementKind
syncStatus?: MojiaSkuSyncStatus
}
export interface MojiaSkuSyncConfig {
autoSyncEnabled: boolean
}
export interface LingxingWarehouseTestResult {
whCode: string
reachable: boolean
matched: boolean
maintained: boolean
productSku?: string | null
barcode?: string | null
length?: number | null
width?: number | null
height?: number | null
weight?: number | null
message: string
}
export interface LingxingConnectionTestResult {
skuCode: string
warehouses: LingxingWarehouseTestResult[]
}
const basePath = '/platform/v1/firstleg/mojia-sku-sync'
export async function listMojiaSkuSync(query: MojiaSkuSyncQuery) {
const response = await http.get<ApiEnvelope<MojiaSkuSyncPage>, ApiEnvelope<MojiaSkuSyncPage>>(
basePath, { params: query },
)
return unwrapApiResponse(response)
}
export async function getMojiaSkuSyncConfig() {
const response = await http.get<ApiEnvelope<MojiaSkuSyncConfig>, ApiEnvelope<MojiaSkuSyncConfig>>(
`${basePath}/config`,
)
return unwrapApiResponse(response)
}
export async function testLingxingConnection(skuCode: string) {
const response = await http.post<ApiEnvelope<LingxingConnectionTestResult>, ApiEnvelope<LingxingConnectionTestResult>>(
`${basePath}/test-connection`, { skuCode },
)
return unwrapApiResponse(response)
}
export async function submitLingxingVerificationCode(code: string) {
const response = await http.post<ApiEnvelope<string>, ApiEnvelope<string>>(
`${basePath}/verification-code`, { code },
)
return unwrapApiResponse(response)
}
export async function updateMojiaSkuSync(id: string, data: {
skuCode: string
lengthCm: number
widthCm: number
heightCm: number
weightKg: number
revision: number
}) {
const response = await http.put<ApiEnvelope<MojiaSkuSyncRecord>, ApiEnvelope<MojiaSkuSyncRecord>>(
`${basePath}/${encodeURIComponent(id)}`, data,
)
return unwrapApiResponse(response)
}
export async function retryMojiaSkuSync(id: string, revision: number) {
const response = await http.post<ApiEnvelope<MojiaSkuSyncRecord>, ApiEnvelope<MojiaSkuSyncRecord>>(
`${basePath}/${encodeURIComponent(id)}/retry`, { revision },
)
return unwrapApiResponse(response)
}
@@ -30,7 +30,9 @@ export interface CreateRelabelItemPayload {
export interface CreateRelabelPayload {
customerOrderNo: string
overseasWarehouseId: string
operationWarehouseId?: string
/** @deprecated compatibility alias for older platform fixtures */
overseasWarehouseId?: string
labelDeadline: string
contactName: string
contactPhone: string
@@ -78,6 +80,8 @@ export interface RelabelSummaryDto {
fulfillmentStatus?: string | null
outboundOrderNo?: string | null
customerConfirmedAt?: string | null
reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER' | null
reviewRemark?: string | null
createdAt?: string | null
}
@@ -0,0 +1,96 @@
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WarehouseTransferReviewDialog from './WarehouseTransferReviewDialog.vue'
import { getWarehouseTransfer, reviewWarehouseTransfer } from '../api/warehouseTransfers'
vi.mock('../api/warehouseTransfers', async importOriginal => ({
...await importOriginal<typeof import('../api/warehouseTransfers')>(),
getWarehouseTransfer: vi.fn(),
reviewWarehouseTransfer: vi.fn(),
}))
const AppDialogStub = defineComponent({
name: 'AppDialog',
props: { modelValue: Boolean },
emits: ['confirm', 'update:modelValue'],
setup(props, { slots }) {
return () => props.modelValue ? h('section', { class: 'dialog' }, slots.default?.()) : null
},
})
const DataTableStub = defineComponent({
name: 'DataTable',
props: { data: { type: Array, default: () => [] } },
setup(props) {
return () => h('div', { class: 'data-table' }, JSON.stringify(props.data))
},
})
function detail(transferNo: string) {
return {
id: `id-${transferNo}`,
transferNo,
packageCount: 2,
needRelabel: false,
status: 'PENDING_REVIEW',
items: [{ id: 'item-1', skuCode: 'SKU-1', productNameZh: '测试货物', packageCount: 2 }],
cartons: [],
pallets: [],
relabelBindings: [],
}
}
function mountDialog(props: { modelValue: boolean; customerId: string; customerName: string; transferNo: string }) {
return mount(WarehouseTransferReviewDialog, {
props,
global: {
stubs: {
AppDialog: AppDialogStub,
DataTable: DataTableStub,
LoadErrorState: true,
ApiErrorMeta: true,
ElForm: { template: '<form><slot /></form>' },
ElFormItem: { template: '<label><slot /></label>' },
ElRadioGroup: { template: '<div><slot /></div>' },
ElRadioButton: { template: '<button type="button"><slot /></button>' },
ElInput: { template: '<textarea />' },
},
},
})
}
describe('WarehouseTransferReviewDialog', () => {
beforeEach(() => {
vi.mocked(getWarehouseTransfer).mockReset()
vi.mocked(reviewWarehouseTransfer).mockReset()
})
it('loads and renders review details when first mounted already open', async () => {
vi.mocked(getWarehouseTransfer).mockResolvedValue(detail('WT-1'))
const wrapper = mountDialog({ modelValue: true, customerId: '20001', customerName: '测试客户', transferNo: 'WT-1' })
await flushPromises()
expect(getWarehouseTransfer).toHaveBeenCalledTimes(1)
expect(getWarehouseTransfer).toHaveBeenCalledWith('20001', 'WT-1')
expect(wrapper.text()).toContain('WT-1')
expect(wrapper.text()).toContain('测试货物')
wrapper.unmount()
})
it('reloads details when an open dialog switches to another transfer', async () => {
vi.mocked(getWarehouseTransfer)
.mockResolvedValueOnce(detail('WT-1'))
.mockResolvedValueOnce(detail('WT-2'))
const wrapper = mountDialog({ modelValue: true, customerId: '20001', customerName: '测试客户', transferNo: 'WT-1' })
await flushPromises()
await wrapper.setProps({ customerId: '20002', transferNo: 'WT-2' })
await flushPromises()
expect(getWarehouseTransfer).toHaveBeenLastCalledWith('20002', 'WT-2')
expect(wrapper.text()).toContain('WT-2')
wrapper.unmount()
})
})
@@ -51,7 +51,16 @@ async function submit() {
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '审核提交失败,请重试').message) }
finally { saving.value = false }
}
watch(() => props.modelValue, (value) => { if (value) { form.decision = 'APPROVE'; form.remark = ''; void load() } })
watch(
() => [props.modelValue, props.customerId, props.transferNo] as const,
([value]) => {
if (!value) return
form.decision = 'APPROVE'
form.remark = ''
void load()
},
{ immediate: true },
)
</script>
<template>
@@ -2,6 +2,19 @@ import { describe, expect, it } from 'vitest'
import { formatOverseasDateTime, inboundPlanStatusLabel, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => {
it('keeps completed and processing relabel applications visible together', () => {
const row = toLastMileOrderListItem({
id: '1', customerId: 'customer-1', orderNo: 'RT-001', currentStatus: 'INBOUND_COMPLETED',
relabelApplications: [
{ relabelNo: 'RL-FIRST', status: 'COMPLETED', quantity: 10, createdAt: '2026-09-21T10:00:00' },
{ relabelNo: 'RL-SECOND', status: 'PROCESSING', quantity: 5, createdAt: '2026-09-21T11:00:00' },
],
})
expect(row.relabelApplications?.map(item => [item.relabelNo, item.statusLabel, item.quantity])).toEqual([
['RL-FIRST', '已完成', 10], ['RL-SECOND', '处理中', 5],
])
})
it('formats overseas timestamps with zero-padded seconds', () => {
expect(formatOverseasDateTime('2026-08-27T16:19:46')).toBe('2026-08-27 16:19:46')
expect(formatOverseasDateTime('2026-08-07T06:09:04')).toBe('2026-08-07 06:09:04')
@@ -104,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)
})
@@ -96,7 +96,12 @@ export function packageDifferenceText(value: number | '—' | null | undefined)
return value > 0 ? `+${value}` : String(value)
}
// 认领后显示处理中,可出库节点统一显示换标完成。
export function relabelApplicationStatusLabel(value: unknown) {
const code = String(value ?? '').trim()
return ({ PENDING_REVIEW: '待审核', PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认', READY_FOR_OUTBOUND: '待出库', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' } as Record<string, string>)[code] || text(code)
}
// 兼容原有单张换标单的汇总展示;子单使用完整业务状态。
export function relabelBusinessStatusLabel(value: unknown) {
const code = String(value ?? '').trim()
if (code === 'PROCESSING') return '处理中'
@@ -185,6 +190,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
outboundOrderNo: text(dto.outboundOrderNo),
dispatchOrderNo: text(dto.deliveryOrderNo),
relabelOrderNo: text(dto.relabelOrderNo),
relabelApplications: (dto.relabelApplications || []).map(application => ({ ...application, statusLabel: relabelApplicationStatusLabel(application.status) })),
relabelStatusCode: String(dto.relabelStatus ?? '').trim(),
relabelStatus: relabelBusinessStatusLabel(dto.relabelStatus),
consolidated: palletMergeLabel(dto.palletMergeFlag),
@@ -1,12 +1,15 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getInboundPlan, type InboundPlanDetailDto } from '../api/inboundPlans'
import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
@@ -14,6 +17,7 @@ const route = useRoute(); const router = useRouter()
const planNo = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const moveOpen = ref(false); const moveSaving = ref(false); const moveRow = ref<{ palletNo: string; palletVersion: number } | null>(null); const targetLocationCode = ref(''); const moveReason = ref('入库托盘上架'); const locationOptions = ref<OverseasLocationDto[]>([])
const referenceValue = computed(() => detail.value?.planNo || (/^\d+$/.test(planNo.value) ? '—' : planNo.value))
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function receivingWarehouse(code: unknown, name: unknown) {
@@ -38,21 +42,41 @@ const summaryGroups = [
const appointmentFields = ['预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注']
const feeColumns: DataTableColumn[] = [{ key: 'index', label: '序号', width: 70, align: 'center' }, { key: 'name', label: '费用项目', minWidth: 180 }, { key: 'currency', label: '币种', width: 100 }, { key: 'amount', label: '金额', width: 140, align: 'right' }, { key: 'remark', label: '备注', minWidth: 200 }]
const palletColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 },
{ key: 'palletOwner', prop: 'palletOwner', label: '托盘所属计划', minWidth: 190 },
{ key: 'packageCount', prop: 'packageCount', label: '对应箱数', width: 110, align: 'right' },
{ key: 'location', label: '当前库位', minWidth: 150, slotName: 'location' },
{ key: 'locationType', label: '库位类型', width: 120, prop: 'locationType' },
{ key: 'putawayStatus', label: '上架状态', width: 110, prop: 'putawayStatus' },
]
const palletRows = computed(() => (detail.value?.allocations || []).map((allocation, index) => {
const line = detail.value?.lines.find(item => item.id === allocation.lineId)
const pallet = detail.value?.pallets.find(item => item.id === allocation.palletId || item.warehousePalletId === allocation.palletId)
const locationCode = pallet?.warehouseLocationCode || null
const locationType = pallet?.sharedOverflow ? 'SHARED_OVERFLOW' : (pallet?.warehouseLocationType || null)
return {
id: `${allocation.lineId}-${allocation.palletId}-${index}`,
inboundCode: text(line?.inboundCode),
palletNo: text(allocation.palletNo),
palletOwner: allocation.palletOwnedByCurrentPlan ? `${allocation.palletOwnerPlanNo}(本计划)` : `${allocation.palletOwnerPlanNo}(跨计划合托)`,
packageCount: Number(allocation.pickedPackageCount ?? allocation.countedPackageCount ?? allocation.plannedPackageCount ?? 0),
palletId: String(allocation.palletId), palletVersion: pallet?.version ?? 0, sourceLocationId: pallet?.warehouseLocationId || null, sourceLocationCode: locationCode,
sourceLocationVersion: null, location: locationCode || (pallet?.warehouseLocationId ? '—' : '虚拟收货位'), locationType: locationType === 'SHARED_OVERFLOW' ? 'A0共享溢出位' : locationType === 'NORMAL' ? '普通库位' : '虚拟收货位', putawayStatus: locationType === 'NORMAL' || locationType === 'SHARED_OVERFLOW' ? '已上架' : '待上架',
}
}))
const availableLocations = computed(() => locationOptions.value.filter(item => item.status !== 'DISABLED'))
async function openMove(row: typeof palletRows.value[number]) {
if (!detail.value?.overseasWarehouseCode) { ElMessage.warning('入库单未返回海外仓范围,暂不能移库'); return }
moveRow.value = { palletNo: row.palletNo, palletVersion: row.palletVersion }; targetLocationCode.value = ''; moveReason.value = '入库托盘上架'; moveOpen.value = true
try { const result = await listOverseasLocations(String((detail.value as InboundPlanDetailDto & { overseasWarehouseId?: string }).overseasWarehouseId || ''), { page: 1, size: 200 }); locationOptions.value = result.records.filter(item => item.locationType !== 'VIRTUAL' && item.status !== 'DISABLED') } catch { locationOptions.value = []; ElMessage.error('可用库位加载失败,请重试') }
}
async function submitMove() {
if (!moveRow.value || !targetLocationCode.value || !moveReason.value.trim()) { ElMessage.warning('请选择目标库位并填写操作原因'); return }
moveSaving.value = true
try { const warehouseId = String(detail.value?.overseasWarehouseId || ''); if (!warehouseId) { ElMessage.error('入库单未返回海外仓 ID,暂不能移库'); return }; const result = await moveInboundPallet({ overseasWarehouseId: warehouseId, sourcePalletCode: moveRow.value.palletNo, targetLocationCode: targetLocationCode.value, expectedPalletVersion: moveRow.value.palletVersion, reason: moveReason.value.trim(), idempotencyKey: `admin-inbound-move-${moveRow.value.palletNo}-${Date.now()}` }); if (result.canMove === false) { ElMessage.error(result.blockReason || '当前托盘不可移库'); return }; ElMessage.success('移库完成'); moveOpen.value = false; await load() } catch (error) { ElMessage.error(getApiErrorDetails(error, '移库失败').message) } finally { moveSaving.value = false }
}
async function load() { if (!planNo.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从入库单列表重新进入' }; return }; loading.value = true; loadError.value = null; try { detail.value = await getInboundPlan(planNo.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '入库单详情加载失败,请重试') } finally { loading.value = false } }
function back() { void router.push({ name: 'overseas-inbound-orders' }) }
onMounted(load)
@@ -61,10 +85,10 @@ onMounted(load)
<template><OverseasDetailShell class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<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 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数。</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="暂无托盘明细" /></div></section>
<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></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; }
@@ -87,4 +87,30 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
expect(overview.text()).toContain('客户预报总箱数10 箱')
expect(overview.text()).not.toContain('实收总箱数')
})
it('renders both return relabel documents with independent states and quantities', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({
order: {
id: 'return-id', customerId: 'customer-1', orderNo: 'RT-001', orderType: 'RETURN_RELABEL', currentStatus: 'INBOUND_COMPLETED',
relabelOrderNo: 'RL-SECOND', relabelStatus: 'PROCESSING',
relabelApplications: [
{ relabelNo: 'RL-FIRST', status: 'COMPLETED', quantity: 10, createdAt: '2026-09-21T10:00:00' },
{ relabelNo: 'RL-SECOND', status: 'PROCESSING', quantity: 5, createdAt: '2026-09-21T11:00:00' },
],
},
cargo: [], fees: [], statusHistory: [], operationHistory: [],
})
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.get('#tab-plan').trigger('click')
await flushPromises()
const rows = wrapper.findAll('.related-panel .el-table__body tbody tr')
expect(rows).toHaveLength(2)
expect(rows[0].text()).toContain('RL-FIRST')
expect(rows[0].text()).toContain('已完成')
expect(rows[0].text()).toContain('10')
expect(rows[1].text()).toContain('RL-SECOND')
expect(rows[1].text()).toContain('处理中')
expect(rows[1].text()).toContain('5')
})
})
@@ -13,14 +13,14 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto } from '../api/lastMileOrders'
import { formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
import { relabelApplicationStatusLabel, formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import OrderReferenceText from '../components/OrderReferenceText.vue'
interface DetailField { label: string; value: string | number; wide?: boolean }
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
interface DocumentRow { type: string; number: string; createdAt: string; quote: string }
interface DocumentRow { status?: string; quantity?: number; type: string; number: string; createdAt: string; quote: string }
interface FeeRow { id: string; item: string; currency: string; amount: string; status: string; description: string }
interface LogRow { id: string; occurredAt: string; node: string; operator: string; result: string; sortAt: string }
@@ -117,14 +117,15 @@ const planFields = computed<DetailField[]>(() => {
{ label: '合托批次', value: text(order?.palletMergeFlag) },
{ label: '计划托数', value: Number(order?.palletCount || 0) },
{ label: '入库单号', value: text(order?.inboundOrderNo) },
{ label: '换标状态', value: lastMileStatusLabel(order?.relabelStatus) },
{ label: '换标子单', value: order?.relabelApplications?.length ? `${order.relabelApplications.length} 张(详见关联单据)` : lastMileStatusLabel(order?.relabelStatus) },
]
})
const documents = computed<DocumentRow[]>(() => {
const order = detail.value?.order
if (!order) return []
return [['入库单', order.inboundOrderNo], ['出库单(只读关联)', order.outboundOrderNo], ['派送单(只读关联)', order.deliveryOrderNo], ['换标单', order.relabelOrderNo]]
.filter((item) => item[1]).map(([type, number]) => ({ type: String(type), number: String(number), createdAt: '—', quote: '—' }))
return [['入库单', order.inboundOrderNo], ['出库单(只读关联)', order.outboundOrderNo], ['派送单(只读关联)', order.deliveryOrderNo], ['换标单', order.relabelApplications?.length ? undefined : order.relabelOrderNo]]
.filter((item) => item[1]).map(([type, number]) => ({ type: String(type), number: String(number), createdAt: '—', quote: '—' } as DocumentRow))
.concat((order.relabelApplications || []).map(application => ({ type: '换标单', number: application.relabelNo, createdAt: formatOverseasDateTime(application.createdAt), quote: '—', status: relabelApplicationStatusLabel(application.status), quantity: application.quantity })))
})
const cargo = computed<CargoRow[]>(() => (detail.value?.cargo || []).map((item) => ({
id: String(item.id), sku: text(item.skuCode), productName: text(item.productNameZh), pallets: Number(item.palletCount || 0),
@@ -152,6 +153,7 @@ const cargoColumns: DataTableColumn[] = [
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 120, align: 'right' },
]
const documentColumns: DataTableColumn[] = [
{ key: 'status', prop: 'status', label: '状态', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '换标数量(件)', minWidth: 130 },
{ key: 'type', prop: 'type', label: '单据类型', minWidth: 150 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 200 },
{ key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 170 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 150 },
]
@@ -15,7 +15,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
@@ -234,8 +234,8 @@ const columns: DataTableColumn[] = [
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
{ key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 150 },
{ key: 'dispatchOrderNo', prop: 'dispatchOrderNo', label: '派送单号', minWidth: 150 },
{ key: 'relabelOrderNo', prop: 'relabelOrderNo', label: '换标单号', minWidth: 150 },
{ key: 'relabelStatus', prop: 'relabelStatus', label: '换标状态', width: 110 },
{ key: 'relabelOrderNo', slotName: 'relabelOrderNo', label: '换标单号', minWidth: 220 },
{ key: 'relabelStatus', slotName: 'relabelStatus', label: '换标子状态', minWidth: 180 },
{ key: 'status', prop: 'status', label: '当前状态', width: 110 },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(仅自己)', minWidth: 160 },
@@ -791,14 +791,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()
@@ -806,6 +806,25 @@ async function reviewReturn(source: object) {
} else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '退货预报审核失败').message)
}
}
async function reviewDirectRelabel(source: object) {
const row = source as LastMileOrderListItem
if (!row.id || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,贴标单将进入海外仓换标作业。', '贴标单审核', { 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('请填写驳回原因,客户可在贴标单详情查看并修改后重新提交。', '驳回贴标单', { 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()
} catch (rejectError) { if (rejectError !== 'cancel' && rejectError !== 'close') ElMessage.error(getApiErrorDetails(rejectError, '贴标单审核失败').message) }
} else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '贴标单审核失败').message)
}
}
function canConfirmRelabel(row: { relabelStatusCode?: string }) { return row.relabelStatusCode === 'CUSTOMER_CONFIRM_PENDING' }
function canReworkRelabel(row: { relabelStatusCode?: string }) { return ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(row.relabelStatusCode ?? '') }
function hasRelabelActions(row: LastMileOrderListItem) {
@@ -830,6 +849,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
const inboundPlanCreated = hasInboundPlan(row)
return [
{ key: 'detail', label: '查看', priority: 500 },
{ key: 'review-direct-relabel', label: '审核', priority: 470, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && row.orderTypeCode === 'RELABEL_APPLICATION' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'inbound-plan', label: '生成入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) && !inboundPlanCreated },
@@ -841,6 +861,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
}
function handleLastMileRowAction(command: string, row: LastMileOrderListItem) {
if (command === 'detail') openDetail(row)
else if (command === 'review-direct-relabel') void reviewDirectRelabel(row)
else if (command === 'review-return') void reviewReturn(row)
else if (command === 'review') openTransferReview(row)
else if (command === 'inbound-plan') openInboundPlan(row)
@@ -853,7 +874,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()
@@ -864,7 +885,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()
@@ -942,6 +963,8 @@ onMounted(() => { void load() })
</section>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || hasSelectedRejectedReturn() || hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template>
<template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }} · {{ application.quantity }} 件</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template>
<template #actions="{ row }"><RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template>
@@ -1082,6 +1105,7 @@ onMounted(() => { void load() })
</template>
<style scoped>
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
.last-mile-toolbar { padding: var(--admin-space-5) var(--admin-space-5) 0; }
.row-actions { white-space: nowrap; }
.command-form { margin-top: var(--admin-space-5); }
@@ -0,0 +1,51 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Save } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { changeOverseasLocationStatus, createOverseasLocation, deleteOverseasLocation, listAllOverseasLocations, listOverseasLocations, updateOverseasLocation, type OverseasLocationDto, type OverseasLocationStatus, type OverseasLocationType } from '../api/locations'
import { overseasWarehousePermissions as permission } from '../permissions'
const access = useAccessPreviewStore(); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]); const rows = ref<OverseasLocationDto[]>([])
const loading = ref(false); const contextLoading = ref(false); const loadError = ref<{ message: string } | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = ref<QueryModel>({ warehouseId: '', locationCode: '', locationType: '', status: '' }); const editorOpen = ref(false); const saving = ref(false); const editing = ref<OverseasLocationDto | null>(null)
const form = reactive({ warehouseId: '', locationCode: '', locationName: '', locationType: 'NORMAL' as const, maxWeightKg: null as number | null, status: 'ACTIVE' as 'ACTIVE' | 'INACTIVE', remark: '' })
const queryFields = computed<QueryField[]>(() => [
{ key: 'warehouseId', type: 'select', label: '海外仓', width: 260, options: warehouses.value.map(item => ({ value: String(item.id), label: `${item.warehouseName}(${item.warehouseCode})` })), props: { filterable: true, clearable: true, disabled: contextLoading.value } },
{ key: 'locationCode', type: 'input', label: '库位编码', width: 180 },
{ key: 'locationType', type: 'select', label: '类型', width: 150, options: [{ label: '普通库位', value: 'NORMAL' }, { label: 'A0共享溢出位', value: 'SHARED_OVERFLOW' }, { label: '虚拟收货位', value: 'VIRTUAL' }] },
{ key: 'status', type: 'select', label: '状态', width: 140, options: [{ label: '可用', value: 'ACTIVE' }, { label: '空闲', value: 'IDLE' }, { label: '已占用', value: 'OCCUPIED' }, { label: '已停用', value: 'DISABLED' }] },
])
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' }, { key: 'locationCode', prop: 'locationCode', label: '库位编码', minWidth: 180 }, { key: 'warehouse', label: '海外仓', minWidth: 190, slotName: 'warehouse' }, { key: 'locationType', label: '类型', width: 130, slotName: 'locationType' }, { key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'occupiedPalletCount', prop: 'occupiedPalletCount', label: '托盘数', width: 100, align: 'right' }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
const typeLabels: Record<OverseasLocationType, string> = { VIRTUAL: '虚拟收货位', NORMAL: '普通库位', SHARED_OVERFLOW: 'A0共享溢出位' }; const statusLabels: Record<OverseasLocationStatus, string> = { ACTIVE: '可用', IDLE: '空闲', OCCUPIED: '已占用', INACTIVE: '已停用', DISABLED: '已停用' }
const selectedWarehouse = computed(() => warehouses.value.find(item => String(item.id) === String(form.warehouseId)))
const warehouseNameById = computed(() => new Map(warehouses.value.map(item => [String(item.id), item.warehouseName])))
function isA0(row: OverseasLocationDto) { return row.sharedOverflow || row.locationType === 'SHARED_OVERFLOW' || row.locationCode.toUpperCase() === 'A0' }
function canEdit(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
function canStatus(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
function canDelete(row: OverseasLocationDto) { return access.can(permission.locationDelete) && !isA0(row) && row.status === 'DISABLED' && Number(row.occupiedPalletCount || 0) === 0 }
function actionError(error: unknown, fallback: string) { ElMessage.error(getApiErrorDetails(error, fallback).message) }
async function loadWarehouses() { contextLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records; await load() } catch (error) { actionError(error, '海外仓列表加载失败') } finally { contextLoading.value = false } }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); if (!warehouses.value.length) { rows.value = []; total.value = 0; return }; loading.value = true; loadError.value = null; try { const selectedWarehouseId = String(filters.value.warehouseId || ''); const result = await (selectedWarehouseId ? listOverseasLocations(selectedWarehouseId, { page: 1, size: 200, keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: (String(filters.value.status || '') === 'DISABLED' ? 'INACTIVE' : String(filters.value.status || '')) as OverseasLocationStatus | '' }) : listAllOverseasLocations(warehouses.value.map(item => String(item.id)), { keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: (String(filters.value.status || '') === 'DISABLED' ? 'INACTIVE' : String(filters.value.status || '')) as OverseasLocationStatus | '' })); const records = result.records.map(row => ({ ...row, warehouseName: row.warehouseName || warehouseNameById.value.get(String(row.warehouseId)) || null })); total.value = records.length; const start = (page.value - 1) * pageSize.value; rows.value = records.slice(start, start + pageSize.value) } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外库位加载失败') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function resetForm() { Object.assign(form, { warehouseId: String(filters.value.warehouseId || ''), locationCode: '', locationName: '', locationType: 'NORMAL', maxWeightKg: null, status: 'ACTIVE', remark: '' }); editing.value = null }
function openCreate() { resetForm(); editorOpen.value = true }
function openEdit(row: OverseasLocationDto) { if (!canEdit(row)) return; Object.assign(form, { warehouseId: row.warehouseId, locationCode: row.locationCode, locationName: row.locationName || '', locationType: 'NORMAL', maxWeightKg: row.maxLoadKg == null ? null : Number(row.maxLoadKg), status: row.status === 'DISABLED' ? 'INACTIVE' : 'ACTIVE', remark: row.remark || '' }); editing.value = row; editorOpen.value = true }
async function save() { if (!form.warehouseId || !form.locationCode.trim() || form.maxWeightKg == null) { ElMessage.warning('请填写海外仓、库位编码和最大承重'); return }; saving.value = true; try { const payload = { locationCode: form.locationCode.trim(), locationType: 'NORMAL' as const, maxLoadKg: form.maxWeightKg, status: form.status, remark: form.remark.trim() || null }; if (editing.value) await updateOverseasLocation(form.warehouseId, editing.value.id, { ...payload, version: editing.value.version }); else await createOverseasLocation(form.warehouseId, payload); editorOpen.value = false; await load() } catch (error) { actionError(error, '保存库位失败') } finally { saving.value = false } }
async function toggleStatus(row: OverseasLocationDto) { if (!canStatus(row)) return; const next = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'}库位“${row.locationCode}”?`, '请确认'); await changeOverseasLocationStatus(row.warehouseId, row.id, { locationCode: row.locationCode, locationType: 'NORMAL', maxLoadKg: Number(row.maxLoadKg || 0), status: next, remark: row.remark || null, version: row.version }); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '更新库位状态失败') } }
async function remove(row: OverseasLocationDto) { if (!canDelete(row)) return; try { await ElMessageBox.confirm(`确认删除库位“${row.locationCode}”?`, '请确认', { type: 'warning' }); await deleteOverseasLocation(row.warehouseId, row.id, row.version); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '删除库位失败') } }
onMounted(() => { void loadWarehouses() })
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="海外仓库位管理"><template #toolbar><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外库位筛选" preference-key="overseas-locations-query" @search="search" @reset="page = 1; load()" /></template><template #state><LoadErrorState v-if="loadError" title="海外库位加载失败" :message="loadError.message" :loading="loading" @retry="load" /></template><section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无库位" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.locationCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template><template #actions="{ row }"><div class="density-actions"><el-button v-if="canEdit(row as OverseasLocationDto)" size="small" class="density-action" @click="openEdit(row as OverseasLocationDto)">编辑</el-button><el-button v-if="canStatus(row as OverseasLocationDto)" size="small" class="density-action" :data-tone="(row as OverseasLocationDto).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as OverseasLocationDto)">{{ (row as OverseasLocationDto).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="canDelete(row as OverseasLocationDto)" size="small" class="density-action" data-tone="danger" @click="remove(row as OverseasLocationDto)">删除</el-button></div></template><template #warehouse="{ row }">{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}</template><template #locationType="{ row }"><el-tag :type="isA0(row as OverseasLocationDto) ? 'warning' : 'info'">{{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}</el-tag></template><template #status="{ row }"><el-tag>{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}</el-tag></template><template #pagination-left>共 {{ total }} 个库位</template></DataTable></section></ListPageLayout><AppDialog v-model="editorOpen" :title="editing ? '编辑海外库位' : '新增海外库位'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" :show-confirm="false"><el-form label-position="top" :disabled="saving"><div class="editor-grid"><el-form-item label="海外仓" required><el-select v-model="form.warehouseId" :disabled="Boolean(editing)" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :value="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" /></el-select></el-form-item><el-form-item label="库位编码" required><el-input v-model="form.locationCode" maxlength="50" placeholder="请输入库位编码" /></el-form-item><el-form-item label="库位名称"><el-input v-model="form.locationName" maxlength="100" placeholder="请输入库位名称(选填)" /></el-form-item><el-form-item label="最大承重(kg)" required><el-input-number v-model="form.maxWeightKg" :min="0" :precision="3" controls-position="right" placeholder="请输入最大承重" /></el-form-item></div><el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" placeholder="请输入备注(选填)" /></el-form-item></el-form><template #footer><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存库位</el-button></template></AppDialog></AdminPage></template>
<style scoped>.editor-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px}.editor-grid :deep(.el-select),.editor-grid :deep(.el-input-number){width:100%}@media(max-width:720px){.editor-grid{grid-template-columns:1fr}}</style>
@@ -0,0 +1,274 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Cable, Edit3, RefreshCw, RotateCw } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
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 MojiaMeasurementKind,
} from '../api/mojiaSkuSync'
const permission = {
list: 'platform:mojia-sku-sync:list',
edit: 'platform:mojia-sku-sync:edit',
retry: 'platform:mojia-sku-sync:retry',
} as const
const access = useAccessPreviewStore()
const canEdit = computed(() => access.can(permission.edit))
const canRetry = computed(() => access.can(permission.retry))
const rows = ref<MojiaSkuSyncRecord[]>([])
const total = ref(0)
const loading = ref(false)
const autoSyncEnabled = ref(false)
const error = ref('')
const actionId = ref('')
const testingConnection = ref(false)
const testOpen = ref(false)
const testResult = ref<LingxingConnectionTestResult | null>(null)
const verificationOpen = ref(false)
const verificationCode = ref('')
const submittingVerification = ref(false)
const pendingVerificationAction = ref<{ type: 'test' } | { type: 'retry', id: string, revision: number } | null>(null)
const page = ref(1)
const size = ref(20)
const form = reactive({ 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: '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 },
{ key: 'syncStatus', label: '同步状态', width: 120, slotName: 'syncStatus' },
{ key: 'match', label: '领星 SKU', minWidth: 150, slotName: 'match' },
{ key: 'measuredAt', label: '测量时间', width: 170, slotName: 'measuredAt' },
{ key: 'failure', label: '失败原因', minWidth: 220, slotName: 'failure' },
]
const statusLabel: Record<MojiaSkuSyncStatus, string> = {
PENDING: '待同步', SUCCESS: '同步成功', FAILED: '同步失败',
}
const statusType: Record<MojiaSkuSyncStatus, 'success' | 'warning' | 'danger' | 'info'> = {
PENDING: 'warning', SUCCESS: 'success', FAILED: 'danger',
}
async function load() {
loading.value = true
error.value = ''
try {
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, '称重机器上传记录加载失败')
} finally { loading.value = false }
}
async function loadConfig() {
try {
autoSyncEnabled.value = (await getMojiaSkuSyncConfig()).autoSyncEnabled
} catch (cause) {
error.value = getApiErrorMessage(cause, '自动同步配置加载失败')
}
}
function search() { page.value = 1; void load() }
function reset() { form.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) {
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.code.trim()
if (!skuCode) { ElMessage.warning('请先输入要查询的 SKU'); return }
testingConnection.value = true
try {
testResult.value = await testLingxingConnection(skuCode)
testOpen.value = true
} catch (cause) {
const message = getApiErrorMessage(cause, '领星连接测试失败')
if (/二次验证码|验证码|安全验证/.test(message)) {
pendingVerificationAction.value = { type: 'test' }
verificationOpen.value = true
}
ElMessage.error(message)
}
finally { testingConnection.value = false }
}
async function submitVerification() {
if (!verificationCode.value.trim()) { ElMessage.warning('请输入验证码'); return }
submittingVerification.value = true
try {
await submitLingxingVerificationCode(verificationCode.value.trim())
const pending = pendingVerificationAction.value
verificationCode.value = ''
verificationOpen.value = false
pendingVerificationAction.value = null
await load()
ElMessage.success('验证码已提交,正在继续原操作')
if (pending?.type === 'test') {
await testConnection()
} else if (pending?.type === 'retry') {
const row = rows.value.find((item) => item.id === pending.id)
if (row) await retry(row, false)
}
} catch (cause) { ElMessage.error(getApiErrorMessage(cause, '验证码提交失败')) }
finally { submittingVerification.value = false }
}
function openEdit(row: MojiaSkuSyncRecord) {
editing.value = row
Object.assign(editForm, { skuCode: row.skuCode || '', lengthCm: row.lengthCm, widthCm: row.widthCm, heightCm: row.heightCm, weightKg: row.weightKg, revision: row.revision })
editOpen.value = true
}
async function saveEdit() {
const row = editing.value
if (!row || !editForm.skuCode.trim() || [editForm.lengthCm, editForm.widthCm, editForm.heightCm, editForm.weightKg].some((value) => value <= 0)) {
ElMessage.warning('请填写有效的 SKU、尺寸和重量')
return
}
actionId.value = row.id
try {
await updateMojiaSkuSync(row.id, { ...editForm, skuCode: editForm.skuCode.trim() })
editOpen.value = false
ElMessage.success('已保存人工修正版,记录将重新进入同步队列')
await load()
} catch (cause) { ElMessage.error(getApiErrorMessage(cause, '保存修正版失败')) }
finally { actionId.value = '' }
}
async function retry(row: MojiaSkuSyncRecord, confirm = true) {
if (confirm) {
try {
await ElMessageBox.confirm(`确认重新推送 SKU「${row.skuCode}」的最新测量值?`, '重新推送领星', { type: 'warning', confirmButtonText: '重新推送', cancelButtonText: '取消', customClass: 'admin-density-confirm' })
} catch { return }
}
actionId.value = row.id
try {
const result = await retryMojiaSkuSync(row.id, row.revision)
ElMessage.success(result.syncStatus === 'SUCCESS' ? '已成功推送到领星' : '推送失败,详情已记录')
await load()
}
catch (cause) {
const message = getApiErrorMessage(cause, '重新推送失败')
if (/二次验证码|验证码|安全验证/.test(message)) {
pendingVerificationAction.value = { type: 'retry', id: row.id, revision: row.revision }
verificationOpen.value = true
}
ElMessage.error(message)
}
finally { actionId.value = '' }
}
onMounted(() => { void Promise.all([loadConfig(), load()]) })
</script>
<template>
<AdminPage class="admin-density mojia-sku-sync-page">
<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
:type="autoSyncEnabled ? 'warning' : 'info'"
:title="autoSyncEnabled ? '领星自动同步已开启' : '领星自动同步已关闭,仅支持人工推送'"
description="开关由 Nacos 配置 firstleg.lingxing.auto-sync-enabled 控制。"
show-icon
:closable="false"
/>
<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.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>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-button :loading="testingConnection" @click="testConnection"><Cable :size="16" />测试连接</el-button>
</div>
</template>
<template #toolbar-actions><el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button></template>
<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.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>
<template #match="{ row }">{{ row.lingxingSkuId || '未匹配' }}</template>
<template #measuredAt="{ row }">{{ formatTime(row.measuredAt) }}</template>
<template #failure="{ row }">{{ row.syncFailureReason || '—' }}</template>
</DataTable>
<div class="sync-pagination"><el-pagination v-model:current-page="page" v-model:page-size="size" :total="total" background layout="total, sizes, prev, pager, next" @current-change="load" @size-change="search" /></div>
</section>
</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" 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" 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>
</AppDialog>
<AppDialog v-model="testOpen" class="admin-density-dialog" title="领星连接测试" width="min(720px, calc(100vw - 28px))" :show-cancel="false" confirm-text="关闭" @confirm="testOpen = false">
<div v-if="testResult" class="connection-result">
<div class="connection-result__sku">查询 SKU:<strong>{{ testResult.skuCode }}</strong></div>
<el-table :data="testResult.warehouses" border>
<el-table-column prop="whCode" label="仓库" width="90" />
<el-table-column label="结果" width="110"><template #default="{ row }"><el-tag :type="row.matched ? 'success' : 'danger'">{{ row.matched ? '已命中' : '未命中' }}</el-tag></template></el-table-column>
<el-table-column label="维护状态" width="110"><template #default="{ row }">{{ row.matched ? (row.maintained ? '已维护' : '待维护') : '—' }}</template></el-table-column>
<el-table-column label="领星数据" min-width="220"><template #default="{ row }">{{ row.matched ? `${row.length ?? 0} × ${row.width ?? 0} × ${row.height ?? 0} cm / ${row.weight ?? 0} kg` : row.message }}</template></el-table-column>
</el-table>
</div>
</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" placeholder="请输入验证码" @keyup.enter="submitVerification" /></el-form-item></el-form>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.sync-filters { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-2); }
.sync-filters .el-input { width: 220px; }
.density-actions { display: flex; gap: var(--admin-space-2); }
.sync-image { width: 52px; height: 40px; border-radius: 4px; background: var(--cc-surface-muted); }
.sync-pagination { display: flex; justify-content: flex-end; padding: var(--admin-space-4); }
.sync-edit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.sync-edit-grid :deep(.el-input-number) { width: 100%; }
.connection-result { display: grid; gap: var(--admin-space-4); }
.connection-result__sku { color: var(--cc-muted); }
.connection-result__sku strong { color: var(--cc-text); }
@media (max-width: 900px) { .sync-filters .el-input { width: 180px; } }
</style>
@@ -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')
})
})
@@ -1,11 +1,11 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, onBeforeUnmount } from 'vue'
import { Eye, FileUp, Undo2 } from '@lucide/vue'
import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -22,6 +22,17 @@ const detail = ref<RelabelDetailDto | null>(null)
const loading = ref(false)
const access = useAccessPreviewStore()
const returning = ref(false)
const confirming = ref(false)
async function confirmCompletion() {
if (confirming.value) return
confirming.value = true
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', '换标完成确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(orderId.value)
ElMessage.success('已确认换标完成,订单已进入后续流程'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标完成确认失败,请刷新后重试').message) }
finally { confirming.value = false }
}
async function returnForRework() {
if (returning.value) return
returning.value = true
@@ -115,7 +126,7 @@ async function previewCompletionPhoto(fileId: string) {
<OverseasReadOnlyDetail class="admin-density" dense title="换标单详情" reference-label="换标单号" :reference-value="referenceValue" back-label="返回换标单" domain="换标单详情" :groups="groups" :field-values="fieldValues" :pending="false" :status-text="detail ? status(detail.application.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back">
<template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #content-after>
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel"><el-button type="warning" :loading="returning" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel relabel-review-actions"><el-button v-if="detail?.application.status === 'CUSTOMER_CONFIRM_PENDING'" type="primary" :loading="confirming" @click="confirmCompletion"><CheckCircle2 :size="15" />确认换标完成</el-button><el-button type="warning" :loading="returning" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<section class="relabel-document-panel" aria-label="PDA换标完工证据"><div class="relabel-mark-list__head"><h2>PDA 换标完工证据</h2><span>{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}</span></div><div v-if="detail?.completionEvidence?.photoFileIds?.length" class="document-row"><strong>共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片</strong><span class="document-actions density-actions"><el-button class="density-action" data-tone="neutral" v-for="(fileId, index) in detail.completionEvidence.photoFileIds" :key="fileId" size="small" @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></span></div><div v-else class="attachment-empty">暂未上传完工照片</div></section>
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
<section class="relabel-mark-list" aria-label="箱唛换标信息">
@@ -144,6 +155,7 @@ async function previewCompletionPhoto(fileId: string) {
table { min-width: 1120px; }
.mark-code { color: #245b9e; font-weight: 600; }
.relabel-document-panel { grid-column: 1 / -1; padding: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
.relabel-review-actions { display:flex; justify-content:flex-end; gap:var(--admin-space-3); }
.document-row { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-5); border:1px solid var(--cc-border); border-radius:var(--admin-radius); }
.file-picker, .inline-file-picker { display:inline-flex; align-items:center; gap:var(--admin-space-2); color:var(--el-color-primary); cursor:pointer; }
.file-picker input, .inline-file-picker input { display:none; }
@@ -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标' })],
}))
})
@@ -124,7 +124,7 @@ async function submitForm() {
return
}
submitting.value = true
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), overseasWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), chargeAmount: Number(form.relabelCost), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, items: [{ skuCode: form.sku.trim(), productNameZh: form.productName.trim(), totalPackageCount: Number(form.cartons), notifiedPackageCount: Number(form.cartons), shipmentNo: form.shipmentNo.trim(), newSkuCode: form.newSkuCode.trim(), labelRequirement: form.labelRequirement }] })
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), operationWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), chargeAmount: Number(form.relabelCost), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, items: [{ skuCode: form.sku.trim(), productNameZh: form.productName.trim(), totalPackageCount: Number(form.cartons), notifiedPackageCount: Number(form.cartons), shipmentNo: form.shipmentNo.trim(), newSkuCode: form.newSkuCode.trim(), labelRequirement: form.labelRequirement }] })
ElMessage.success('换标单已创建')
} else {
if (!allowedStatusOptions.value.includes(form.status)) { ElMessage.warning('当前状态没有可提交的审核流转'); return }
@@ -164,7 +164,8 @@ function relabelStatusCode(value: unknown) {
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || String(value || '—') }
function canAudit(source: object) {
const row = source as RelabelOrderListItem
return (row.rawStatus || relabelStatusCode(row.status)) === 'PENDING_REVIEW'
return row.reviewChannel !== 'TAIL_ORDER'
&& (row.rawStatus || relabelStatusCode(row.status)) === 'PENDING_REVIEW'
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
@@ -174,6 +175,7 @@ async function load(model: QueryModel = filters.value) {
rows.value = result.records.map((item) => ({
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
reviewChannel: item.reviewChannel || undefined,
createdAt: formatOverseasDateTime(item.createdAt),
}))
total.value = result.total
@@ -37,6 +37,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
['PalletsView.vue', 'overseas-pallets-query'],
['CartonsView.vue', 'overseas-cartons-query'],
['ProductsView.vue', 'overseas-products-query'],
['LocationsView.vue', 'overseas-locations-query'],
])
const keys = [...expected.entries()].map(([fileName, preferenceKey]) => {
const value = source(fileName)
@@ -127,6 +128,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('@back="back"')
expect(value).not.toContain('AppDrawer')
if (fileName === 'LastMileOrderDetailView.vue') expect(value).toContain('title="新增费用"')
else if (fileName === 'InboundOrderDetailView.vue') expect(value).toContain('移库')
else expect(value).not.toContain('AppDialog')
}
})
@@ -342,10 +344,17 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('<RowActions :actions="rowActions(row as DispatchListItem)"')
expect(value).toContain("{ key: 'detail', label: '查看', priority: 300 }")
expect(value).toContain("if (command === 'detail') openDetail(row)")
} else if (fileName === 'LastMileOrdersView.vue') {
expect(value).toContain('<RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)"')
expect(value).toContain("{ key: 'detail', label: '查看', priority: 500 }")
expect(value).toContain("if (command === 'detail') openDetail(row)")
} else {
expect(value).toMatch(/@click="openDetail\(row(?: as [^"]+)?\)">(?:<Eye :size="15" \/>)?查看<\/el-button>/)
}
}
const locations = source('LocationsView.vue')
expect(locations).toContain('海外仓库位管理')
expect(locations).toContain('库位编码')
})
it('connects the dispatch prototype detail to the real overseas API', () => {
@@ -387,12 +396,12 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(columnKeys('LastMileOrdersView.vue')).toEqual([
'actions', 'selection', 'orderNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse',
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'cartons', 'cartonDifference', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
'createdAt', 'internalRemark', 'customerRemark', 'warehouseRemark',
])
expect(columnKeys('InboundOrdersView.vue')).toEqual([
'actions', 'selection', 'orderNo', 'inboundCode', 'lxCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'volume',
'actions', 'selection', 'orderNo', 'inboundCode', 'lxCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'grossWeight', 'netWeight', 'volume',
'pallets', 'assigned', 'mergeAllowed', 'urgent', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt',
])
expect(columnKeys('InventoryView.vue')).toEqual([
@@ -49,4 +49,7 @@ export const overseasWarehousePermissions = {
productList: 'platform:overseas-warehouse:product:list',
productCreate: 'platform:overseas-warehouse:product:create',
productEdit: 'platform:overseas-warehouse:product:edit',
mojiaSkuSyncList: 'platform:mojia-sku-sync:list',
mojiaSkuSyncEdit: 'platform:mojia-sku-sync:edit',
mojiaSkuSyncRetry: 'platform:mojia-sku-sync:retry',
} as const
@@ -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],
])
})
+12
View File
@@ -130,4 +130,16 @@ export const overseasWarehouseRoutes = [
component: () => import('./pages/PalletDetailView.vue'),
meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false },
},
{
path: 'overseas-warehouse/locations',
name: 'overseas-locations',
component: () => import('./pages/LocationsView.vue'),
meta: { title: '库位管理', description: '维护海外仓普通库位与 A0 共享溢出位', permission: permission.locationList },
},
{
path: 'general/mojia-sku-sync',
name: 'mojia-sku-sync',
component: () => import('./pages/MojiaSkuSyncView.vue'),
meta: { title: '称重机器', description: '查看墨家机器上传的箱码和 SKU 测量记录', permission: permission.mojiaSkuSyncList },
},
] satisfies RouteRecordRaw[]
+2
View File
@@ -48,6 +48,7 @@ export interface LastMileOrderListItem {
/** Read-only association fact; this module does not implement overseas delivery. */
dispatchOrderNo: string
relabelOrderNo: string
relabelApplications?: Array<{ relabelNo: string; status: string; statusLabel: string; quantity: number; createdAt: string }>
relabelStatusCode: string
relabelStatus: string
consolidated: string
@@ -151,6 +152,7 @@ export interface RelabelOrderListItem {
fileName: string
status: string
rawStatus?: string
reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER'
remark: string
createdAt: string
}
@@ -0,0 +1,204 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { SailingAssignment, SailingBookingAdvice, SailingBookingLinkRequest, SailingCalendarEntry, SailingCalendarQuery, SailingCalendarResponse, SailingChannelResource, SailingChannelSaveRequest, SailingDetail, SailingExceptionPage, SailingExceptionQuery, SailingExceptionRecord, SailingExceptionReassignRequest, SailingExceptionReassignResult, SailingGenerationPreview, SailingGenerationResult, SailingSaveRequest, SailingTemplateResource, SailingTemplateSaveRequest, SailingVolumeDetail } from '../types'
const basePath = '/platform/v1/sailings'
function number(value: unknown, fallback = 0) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
function entry(raw: SailingCalendarEntry): SailingCalendarEntry {
return {
...raw,
id: String(raw.id),
sailingNo: String(raw.sailingNo ?? ''),
warehouseId: String(raw.warehouseId ?? ''),
channelCode: String(raw.channelCode ?? ''),
channelName: String(raw.channelName ?? raw.channelCode ?? ''),
templateId: raw.templateId == null ? null : String(raw.templateId),
channelId: raw.channelId == null ? null : String(raw.channelId),
etaAt: raw.etaAt ?? null,
sourceType: raw.sourceType ?? null,
plannedCapacityCbm: raw.plannedCapacityCbm == null ? null : number(raw.plannedCapacityCbm),
volumeSummary: summary(raw.volumeSummary),
version: number(raw.version),
}
}
function summary(raw?: Partial<SailingCalendarEntry['volumeSummary']> | null) {
return {
actualReceivedCbm: number(raw?.actualReceivedCbm),
catchableForecastCbm: number(raw?.catchableForecastCbm),
riskUnmatchedCbm: number(raw?.riskUnmatchedCbm),
bookedContainerCount: number(raw?.bookedContainerCount),
recommendedContainerCount: number(raw?.recommendedContainerCount),
pendingContainerCount: number(raw?.pendingContainerCount),
lateBatchCount: number(raw?.lateBatchCount),
overCapacity: Boolean(raw?.overCapacity),
planningCapacityCbm: raw?.planningCapacityCbm == null ? null : number(raw.planningCapacityCbm),
bookedPlanningCapacityCbm: raw?.bookedPlanningCapacityCbm == null ? null : number(raw.bookedPlanningCapacityCbm),
catchableReceivedCbm: raw?.catchableReceivedCbm == null ? null : number(raw.catchableReceivedCbm),
lateReceivedCbm: raw?.lateReceivedCbm == null ? null : number(raw.lateReceivedCbm),
snapshotAt: raw?.snapshotAt ?? null,
}
}
function assignment(raw: SailingAssignment): SailingAssignment {
return {
...raw,
id: String(raw.id),
volumeCbm: number(raw.volumeCbm),
assignmentVersion: raw.assignmentVersion == null ? null : number(raw.assignmentVersion),
}
}
function history(raw: SailingDetail['histories'][number]): SailingDetail['histories'][number] {
return { ...raw, id: String(raw.id) }
}
function calendar(raw: SailingCalendarResponse): SailingCalendarResponse {
const source = raw || {} as SailingCalendarResponse
const rawSummary = source.summary || {} as SailingCalendarResponse['summary']
return {
...source,
records: (source.records || []).map(entry),
summary: { ...summary(rawSummary), sailingCount: number(rawSummary.sailingCount) },
filterOptions: {
warehouses: source.filterOptions?.warehouses || [],
channels: source.filterOptions?.channels || [],
departurePorts: source.filterOptions?.departurePorts || [],
destinationPorts: source.filterOptions?.destinationPorts || [],
},
}
}
export async function listSailingCalendar(query: SailingCalendarQuery) {
const response = await http.get<ApiEnvelope<SailingCalendarResponse>, ApiEnvelope<SailingCalendarResponse>>(`${basePath}/calendar`, { params: query })
return calendar(unwrapApiResponse(response))
}
export async function getSailing(id: string) {
const response = await http.get<ApiEnvelope<SailingDetail>, ApiEnvelope<SailingDetail>>(`${basePath}/${encodeURIComponent(id)}`)
const result = unwrapApiResponse(response)
return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
}
function idempotencyKey(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function createSailing(payload: SailingSaveRequest) {
const response = await http.post<ApiEnvelope<SailingDetail>, ApiEnvelope<SailingDetail>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing') } })
const result = unwrapApiResponse(response)
return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
}
export async function updateSailing(id: string, payload: SailingSaveRequest) {
const response = await http.put<ApiEnvelope<SailingDetail>, ApiEnvelope<SailingDetail>>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing') } })
const result = unwrapApiResponse(response)
return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
}
export async function cancelSailing(id: string, expectedVersion: number, reason: string) {
const response = await http.post<ApiEnvelope<SailingDetail>, ApiEnvelope<SailingDetail>>(`${basePath}/${encodeURIComponent(id)}/cancel`, { expectedVersion, reason }, { headers: { 'Idempotency-Key': idempotencyKey('sailing-cancel') } })
const result = unwrapApiResponse(response)
return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
}
export async function copySailing(id: string, payload: { etdAt: string; cutoffAt: string }) {
const response = await http.post<ApiEnvelope<SailingDetail>, ApiEnvelope<SailingDetail>>(`${basePath}/${encodeURIComponent(id)}/copy`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-copy') } })
const result = unwrapApiResponse(response)
return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
}
export async function getSailingVolumeDetail(id: string) {
const response = await http.get<ApiEnvelope<SailingVolumeDetail>, ApiEnvelope<SailingVolumeDetail>>(`${basePath}/${encodeURIComponent(id)}/volume-summary`)
const result = unwrapApiResponse(response)
return { ...result, summary: summary(result.summary), assignments: (result.assignments || []).map(assignment) }
}
export async function getSailingBookingAdvice(id: string) {
const response = await http.get<ApiEnvelope<SailingBookingAdvice>, ApiEnvelope<SailingBookingAdvice>>(`${basePath}/${encodeURIComponent(id)}/booking-advice`)
const result = unwrapApiResponse(response)
return {
...result,
actualReceivedCbm: number(result.actualReceivedCbm),
catchableTotalCbm: number(result.catchableTotalCbm),
planningCapacityCbm: result.planningCapacityCbm == null ? null : number(result.planningCapacityCbm),
bookedContainerCount: number(result.bookedContainerCount),
recommendedContainerCount: number(result.recommendedContainerCount),
pendingContainerCount: number(result.pendingContainerCount),
catchableReceivedCbm: result.catchableReceivedCbm == null ? null : number(result.catchableReceivedCbm),
lateReceivedCbm: result.lateReceivedCbm == null ? null : number(result.lateReceivedCbm),
scenarios: (result.scenarios || []).map((scenario) => ({ ...scenario, capacityCbm: number(scenario.capacityCbm), recommendedContainerCount: number(scenario.recommendedContainerCount), pendingContainerCount: number(scenario.pendingContainerCount) })),
}
}
export async function createSailingBookingLink(id: string, payload: SailingBookingLinkRequest) {
const response = await http.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`${basePath}/${encodeURIComponent(id)}/booking-links`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-booking-link') } })
return unwrapApiResponse(response)
}
const channelPath = '/platform/v1/sailing-channels'
const templatePath = '/platform/v1/sailing-templates'
const exceptionPath = '/platform/v1/sailing-assignments/exceptions'
function channel(raw: SailingChannelResource): SailingChannelResource { return { ...raw, id: String(raw.id), defaultCutoffDays: number(raw.defaultCutoffDays), version: number(raw.version) } }
function template(raw: SailingTemplateResource): SailingTemplateResource { return { ...raw, id: String(raw.id), channelId: String(raw.channelId), warehouseId: String(raw.warehouseId), weekdays: (raw.weekdays || []).map((value) => number(value)), cutoffDays: number(raw.cutoffDays), version: number(raw.version) } }
export async function listSailingChannels() {
const response = await http.get<ApiEnvelope<SailingChannelResource[]>, ApiEnvelope<SailingChannelResource[]>>(channelPath)
const result = unwrapApiResponse(response) as SailingChannelResource[] | { records?: SailingChannelResource[] } | null
return (Array.isArray(result) ? result : result?.records || []).map(channel)
}
export async function createSailingChannel(payload: SailingChannelSaveRequest) {
const response = await http.post<ApiEnvelope<SailingChannelResource>, ApiEnvelope<SailingChannelResource>>(channelPath, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-channel') } })
return channel(unwrapApiResponse(response))
}
export async function updateSailingChannel(id: string, payload: SailingChannelSaveRequest) {
const response = await http.put<ApiEnvelope<SailingChannelResource>, ApiEnvelope<SailingChannelResource>>(`${channelPath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-channel') } })
return channel(unwrapApiResponse(response))
}
export async function listSailingTemplates() {
const response = await http.get<ApiEnvelope<SailingTemplateResource[]>, ApiEnvelope<SailingTemplateResource[]>>(templatePath, { params: { page: 1, size: 200 } })
const result = unwrapApiResponse(response) as SailingTemplateResource[] | { records?: SailingTemplateResource[] } | null
return (Array.isArray(result) ? result : result?.records || []).map(template)
}
export async function createSailingTemplate(payload: SailingTemplateSaveRequest) {
const response = await http.post<ApiEnvelope<SailingTemplateResource>, ApiEnvelope<SailingTemplateResource>>(templatePath, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-template') } })
return template(unwrapApiResponse(response))
}
export async function updateSailingTemplate(id: string, payload: SailingTemplateSaveRequest) {
const response = await http.put<ApiEnvelope<SailingTemplateResource>, ApiEnvelope<SailingTemplateResource>>(`${templatePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-template') } })
return template(unwrapApiResponse(response))
}
export async function previewSailingTemplateGeneration(id: string, payload: { from: string; to: string }) {
const response = await http.post<ApiEnvelope<SailingGenerationPreview>, ApiEnvelope<SailingGenerationPreview>>(`${templatePath}/${encodeURIComponent(id)}/generation-preview`, payload)
return unwrapApiResponse(response)
}
export async function generateSailingTemplate(id: string, payload: { from: string; to: string }) {
const response = await http.post<ApiEnvelope<SailingGenerationResult>, ApiEnvelope<SailingGenerationResult>>(`${templatePath}/${encodeURIComponent(id)}/generate`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-template-generate') } })
return unwrapApiResponse(response)
}
function exceptionRecord(raw: SailingExceptionRecord): SailingExceptionRecord {
return {
...raw,
assignmentId: String(raw.assignmentId),
volumeCbm: number(raw.volumeCbm),
waitingDays: raw.waitingDays == null ? null : number(raw.waitingDays),
assignmentVersion: raw.assignmentVersion == null ? null : number(raw.assignmentVersion),
}
}
export async function listSailingExceptions(query: SailingExceptionQuery = {}) {
const response = await http.get<ApiEnvelope<SailingExceptionPage>, ApiEnvelope<SailingExceptionPage>>(exceptionPath, { params: query })
const raw = unwrapApiResponse(response) as SailingExceptionPage | SailingExceptionRecord[] | null
if (Array.isArray(raw)) return { records: raw.map(exceptionRecord), page: 1, size: raw.length, total: raw.length, pages: raw.length ? 1 : 0 }
return { ...(raw || { records: [], page: 1, size: 20, total: 0, pages: 0 }), records: (raw?.records || []).map(exceptionRecord) }
}
/** Restricted exception reassignment; the server validates cutoff, route and warehouse scope. */
export async function reassignSailingAssignment(id: string, payload: SailingExceptionReassignRequest) {
const response = await http.post<ApiEnvelope<SailingExceptionReassignResult>, ApiEnvelope<SailingExceptionReassignResult>>(`${basePath}/${encodeURIComponent(id)}/exception-reassign`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-exception-reassign') } })
return unwrapApiResponse(response)
}
+2
View File
@@ -0,0 +1,2 @@
export * from './types'
export * from './api/platformSailingCalendar'
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
import type { RouteRecordRaw } from 'vue-router'
export const sailingCalendarRoutes = [{
path: 'sailing-calendar',
name: 'sailing-calendar',
component: () => import('./pages/SailingCalendarView.vue'),
meta: { title: '船期日历', description: '维护计划船期、查看收货体积和订柜建议', permission: 'platform:firstleg:sailing-calendar:list' },
}] satisfies RouteRecordRaw[]
+300
View File
@@ -0,0 +1,300 @@
export type SailingStatus = 'DRAFT' | 'OPEN' | 'CLOSED' | 'DEPARTED' | 'CANCELLED' | 'COMPLETED'
export type SailingAssignmentStatus = 'FORECAST_MATCHED' | 'RECEIVED' | 'LATE' | 'UNMATCHED' | 'SUPERSEDED'
export interface SailingVolumeSummary {
actualReceivedCbm: number
catchableForecastCbm: number
riskUnmatchedCbm: number
bookedContainerCount: number
recommendedContainerCount: number
pendingContainerCount: number
lateBatchCount: number
overCapacity: boolean
planningCapacityCbm?: number | null
bookedPlanningCapacityCbm?: number | null
catchableReceivedCbm?: number | null
lateReceivedCbm?: number | null
snapshotAt?: string | null
}
export interface SailingCalendarEntry {
id: string
sailingNo: string
warehouseId: string
warehouseName: string
channelCode: string
channelName: string
vesselName: string | null
voyageNo: string | null
departurePortCode: string
departurePortName?: string | null
destinationPortCode: string
destinationPortName?: string | null
etdAt: string
cutoffAt: string
status: SailingStatus
volumeSummary: SailingVolumeSummary
version: number
templateId?: string | null
channelId?: string | null
etaAt?: string | null
sourceType?: string | null
plannedCapacityCbm?: number | null
remark?: string | null
}
export interface SailingFilterOption {
value: string
label: string
}
export interface SailingCalendarFilterOptions {
warehouses: SailingFilterOption[]
channels: SailingFilterOption[]
departurePorts: SailingFilterOption[]
destinationPorts: SailingFilterOption[]
}
export interface SailingCalendarSummary extends SailingVolumeSummary {
sailingCount: number
}
export interface SailingCalendarResponse {
month: string
timezone: string
records: SailingCalendarEntry[]
summary: SailingCalendarSummary
filterOptions: SailingCalendarFilterOptions
}
export interface SailingCalendarQuery {
month: string
warehouseId?: string
channelCode?: string
departurePortCode?: string
destinationPortCode?: string
keyword?: string
status?: SailingStatus
}
export interface SailingSaveRequest {
templateId?: string | null
channelId?: string | null
warehouseId: string
warehouseName?: string | null
channelCode: string
channelName: string
vesselName: string | null
voyageNo: string | null
departurePortCode: string
departurePortName?: string | null
destinationPortCode: string
destinationPortName?: string | null
etdAt: string
etaAt?: string | null
cutoffAt: string
sourceType?: string | null
plannedCapacityCbm?: number | null
remark?: string | null
expectedVersion?: number
}
export interface SailingDetail {
sailing: SailingCalendarEntry
histories: SailingHistory[]
}
export interface SailingHistory {
id: string
action: string
fromStatus?: string | null
toStatus?: string | null
reason?: string | null
operatorName?: string | null
createdAt: string
}
export interface SailingVolumeDetail {
summary: SailingVolumeSummary
assignments: SailingAssignment[]
}
export interface SailingAssignment {
id: string
receiptBatchId?: string | null
forecastId?: string | null
inboundCode?: string | null
receivedAt?: string | null
volumeCbm: number
status: SailingAssignmentStatus
assignmentSource: 'AUTO' | 'MANUAL' | 'ROLLOVER'
assignmentVersion?: number | null
reason?: string | null
}
export interface SailingExceptionReassignRequest {
assignmentId: string
targetSailingId: string
expectedAssignmentVersion: number
expectedTargetVersion: number
reason: string
}
export interface SailingExceptionReassignResult {
oldAssignmentId: string
newAssignmentId: string
sourceSailingId: string
targetSailingId: string
assignmentVersion: number
matchStatus: SailingAssignmentStatus
volumeCbm?: number | null
forecastVolumeCbm?: number | null
reason?: string | null
}
export type SailingExceptionStatus = Extract<SailingAssignmentStatus, 'LATE' | 'UNMATCHED'>
export interface SailingExceptionRecord {
assignmentId: string
inboundCode?: string | null
receiptBatchId?: string | null
forecastId?: string | null
warehouseId: string
warehouseName?: string | null
channelCode?: string | null
channelName?: string | null
sourceSailingNo?: string | null
sourceVoyageNo?: string | null
status: SailingExceptionStatus
volumeCbm: number
receivedAt?: string | null
expectedArrivalAt?: string | null
waitingDays?: number | null
reason?: string | null
assignmentVersion?: number | null
}
export interface SailingExceptionPage {
records: SailingExceptionRecord[]
page: number
size: number
total: number
pages: number
}
export interface SailingExceptionQuery {
warehouseId?: string
channelCode?: string
status?: SailingExceptionStatus
page?: number
size?: number
}
export interface SailingBookingAdvice {
actualReceivedCbm: number
catchableTotalCbm: number
planningCapacityCbm?: number | null
bookedContainerCount: number
recommendedContainerCount: number
pendingContainerCount: number
scenarios: Array<{
containerTypeCode: string
containerTypeName: string
capacityCbm: number
recommendedContainerCount: number
pendingContainerCount: number
}>
calculatedAt: string
catchableReceivedCbm?: number | null
lateReceivedCbm?: number | null
}
export interface SailingBookingLinkRequest {
oceanBookingId: string
expectedVersion?: number
}
export type SailingChannelStatus = 'ACTIVE' | 'INACTIVE'
export type SailingChannelSourceType = 'CATALOG' | 'SCHEDULE_ONLY'
export interface SailingChannelResource {
id: string
channelCode: string
channelName: string
sourceType: SailingChannelSourceType
mappedChannelCode?: string | null
defaultDeparturePortCode?: string | null
defaultDestinationPortCode?: string | null
defaultCutoffDays: number
status: SailingChannelStatus
remark?: string | null
version: number
}
export interface SailingChannelSaveRequest {
channelCode: string
channelName: string
sourceType: SailingChannelSourceType
mappedChannelCode?: string | null
defaultDeparturePortCode?: string | null
defaultDestinationPortCode?: string | null
defaultCutoffDays: number
remark?: string | null
expectedVersion?: number
status?: SailingChannelStatus
}
export type SailingTemplateStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE'
export interface SailingTemplateResource {
id: string
templateNo: string
channelId: string
channelName: string
warehouseId: string
warehouseName?: string | null
weekdays: number[]
templateName?: string | null
effectiveFrom: string
effectiveTo?: string | null
departureTime: string
cutoffDays: number
cutoffLocalTime: string
voyageRule?: string | null
vesselName?: string | null
departurePortCode?: string | null
destinationPortCode?: string | null
remark?: string | null
status: SailingTemplateStatus
version: number
}
export interface SailingTemplateSaveRequest {
templateName?: string | null
channelId: string
warehouseId: string
warehouseName?: string | null
weekdays: number[]
effectiveFrom: string
effectiveTo?: string | null
departureTime: string
cutoffDays: number
cutoffLocalTime: string
voyageRule?: string | null
vesselName?: string | null
departurePortCode?: string | null
destinationPortCode?: string | null
remark?: string | null
expectedVersion?: number
}
export interface SailingGenerationPreview {
templateId: string
records: Array<{ date?: string; etdAt: string; cutoffAt: string; voyageNo?: string | null; sailingNo?: string | null; conflict?: boolean; conflictReason?: string | null }>
}
export interface SailingGenerationResult extends SailingGenerationPreview {
generatedCount: number
skippedCount: number
}
@@ -249,7 +249,7 @@ describe('UnifiedOrdersView contract', () => {
it('counts detail in the three directly visible row actions', () => {
expect(source).toContain("{ key: 'detail', label: '详情', priority: 500 }")
expect(source).toContain('...unifiedOrderSecondaryActions(row).map')
expect(source).toContain('<RowActions :actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
expect(source).toContain('<RowActions class="density-action" :actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
expect(source).toContain("if (action === 'approve') return '审核'")
expect(source).not.toContain("if (action === 'approve') return '通过'")
})
@@ -2740,7 +2740,7 @@ onBeforeUnmount(() => {
<el-button v-if="can('platform:firstleg:inbound:view')" :loading="printingCartonLabels" :disabled="loading || printingCartonLabels || !selectedCartonLabelOrders.length" @click="printSelectedCartonLabels"><Printer :size="16" />打印箱唛<span v-if="selectedCartonLabelOrders.length">({{ selectedCartonLabelOrders.length }})</span></el-button>
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
</template>
<template #actions="{ row }"><RowActions :actions="unifiedOrderRowActions(row as UnifiedOrderResource)" :more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" @action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)" /></template>
<template #actions="{ row }"><RowActions class="density-action" :actions="unifiedOrderRowActions(row as UnifiedOrderResource)" :more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" @action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)" /></template>
<template #inboundCode="{ row }">
<div class="inbound-relation-cell order-code-cell">
<strong class="inbound-code-value" :title="(row as UnifiedOrderResource).inboundCode || '待生成入仓码'">{{ (row as UnifiedOrderResource).inboundCode || '待生成入仓码' }}</strong>
+5 -4
View File
@@ -108,6 +108,7 @@ const domesticPages = [
{ title: '库位管理', path: '/master-data/locations', file: 'warehouse-resources/locations/pages/LocationsView.vue' },
{ title: '托盘管理', path: '/master-data/pallets', file: 'warehouse-resources/pallets/pages/PalletsView.vue' },
{ title: '整柜业务', path: '/domestic-containers', file: 'domestic-containers/pages/DomesticContainersView.vue' },
{ title: '船期日历', path: '/sailing-calendar', file: 'sailing-calendar/pages/SailingCalendarView.vue' },
]
const customerPages = [
{ title: '客户列表', path: '/customers', file: 'customers/pages/CustomersView.vue' },
@@ -247,11 +248,11 @@ describe('approved Admin density ownership', () => {
expect(read('modules/overseas-warehouse/pages/LastMileOrderDetailView.vue')).toContain('density-detail-overview')
})
it('covers all ten overseas warehouse menus, details and both return inventory modes', () => {
it('covers all eleven overseas warehouse menus, details and both return inventory modes', () => {
const navigation = read('core/navigation/adminNavigation.ts')
const section = navigation.slice(navigation.indexOf("key: 'overseas-inventory'"), navigation.indexOf("key: 'customer-service'"))
for (const path of ['last-mile-orders', 'inbound-orders', 'dispatches', 'outbounds', 'delivery-orders', 'relabel-orders', 'pallets', 'inventory', 'return-inventory', 'return-cartons']) expect(section).toContain("path: '/overseas-warehouse/" + path + "'")
expect([...section.matchAll(/path: '/g)]).toHaveLength(10)
for (const path of ['last-mile-orders', 'inbound-orders', 'locations', 'dispatches', 'outbounds', 'delivery-orders', 'relabel-orders', 'pallets', 'inventory', 'return-inventory', 'return-cartons']) expect(section).toContain("path: '/overseas-warehouse/" + path + "'")
expect([...section.matchAll(/path: '/g)]).toHaveLength(11)
const routes = read('modules/overseas-warehouse/routes.ts')
for (const mode of ['inventory', 'cartons']) expect(routes).toContain("mode: '" + mode + "'")
for (const file of overseasConsumers) {
@@ -376,7 +377,7 @@ describe('approved Admin density ownership', () => {
}
})
it('covers all seven domestic menu entries and loads density CSS on direct entry', () => {
it('covers all eight domestic menu entries and loads density CSS on direct entry', () => {
const navigation = read('core/navigation/adminNavigation.ts')
const domestic = navigation.slice(navigation.indexOf("key: 'domestic-warehouse'"), navigation.indexOf("key: 'overseas-delivery'"))
expect([...domestic.matchAll(/path: '/g)]).toHaveLength(domesticPages.length)