Merge pull request 'Release/test' (#11) from release/test into main
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -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 核对受影响的状态和桌面布局;缺少截图标记“待人工截图确认”,不自行批量取证。自动检查单独报告“自动检查通过/失败”;纯逻辑且无视觉变化标记“无需视觉验收”,不能据此声称真实页面验收通过。
|
||||
|
||||
## 命令与质量门禁
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('domestic outbound list responsive contracts', () => {
|
||||
const content = source(path)
|
||||
const columns = [...content.matchAll(/\{[^{}]*(?:key|prop): '[^']+'[^{}]*label: '[^']+'[^{}]*\}/g)]
|
||||
.map((match) => match[0])
|
||||
.filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'"))
|
||||
.filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'") && !column.includes('priority:') && !column.includes("key: 'view'"))
|
||||
expect(columns.length).toBeGreaterThan(1)
|
||||
for (const column of columns) expect(column).toMatch(/\b(?:width|minWidth):\s*\d+/)
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes'
|
||||
import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
|
||||
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
|
||||
import { pdaReleaseRoutes } from '@/modules/pda-releases/routes'
|
||||
import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -77,6 +78,7 @@ const router = createRouter({
|
||||
...downloadRoutes,
|
||||
...iamRoutes,
|
||||
...pdaReleaseRoutes,
|
||||
...sailingCalendarRoutes,
|
||||
{
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
|
||||
@@ -35,6 +35,7 @@ describe('adminNavigation', () => {
|
||||
'库位管理',
|
||||
'托盘管理',
|
||||
'整柜业务',
|
||||
'船期日历',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访'])
|
||||
expect(adminNavigation.find((item) => item.key === 'supplier-center')).toBeUndefined()
|
||||
@@ -47,6 +48,7 @@ describe('adminNavigation', () => {
|
||||
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
|
||||
'尾程订单',
|
||||
'入库单',
|
||||
'库位管理',
|
||||
'排车列表',
|
||||
'出库单',
|
||||
'派送单列表',
|
||||
@@ -68,6 +70,7 @@ describe('adminNavigation', () => {
|
||||
'船期价格表',
|
||||
'车辆列表',
|
||||
'下载中心',
|
||||
'称重机器',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
|
||||
@@ -76,6 +79,7 @@ describe('adminNavigation', () => {
|
||||
'租户管理',
|
||||
'品牌设置',
|
||||
'菜单权限',
|
||||
'PDA版本',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
Building2,
|
||||
CalendarDays,
|
||||
ContactRound,
|
||||
ClipboardList,
|
||||
CircleDollarSign,
|
||||
@@ -70,6 +71,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
|
||||
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
|
||||
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
|
||||
{ key: 'sailing-calendar', title: '船期日历', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -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,4 +1,5 @@
|
||||
export * from './api/platformContainerLoading'
|
||||
export * from './types'
|
||||
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
|
||||
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
|
||||
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
|
||||
|
||||
@@ -47,7 +47,8 @@ describe('ContainerDetailView customs integration', () => {
|
||||
expect(source).toContain('<el-tabs v-model="activeTab"')
|
||||
expect(source).toContain("v-if=\"isDomestic && activeTab === 'loading-list'\"")
|
||||
expect(source).toContain("loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)")
|
||||
expect(source).toContain("getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)")
|
||||
expect(source).toContain('getContainer(containerId.value, props.workbench)')
|
||||
expect(source).toContain('getContainerOverview(containerId.value)')
|
||||
expect(source).toContain(':can-edit="false"')
|
||||
})
|
||||
|
||||
|
||||
@@ -1070,7 +1070,7 @@ onMounted(() => { void loadCustomers() })
|
||||
<div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" :stripe="true" border>
|
||||
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers">导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate">新增客户</el-button></div></template>
|
||||
<template #actions="{ row }"><RowActions :actions="customerRowActions(row as PlatformTenantResource)" :more-label="customerMoreActionsLabel(row as PlatformTenantResource)" @action="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></template>
|
||||
<template #actions="{ row }"><RowActions class="density-action" :actions="customerRowActions(row as PlatformTenantResource)" :more-label="customerMoreActionsLabel(row as PlatformTenantResource)" @action="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></template>
|
||||
<template #company="{ row }"><strong class="customer-company-name">{{ (row as PlatformTenantResource).tenantName }}</strong></template>
|
||||
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
|
||||
<template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template>
|
||||
|
||||
@@ -5,3 +5,5 @@ export {
|
||||
setCustomerChargeRuleDefault,
|
||||
} from './customer-rules/api'
|
||||
export type { CustomerChargeRule, CreateCustomerChargeRuleRequest } from './customer-rules/api'
|
||||
export { getPlatformPriceBook, listPlatformPriceBooks } from './carrier-rates/api/platformPriceBooks'
|
||||
export type { PlatformPriceBookParsedChannel } from './carrier-rates/api/platformPriceBooks'
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('finance presentation governance', () => {
|
||||
expect(statements).toContain('createReconciliationStatement')
|
||||
expect(statements).toContain('actionReconciliationStatement')
|
||||
expect(statements).toContain('<template #toolbar-actions>')
|
||||
expect(statements).toContain('aria-label="更多对账单操作"')
|
||||
expect(statements).toContain(':more-label="`更多对账单操作 · ${(row as FinanceStatementReadModel).billNo}`"')
|
||||
expect(statements).toContain('adjustmentLines: selected.map')
|
||||
expect(statements).not.toContain('disabled title="接口尚未提供')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './api/platformSailingCalendar'
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const sailingCalendarRoutes = [{
|
||||
path: 'sailing-calendar',
|
||||
name: 'sailing-calendar',
|
||||
component: () => import('./pages/SailingCalendarView.vue'),
|
||||
meta: { title: '船期日历', description: '维护计划船期、查看收货体积和订柜建议', permission: 'platform:firstleg:sailing-calendar:list' },
|
||||
}] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,300 @@
|
||||
export type SailingStatus = 'DRAFT' | 'OPEN' | 'CLOSED' | 'DEPARTED' | 'CANCELLED' | 'COMPLETED'
|
||||
|
||||
export type SailingAssignmentStatus = 'FORECAST_MATCHED' | 'RECEIVED' | 'LATE' | 'UNMATCHED' | 'SUPERSEDED'
|
||||
|
||||
export interface SailingVolumeSummary {
|
||||
actualReceivedCbm: number
|
||||
catchableForecastCbm: number
|
||||
riskUnmatchedCbm: number
|
||||
bookedContainerCount: number
|
||||
recommendedContainerCount: number
|
||||
pendingContainerCount: number
|
||||
lateBatchCount: number
|
||||
overCapacity: boolean
|
||||
planningCapacityCbm?: number | null
|
||||
bookedPlanningCapacityCbm?: number | null
|
||||
catchableReceivedCbm?: number | null
|
||||
lateReceivedCbm?: number | null
|
||||
snapshotAt?: string | null
|
||||
}
|
||||
|
||||
export interface SailingCalendarEntry {
|
||||
id: string
|
||||
sailingNo: string
|
||||
warehouseId: string
|
||||
warehouseName: string
|
||||
channelCode: string
|
||||
channelName: string
|
||||
vesselName: string | null
|
||||
voyageNo: string | null
|
||||
departurePortCode: string
|
||||
departurePortName?: string | null
|
||||
destinationPortCode: string
|
||||
destinationPortName?: string | null
|
||||
etdAt: string
|
||||
cutoffAt: string
|
||||
status: SailingStatus
|
||||
volumeSummary: SailingVolumeSummary
|
||||
version: number
|
||||
templateId?: string | null
|
||||
channelId?: string | null
|
||||
etaAt?: string | null
|
||||
sourceType?: string | null
|
||||
plannedCapacityCbm?: number | null
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface SailingFilterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SailingCalendarFilterOptions {
|
||||
warehouses: SailingFilterOption[]
|
||||
channels: SailingFilterOption[]
|
||||
departurePorts: SailingFilterOption[]
|
||||
destinationPorts: SailingFilterOption[]
|
||||
}
|
||||
|
||||
export interface SailingCalendarSummary extends SailingVolumeSummary {
|
||||
sailingCount: number
|
||||
}
|
||||
|
||||
export interface SailingCalendarResponse {
|
||||
month: string
|
||||
timezone: string
|
||||
records: SailingCalendarEntry[]
|
||||
summary: SailingCalendarSummary
|
||||
filterOptions: SailingCalendarFilterOptions
|
||||
}
|
||||
|
||||
export interface SailingCalendarQuery {
|
||||
month: string
|
||||
warehouseId?: string
|
||||
channelCode?: string
|
||||
departurePortCode?: string
|
||||
destinationPortCode?: string
|
||||
keyword?: string
|
||||
status?: SailingStatus
|
||||
}
|
||||
|
||||
export interface SailingSaveRequest {
|
||||
templateId?: string | null
|
||||
channelId?: string | null
|
||||
warehouseId: string
|
||||
warehouseName?: string | null
|
||||
channelCode: string
|
||||
channelName: string
|
||||
vesselName: string | null
|
||||
voyageNo: string | null
|
||||
departurePortCode: string
|
||||
departurePortName?: string | null
|
||||
destinationPortCode: string
|
||||
destinationPortName?: string | null
|
||||
etdAt: string
|
||||
etaAt?: string | null
|
||||
cutoffAt: string
|
||||
sourceType?: string | null
|
||||
plannedCapacityCbm?: number | null
|
||||
remark?: string | null
|
||||
expectedVersion?: number
|
||||
}
|
||||
|
||||
export interface SailingDetail {
|
||||
sailing: SailingCalendarEntry
|
||||
histories: SailingHistory[]
|
||||
}
|
||||
|
||||
export interface SailingHistory {
|
||||
id: string
|
||||
action: string
|
||||
fromStatus?: string | null
|
||||
toStatus?: string | null
|
||||
reason?: string | null
|
||||
operatorName?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SailingVolumeDetail {
|
||||
summary: SailingVolumeSummary
|
||||
assignments: SailingAssignment[]
|
||||
}
|
||||
|
||||
export interface SailingAssignment {
|
||||
id: string
|
||||
receiptBatchId?: string | null
|
||||
forecastId?: string | null
|
||||
inboundCode?: string | null
|
||||
receivedAt?: string | null
|
||||
volumeCbm: number
|
||||
status: SailingAssignmentStatus
|
||||
assignmentSource: 'AUTO' | 'MANUAL' | 'ROLLOVER'
|
||||
assignmentVersion?: number | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export interface SailingExceptionReassignRequest {
|
||||
assignmentId: string
|
||||
targetSailingId: string
|
||||
expectedAssignmentVersion: number
|
||||
expectedTargetVersion: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface SailingExceptionReassignResult {
|
||||
oldAssignmentId: string
|
||||
newAssignmentId: string
|
||||
sourceSailingId: string
|
||||
targetSailingId: string
|
||||
assignmentVersion: number
|
||||
matchStatus: SailingAssignmentStatus
|
||||
volumeCbm?: number | null
|
||||
forecastVolumeCbm?: number | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export type SailingExceptionStatus = Extract<SailingAssignmentStatus, 'LATE' | 'UNMATCHED'>
|
||||
|
||||
export interface SailingExceptionRecord {
|
||||
assignmentId: string
|
||||
inboundCode?: string | null
|
||||
receiptBatchId?: string | null
|
||||
forecastId?: string | null
|
||||
warehouseId: string
|
||||
warehouseName?: string | null
|
||||
channelCode?: string | null
|
||||
channelName?: string | null
|
||||
sourceSailingNo?: string | null
|
||||
sourceVoyageNo?: string | null
|
||||
status: SailingExceptionStatus
|
||||
volumeCbm: number
|
||||
receivedAt?: string | null
|
||||
expectedArrivalAt?: string | null
|
||||
waitingDays?: number | null
|
||||
reason?: string | null
|
||||
assignmentVersion?: number | null
|
||||
}
|
||||
|
||||
export interface SailingExceptionPage {
|
||||
records: SailingExceptionRecord[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface SailingExceptionQuery {
|
||||
warehouseId?: string
|
||||
channelCode?: string
|
||||
status?: SailingExceptionStatus
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface SailingBookingAdvice {
|
||||
actualReceivedCbm: number
|
||||
catchableTotalCbm: number
|
||||
planningCapacityCbm?: number | null
|
||||
bookedContainerCount: number
|
||||
recommendedContainerCount: number
|
||||
pendingContainerCount: number
|
||||
scenarios: Array<{
|
||||
containerTypeCode: string
|
||||
containerTypeName: string
|
||||
capacityCbm: number
|
||||
recommendedContainerCount: number
|
||||
pendingContainerCount: number
|
||||
}>
|
||||
calculatedAt: string
|
||||
catchableReceivedCbm?: number | null
|
||||
lateReceivedCbm?: number | null
|
||||
}
|
||||
|
||||
export interface SailingBookingLinkRequest {
|
||||
oceanBookingId: string
|
||||
expectedVersion?: number
|
||||
}
|
||||
|
||||
export type SailingChannelStatus = 'ACTIVE' | 'INACTIVE'
|
||||
export type SailingChannelSourceType = 'CATALOG' | 'SCHEDULE_ONLY'
|
||||
|
||||
export interface SailingChannelResource {
|
||||
id: string
|
||||
channelCode: string
|
||||
channelName: string
|
||||
sourceType: SailingChannelSourceType
|
||||
mappedChannelCode?: string | null
|
||||
defaultDeparturePortCode?: string | null
|
||||
defaultDestinationPortCode?: string | null
|
||||
defaultCutoffDays: number
|
||||
status: SailingChannelStatus
|
||||
remark?: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface SailingChannelSaveRequest {
|
||||
channelCode: string
|
||||
channelName: string
|
||||
sourceType: SailingChannelSourceType
|
||||
mappedChannelCode?: string | null
|
||||
defaultDeparturePortCode?: string | null
|
||||
defaultDestinationPortCode?: string | null
|
||||
defaultCutoffDays: number
|
||||
remark?: string | null
|
||||
expectedVersion?: number
|
||||
status?: SailingChannelStatus
|
||||
}
|
||||
|
||||
export type SailingTemplateStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE'
|
||||
|
||||
export interface SailingTemplateResource {
|
||||
id: string
|
||||
templateNo: string
|
||||
channelId: string
|
||||
channelName: string
|
||||
warehouseId: string
|
||||
warehouseName?: string | null
|
||||
weekdays: number[]
|
||||
templateName?: string | null
|
||||
effectiveFrom: string
|
||||
effectiveTo?: string | null
|
||||
departureTime: string
|
||||
cutoffDays: number
|
||||
cutoffLocalTime: string
|
||||
voyageRule?: string | null
|
||||
vesselName?: string | null
|
||||
departurePortCode?: string | null
|
||||
destinationPortCode?: string | null
|
||||
remark?: string | null
|
||||
status: SailingTemplateStatus
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface SailingTemplateSaveRequest {
|
||||
templateName?: string | null
|
||||
channelId: string
|
||||
warehouseId: string
|
||||
warehouseName?: string | null
|
||||
weekdays: number[]
|
||||
effectiveFrom: string
|
||||
effectiveTo?: string | null
|
||||
departureTime: string
|
||||
cutoffDays: number
|
||||
cutoffLocalTime: string
|
||||
voyageRule?: string | null
|
||||
vesselName?: string | null
|
||||
departurePortCode?: string | null
|
||||
destinationPortCode?: string | null
|
||||
remark?: string | null
|
||||
expectedVersion?: number
|
||||
}
|
||||
|
||||
export interface SailingGenerationPreview {
|
||||
templateId: string
|
||||
records: Array<{ date?: string; etdAt: string; cutoffAt: string; voyageNo?: string | null; sailingNo?: string | null; conflict?: boolean; conflictReason?: string | null }>
|
||||
}
|
||||
|
||||
export interface SailingGenerationResult extends SailingGenerationPreview {
|
||||
generatedCount: number
|
||||
skippedCount: number
|
||||
}
|
||||
@@ -249,7 +249,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
it('counts detail in the three directly visible row actions', () => {
|
||||
expect(source).toContain("{ key: 'detail', label: '详情', priority: 500 }")
|
||||
expect(source).toContain('...unifiedOrderSecondaryActions(row).map')
|
||||
expect(source).toContain('<RowActions :actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
|
||||
expect(source).toContain('<RowActions class="density-action" :actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
|
||||
expect(source).toContain("if (action === 'approve') return '审核'")
|
||||
expect(source).not.toContain("if (action === 'approve') return '通过'")
|
||||
})
|
||||
|
||||
@@ -2740,7 +2740,7 @@ onBeforeUnmount(() => {
|
||||
<el-button v-if="can('platform:firstleg:inbound:view')" :loading="printingCartonLabels" :disabled="loading || printingCartonLabels || !selectedCartonLabelOrders.length" @click="printSelectedCartonLabels"><Printer :size="16" />打印箱唛<span v-if="selectedCartonLabelOrders.length">({{ selectedCartonLabelOrders.length }})</span></el-button>
|
||||
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }"><RowActions :actions="unifiedOrderRowActions(row as UnifiedOrderResource)" :more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" @action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)" /></template>
|
||||
<template #actions="{ row }"><RowActions class="density-action" :actions="unifiedOrderRowActions(row as UnifiedOrderResource)" :more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" @action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)" /></template>
|
||||
<template #inboundCode="{ row }">
|
||||
<div class="inbound-relation-cell order-code-cell">
|
||||
<strong class="inbound-code-value" :title="(row as UnifiedOrderResource).inboundCode || '待生成入仓码'">{{ (row as UnifiedOrderResource).inboundCode || '待生成入仓码' }}</strong>
|
||||
|
||||
@@ -108,6 +108,7 @@ const domesticPages = [
|
||||
{ title: '库位管理', path: '/master-data/locations', file: 'warehouse-resources/locations/pages/LocationsView.vue' },
|
||||
{ title: '托盘管理', path: '/master-data/pallets', file: 'warehouse-resources/pallets/pages/PalletsView.vue' },
|
||||
{ title: '整柜业务', path: '/domestic-containers', file: 'domestic-containers/pages/DomesticContainersView.vue' },
|
||||
{ title: '船期日历', path: '/sailing-calendar', file: 'sailing-calendar/pages/SailingCalendarView.vue' },
|
||||
]
|
||||
const customerPages = [
|
||||
{ title: '客户列表', path: '/customers', file: 'customers/pages/CustomersView.vue' },
|
||||
@@ -247,11 +248,11 @@ describe('approved Admin density ownership', () => {
|
||||
expect(read('modules/overseas-warehouse/pages/LastMileOrderDetailView.vue')).toContain('density-detail-overview')
|
||||
})
|
||||
|
||||
it('covers all ten overseas warehouse menus, details and both return inventory modes', () => {
|
||||
it('covers all eleven overseas warehouse menus, details and both return inventory modes', () => {
|
||||
const navigation = read('core/navigation/adminNavigation.ts')
|
||||
const section = navigation.slice(navigation.indexOf("key: 'overseas-inventory'"), navigation.indexOf("key: 'customer-service'"))
|
||||
for (const path of ['last-mile-orders', 'inbound-orders', 'dispatches', 'outbounds', 'delivery-orders', 'relabel-orders', 'pallets', 'inventory', 'return-inventory', 'return-cartons']) expect(section).toContain("path: '/overseas-warehouse/" + path + "'")
|
||||
expect([...section.matchAll(/path: '/g)]).toHaveLength(10)
|
||||
for (const path of ['last-mile-orders', 'inbound-orders', 'locations', 'dispatches', 'outbounds', 'delivery-orders', 'relabel-orders', 'pallets', 'inventory', 'return-inventory', 'return-cartons']) expect(section).toContain("path: '/overseas-warehouse/" + path + "'")
|
||||
expect([...section.matchAll(/path: '/g)]).toHaveLength(11)
|
||||
const routes = read('modules/overseas-warehouse/routes.ts')
|
||||
for (const mode of ['inventory', 'cartons']) expect(routes).toContain("mode: '" + mode + "'")
|
||||
for (const file of overseasConsumers) {
|
||||
@@ -376,7 +377,7 @@ describe('approved Admin density ownership', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('covers all seven domestic menu entries and loads density CSS on direct entry', () => {
|
||||
it('covers all eight domestic menu entries and loads density CSS on direct entry', () => {
|
||||
const navigation = read('core/navigation/adminNavigation.ts')
|
||||
const domestic = navigation.slice(navigation.indexOf("key: 'domestic-warehouse'"), navigation.indexOf("key: 'overseas-delivery'"))
|
||||
expect([...domestic.matchAll(/path: '/g)]).toHaveLength(domesticPages.length)
|
||||
|
||||
Reference in New Issue
Block a user