Release/test 合并上生产 #2
@@ -81,12 +81,10 @@ describe('admin router access metadata', () => {
|
||||
|
||||
it('registers the confirmed phase-four routes with stable list permissions', () => {
|
||||
const expected = [
|
||||
['overseas-last-mile-orders', '/overseas-warehouse/last-mile-orders', 'platform:overseas-warehouse:last-mile-order:list'],
|
||||
['overseas-last-mile-orders', '/overseas-warehouse/last-mile-orders', 'platform:lastleg:order:list'],
|
||||
['overseas-inbound-orders', '/overseas-warehouse/inbound-orders', 'platform:lastleg:inbound-plan:list'],
|
||||
['overseas-relabel-orders', '/overseas-warehouse/relabel-orders', 'platform:overseas-warehouse:relabel-order:list'],
|
||||
['overseas-relabel-orders', '/overseas-warehouse/relabel-orders', 'platform:lastleg:relabel:list'],
|
||||
['overseas-pallets', '/overseas-warehouse/pallets', 'platform:pallet:list'],
|
||||
['overseas-cartons', '/overseas-warehouse/cartons', 'platform:overseas-warehouse:carton:list'],
|
||||
['overseas-products', '/overseas-warehouse/products', 'platform:overseas-warehouse:product:list'],
|
||||
['overseas-inventory', '/overseas-warehouse/inventory', 'platform:lastleg:inventory:list'],
|
||||
['overseas-business-purchases', '/overseas-business/purchases', 'platform:overseas-business:purchase:list'],
|
||||
['overseas-business-trucks', '/overseas-business/trucks', 'platform:overseas-business:truck:list'],
|
||||
|
||||
@@ -52,8 +52,6 @@ describe('adminNavigation', () => {
|
||||
'入库单',
|
||||
'换标单',
|
||||
'托盘管理',
|
||||
'箱号管理',
|
||||
'产品库',
|
||||
'库存管理',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
|
||||
@@ -139,12 +137,10 @@ describe('adminNavigation', () => {
|
||||
|
||||
it('shows only the confirmed phase-four pages with their list permissions', () => {
|
||||
const filtered = filterAdminNavigation(adminNavigation, new Set([
|
||||
'platform:overseas-warehouse:last-mile-order:list',
|
||||
'platform:lastleg:order:list',
|
||||
'platform:lastleg:inbound-plan:list',
|
||||
'platform:overseas-warehouse:relabel-order:list',
|
||||
'platform:lastleg:relabel:list',
|
||||
'platform:pallet:list',
|
||||
'platform:overseas-warehouse:carton:list',
|
||||
'platform:overseas-warehouse:product:list',
|
||||
'platform:lastleg:inventory:list',
|
||||
'platform:overseas-business:purchase:list',
|
||||
'platform:overseas-business:truck:list',
|
||||
@@ -160,8 +156,6 @@ describe('adminNavigation', () => {
|
||||
'/overseas-warehouse/inbound-orders',
|
||||
'/overseas-warehouse/relabel-orders',
|
||||
'/overseas-warehouse/pallets',
|
||||
'/overseas-warehouse/cartons',
|
||||
'/overseas-warehouse/products',
|
||||
'/overseas-warehouse/inventory',
|
||||
'/overseas-business/purchases',
|
||||
'/overseas-business/trucks',
|
||||
@@ -170,6 +164,8 @@ describe('adminNavigation', () => {
|
||||
'/overseas-master-data/quotes',
|
||||
'/overseas-master-data/vehicles',
|
||||
]))
|
||||
expect(paths).not.toContain('/overseas-warehouse/cartons')
|
||||
expect(paths).not.toContain('/overseas-warehouse/products')
|
||||
expect(paths.some((path) => path.includes('outbound') || path.includes('delivery'))).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -120,12 +120,10 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
title: '海外仓',
|
||||
icon: PrototypeOverseasWarehouseIcon,
|
||||
children: [
|
||||
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
|
||||
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:lastleg:order:list' },
|
||||
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:lastleg:inbound-plan:list' },
|
||||
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
|
||||
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:lastleg:relabel:list' },
|
||||
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:pallet:list' },
|
||||
{ key: 'fba-carton', title: '箱号管理', icon: ClipboardList, path: '/overseas-warehouse/cartons', permission: 'platform:overseas-warehouse:carton:list' },
|
||||
{ key: 'fba-product', title: '产品库', icon: Database, path: '/overseas-warehouse/products', permission: 'platform:overseas-warehouse:product:list' },
|
||||
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:lastleg:inventory:list' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock('@/shared/api/http', () => ({
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
|
||||
const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
|
||||
const plan = {
|
||||
id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'LOADING',
|
||||
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED',
|
||||
@@ -38,7 +38,7 @@ describe('platform container loading API', () => {
|
||||
it('normalizes paged container ids and numeric metadata', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } })
|
||||
const result = await listContainers({ page: 1, size: 20 })
|
||||
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', version: 2, capacityCbm: 76 })
|
||||
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: 2, capacityCbm: 76 })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } })
|
||||
})
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
|
||||
oceanBookingRequirementId: optionalId(raw.oceanBookingRequirementId),
|
||||
customerId: optionalId(raw.customerId),
|
||||
shippingCompanyId: optionalId(raw.shippingCompanyId),
|
||||
emptyReportSourceVersion: optionalId(raw.emptyReportSourceVersion),
|
||||
version: numberValue(raw.version),
|
||||
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
|
||||
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
|
||||
|
||||
@@ -28,4 +28,13 @@ describe('ContainerDetailView', () => {
|
||||
expect(source).toContain(':ocean-booking-id="detail.oceanBookingId"')
|
||||
expect(source).toContain(':container-booking-id="detail.id"')
|
||||
})
|
||||
|
||||
it('shows overseas unloading and empty-report facts without adding upload or PDA actions', () => {
|
||||
expect(source).toContain('detail.unloadingCompletedAt')
|
||||
expect(source).toContain('detail.unloadingPhotoUploadedAt')
|
||||
expect(source).toContain('detail.estimatedEmptyReportedAt')
|
||||
expect(source).toContain('detail.actualEmptyReportedAt')
|
||||
expect(source).not.toContain('uploadUnloadingPhoto')
|
||||
expect(source).not.toContain('submitEmptyReport')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -153,7 +153,7 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
|
||||
</section>
|
||||
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></section>
|
||||
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></section>
|
||||
<div class="surface-panel"><CarrierScheduleQueryPanel :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" /></div>
|
||||
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
||||
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocument" @download-document="downloadDocument" @manage-exception="openException" @preview-photo="previewPhoto" />
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface ContainerBookingResource {
|
||||
actualPortArrivalAt: string | null
|
||||
estimatedWarehouseArrivalAt: string | null
|
||||
actualWarehouseArrivalAt: string | null
|
||||
unloadingCompletedAt?: string | null
|
||||
unloadingPhotoUploadedAt?: string | null
|
||||
estimatedEmptyReportedAt?: string | null
|
||||
actualEmptyReportedAt?: string | null
|
||||
emptyReportSourceVersion?: string | null
|
||||
planStatus: ContainerPlanStatus
|
||||
exceptionActive?: boolean
|
||||
destinationClearanceDocumentStatus?: ContainerDocumentStatus
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
# 海外业务真实接口接入与剩余缺口
|
||||
# 海外业务真实接口契约
|
||||
|
||||
模块通过 `@/shared/api/overseasHttp` 访问海外 Gateway,业务路径只传 `/platform/v1/...`。页面不读取 Token、不调用兑换端点,也不感知区域 Base。已接入:
|
||||
模块通过 `@/shared/api/overseasHttp` 访问海外 Gateway,业务路径只传 `/platform/v1/...`。页面不读取 Token、不调用兑换端点,也不感知区域 Base。
|
||||
|
||||
- 海外采购列表、详情及服务端允许字段的 `PATCH` 编辑;
|
||||
- 本土卡车列表及详情数据(详情只用于原型既有编辑页的数据承载,不新增详情路由);
|
||||
- 长整型 ID、金额/重量/体积的字符串归一化,真实状态映射,以及加载、失败、重试和空态。
|
||||
当前已接入:
|
||||
|
||||
仍缺少或明确不接入:
|
||||
- 海外采购:客户候选、采购仓候选、列表、详情操作流水及 `PATCH` 三态编辑;缺失字段不修改,显式 `null` 清空。
|
||||
- 本土卡车:平台列表、详情、创建和仅待审核状态允许的普通编辑。
|
||||
- 本土散板:独立稳定子域的列表、详情数据承载、创建和仅待接单状态允许的编辑;详情只用于既有编辑页预填,不新增详情路由。
|
||||
- canonical 权限统一为 `platform:overseas-business:*`;长整型 ID 与 decimal 字段在适配层归一化。
|
||||
|
||||
- 海外采购:后端详情只有出库记录,没有原型“进销存明细”所需的完整入库/余额流水;客户与采购仓库候选接口尚未冻结。平台 `review` 与 `outbounds` 不在本专项接入范围。
|
||||
- 本土卡车:平台端只有列表、详情、状态流转和收托盘,没有平台新增或普通编辑接口;新增/编辑提交继续明确阻断,不借用 Merchant、状态流转或收托盘端点。
|
||||
- 本土散板:`lastleg-service` 尚无独立稳定 Controller/DTO/OpenAPI,列表、新增、编辑均保持缺口,不借用卡车端点。
|
||||
- 权限:前端冻结为 `platform:overseas-business:*`,当前海外服务端采购和卡车管理权限分别为 `lastleg:overseas-procurement:manage`、`lastleg:us-trucking:manage`。冲突只记录,页面不绕过;由共享权限总控统一收口。
|
||||
明确不接入:
|
||||
|
||||
任何后续写接口都应继续以海外服务 Controller、DTO、OpenAPI 和状态机为准,不发明路径、不模拟成功、不连接国内 Gateway。
|
||||
- 采购审核 `review`:后端存在,但当前页面原型与本专项范围排除。
|
||||
- 采购出库:后端已移除 `/outbounds`,四期不承载采购出库或库存离仓。
|
||||
- 卡车状态推进、收托盘和散板状态推进:后端存在,但当前原型没有对应操作承载方式。
|
||||
- 海外出库、海外派送和 Merchant API。
|
||||
|
||||
剩余验收项仅为真实 Gateway/Token 环境 HTTP 验收及 `1920×1080`、浏览器缩放 `100%` 的人工页面操作和截图确认。
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式 | 冻结结论 |
|
||||
|---|---|---|---|---|
|
||||
| 海外采购订单 | 列表/筛选 | `page-overseas-purchase` 2–12 行;海外面板 9–11 行;`renderOverseasPurchaseList()` 13531–13534 行 | 独立列表页,订单编号/采购品类/商品名称筛选 | 仅实现海外面板;国内页与 `domestic-*` 全部排除;操作列第一列 |
|
||||
| 海外采购订单 | 列表/筛选 | `page-overseas-purchase` 2–12 行;海外面板 9–11 行;`renderOverseasPurchaseList()` 13531–13534 行 | 独立列表页,订单编号/采购品类/商品名称筛选 | 仅实现海外面板;国内页与 `domestic-*` 全部排除;操作列第一列;真实联调补充后端客户候选筛选 |
|
||||
| 海外采购订单 | 详情 | `page-overseas-purchase-detail` 204–206 行;`showOverseasPurchaseDetail()` 13538–13540 行 | 独立详情页,商品/库存/订单/快递/进销存五分区 | 建立正式详情路由,详情复用 `purchase:list` |
|
||||
| 海外采购订单 | 编辑 | `page-overseas-purchase-edit` 143–150 行;`editOverseasPurchase()`、`saveOverseasPurchaseEdit()` 13536–13537 行 | 独立编辑页 | 建立正式编辑路由,使用独立 `purchase:edit` 权限 |
|
||||
| 海外采购订单 | 新增 | 14–15 行明确“历史新增表单仅保留为不可达模板” | 不可达 | 不建立新增入口或路由 |
|
||||
| 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
|
||||
| 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;端点未冻结前提交禁用 |
|
||||
| 本土卡车 | 编辑 | `page-overseas-truck-edit` 313–362 行;`editTruckOrder()`、`saveTruckOrderEdit()` 13630–13674 行 | 独立页面 | 建立正式编辑路由;端点未冻结前提交禁用 |
|
||||
| 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;使用平台创建接口真实提交并显式选择客户 |
|
||||
| 本土卡车 | 编辑 | `page-overseas-truck-edit` 313–362 行;`editTruckOrder()`、`saveTruckOrderEdit()` 13630–13674 行 | 独立页面 | 建立正式编辑路由;仅 `PENDING_REVIEW` 可真实提交 |
|
||||
| 本土卡车 | 详情 | 列表渲染函数 13563 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
|
||||
| 本土散板 | 列表 | `page-overseas-looseboard` 222–244 行;`renderLooseboardList()` 13589–13605 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
|
||||
| 本土散板 | 新增 | `page-overseas-looseboard-add` 247–310 行;`saveLooseboardOrder()` 13606–13622 行 | 独立页面 | 建立正式新增路由;端点未冻结前提交禁用 |
|
||||
| 本土散板 | 编辑 | `page-overseas-looseboard-edit` 365–430 行;`editLooseboardOrder()`、`saveLooseboardOrderEdit()` 13676–13726 行 | 独立页面 | 建立正式编辑路由;端点未冻结前提交禁用 |
|
||||
| 本土散板 | 新增 | `page-overseas-looseboard-add` 247–310 行;`saveLooseboardOrder()` 13606–13622 行 | 独立页面 | 建立正式新增路由;使用独立稳定子域真实提交并显式选择客户 |
|
||||
| 本土散板 | 编辑 | `page-overseas-looseboard-edit` 365–430 行;`editLooseboardOrder()`、`saveLooseboardOrderEdit()` 13676–13726 行 | 独立页面 | 建立正式编辑路由;仅 `PENDING_ACCEPTANCE` 可真实提交 |
|
||||
| 本土散板 | 详情 | 列表渲染函数 13602 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
|
||||
|
||||
删除、审核/确认和文件预览在三类正式页面中均未定义,本批不自行补充承载方式。
|
||||
|
||||
@@ -1,55 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const overseas = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn() }))
|
||||
const overseas = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn(), post: vi.fn(), put: vi.fn() }))
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: overseas }))
|
||||
|
||||
import { getOverseasPurchase, getUsTruckOrder, listOverseasPurchases, listUsTruckOrders, updateOverseasPurchase } from './platformOverseasBusiness'
|
||||
import {
|
||||
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
|
||||
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases,
|
||||
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase,
|
||||
updateUsTruckOrder,
|
||||
} from './platformOverseasBusiness'
|
||||
|
||||
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
|
||||
const purchaseSummary = {
|
||||
id: '9007199254740993', orderNo: 'OP-1', customerId: 'C-1', customerName: '客户一', purchaseCategory: '服装', productName: '外套',
|
||||
id: '9007199254740993', orderNo: 'OP-1', customerId: '10001', customerName: '客户一', purchaseCategory: '服装', productName: '外套',
|
||||
productSpecification: null, purchaseQuantity: 10, budgetUnitPrice: 12.34, currency: 'USD', purchaseWarehouseId: '9007199254740995',
|
||||
purchaseWarehouseName: '采购仓', inboundQuantity: 5, outboundQuantity: 2, inventoryQuantity: 3, status: 'APPROVED', version: 4,
|
||||
purchaseWarehouseName: '采购仓', inboundQuantity: 5, inventoryQuantity: 5, status: 'APPROVED', version: 4,
|
||||
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
|
||||
}
|
||||
const truckSummary = {
|
||||
id: '9007199254740997', customerId: 'C-2', orderNo: 'TR-1', transportType: 'FTL', origin: 'LA', destination: 'ONT',
|
||||
id: '9007199254740997', customerId: '10002', orderNo: 'TR-1', transportType: 'PORT_PICKUP', origin: 'LA', destination: 'ONT',
|
||||
containerType: '40HQ', palletCount: 2, packageCount: 10, receivedPalletCount: 1, allocatedPalletCount: 0, shippedPalletCount: 0,
|
||||
totalWeightKg: 123.45, totalVolumeCbm: 6.7, budgetAmount: 88.5, currency: 'USD', contactName: 'A', contactInfo: '1',
|
||||
requestedDate: '2026-08-20', status: 'WAITING_INBOUND', version: 2, createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
|
||||
requestedDate: '2026-08-20', status: 'PENDING_REVIEW', version: 2, createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
|
||||
}
|
||||
const looseboardSummary = {
|
||||
id: '9007199254741005', customerId: '10003', orderNo: 'LB-1', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
|
||||
origin: 'LA', destination: 'ONT', containerType: null, packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2,
|
||||
budgetAmount: 50, currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0,
|
||||
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-01T00:00:00',
|
||||
}
|
||||
|
||||
describe('platform overseas business API adapter', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('uses the overseas client and normalizes procurement pages', async () => {
|
||||
overseas.get.mockResolvedValue(envelope({ records: [purchaseSummary], page: 1, size: 20, total: 1, pages: 1 }))
|
||||
it('loads procurement candidates and normalizes procurement pages', async () => {
|
||||
overseas.get
|
||||
.mockResolvedValueOnce(envelope([{ customerId: '10001', customerName: '客户一' }]))
|
||||
.mockResolvedValueOnce(envelope([{ warehouseId: '9007199254740995', warehouseCode: 'LAX', warehouseName: '采购仓', countryCode: 'US' }]))
|
||||
.mockResolvedValueOnce(envelope({ records: [purchaseSummary], page: 1, size: 20, total: 1, pages: 1 }))
|
||||
expect(await listOverseasPurchaseCustomerOptions(' 客户 ', 50)).toEqual([{ customerId: '10001', customerName: '客户一' }])
|
||||
expect(overseas.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/overseas-procurements/customer-options', { params: { keyword: '客户', limit: 50 } })
|
||||
expect((await listOverseasPurchaseWarehouseOptions())[0]?.warehouseId).toBe('9007199254740995')
|
||||
const result = await listOverseasPurchases({ page: 1, size: 20, orderNo: ' OP-1 ' })
|
||||
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements', expect.objectContaining({ params: expect.objectContaining({ orderNo: 'OP-1' }) }))
|
||||
expect(result.records[0]).toMatchObject({ id: '9007199254740993', purchaseWarehouseId: '9007199254740995', budgetUnitPrice: '12.34' })
|
||||
})
|
||||
|
||||
it('loads and patches procurement details with customer isolation', async () => {
|
||||
const detail = { summary: purchaseSummary, productUrl: null, outbounds: [{ id: '9007199254740999', outboundQuantity: 2 }], histories: [] }
|
||||
it('loads and patches procurement detail with three-state null payloads', async () => {
|
||||
const detail = { summary: purchaseSummary, productUrl: null, histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: '7', changeDetail: '{}', remark: null, createdAt: '2026-08-02T00:00:00' }] }
|
||||
overseas.get.mockResolvedValue(envelope(detail))
|
||||
expect((await getOverseasPurchase('OP/1', 'C-1')).outbounds[0]?.id).toBe('9007199254740999')
|
||||
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { params: { customerId: 'C-1' } })
|
||||
|
||||
expect((await getOverseasPurchase('OP/1', '10001')).histories[0]?.operationType).toBe('UPDATE')
|
||||
overseas.patch.mockResolvedValue(envelope(detail))
|
||||
const payload = { version: 4, productName: '新商品' }
|
||||
await updateOverseasPurchase('OP-1', 'C-1', payload)
|
||||
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP-1', payload, { params: { customerId: 'C-1' } })
|
||||
const payload = { version: 4, productUrl: null, estimatedArrivalAt: null }
|
||||
await updateOverseasPurchase('OP-1', '10001', payload)
|
||||
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP-1', payload, { params: { customerId: '10001' } })
|
||||
})
|
||||
|
||||
it('normalizes truck list/detail numeric fields without exposing write methods', async () => {
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [truckSummary], page: '1', size: '20', total: '1', pages: '1' }))
|
||||
const page = await listUsTruckOrders({ routeKeyword: ' LA ' })
|
||||
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders', expect.objectContaining({ params: expect.objectContaining({ routeKeyword: 'LA' }) }))
|
||||
expect(page.records[0]).toMatchObject({ id: '9007199254740997', totalWeightKg: '123.45', totalVolumeCbm: '6.7', budgetAmount: '88.5' })
|
||||
it('supports platform truck list/detail/create/update without status endpoints', async () => {
|
||||
const detail = { summary: truckSummary, pallets: [{ id: '9007199254741001', warehousePalletId: '9007199254741003', palletNo: 'P-1' }], histories: [] }
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [truckSummary], page: '1', size: '20', total: '1', pages: '1' })).mockResolvedValueOnce(envelope(detail))
|
||||
expect((await listUsTruckOrders({ routeKeyword: ' LA ' })).records[0]?.totalWeightKg).toBe('123.45')
|
||||
expect((await getUsTruckOrder('TR-1', '10002')).pallets[0]?.warehousePalletId).toBe('9007199254741003')
|
||||
const create = { transportType: 'PORT_PICKUP' as const, origin: 'LA', destination: 'ONT', palletCount: 2, packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, contactName: 'A', contactInfo: '1', requestedDate: '2026-08-20', idempotencyKey: 'truck-key' }
|
||||
overseas.post.mockResolvedValue(envelope(detail)); await createUsTruckOrder('10002', create)
|
||||
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders', create, { params: { customerId: '10002' } })
|
||||
const { idempotencyKey: _truckKey, ...truckFields } = create
|
||||
const update = { ...truckFields, version: 2 }
|
||||
overseas.patch.mockResolvedValue(envelope(detail)); await updateUsTruckOrder('TR-1', '10002', update)
|
||||
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders/TR-1', update, { params: { customerId: '10002' } })
|
||||
})
|
||||
|
||||
overseas.get.mockResolvedValueOnce(envelope({ summary: truckSummary, pallets: [{ id: '9007199254741001', warehousePalletId: '9007199254741003', palletNo: 'P-1' }], histories: [] }))
|
||||
const detail = await getUsTruckOrder('TR-1', 'C-2')
|
||||
expect(detail.pallets[0]).toMatchObject({ id: '9007199254741001', warehousePalletId: '9007199254741003' })
|
||||
expect(overseas.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/us-truck-orders/TR-1', { params: { customerId: 'C-2' } })
|
||||
it('supports looseboard list/detail/create and PUT update', async () => {
|
||||
const detail = { summary: looseboardSummary, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [looseboardSummary], page: 1, size: 20, total: 1, pages: 1 })).mockResolvedValueOnce(envelope(detail))
|
||||
expect((await listLocalLooseboardOrders({ status: 'PENDING_ACCEPTANCE' })).records[0]?.id).toBe('9007199254741005')
|
||||
expect((await getLocalLooseboardOrder('LB-1', '10003')).status).toBe('PENDING_ACCEPTANCE')
|
||||
const create = { transportMode: 'SHARED_TRUCK' as const, palletRequired: 'YES' as const, transportType: 'WAREHOUSE_TO_WAREHOUSE' as const, origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'loose-key' }
|
||||
overseas.post.mockResolvedValue(envelope(detail)); await createLocalLooseboardOrder('10003', create)
|
||||
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders', create, { params: { customerId: '10003' } })
|
||||
const { idempotencyKey: _looseKey, ...looseboardFields } = create
|
||||
const update = { ...looseboardFields, version: 0 }
|
||||
overseas.put.mockResolvedValue(envelope(detail)); await updateLocalLooseboardOrder('LB-1', '10003', update)
|
||||
expect(overseas.put).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders/LB-1', update, { params: { customerId: '10003' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { LocalTransportDetail, LocalTransportOrder, LocalTransportPallet, LocalTransportQuery, OverseasBusinessHistory, OverseasBusinessPage, OverseasProcurementOutbound, OverseasPurchaseDetail, OverseasPurchaseQuery, OverseasPurchaseSummary, UpdateOverseasPurchaseRequest } from '../types'
|
||||
import type {
|
||||
CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardDetail, LocalLooseboardOrder,
|
||||
LocalLooseboardQuery, LocalTransportDetail, LocalTransportOrder, LocalTransportPallet, LocalTransportQuery,
|
||||
OverseasBusinessHistory, OverseasBusinessPage, OverseasPurchaseCustomerOption, OverseasPurchaseDetail,
|
||||
OverseasPurchaseQuery, OverseasPurchaseSummary, OverseasPurchaseWarehouseOption, UpdateLocalLooseboardOrderRequest,
|
||||
UpdateLocalTransportOrderRequest, UpdateOverseasPurchaseRequest,
|
||||
} from '../types'
|
||||
|
||||
const procurementBasePath = '/platform/v1/last-leg/overseas-procurements'
|
||||
const truckBasePath = '/platform/v1/last-leg/us-truck-orders'
|
||||
const looseboardBasePath = '/platform/v1/last-leg/local-looseboard-orders'
|
||||
type Numeric = number | string
|
||||
interface PagePayload<T> { records?: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
interface ProcurementSummaryPayload extends Omit<OverseasPurchaseSummary, 'id' | 'purchaseWarehouseId' | 'budgetUnitPrice'> { id: Numeric; purchaseWarehouseId?: Numeric | null; budgetUnitPrice?: Numeric | null }
|
||||
interface ProcurementOutboundPayload extends Omit<OverseasProcurementOutbound, 'id'> { id: Numeric }
|
||||
interface ProcurementDetailPayload {
|
||||
summary: ProcurementSummaryPayload; productUrl?: string | null; receivingAddress?: string | null
|
||||
contactName?: string | null; contactPhone?: string | null; domesticCarrier?: string | null; domesticTrackingNo?: string | null
|
||||
overseasCarrier?: string | null; overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
|
||||
createdBy?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; reviewedAt?: string | null
|
||||
outbounds?: ProcurementOutboundPayload[]; histories?: OverseasBusinessHistory[]
|
||||
histories?: OverseasBusinessHistory[]
|
||||
}
|
||||
interface TruckSummaryPayload extends Omit<LocalTransportOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
|
||||
interface TruckPalletPayload extends Omit<LocalTransportPallet, 'id' | 'warehousePalletId'> { id: Numeric; warehousePalletId?: Numeric | null }
|
||||
@@ -21,6 +27,11 @@ interface TruckDetailPayload {
|
||||
summary: TruckSummaryPayload; originAddress?: string | null; destinationAddress?: string | null
|
||||
remark?: string | null; createdBy?: string | null; pallets?: TruckPalletPayload[]; histories?: OverseasBusinessHistory[]
|
||||
}
|
||||
interface LooseboardSummaryPayload extends Omit<LocalLooseboardOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
|
||||
interface LooseboardDetailPayload {
|
||||
summary: LooseboardSummaryPayload; originAddress?: string | null; destinationAddress?: string | null
|
||||
remark?: string | null; createdBy?: string | null; histories?: OverseasBusinessHistory[]
|
||||
}
|
||||
|
||||
function normalizePage<TPayload, TResult>(payload: PagePayload<TPayload>, normalize: (value: TPayload) => TResult): OverseasBusinessPage<TResult> {
|
||||
return { records: (payload.records || []).map(normalize), page: Number(payload.page), size: Number(payload.size), total: Number(payload.total), pages: Number(payload.pages) }
|
||||
@@ -29,7 +40,7 @@ function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseS
|
||||
return { ...value, id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) }
|
||||
}
|
||||
function procurementDetail(value: ProcurementDetailPayload): OverseasPurchaseDetail {
|
||||
return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, outbounds: (value.outbounds || []).map((item) => ({ ...item, id: String(item.id) })), histories: value.histories || [] }
|
||||
return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, histories: value.histories || [] }
|
||||
}
|
||||
function truckSummary(value: TruckSummaryPayload): LocalTransportOrder {
|
||||
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
|
||||
@@ -37,7 +48,21 @@ function truckSummary(value: TruckSummaryPayload): LocalTransportOrder {
|
||||
function truckDetail(value: TruckDetailPayload): LocalTransportDetail {
|
||||
return { ...truckSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, pallets: (value.pallets || []).map((item) => ({ ...item, id: String(item.id), warehousePalletId: item.warehousePalletId == null ? null : String(item.warehousePalletId) })), histories: value.histories || [] }
|
||||
}
|
||||
function looseboardSummary(value: LooseboardSummaryPayload): LocalLooseboardOrder {
|
||||
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
|
||||
}
|
||||
function looseboardDetail(value: LooseboardDetailPayload): LocalLooseboardDetail {
|
||||
return { ...looseboardSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, histories: value.histories || [] }
|
||||
}
|
||||
|
||||
export async function listOverseasPurchaseCustomerOptions(keyword = '', limit = 200) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPurchaseCustomerOption[]>, ApiEnvelope<OverseasPurchaseCustomerOption[]>>(`${procurementBasePath}/customer-options`, { params: { keyword: keyword.trim() || undefined, limit } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function listOverseasPurchaseWarehouseOptions() {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPurchaseWarehouseOption[]>, ApiEnvelope<OverseasPurchaseWarehouseOption[]>>(`${procurementBasePath}/warehouse-options`)
|
||||
return unwrapApiResponse(response).map((item) => ({ ...item, warehouseId: String(item.warehouseId) }))
|
||||
}
|
||||
export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<PagePayload<ProcurementSummaryPayload>>, ApiEnvelope<PagePayload<ProcurementSummaryPayload>>>(procurementBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, customerKeyword: query.customerKeyword?.trim() || undefined, purchaseCategory: query.purchaseCategory?.trim() || undefined, productName: query.productName?.trim() || undefined, status: query.status || undefined, purchaseWarehouseId: query.purchaseWarehouseId || undefined } })
|
||||
return normalizePage(unwrapApiResponse(response), procurementSummary)
|
||||
@@ -58,3 +83,27 @@ export async function getUsTruckOrder(orderNo: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
|
||||
return truckDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function createUsTruckOrder(customerId: string, payload: CreateLocalTransportOrderRequest) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(truckBasePath, payload, { params: { customerId } })
|
||||
return truckDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function updateUsTruckOrder(orderNo: string, customerId: string, payload: UpdateLocalTransportOrderRequest) {
|
||||
const response = await overseasHttp.patch<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
|
||||
return truckDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function listLocalLooseboardOrders(query: LocalLooseboardQuery = {}) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<PagePayload<LooseboardSummaryPayload>>, ApiEnvelope<PagePayload<LooseboardSummaryPayload>>>(looseboardBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportMode: query.transportMode || undefined } })
|
||||
return normalizePage(unwrapApiResponse(response), looseboardSummary)
|
||||
}
|
||||
export async function getLocalLooseboardOrder(orderNo: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
|
||||
return looseboardDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function createLocalLooseboardOrder(customerId: string, payload: CreateLocalLooseboardOrderRequest) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(looseboardBasePath, payload, { params: { customerId } })
|
||||
return looseboardDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function updateLocalLooseboardOrder(orderNo: string, customerId: string, payload: UpdateLocalLooseboardOrderRequest) {
|
||||
const response = await overseasHttp.put<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
|
||||
return looseboardDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ domain: string; compact?: boolean }>(), { compact: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-alert class="contract-pending" type="warning" :closable="false" show-icon>
|
||||
<template #title>{{ domain }}接口能力待后端补齐</template>
|
||||
<p v-if="!compact">页面请求边界固定为 gateway-overseas → lastleg-service。当前没有可用于该操作的平台端点或稳定业务子域,页面保持阻断,不会借用 Merchant、状态推进或其他相邻接口模拟成功。</p>
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contract-pending p { margin: 5px 0 0; color: var(--el-text-color-regular); font-size: 13px; line-height: 1.55; }
|
||||
</style>
|
||||
@@ -6,61 +6,45 @@ const read = (path: string) => readFileSync(resolve(process.cwd(), `src/modules/
|
||||
|
||||
describe('overseas business real contract pages', () => {
|
||||
it('keeps operation columns first and renders no sample records', () => {
|
||||
const purchase = read('pages/PurchaseOrdersView.vue')
|
||||
const transport = read('pages/TransportOrdersView.vue')
|
||||
const purchaseColumns = purchase.slice(purchase.indexOf('const columns'))
|
||||
const truckColumns = transport.slice(transport.indexOf('const truckColumns'))
|
||||
const looseboardColumns = transport.slice(transport.indexOf('const looseboardColumns'))
|
||||
const purchase = read('pages/PurchaseOrdersView.vue'); const transport = read('pages/TransportOrdersView.vue')
|
||||
const purchaseColumns = purchase.slice(purchase.indexOf('const columns')); const truckColumns = transport.slice(transport.indexOf('const truckColumns')); const looseboardColumns = transport.slice(transport.indexOf('const looseboardColumns'))
|
||||
expect(purchaseColumns.indexOf("key: 'actions'")).toBeLessThan(purchaseColumns.indexOf("key: 'orderNo'"))
|
||||
expect(truckColumns.indexOf("key: 'actions'")).toBeLessThan(truckColumns.indexOf("key: 'orderNo'"))
|
||||
expect(looseboardColumns.indexOf("key: 'actions'")).toBeLessThan(looseboardColumns.indexOf("key: 'orderNo'"))
|
||||
expect(purchase).toContain('const rows = ref<OverseasPurchaseSummary[]>([])')
|
||||
expect(transport).toContain('const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([])')
|
||||
expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/)
|
||||
})
|
||||
|
||||
it('gives every formal list a stable query preference key', () => {
|
||||
const purchase = read('pages/PurchaseOrdersView.vue')
|
||||
const transport = read('pages/TransportOrdersView.vue')
|
||||
expect(purchase.match(/<QueryToolbar[^>]*\/>/)?.[0]).toContain('preference-key="overseas-business-purchases-query"')
|
||||
expect(transport.match(/<QueryToolbar[^>]*\/>/)?.[0]).toContain(':preference-key="queryPreferenceKey"')
|
||||
expect(transport).toContain("'overseas-business-truck-query'")
|
||||
expect(transport).toContain("'overseas-business-looseboard-query'")
|
||||
})
|
||||
|
||||
it('loads procurement list/detail and saves only the backend update contract', () => {
|
||||
const list = read('pages/PurchaseOrdersView.vue')
|
||||
const detail = read('pages/PurchaseOrderDetailView.vue')
|
||||
const edit = read('pages/PurchaseOrderEditView.vue')
|
||||
expect(list).toContain('listOverseasPurchases')
|
||||
expect(list).toContain("query: { customerId: row.customerId }")
|
||||
expect(detail).toContain('getOverseasPurchase(purchaseId.value, customerId.value)')
|
||||
expect(edit).toContain('updateOverseasPurchase(purchaseId.value, customerId.value, payload)')
|
||||
expect(edit).toContain('inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined')
|
||||
expect(edit).toContain("form.status === 'APPROVED'")
|
||||
it('loads procurement candidates, operations and three-state PATCH fields', () => {
|
||||
const list = read('pages/PurchaseOrdersView.vue'); const detail = read('pages/PurchaseOrderDetailView.vue'); const edit = read('pages/PurchaseOrderEditView.vue')
|
||||
expect(list).toContain('listOverseasPurchaseCustomerOptions')
|
||||
expect(edit).toContain('listOverseasPurchaseWarehouseOptions')
|
||||
expect(detail).toContain('detail?.histories || []')
|
||||
expect(detail).not.toContain('outbounds')
|
||||
expect(edit).toContain('estimatedArrivalAt: form.estimatedArrivalAt || null')
|
||||
expect(edit).toContain('purchaseWarehouseId: form.purchaseWarehouseId || null')
|
||||
expect(edit).not.toMatch(/payload[^\n]*(outboundQuantity|status):/)
|
||||
expect(`${list}${detail}${edit}`).toContain('LoadErrorState')
|
||||
})
|
||||
|
||||
it('loads truck list/detail but keeps unsupported writes and looseboard blocked', () => {
|
||||
const list = read('pages/TransportOrdersView.vue')
|
||||
const form = read('pages/TransportOrderFormView.vue')
|
||||
expect(list).toContain('listUsTruckOrders')
|
||||
expect(list).toContain("query: { customerId: row.customerId }")
|
||||
expect(form).toContain('getUsTruckOrder(orderId.value, customerId.value)')
|
||||
expect(form).not.toMatch(/createUsTruck|updateUsTruck/)
|
||||
expect(form).toContain('平台创建/普通编辑端点待后端补齐')
|
||||
expect(list).toContain('本土散板接口待后端补齐')
|
||||
expect(form).toContain('disabled><Save')
|
||||
it('uses only platform truck create/update and the stable looseboard subdomain', () => {
|
||||
const api = read('api/platformOverseasBusiness.ts'); const list = read('pages/TransportOrdersView.vue'); const form = read('pages/TransportOrderFormView.vue')
|
||||
expect(api).toContain("const looseboardBasePath = '/platform/v1/last-leg/local-looseboard-orders'")
|
||||
expect(api).toContain('createUsTruckOrder')
|
||||
expect(api).toContain('updateUsTruckOrder')
|
||||
expect(api).toContain('createLocalLooseboardOrder')
|
||||
expect(api).toContain('updateLocalLooseboardOrder')
|
||||
expect(api).not.toMatch(/\/merchant\/|receive-pallets|\/status/)
|
||||
expect(list).toContain('listLocalLooseboardOrders')
|
||||
expect(form).toContain('listPlatformTenants')
|
||||
expect(form).not.toContain('ContractPendingNotice')
|
||||
})
|
||||
|
||||
it('keeps unsaved-work protection on both prototype-backed forms', () => {
|
||||
const purchaseEdit = read('pages/PurchaseOrderEditView.vue')
|
||||
const transportForm = read('pages/TransportOrderFormView.vue')
|
||||
it('keeps state-constrained editing and unsaved-work protection', () => {
|
||||
const purchaseEdit = read('pages/PurchaseOrderEditView.vue'); const transportList = read('pages/TransportOrdersView.vue'); const transportForm = read('pages/TransportOrderFormView.vue')
|
||||
expect(purchaseEdit).toContain('useUnsavedWorkRegistration')
|
||||
expect(purchaseEdit).toContain(':min="form.outboundQuantity"')
|
||||
expect(transportForm).toContain('useUnsavedWorkRegistration')
|
||||
expect(transportForm).toContain("message: '请输入起运地'")
|
||||
expect(transportForm).toContain("message: '请输入目的地'")
|
||||
expect(transportList).toContain("row.status === 'PENDING_ACCEPTANCE'")
|
||||
expect(transportList).toContain("row.status === 'PENDING_REVIEW'")
|
||||
expect(transportForm).toContain("form.status === 'PENDING_ACCEPTANCE'")
|
||||
expect(transportForm).toContain("form.status === 'PENDING_REVIEW'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder, OverseasPurchaseDetail } from '../types'
|
||||
import {
|
||||
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
|
||||
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchaseWarehouseOptions,
|
||||
listOverseasPurchases, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder,
|
||||
} from '../api/platformOverseasBusiness'
|
||||
import PurchaseOrdersView from './PurchaseOrdersView.vue'
|
||||
import PurchaseOrderDetailView from './PurchaseOrderDetailView.vue'
|
||||
import PurchaseOrderEditView from './PurchaseOrderEditView.vue'
|
||||
import TransportOrdersView from './TransportOrdersView.vue'
|
||||
import TransportOrderFormView from './TransportOrderFormView.vue'
|
||||
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/platformOverseasBusiness', () => ({
|
||||
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), updateOverseasPurchase: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
|
||||
listUsTruckOrders: vi.fn(), getUsTruckOrder: vi.fn(), createUsTruckOrder: vi.fn(), updateUsTruckOrder: vi.fn(),
|
||||
listLocalLooseboardOrders: vi.fn(), getLocalLooseboardOrder: vi.fn(), createLocalLooseboardOrder: vi.fn(), updateLocalLooseboardOrder: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||
|
||||
const purchase: OverseasPurchaseDetail = {
|
||||
id: '9007199254740993', orderNo: 'OP-100', customerId: '10001', customerName: '客户甲', purchaseCategory: '家居', productName: '置物架', productSpecification: '黑色', productUrl: 'https://example.test/product',
|
||||
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', inboundQuantity: 10, inventoryQuantity: 10, status: 'APPROVED', version: 3,
|
||||
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086', domesticCarrier: null, domesticTrackingNo: null,
|
||||
overseasCarrier: 'UPS', overseasTrackingNo: 'UPS-1', estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
|
||||
histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: '7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }],
|
||||
}
|
||||
const truck: LocalTransportOrder = { id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
|
||||
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
|
||||
const looseboard: LocalLooseboardOrder = { id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: null, packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
|
||||
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
|
||||
|
||||
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
|
||||
function mountPage(component: Parameters<typeof mount>[0], props?: Record<string, unknown>) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) }
|
||||
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('overseas business mounted pages', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = ''
|
||||
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
|
||||
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(purchase); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 })
|
||||
vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }])
|
||||
vi.mocked(listUsTruckOrders).mockReset().mockResolvedValue({ records: [truck], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(createUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(updateUsTruckOrder).mockReset().mockResolvedValue(truckDetail)
|
||||
vi.mocked(listLocalLooseboardOrders).mockReset().mockResolvedValue({ records: [looseboard], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(createLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(updateLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail)
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '可选客户', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
})
|
||||
|
||||
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('已通过') })
|
||||
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
|
||||
it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') })
|
||||
it('mounts procurement detail operation history', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑') })
|
||||
it('submits procurement null clears and keeps a 409 visible', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409)); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().trim() === '保存')?.trigger('click'); await flushPromises(); expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 3, domesticCarrier: null, estimatedArrivalAt: '2026-08-25T12:00:00', purchaseWarehouseId: '20001' })); expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('港口提柜'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
|
||||
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, transportType: 'PORT_PICKUP', palletCount: 2 })) })
|
||||
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
|
||||
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, transportMode: 'SHARED_TRUCK', palletRequired: 'YES' })) })
|
||||
it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
})
|
||||
@@ -9,7 +9,7 @@ import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import type { OverseasProcurementOutbound, OverseasPurchaseDetail, OverseasPurchaseStatus } from '../types'
|
||||
import type { OverseasBusinessHistory, OverseasPurchaseDetail, OverseasPurchaseStatus } from '../types'
|
||||
|
||||
const route = useRoute(); const router = useRouter()
|
||||
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
|
||||
@@ -17,18 +17,19 @@ const customerId = computed(() => typeof route.query.customerId === 'string' ? r
|
||||
const detail = ref<OverseasPurchaseDetail | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
|
||||
const productFields = computed(() => [['客户名称', detail.value?.customerName], ['采购仓库', detail.value?.purchaseWarehouseName], ['采购品类', detail.value?.purchaseCategory], ['商品名称', detail.value?.productName], ['商品规格', detail.value?.productSpecification], ['商品链接', detail.value?.productUrl]] as const)
|
||||
const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity], ['出库量', detail.value?.outboundQuantity], ['库存量', detail.value?.inventoryQuantity], ['库存状态', detail.value ? (detail.value.inventoryQuantity > 0 ? '在库' : '无库存') : null]] as const)
|
||||
const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity], ['库存量', detail.value?.inventoryQuantity], ['库存状态', detail.value ? (detail.value.inventoryQuantity > 0 ? '在库' : '无库存') : null]] as const)
|
||||
const orderFields = computed(() => [['订单编号', detail.value?.orderNo], ['采购数量', detail.value?.purchaseQuantity], ['预算单价', detail.value ? formatAmount(detail.value.budgetUnitPrice, detail.value.currency) : null], ['订单状态', detail.value ? statusLabels[detail.value.status] : null], ['联系人', detail.value?.contactName], ['联系电话', detail.value?.contactPhone], ['收货地址', detail.value?.receivingAddress], ['订单备注', detail.value?.remark]] as const)
|
||||
const courierFields = computed(() => [['国内承运商', detail.value?.domesticCarrier], ['国内运单号', detail.value?.domesticTrackingNo], ['海外承运商', detail.value?.overseasCarrier], ['海外运单号', detail.value?.overseasTrackingNo], ['预计到达时间', formatDateTime(detail.value?.estimatedArrivalAt)]] as const)
|
||||
const outboundColumns: DataTableColumn[] = [
|
||||
{ key: 'outboundAt', label: '时间', minWidth: 155, formatter: (row: OverseasProcurementOutbound) => formatDateTime(row.outboundAt) },
|
||||
{ key: 'documentType', label: '单据类型', width: 110, formatter: () => '采购出库' },
|
||||
{ key: 'outboundNo', prop: 'outboundNo', label: '关联单号', minWidth: 160 },
|
||||
{ key: 'quantity', prop: 'quantity', label: '出库量', width: 95, align: 'right' },
|
||||
{ key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 120 },
|
||||
{ key: 'trackingNo', prop: 'trackingNo', label: '运单号', minWidth: 150 },
|
||||
const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'createdAt', label: '时间', minWidth: 155, formatter: (row: OverseasBusinessHistory) => formatDateTime(row.createdAt) },
|
||||
{ key: 'operationType', label: '操作类型', width: 110, formatter: (row: OverseasBusinessHistory) => operationLabels[row.operationType || ''] || row.operationType || '—' },
|
||||
{ key: 'status', label: '状态变化', minWidth: 150, formatter: (row: OverseasBusinessHistory) => row.fromStatus ? `${statusLabel(row.fromStatus)} → ${statusLabel(row.toStatus)}` : statusLabel(row.toStatus) },
|
||||
{ key: 'changeDetail', prop: 'changeDetail', label: '变更明细', minWidth: 260 },
|
||||
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 120 },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
const operationLabels: Record<string, string> = { CREATE: '创建', REVIEW: '审核', UPDATE: '编辑' }
|
||||
function statusLabel(status?: string | null) { return status ? (statusLabels[status as OverseasPurchaseStatus] || status) : '—' }
|
||||
function display(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function formatAmount(value: string | null, currency: string) { return value == null ? '—' : `${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` }
|
||||
@@ -51,7 +52,7 @@ watch([purchaseId, customerId], () => { void load() }, { immediate: true })
|
||||
<section><h3>库存信息</h3><dl><div v-for="field in stockFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>订单信息</h3><dl><div v-for="field in orderFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>快递信息</h3><dl><div v-for="field in courierFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section class="full"><div class="section-heading"><h3>进销存明细</h3><span>当前接口仅返回采购出库记录,完整入库流水与结存流水字段待后端补齐。</span></div><DataTable :data="detail?.outbounds || []" :columns="outboundColumns" row-key="id" empty-text="暂无采购出库记录" /></section>
|
||||
<section class="full"><div class="section-heading"><h3>操作流水</h3><span>展示创建、审核和编辑变更;四期不承载采购出库。</span></div><DataTable :data="detail?.histories || []" :columns="historyColumns" row-key="createdAt" empty-text="暂无操作流水" /></section>
|
||||
</div>
|
||||
</div>
|
||||
</DetailPageLayout></AdminPage>
|
||||
|
||||
@@ -11,13 +11,13 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { getOverseasPurchase, updateOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import { getOverseasPurchase, listOverseasPurchaseWarehouseOptions, updateOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { OverseasPurchaseStatus, UpdateOverseasPurchaseRequest } from '../types'
|
||||
import type { OverseasPurchaseStatus, OverseasPurchaseWarehouseOption, UpdateOverseasPurchaseRequest } from '../types'
|
||||
|
||||
interface PurchaseEditModel {
|
||||
orderNo: string; purchaseCategory: string; productName: string; productSpecification: string | null; productUrl: string | null
|
||||
inboundQuantity: number; outboundQuantity: number; status: OverseasPurchaseStatus; purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
|
||||
inboundQuantity: number; status: OverseasPurchaseStatus; purchaseWarehouseId: string | null
|
||||
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
|
||||
estimatedArrivalAt: string | null; remark: string | null; version: number
|
||||
}
|
||||
@@ -26,7 +26,8 @@ const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ?
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const formRef = ref<FormInstance>(); const loading = ref(false); const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const initial = (): PurchaseEditModel => ({ orderNo: '', purchaseCategory: '', productName: '', productSpecification: null, productUrl: null, inboundQuantity: 0, outboundQuantity: 0, status: 'PENDING_REVIEW', purchaseWarehouseId: null, purchaseWarehouseName: null, domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null, version: 0 })
|
||||
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehousesLoading = ref(false); const warehousesError = ref('')
|
||||
const initial = (): PurchaseEditModel => ({ orderNo: '', purchaseCategory: '', productName: '', productSpecification: null, productUrl: null, inboundQuantity: 0, status: 'PENDING_REVIEW', purchaseWarehouseId: null, domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null, version: 0 })
|
||||
const form = reactive<PurchaseEditModel>(initial())
|
||||
const rules: FormRules<PurchaseEditModel> = {
|
||||
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
|
||||
@@ -36,28 +37,34 @@ const rules: FormRules<PurchaseEditModel> = {
|
||||
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
|
||||
const inboundEditable = computed(() => form.status === 'APPROVED')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑海外采购订单'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
async function loadWarehouses() {
|
||||
warehousesLoading.value = true; warehousesError.value = ''
|
||||
try { warehouses.value = await listOverseasPurchaseWarehouseOptions() }
|
||||
catch { warehouses.value = []; warehousesError.value = '采购仓候选加载失败,请稍后重试' }
|
||||
finally { warehousesLoading.value = false }
|
||||
}
|
||||
async function load() {
|
||||
Object.assign(form, initial()); loadError.value = null; submitError.value = null
|
||||
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; captureBaseline(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const detail = await getOverseasPurchase(purchaseId.value, customerId.value)
|
||||
Object.assign(form, { orderNo: detail.orderNo, purchaseCategory: detail.purchaseCategory, productName: detail.productName, productSpecification: detail.productSpecification, productUrl: detail.productUrl, inboundQuantity: detail.inboundQuantity, outboundQuantity: detail.outboundQuantity, status: detail.status, purchaseWarehouseId: detail.purchaseWarehouseId, purchaseWarehouseName: detail.purchaseWarehouseName, domesticCarrier: detail.domesticCarrier, domesticTrackingNo: detail.domesticTrackingNo, overseasCarrier: detail.overseasCarrier, overseasTrackingNo: detail.overseasTrackingNo, estimatedArrivalAt: detail.estimatedArrivalAt, remark: detail.remark, version: detail.version })
|
||||
Object.assign(form, { orderNo: detail.orderNo, purchaseCategory: detail.purchaseCategory, productName: detail.productName, productSpecification: detail.productSpecification, productUrl: detail.productUrl, inboundQuantity: detail.inboundQuantity, status: detail.status, purchaseWarehouseId: detail.purchaseWarehouseId, domesticCarrier: detail.domesticCarrier, domesticTrackingNo: detail.domesticTrackingNo, overseasCarrier: detail.overseasCarrier, overseasTrackingNo: detail.overseasTrackingNo, estimatedArrivalAt: detail.estimatedArrivalAt, remark: detail.remark, version: detail.version })
|
||||
if (detail.purchaseWarehouseId && !warehouses.value.some((item) => item.warehouseId === detail.purchaseWarehouseId)) warehouses.value.push({ warehouseId: detail.purchaseWarehouseId, warehouseCode: '', warehouseName: detail.purchaseWarehouseName || '当前采购仓', countryCode: '' })
|
||||
captureBaseline()
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单加载失败') } finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
saving.value = true; submitError.value = null
|
||||
const payload: UpdateOverseasPurchaseRequest = { version: form.version, purchaseCategory: form.purchaseCategory.trim(), productName: form.productName.trim(), productSpecification: optional(form.productSpecification), productUrl: optional(form.productUrl), inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined, purchaseWarehouseId: optional(form.purchaseWarehouseId), purchaseWarehouseName: optional(form.purchaseWarehouseName), domesticCarrier: optional(form.domesticCarrier), domesticTrackingNo: optional(form.domesticTrackingNo), overseasCarrier: optional(form.overseasCarrier), overseasTrackingNo: optional(form.overseasTrackingNo), estimatedArrivalAt: form.estimatedArrivalAt || null, remark: optional(form.remark) }
|
||||
const payload: UpdateOverseasPurchaseRequest = { version: form.version, purchaseCategory: form.purchaseCategory.trim(), productName: form.productName.trim(), productSpecification: form.productSpecification?.trim() || null, productUrl: form.productUrl?.trim() || null, inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined, purchaseWarehouseId: form.purchaseWarehouseId || null, domesticCarrier: form.domesticCarrier?.trim() || null, domesticTrackingNo: form.domesticTrackingNo?.trim() || null, overseasCarrier: form.overseasCarrier?.trim() || null, overseasTrackingNo: form.overseasTrackingNo?.trim() || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark?.trim() || null }
|
||||
try {
|
||||
const updated = await updateOverseasPurchase(purchaseId.value, customerId.value, payload)
|
||||
form.version = updated.version; captureBaseline(); ElMessage.success('海外采购订单已保存')
|
||||
await router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: updated.orderNo }, query: { customerId: updated.customerId } })
|
||||
} catch (error) { submitError.value = getApiErrorDetails(error, '海外采购订单保存失败') } finally { saving.value = false }
|
||||
}
|
||||
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
|
||||
watch([purchaseId, customerId], () => { void loadWarehouses().then(load) }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,11 +73,11 @@ watch([purchaseId, customerId], () => { void load() }, { immediate: true })
|
||||
<LoadErrorState v-if="loadError" title="采购订单暂时无法编辑" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<section v-else v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
|
||||
<FormSection title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled /></el-form-item><el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model="form.purchaseCategory" maxlength="128" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="255" /></el-form-item><el-form-item class="span-2" label="商品规格"><el-input v-model="form.productSpecification" maxlength="500" /></el-form-item><el-form-item class="span-3" label="商品链接"><el-input v-model="form.productUrl" maxlength="2000" /></el-form-item></div></FormSection>
|
||||
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量" prop="inboundQuantity"><el-input-number v-model="form.inboundQuantity" :min="form.outboundQuantity" :disabled="!inboundEditable" controls-position="right" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="出库量"><el-input-number v-model="form.outboundQuantity" disabled /></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库编码"><el-input v-model="form.purchaseWarehouseId" maxlength="128" /></el-form-item><el-form-item label="采购仓库"><el-input v-model="form.purchaseWarehouseName" maxlength="255" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
|
||||
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量" prop="inboundQuantity"><el-input-number v-model="form.inboundQuantity" :min="0" :disabled="!inboundEditable" controls-position="right" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库"><el-select v-model="form.purchaseWarehouseId" clearable filterable :loading="warehousesLoading" :disabled="Boolean(warehousesError)" placeholder="请选择采购仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :label="`${item.warehouseName}${item.warehouseCode ? ` · ${item.warehouseCode}` : ''}`" :value="item.warehouseId" /></el-select><small v-if="warehousesError" class="field-error">{{ warehousesError }}</small></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })">取消</el-button></footer></section>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { padding:20px 24px; }.form-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 20px; }.span-2 { grid-column:span 2; }.span-3 { grid-column:1 / -1; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.form-grid small { display:block; margin-top:5px; color:var(--cc-muted); font-size:12px; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
|
||||
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { padding:20px 24px; }.form-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 20px; }.span-2 { grid-column:span 2; }.span-3 { grid-column:1 / -1; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.form-grid small { display:block; margin-top:5px; color:var(--cc-muted); font-size:12px; }.form-grid .field-error { color:var(--el-color-danger); }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye, Pencil } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -10,19 +10,21 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listOverseasPurchases } from '../api/platformOverseasBusiness'
|
||||
import { listOverseasPurchaseCustomerOptions, listOverseasPurchases } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
|
||||
import type { OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
|
||||
|
||||
const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const filters = ref<QueryModel>({ orderNo: '', purchaseCategory: '', productName: '' }); const applied = ref({ ...filters.value })
|
||||
const fields: QueryField[] = [
|
||||
const filters = ref<QueryModel>({ orderNo: '', customerId: '', purchaseCategory: '', productName: '' }); const applied = ref({ ...filters.value })
|
||||
const customerOptions = ref<OverseasPurchaseCustomerOption[]>([]); const customersLoading = ref(false)
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
|
||||
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
|
||||
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
|
||||
]
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
@@ -33,7 +35,6 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
|
||||
{ key: 'budgetUnitPrice', label: '采购单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => formatAmount(row.budgetUnitPrice, row.currency) },
|
||||
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
|
||||
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
|
||||
{ key: 'inventoryQuantity', prop: 'inventoryQuantity', label: '库存量', width: 95, align: 'right' },
|
||||
{ key: 'status', label: '状态', width: 105, formatter: (row: OverseasPurchaseSummary) => purchaseStatusLabel(row.status) },
|
||||
]
|
||||
@@ -43,13 +44,14 @@ function formatAmount(value: string | null, currency: string) { if (value == nul
|
||||
async function load() {
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const result = await listOverseasPurchases({ page: page.value, size: pageSize.value, orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || '') } satisfies OverseasPurchaseQuery)
|
||||
const result = await listOverseasPurchases({ page: page.value, size: pageSize.value, customerId: String(applied.value.customerId || ''), orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || '') } satisfies OverseasPurchaseQuery)
|
||||
rows.value = result.records; total.value = result.total
|
||||
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外采购订单加载失败,请稍后重试') } finally { loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function open(row: OverseasPurchaseSummary, edit = false) { void router.push({ name: edit ? 'overseas-business-purchase-edit' : 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
watch([page, pageSize], () => { void load() }); onMounted(() => { void load() })
|
||||
async function loadCustomerOptions() { customersLoading.value = true; try { customerOptions.value = await listOverseasPurchaseCustomerOptions() } catch { customerOptions.value = [] } finally { customersLoading.value = false } }
|
||||
watch([page, pageSize], () => { void load() }); onMounted(() => { void load(); void loadCustomerOptions() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -2,69 +2,122 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Save } from '@lucide/vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import { getUsTruckOrder } from '../api/platformOverseasBusiness'
|
||||
import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, updateLocalLooseboardOrder, updateUsTruckOrder } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { LocalTransportStatus, SaveLocalLooseboardOrderRequest } from '../types'
|
||||
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, LocalTransportType, LooseboardTransportMode, PalletRequired, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const route = useRoute(); const router = useRouter(); const formRef = ref<FormInstance>()
|
||||
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const routeCustomerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const editing = computed(() => Boolean(orderId.value)); const isLooseboard = computed(() => props.domain === 'looseboard')
|
||||
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车'); const listRouteName = computed(() => isLooseboard.value ? 'overseas-business-looseboards' : 'overseas-business-trucks')
|
||||
const requiredPermission = computed(() => isLooseboard.value ? (editing.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.looseboardCreate) : (editing.value ? overseasBusinessPermissions.truckEdit : overseasBusinessPermissions.truckCreate))
|
||||
interface TransportFormModel extends SaveLocalLooseboardOrderRequest { orderNo: string; status: LocalTransportStatus }
|
||||
const initial = (): TransportFormModel => ({ orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: '20GP', palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'CNY', requestedDate: null, remark: null, status: 'PENDING_REVIEW', version: 0 })
|
||||
const form = reactive<TransportFormModel>(initial()); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
type FormStatus = LocalTransportStatus | LocalLooseboardStatus
|
||||
interface TransportFormModel {
|
||||
customerId: string; orderNo: string; transportMode: LooseboardTransportMode | ''; palletRequired: PalletRequired | ''
|
||||
transportType: LocalTransportType | ''; origin: string; destination: string; originAddress: string | null; destinationAddress: string | null
|
||||
contactName: string | null; contactInfo: string | null; containerType: string | null; palletCount: number; packageCount: number
|
||||
totalWeightKg: string | null; totalVolumeCbm: string | null; budgetAmount: string | null; currency: string
|
||||
requestedDate: string | null; remark: string | null; status: FormStatus; version: number; idempotencyKey: string
|
||||
}
|
||||
function newIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `admin-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
const initial = (): TransportFormModel => ({ customerId: '', orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: '20GP', palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'USD', requestedDate: null, remark: null, status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() })
|
||||
const form = reactive<TransportFormModel>(initial()); const loading = ref(false); const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref('')
|
||||
const requiredDecimal = (label: string) => ({ validator: (_rule: unknown, value: string | null, callback: (error?: Error) => void) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 ? callback() : callback(new Error(`请输入有效的${label}`)), trigger: 'blur' })
|
||||
const rules: FormRules<TransportFormModel> = {
|
||||
transportMode: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择运输方式')), trigger: 'change' }],
|
||||
palletRequired: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择是否打盘')), trigger: 'change' }],
|
||||
transportType: [{ required: true, message: '请选择运输类型', trigger: 'change' }], origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }], destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
|
||||
palletCount: [{ required: true, type: 'number', min: 1, message: '托数至少为 1', trigger: 'change' }], packageCount: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }],
|
||||
totalWeightKg: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总重量最多保留 3 位小数', trigger: 'blur' }], totalVolumeCbm: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总体积最多保留 3 位小数', trigger: 'blur' }], budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
|
||||
palletCount: [{ validator: (_rule, value, callback) => (isLooseboard.value || (Number.isInteger(value) && value >= 1)) ? callback() : callback(new Error('托数至少为 1')), trigger: 'change' }],
|
||||
packageCount: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }], totalWeightKg: [requiredDecimal('总重量')], totalVolumeCbm: [requiredDecimal('总体积')],
|
||||
contactName: [{ validator: (_rule, value, callback) => (isLooseboard.value || String(value || '').trim()) ? callback() : callback(new Error('请输入联系人')), trigger: 'blur' }],
|
||||
contactInfo: [{ validator: (_rule, value, callback) => (isLooseboard.value || String(value || '').trim()) ? callback() : callback(new Error('请输入联系方式')), trigger: 'blur' }],
|
||||
requestedDate: [{ validator: (_rule, value, callback) => (isLooseboard.value || value) ? callback() : callback(new Error('请选择要求时间')), trigger: 'change' }],
|
||||
budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
|
||||
}
|
||||
const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }]
|
||||
const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }]
|
||||
const statusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
|
||||
const statusLabel = computed(() => isLooseboard.value ? looseboardStatusLabels[form.status as LocalLooseboardStatus] : truckStatusLabels[form.status as LocalTransportStatus])
|
||||
const editableStatus = computed(() => !editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW'))
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: `overseas-${props.domain}-editor`, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
function amount(value: string | null) { return value === null || value === '' ? null : Number(value) }
|
||||
async function loadCustomers() {
|
||||
if (editing.value) return
|
||||
customersLoading.value = true; customersError.value = ''
|
||||
try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records.filter((item) => item.status === 'ACTIVE') }
|
||||
catch { customers.value = []; customersError.value = '客户候选加载失败,请稍后重试' }
|
||||
finally { customersLoading.value = false }
|
||||
}
|
||||
async function load() {
|
||||
Object.assign(form, initial()); loadError.value = null
|
||||
if (isLooseboard.value || !editing.value) { captureBaseline(); return }
|
||||
if (!customerId.value) { loadError.value = { message: '缺少客户标识,请从本土卡车列表重新进入' }; captureBaseline(); return }
|
||||
Object.assign(form, initial()); loadError.value = null; submitError.value = null
|
||||
if (!editing.value) { captureBaseline(); return }
|
||||
if (!routeCustomerId.value) { loadError.value = { message: `缺少客户标识,请从${domainLabel.value}列表重新进入` }; captureBaseline(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const detail = await getUsTruckOrder(orderId.value, customerId.value)
|
||||
Object.assign(form, { orderNo: detail.orderNo, transportType: detail.transportType || '', origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, palletCount: detail.palletCount, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
|
||||
if (isLooseboard.value) {
|
||||
const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value)
|
||||
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportMode: detail.transportMode, palletRequired: detail.palletRequired, transportType: detail.transportType, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
|
||||
} else {
|
||||
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
|
||||
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportType: detail.transportType || '', origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, palletCount: detail.palletCount, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
|
||||
}
|
||||
captureBaseline()
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '本土卡车订单详情加载失败') } finally { loading.value = false }
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单详情加载失败`) } finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!editableStatus.value) { submitError.value = { message: `当前${domainLabel.value}订单状态不可编辑` }; return }
|
||||
if (!editing.value && !form.customerId) { submitError.value = { message: '请选择客户' }; return }
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
const common = { transportType: form.transportType as LocalTransportType, origin: form.origin.trim(), originAddress: optional(form.originAddress), destination: form.destination.trim(), destinationAddress: optional(form.destinationAddress), containerType: optional(form.containerType), packageCount: form.packageCount, totalWeightKg: Number(form.totalWeightKg), totalVolumeCbm: Number(form.totalVolumeCbm), budgetAmount: amount(form.budgetAmount), currency: form.currency, contactName: optional(form.contactName), contactInfo: optional(form.contactInfo), requestedDate: form.requestedDate, remark: optional(form.remark) }
|
||||
saving.value = true; submitError.value = null
|
||||
try {
|
||||
if (isLooseboard.value) {
|
||||
const loose = { ...common, transportMode: form.transportMode as LooseboardTransportMode, palletRequired: form.palletRequired as PalletRequired }
|
||||
if (editing.value) await updateLocalLooseboardOrder(orderId.value, routeCustomerId.value, { ...loose, version: form.version } satisfies UpdateLocalLooseboardOrderRequest)
|
||||
else await createLocalLooseboardOrder(form.customerId, { ...loose, idempotencyKey: form.idempotencyKey } satisfies CreateLocalLooseboardOrderRequest)
|
||||
} else {
|
||||
const truck = { ...common, contactName: form.contactName!.trim(), contactInfo: form.contactInfo!.trim(), requestedDate: form.requestedDate!, palletCount: form.palletCount }
|
||||
if (editing.value) await updateUsTruckOrder(orderId.value, routeCustomerId.value, { ...truck, version: form.version } satisfies UpdateLocalTransportOrderRequest)
|
||||
else await createUsTruckOrder(form.customerId, { ...truck, idempotencyKey: form.idempotencyKey } satisfies CreateLocalTransportOrderRequest)
|
||||
}
|
||||
captureBaseline(); ElMessage.success(`${domainLabel.value}订单已${editing.value ? '保存' : '创建'}`); await router.push({ name: listRouteName.value })
|
||||
} catch (error) { submitError.value = getApiErrorDetails(error, `${domainLabel.value}订单保存失败`) } finally { saving.value = false }
|
||||
}
|
||||
function back() { void router.push({ name: listRouteName.value }) }
|
||||
watch([orderId, customerId, isLooseboard], () => { void load() }, { immediate: true })
|
||||
watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCustomers() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage width="comfortable"><DetailPageLayout :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
|
||||
<AdminPage width="comfortable"><OperationErrorToast :details="submitError" /><DetailPageLayout :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
|
||||
<template #header><div class="form-header"><div><h2>{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h2><span v-if="editing">订单编号:{{ form.orderNo || orderId }}</span></div><el-button @click="back"><ArrowLeft :size="16" />返回列表</el-button></div></template>
|
||||
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else><ContractPendingNotice :domain="isLooseboard ? '本土散板订单' : (editing ? '本土卡车普通编辑' : '本土卡车平台新增')" />
|
||||
<section v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<section v-else v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || !editableStatus">
|
||||
<FormSection v-if="!editing" title="客户信息"><el-form-item label="客户"><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
|
||||
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
|
||||
<FormSection v-if="isLooseboard" title="散板方式"><div class="choice-block"><el-form-item label="运输方式" prop="transportMode"><el-radio-group v-model="form.transportMode"><el-radio-button v-for="item in transportModes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><el-form-item label="是否打盘" prop="palletRequired"><el-radio-group v-model="form.palletRequired"><el-radio-button value="YES">是</el-radio-button><el-radio-button value="NO">否</el-radio-button></el-radio-group></el-form-item></div></FormSection>
|
||||
<FormSection title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="128" /></el-form-item><el-form-item label="联系方式"><el-input v-model="form.contactInfo" maxlength="128" /></el-form-item></div></FormSection>
|
||||
<FormSection title="📦 货物信息"><div class="form-grid five"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" /></el-form-item></div></FormSection>
|
||||
<FormSection title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价"><el-input v-model="form.budgetAmount" inputmode="decimal"><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-tooltip :content="isLooseboard ? '本土散板接口待后端补齐' : '平台创建/普通编辑端点待后端补齐'"><span><el-button type="primary" disabled><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></span></el-tooltip></footer></section></template>
|
||||
<FormSection title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="128" /></el-form-item><el-form-item label="联系方式" prop="contactInfo"><el-input v-model="form.contactInfo" maxlength="128" /></el-form-item></div></FormSection>
|
||||
<FormSection title="📦 货物信息"><div class="form-grid" :class="isLooseboard ? 'four' : 'five'"><el-form-item label="柜型"><el-select v-model="form.containerType" clearable><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item v-if="!isLooseboard" label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" /></el-form-item></div></FormSection>
|
||||
<FormSection title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal"><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间" prop="requestedDate"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" clearable /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabel" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { max-width:900px; padding:20px 24px; }.form-grid { display:grid; gap:0 24px; }.form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }.form-grid.five { grid-template-columns:repeat(5,minmax(0,1fr)); gap:0 16px; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.choice-block { display:grid; grid-template-columns:1fr 1fr; gap:24px; }.span-2 { grid-column:1 / -1; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
|
||||
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { max-width:900px; padding:20px 24px; }.form-grid { display:grid; gap:0 24px; }.form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }.form-grid.four { grid-template-columns:repeat(4,minmax(0,1fr)); gap:0 16px; }.form-grid.five { grid-template-columns:repeat(5,minmax(0,1fr)); gap:0 16px; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.choice-block { display:grid; grid-template-columns:1fr 1fr; gap:24px; }.span-2 { grid-column:1 / -1; }.field-error { display:block; margin-top:5px; color:var(--el-color-danger); font-size:12px; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
|
||||
</style>
|
||||
|
||||
@@ -10,10 +10,9 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import { listUsTruckOrders } from '../api/platformOverseasBusiness'
|
||||
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { LocalLooseboardOrder, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType } from '../types'
|
||||
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType, LooseboardTransportMode } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const router = useRouter(); const access = useAccessPreviewStore()
|
||||
@@ -25,56 +24,62 @@ const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const l
|
||||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const filters = ref<QueryModel>({ status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
|
||||
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
|
||||
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
|
||||
const transportModeLabels: Record<LooseboardTransportMode, string> = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' }
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: isLooseboard.value ? ['待接单', '已派车', '运输中', '已完成'].map((label) => ({ label, value: label })) : Object.entries(truckStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: Object.entries(isLooseboard.value ? looseboardStatusLabels : truckStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
{ key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 },
|
||||
])
|
||||
const truckColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalTransportOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' },
|
||||
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 }, { key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 },
|
||||
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90 }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
|
||||
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
|
||||
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] || row.status },
|
||||
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] },
|
||||
]
|
||||
const looseboardColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'transportMode', prop: 'transportMode', label: '运输方式', minWidth: 120 }, { key: 'palletRequired', prop: 'palletRequired', label: '是否打盘', width: 100 },
|
||||
{ key: 'transportType', prop: 'transportType', label: '运输类型', minWidth: 120 }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
|
||||
{ key: 'transportMode', label: '运输方式', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportModeLabels[row.transportMode] }, { key: 'palletRequired', label: '是否打盘', width: 100, formatter: (row: LocalLooseboardOrder) => row.palletRequired === 'YES' ? '是' : '否' },
|
||||
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportTypeLabels[row.transportType] }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
|
||||
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
|
||||
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
|
||||
{ key: 'budgetAmount', prop: 'budgetAmount', label: '预算总价', width: 115, align: 'right' }, { key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 },
|
||||
{ key: 'status', prop: 'status', label: '状态', width: 105 },
|
||||
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
|
||||
{ key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' }, { key: 'budgetAmount', label: '预算总价', width: 115, align: 'right', formatter: (row: LocalLooseboardOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
|
||||
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
|
||||
]
|
||||
const columns = computed(() => isLooseboard.value ? looseboardColumns : truckColumns)
|
||||
async function load() {
|
||||
rows.value = []; total.value = 0; loadError.value = null
|
||||
if (isLooseboard.value) return
|
||||
loading.value = true
|
||||
try { const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery); rows.value = result.records; total.value = result.total } catch (error) { loadError.value = getApiErrorDetails(error, '本土卡车订单加载失败,请稍后重试') } finally { loading.value = false }
|
||||
rows.value = []; total.value = 0; loadError.value = null; loading.value = true
|
||||
try {
|
||||
if (isLooseboard.value) {
|
||||
const result = await listLocalLooseboardOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalLooseboardStatus | '' } satisfies LocalLooseboardQuery)
|
||||
rows.value = result.records; total.value = result.total
|
||||
} else {
|
||||
const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery)
|
||||
rows.value = result.records; total.value = result.total
|
||||
}
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单加载失败,请稍后重试`) } finally { loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (isLooseboard.value) return; void router.push({ name: 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
|
||||
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW' }
|
||||
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page.value = 1; applied.value = { status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout :aria-label="`${domainLabel}订单列表`">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="search" /></template>
|
||||
<template v-if="isLooseboard" #state><ContractPendingNotice domain="本土散板订单" /></template>
|
||||
<template v-else-if="loadError" #state><LoadErrorState title="本土卡车订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="isLooseboard || !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-business-${domain}`" row-key="id" :empty-text="isLooseboard ? '本土散板稳定子域与接口待后端补齐' : '暂无符合筛选条件的本土卡车订单'">
|
||||
<template #toolbar-actions><el-tooltip v-if="access.can(createPermission)" :content="isLooseboard ? '本土散板接口待后端补齐' : '平台创建端点待后端补齐'"><span><el-button type="primary" disabled><Plus :size="16" />新增订单</el-button></span></el-tooltip></template>
|
||||
<template #actions="{ row }"><el-button v-if="access.can(editPermission)" size="small" :disabled="isLooseboard" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<template v-if="loadError" #state><LoadErrorState :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></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" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="`暂无符合筛选条件的${domainLabel}订单`">
|
||||
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
|
||||
<template #actions="{ row }"><el-tooltip v-if="access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)"><Pencil :size="15" />编辑</el-button></span></el-tooltip></template>
|
||||
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-panel { overflow:hidden; }
|
||||
</style>
|
||||
<style scoped>.table-panel { overflow:hidden; }</style>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
export const overseasBusinessPermissions = {
|
||||
purchaseList: 'platform:overseas-business:purchase:list',
|
||||
purchaseEdit: 'platform:overseas-business:purchase:edit',
|
||||
purchaseReview: 'platform:overseas-business:purchase:review',
|
||||
truckList: 'platform:overseas-business:truck:list',
|
||||
truckCreate: 'platform:overseas-business:truck:create',
|
||||
truckEdit: 'platform:overseas-business:truck:edit',
|
||||
truckStatus: 'platform:overseas-business:truck:status',
|
||||
looseboardList: 'platform:overseas-business:looseboard:list',
|
||||
looseboardCreate: 'platform:overseas-business:looseboard:create',
|
||||
looseboardEdit: 'platform:overseas-business:looseboard:edit',
|
||||
looseboardStatus: 'platform:overseas-business:looseboard:status',
|
||||
} as const
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export interface OverseasBusinessPage<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
|
||||
export interface OverseasBusinessHistory {
|
||||
operationType?: string | null; fromStatus: string | null; toStatus: string | null
|
||||
operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string
|
||||
}
|
||||
|
||||
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED'
|
||||
export interface OverseasPurchaseQuery {
|
||||
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
|
||||
@@ -11,27 +16,22 @@ export interface OverseasPurchaseSummary {
|
||||
purchaseCategory: string; productName: string; productSpecification: string | null
|
||||
purchaseQuantity: number; budgetUnitPrice: string | null; currency: string
|
||||
purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
|
||||
inboundQuantity: number; outboundQuantity: number; inventoryQuantity: number
|
||||
inboundQuantity: number; inventoryQuantity: number
|
||||
status: OverseasPurchaseStatus; version: number; createdAt: string; updatedAt: string
|
||||
}
|
||||
export interface OverseasProcurementOutbound {
|
||||
id: string; quantity: number; outboundNo: string; carrier: string | null; trackingNo: string | null
|
||||
outboundAt: string | null; remark: string | null; createdBy: string | null; createdAt: string
|
||||
}
|
||||
export interface OverseasBusinessHistory {
|
||||
fromStatus: string | null; toStatus: string; operatorId: string | null; remark: string | null; createdAt: string
|
||||
}
|
||||
export interface OverseasPurchaseDetail extends OverseasPurchaseSummary {
|
||||
productUrl: string | null; receivingAddress: string | null; contactName: string | null; contactPhone: string | null
|
||||
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
|
||||
estimatedArrivalAt: string | null; remark: string | null; createdBy: string | null; reviewedBy: string | null
|
||||
reviewRemark: string | null; reviewedAt: string | null; outbounds: OverseasProcurementOutbound[]; histories: OverseasBusinessHistory[]
|
||||
reviewRemark: string | null; reviewedAt: string | null; histories: OverseasBusinessHistory[]
|
||||
}
|
||||
export interface OverseasPurchaseCustomerOption { customerId: string; customerName: string }
|
||||
export interface OverseasPurchaseWarehouseOption { warehouseId: string; warehouseCode: string; warehouseName: string; countryCode: string }
|
||||
export interface UpdateOverseasPurchaseRequest {
|
||||
version: number; purchaseCategory?: string | null; productName?: string | null; productSpecification?: string | null
|
||||
productUrl?: string | null; inboundQuantity?: number | null; purchaseWarehouseId?: string | null
|
||||
purchaseWarehouseName?: string | null; domesticCarrier?: string | null; domesticTrackingNo?: string | null
|
||||
overseasCarrier?: string | null; overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
|
||||
version: number; purchaseCategory?: string; productName?: string; productSpecification?: string | null
|
||||
productUrl?: string | null; inboundQuantity?: number; purchaseWarehouseId?: string | null
|
||||
domesticCarrier?: string | null; domesticTrackingNo?: string | null; overseasCarrier?: string | null
|
||||
overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
|
||||
}
|
||||
|
||||
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
|
||||
@@ -56,15 +56,37 @@ export interface LocalTransportDetail extends LocalTransportOrder {
|
||||
originAddress: string | null; destinationAddress: string | null; remark: string | null; createdBy: string | null
|
||||
pallets: LocalTransportPallet[]; histories: OverseasBusinessHistory[]
|
||||
}
|
||||
export interface SaveLocalTransportOrderRequest {
|
||||
version?: number; transportType: LocalTransportType | ''; origin: string; destination: string
|
||||
originAddress: string | null; destinationAddress: string | null; contactName: string | null; contactInfo: string | null
|
||||
containerType: string | null; palletCount: number; packageCount: number; totalWeightKg: string | null
|
||||
totalVolumeCbm: string | null; budgetAmount: string | null; currency: string; requestedDate: string | null; remark: string | null
|
||||
export interface CreateLocalTransportOrderRequest {
|
||||
transportType?: LocalTransportType; origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
|
||||
containerType?: string | null; palletCount: number; packageCount?: number; totalWeightKg: number; totalVolumeCbm: number
|
||||
budgetAmount?: number | null; currency?: string; contactName: string; contactInfo: string; requestedDate: string
|
||||
remark?: string | null; idempotencyKey: string
|
||||
}
|
||||
export interface UpdateLocalTransportOrderRequest extends Omit<CreateLocalTransportOrderRequest, 'idempotencyKey'> { version: number }
|
||||
|
||||
// 本土散板后端稳定子域尚不存在;仅保留原型表单模型,不映射到卡车端点。
|
||||
export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
|
||||
export type PalletRequired = 'YES' | 'NO'
|
||||
export interface LocalLooseboardOrder extends LocalTransportOrder { transportMode: LooseboardTransportMode; palletRequired: PalletRequired }
|
||||
export interface SaveLocalLooseboardOrderRequest extends SaveLocalTransportOrderRequest { transportMode: LooseboardTransportMode | ''; palletRequired: PalletRequired | '' }
|
||||
export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
|
||||
export interface LocalLooseboardQuery {
|
||||
page?: number; size?: number; customerId?: string; orderNo?: string; routeKeyword?: string
|
||||
status?: LocalLooseboardStatus | ''; transportMode?: LooseboardTransportMode | ''; requestedFrom?: string; requestedTo?: string
|
||||
}
|
||||
export interface LocalLooseboardOrder {
|
||||
id: string; customerId: string; orderNo: string; transportMode: LooseboardTransportMode; palletRequired: PalletRequired
|
||||
transportType: LocalTransportType; origin: string; destination: string; containerType: string | null; packageCount: number
|
||||
totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string
|
||||
contactName: string | null; contactInfo: string | null; requestedDate: string | null; status: LocalLooseboardStatus
|
||||
version: number; createdAt: string; updatedAt: string
|
||||
}
|
||||
export interface LocalLooseboardDetail extends LocalLooseboardOrder {
|
||||
originAddress: string | null; destinationAddress: string | null; remark: string | null; createdBy: string | null
|
||||
histories: OverseasBusinessHistory[]
|
||||
}
|
||||
export interface CreateLocalLooseboardOrderRequest {
|
||||
transportMode: LooseboardTransportMode; palletRequired: PalletRequired; transportType: LocalTransportType
|
||||
origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
|
||||
containerType?: string | null; packageCount: number; totalWeightKg: number; totalVolumeCbm: number
|
||||
budgetAmount?: number | null; currency?: string; contactName?: string | null; contactInfo?: string | null
|
||||
requestedDate?: string | null; remark?: string | null; idempotencyKey: string
|
||||
}
|
||||
export interface UpdateLocalLooseboardOrderRequest extends Omit<CreateLocalLooseboardOrderRequest, 'idempotencyKey'> { version: number }
|
||||
|
||||
@@ -1,44 +1,13 @@
|
||||
# 海外通用资料接口缺口
|
||||
# 海外通用资料真实接口与剩余缺口
|
||||
|
||||
本文件记录 Admin 海外通用资料与当前 Java 契约之间的实际差距。前端页面类型只是原型展示模型,不是已经发布的服务端 DTO。
|
||||
三域统一经 `overseasHttp` 调用 `/api/platform/v1/last-leg/master-data`,长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。
|
||||
|
||||
## 已确认的服务入口
|
||||
- 报价:已接分页、详情、创建、编辑、启停、CSV 导出和 `/overseas-quotes/warehouse-options`。浏览器不调用国内 `/internal/warehouses/overseas-partners`。
|
||||
- 汇率:已接摘要、分页、详情、创建、覆盖编辑、状态和 CSV 导出。
|
||||
- 车辆:已接分页、独立详情、创建、编辑、状态和 CSV 导出。
|
||||
|
||||
- `gateway-overseas` 将 `/api/platform/v1/last-leg/**` 路由到 `lastleg-service`。
|
||||
- `gateway-overseas` 将 `/api/platform/v1/warehouses/**`、`/api/platform/v1/warehouse-admin/**` 路由到 `warehouse-overseas-service`。
|
||||
- 网关海外 Token Audience 为 `lastleg-api`。
|
||||
- 两个海外服务没有提交对应业务的静态 OpenAPI YAML;运行时契约由 Springdoc 注解生成,网关暴露 `/lastleg-service/v3/api-docs/**` 和 `/warehouse-overseas-service/v3/api-docs/**`。
|
||||
剩余后端契约缺口:
|
||||
|
||||
`warehouse-overseas-service` 已有实际海外仓聚合接口:
|
||||
|
||||
| 操作 | 端点 | 请求/响应 | 当前限制 |
|
||||
|---|---|---|---|
|
||||
| 仓库列表 | `GET /api/platform/v1/warehouse-admin` | 必填 `customerId`;`page`、`size`、`countryCode`、`capabilityCode`、`status`;返回 `PageResult<WarehouseEntity>` | 仅校验平台身份,没有与本模块冻结权限对应的细粒度权限;按客户隔离,不能直接当作平台全局报价仓候选 |
|
||||
| 仓库详情 | `GET /api/platform/v1/warehouse-admin/{id}` | 必填 `customerId`;返回仓库、地址、能力聚合 | 需要先冻结报价/车辆记录与客户、海外仓的外键口径 |
|
||||
| 仓库新增/编辑/启停 | `POST`、`PUT /{id}`、`PATCH /{id}/status` | `WarehouseRequests.SaveRequest`;状态仅 `ACTIVE/INACTIVE` | 属于实际海外仓资源,不是国内公共合作仓类型,也不是报价、汇率或车辆主数据接口 |
|
||||
|
||||
上述接口不在本模块接入:它们是按客户隔离的实际海外仓资源,不是平台公共合作仓,也不是海外报价、汇率或车辆主数据。业务页面不得为了补候选数据而跨区域拼接这两类仓库资源。
|
||||
|
||||
国内 Warehouse 已正式支持公共类型 `OVERSEAS_PARTNER`,Admin 在现有 `/api/platform/v1/warehouse-admin` 仓库页完成筛选、新增和编辑。国内服务另有受 `X-Internal-Service-Token` 保护的 `GET /internal/warehouses/overseas-partners`,仅供未来海外报价服务内部读取 `ACTIVE + OVERSEAS_PARTNER` 候选;前端不得调用该内部端点,报价页面也不跨区域拉取国内合作仓列表。
|
||||
|
||||
## 页面与操作缺口
|
||||
|
||||
| 页面/操作 | 端点 | DTO | 权限 | 状态机/规则 | 结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| 海外仓报价列表、筛选、详情 | 缺失 | 缺少报价概要、仓储费用、中转派送价格及分页 DTO | 已冻结只读权限 `platform:overseas-master-data:quote:list`,服务端未实现 | 需定义生效区间、版本唯一性及当前有效版本规则 | 保持结构化空态 |
|
||||
| 海外仓报价新增、编辑 | 缺失 | 缺少基础信息、费用明细、派送价格保存 DTO;海外仓必须使用真实 ID,不能只传名称 | create/edit 动作权限未冻结 | 需定义并发版本、日期重叠、费用组合去重与金额精度 | 写按钮禁用 |
|
||||
| 海外仓报价启停、删除、导出 | 缺失 | 缺少状态命令、删除约束和导出任务 DTO | status/delete/export 动作权限未冻结 | 需定义 `ACTIVE/INACTIVE`、历史引用后的删除规则和导出方式 | 写按钮禁用 |
|
||||
| 汇率摘要、列表、详情 | 缺失 | 缺少 `yyyy-MM`、USD→CNY 月平均值、更新人/时间及备注 DTO | 已冻结只读权限 `platform:overseas-master-data:exchange-rate:list`,服务端未实现 | 固定方向应为 `1 USD = X CNY`;需定义精度和月份时区 | 保持结构化空态 |
|
||||
| 汇率新增、覆盖编辑 | 缺失 | 缺少月度汇率保存 DTO | edit 动作权限未冻结 | 需定义同月唯一、覆盖/乐观锁及下游引用后的修改规则 | 写按钮禁用 |
|
||||
| 车辆列表、详情 | 缺失 | 缺少车辆档案、归属、区域、里程、司机/外派单位、派送订单与更换历史 DTO | 已冻结只读权限 `platform:overseas-master-data:vehicle:list`,服务端未实现 | 需定义车辆状态及其与派送订单的关联来源 | 保持结构化空态 |
|
||||
| 车辆新增、编辑、删除、导出 | 缺失 | 缺少车辆保存、删除约束和导出 DTO | create/edit/delete/export 动作权限未冻结 | 需定义车牌唯一性、归属变化、里程校验和被引用车辆删除规则 | 写按钮禁用 |
|
||||
|
||||
`lastleg-service` 的 `GET /api/platform/v1/last-leg/us-truck-orders` 等接口管理的是本土卡车业务订单,写操作权限为 `lastleg:us-trucking:manage`,状态从 `PENDING_REVIEW` 经收货、分配、派车、运输推进至 `COMPLETED`。它不是车辆档案,不得用订单字段拼装车辆主数据或复用其权限。
|
||||
|
||||
## 前端权限边界
|
||||
|
||||
- 海外仓报价:`platform:overseas-master-data:quote:list`
|
||||
- 汇率管理:`platform:overseas-master-data:exchange-rate:list`
|
||||
- 车辆列表/详情:`platform:overseas-master-data:vehicle:list`
|
||||
|
||||
新增、编辑、启停、删除、导出等写操作继续按动作使用独立权限。列表权限只包含对应列表、详情和只读辅助数据访问。
|
||||
- 报价和车辆 Controller/OpenAPI 没有删除端点,前端不展示删除操作。
|
||||
- 车辆详情明确不包含派送履约与车辆更换历史;页面保留原型分区并标记当前接口边界,不拼装其他订单数据。
|
||||
- 权限资源由总控统一写入 IAM/SQL;本模块只使用 canonical `platform:overseas-master-data:{quote|exchange-rate|vehicle}:{list|create|edit|status|export}`。
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
|---|---|---|---|---|---|---|
|
||||
| 合作仓 `gen-partner-warehouse` | 列表/筛选 | `10-general.html:50-56` | 现有仓库管理页内分类 | 自有仓筛选、代码/地址、国家;操作列在表格末尾(与仓库稳定页的全局操作列第一约定冲突时服从全局约定) | 现有仓库页新增 `OVERSEAS_PARTNER` 类型筛选,不新增重复页面 | 使用国内公共仓库接口与 `platform:warehouse:list`;普通 `OVERSEAS` 语义不变 |
|
||||
| 合作仓 | 新增/编辑 | `overlays.html:1731-1756`、`app.js:1655-1701` | 居中弹框 `modal-fba-add` | 最大约 560px;单列标签表单 | 复用现有仓库居中弹框,开放 `OVERSEAS_PARTNER` 新增和编辑 | 分别复用 `platform:warehouse:create`、`platform:warehouse:edit`,不跨区域查询报价候选 |
|
||||
| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14007-14039` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 正式模块列表页、结构化空态 | 保留原型字段;等待海外查询接口 |
|
||||
| 海外仓报价 | 详情 | `10-general.html:201-210`、`app.js:14043-14081` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 写入口禁用,详情无记录时不生成示例对象 | 接口接入后使用居中弹框,不改抽屉或独立页 |
|
||||
| 海外仓报价 | 新增/编辑 | `10-general.html:171-199`、`app.js:14135-14202` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 工具栏写按钮禁用 | 等写接口与动作权限确认后接入,不模拟保存成功 |
|
||||
| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14007-14039` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 已接海外分页与服务端合作仓候选 | 使用 quote:list,不跨区拼装候选 |
|
||||
| 海外仓报价 | 详情 | `10-general.html:201-210`、`app.js:14043-14081` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 已接真实详情弹框 | 使用 quote:list |
|
||||
| 海外仓报价 | 新增/编辑 | `10-general.html:171-199`、`app.js:14135-14202` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 已接真实创建/编辑与 recordVersion | quote:create/edit 独立校验 |
|
||||
| 海外仓报价 | 启停/删除/导出 | `10-general.html:154-169`、`app.js:14203-14218` | 列表行操作/工具栏 | 操作列第一列 | 无记录;写入口禁用 | 等独立动作权限与接口 |
|
||||
| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3373-3397` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 正式模块列表页、结构化空态 | 统一展示 `1 USD = X CNY`,等待查询接口 |
|
||||
| 汇率管理 | 更新/编辑 | `overlays.html:1648-1658`、`app.js:3399-3434` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 更新按钮禁用 | 等写接口与动作权限,不模拟覆盖成功 |
|
||||
| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3373-3397` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 已接摘要与真实分页 | 统一展示 `1 USD = X CNY` |
|
||||
| 汇率管理 | 更新/编辑 | `overlays.html:1648-1658`、`app.js:3399-3434` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 已接创建/覆盖、状态与导出 | recordVersion 冲突保留服务端错误 |
|
||||
| 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:9719-9806`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 正式模块将区域降为查询下拉框,仅保留自有/外派归属页签;归属页签直接沿用订单列表的查询面板、底部指示条和键盘切换结构;操作列按全局约定移到第一列 | 当前对话确认高于原型;保留会改变表格列结构的归属页签,区域作为普通筛选;等待查询接口 |
|
||||
| 车辆 | 详情 | `10-general.html:421-448`、`app.js:9871-9947` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 正式详情路由;无记录时显示契约待接入态 | 不改弹框或抽屉,不生成示例车辆 |
|
||||
| 车辆 | 新增/编辑 | `10-general.html:353-383`、`app.js:9808-9869` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 写按钮禁用 | 等写接口与动作权限,不模拟保存成功 |
|
||||
| 车辆 | 详情 | `10-general.html:421-448`、`app.js:9871-9947` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;履约与更换区显示后端明确排除说明 | 不拼装订单/派送数据 |
|
||||
| 车辆 | 新增/编辑 | `10-general.html:353-383`、`app.js:9808-9869` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 已接创建/编辑、独立状态与导出 | create/edit/status 权限独立 |
|
||||
| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:9759-9806` | 列表行操作/工具栏 | 原型列表操作 | 无记录;写入口禁用 | 等独立动作权限与接口 |
|
||||
|
||||
原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import {
|
||||
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
|
||||
exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getExchangeRateSummary, getQuote, getVehicle,
|
||||
listExchangeRates, listQuotes, listQuoteWarehouseOptions, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
|
||||
} from './overseasMasterData'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() } }))
|
||||
const ok = (data: unknown) => ({ code: '200', message: 'success', data })
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('overseas master data API', () => {
|
||||
it('uses the overseas quote contract including server-owned partner options', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: '1', size: '20', total: '0', pages: '0' }))
|
||||
await listQuotes({ page: 1, size: 20, keyword: 'V1' })
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes', { params: { page: 1, size: 20, keyword: 'V1' } })
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok([])); await listQuoteWarehouseOptions()
|
||||
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options')
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ summary: { id: '9223372036854775807' }, warehouseFees: [], transitPrices: [] })); await getQuote('9223372036854775807')
|
||||
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/9223372036854775807')
|
||||
const payload = { name: '报价', warehouseId: '9', providerName: '服务商', currency: 'USD' as const, effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', warehouseFees: [], transitPrices: [], remark: null }
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createQuote(payload)
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateQuote('9', { ...payload, recordVersion: 2 })
|
||||
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeQuoteStatus('9', 'INACTIVE', 2)
|
||||
expect(overseasHttp.patch).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes/9/status', { status: 'INACTIVE', recordVersion: 2 })
|
||||
})
|
||||
|
||||
it('uses the monthly exchange-rate contract', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })); await listExchangeRates({ page: 1, size: 20, monthFrom: '2026-08', monthTo: '2026-08' })
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ latestMonth: '2026-08' })); await getExchangeRateSummary()
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ month: '2026-08' })); await getExchangeRate('2026-08')
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createExchangeRate({ month: '2026-08', usdToCnyAverage: 7.18, remark: null })
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateExchangeRate('2026-08', { recordVersion: 1, usdToCnyAverage: 7.19, remark: null })
|
||||
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeExchangeRateStatus('2026-08', 'INACTIVE', 1, '停用')
|
||||
expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/exchange-rates/2026-08/status', { status: 'INACTIVE', recordVersion: 1, remark: '停用' })
|
||||
})
|
||||
|
||||
it('uses the vehicle contract and preserves string IDs', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })); await listVehicles({ page: 1, size: 20, ownership: 'OWNED' })
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue(ok({ id: '9223372036854775807' })); await getVehicle('9223372036854775807')
|
||||
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/vehicles/9223372036854775807')
|
||||
const payload = { plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12, mileageKm: 1, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null, status: 'IN_SERVICE' as const }
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue(ok({})); await createVehicle(payload)
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue(ok({})); await updateVehicle('9', { ...payload, recordVersion: 1 })
|
||||
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({})); await changeVehicleStatus('9', 'INACTIVE', 1)
|
||||
expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/vehicles/9/status', { status: 'INACTIVE', recordVersion: 1 })
|
||||
})
|
||||
|
||||
it('downloads each CSV through overseasHttp', async () => {
|
||||
const blob = new Blob(['csv']); vi.mocked(overseasHttp.get).mockResolvedValue(blob)
|
||||
await exportQuotes({ status: 'ACTIVE' }); await exportExchangeRates({}); await exportVehicles({ ownership: 'OWNED' })
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes/export', { params: { status: 'ACTIVE' }, responseType: 'blob' })
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'blob' })
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'blob' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
MasterDataStatus, MonthlyExchangeRate, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption,
|
||||
VehicleDetail, VehicleOwnership, VehicleRegion, VehicleStatus,
|
||||
} from '../types'
|
||||
|
||||
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
type PagePayload<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: number | string; size: number | string; total: number | string; pages: number | string }
|
||||
const base = '/platform/v1/last-leg/master-data'
|
||||
function page<T>(value: PagePayload<T>): PageResult<T> { return { ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) } }
|
||||
async function getPage<T>(path: string, params: object) { return page(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PagePayload<T>>, ApiEnvelope<PagePayload<T>>>(path, { params }))) }
|
||||
async function download(path: string, params: object) { return overseasHttp.get<Blob, Blob>(path, { params, responseType: 'blob' }) }
|
||||
|
||||
export interface QuoteQuery { page: number; size: number; keyword?: string; warehouseId?: string; providerName?: string; currency?: string; status?: MasterDataStatus }
|
||||
export type QuoteSave = Pick<OverseasWarehouseQuote, 'name' | 'providerName' | 'currency' | 'effectiveStart' | 'effectiveEnd' | 'warehouseFees' | 'transitPrices' | 'remark'>
|
||||
& { warehouseId?: string; versionCode?: string; recordVersion?: number }
|
||||
export const listQuotes = (query: QuoteQuery) => getPage<OverseasWarehouseQuote>(`${base}/overseas-quotes`, query)
|
||||
export async function getQuote(id: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`)) }
|
||||
export async function listQuoteWarehouseOptions() { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PartnerWarehouseOption[]>, ApiEnvelope<PartnerWarehouseOption[]>>(`${base}/overseas-quotes/warehouse-options`)) }
|
||||
export async function createQuote(payload: QuoteSave) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes`, payload)) }
|
||||
export async function updateQuote(id: string, payload: QuoteSave) { return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload)) }
|
||||
export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<OverseasWarehouseQuoteDetail>, ApiEnvelope<OverseasWarehouseQuoteDetail>>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion })) }
|
||||
export const exportQuotes = (query: Omit<QuoteQuery, 'page' | 'size'>) => download(`${base}/overseas-quotes/export`, query)
|
||||
|
||||
export interface ExchangeRateQuery { page: number; size: number; monthFrom?: string; monthTo?: string; status?: MasterDataStatus }
|
||||
export const listExchangeRates = (query: ExchangeRateQuery) => getPage<MonthlyExchangeRate>(`${base}/exchange-rates`, query)
|
||||
export async function getExchangeRate(month: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates/${encodeURIComponent(month)}`)) }
|
||||
export async function getExchangeRateSummary() { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<{ latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }>, ApiEnvelope<{ latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }>>(`${base}/exchange-rates/summary`)) }
|
||||
export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates`, payload)) }
|
||||
export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload)) }
|
||||
export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<MonthlyExchangeRate>, ApiEnvelope<MonthlyExchangeRate>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark })) }
|
||||
export const exportExchangeRates = (query: Omit<ExchangeRateQuery, 'page' | 'size'>) => download(`${base}/exchange-rates/export`, query)
|
||||
|
||||
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
|
||||
export type VehicleSave = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'> & { status?: VehicleStatus; recordVersion?: number }
|
||||
export const listVehicles = (query: VehicleQuery) => getPage<VehicleDetail>(`${base}/vehicles`, query)
|
||||
export async function getVehicle(id: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles/${encodeURIComponent(id)}`)) }
|
||||
export async function createVehicle(payload: VehicleSave) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles`, payload)) }
|
||||
export async function updateVehicle(id: string, payload: VehicleSave) { return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles/${encodeURIComponent(id)}`, payload)) }
|
||||
export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<VehicleDetail>, ApiEnvelope<VehicleDetail>>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion })) }
|
||||
export const exportVehicles = (query: Omit<VehicleQuery, 'page' | 'size'>) => download(`${base}/vehicles/export`, query)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
|
||||
export function masterDataErrorMessage(error: unknown, fallback: string) {
|
||||
const details = getApiErrorDetails(error, fallback)
|
||||
const reference = details.requestId || details.traceId
|
||||
return reference ? `${details.message}(请求标识:${reference})` : details.message
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function saveCsv(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
try { anchor.href = url; anchor.download = fileName; anchor.click() }
|
||||
finally { URL.revokeObjectURL(url) }
|
||||
}
|
||||
@@ -1,68 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Download, Pencil, Plus, Power } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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 QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import type { MonthlyExchangeRate } from '../types'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { changeExchangeRateStatus, createExchangeRate, exportExchangeRates, getExchangeRate, getExchangeRateSummary, listExchangeRates, updateExchangeRate } from '../api/overseasMasterData'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { saveCsv } from '../downloadCsv'
|
||||
import { masterDataErrorMessage } from '../apiError'
|
||||
import type { MasterDataStatus, MonthlyExchangeRate } from '../types'
|
||||
|
||||
const rows = ref<MonthlyExchangeRate[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ month: '' })
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<MonthlyExchangeRate[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false), saving = ref(false), exporting = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), editorOpen = ref(false), editingMonth = ref('')
|
||||
const filters = ref<QueryModel>({ month: '', status: '' })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'month', type: 'date', label: '查询月份', placeholder: '请选择月份', width: 200, props: { type: 'month', 'value-format': 'YYYY-MM' } },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 130, options: [{ label: '启用', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }] },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'month', prop: 'month', label: '汇率月份', minWidth: 120 },
|
||||
{ key: 'usdToCnyAverage', prop: 'usdToCnyAverage', label: '平均汇率', minWidth: 120 },
|
||||
{ key: 'conversion', label: '换算说明', minWidth: 190, slotName: 'conversion' },
|
||||
{ key: 'recordStatus', label: '状态', minWidth: 110 },
|
||||
{ key: 'updatedBy', prop: 'updatedBy', label: '更新人', minWidth: 120 },
|
||||
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 200 },
|
||||
]
|
||||
const summaries = [
|
||||
{ label: '最新维护汇率', value: '—' },
|
||||
{ label: '汇率月份', value: '—' },
|
||||
{ label: '最后更新人', value: '—' },
|
||||
{ label: '最后更新时间', value: '—' },
|
||||
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' }, { key: 'month', prop: 'month', label: '汇率月份', minWidth: 120 },
|
||||
{ key: 'usdToCnyAverage', prop: 'usdToCnyAverage', label: '平均汇率', minWidth: 120 }, { key: 'conversion', label: '换算说明', minWidth: 190, slotName: 'conversion' },
|
||||
{ key: 'status', label: '状态', width: 90, slotName: 'status' }, { key: 'updatedBy', prop: 'updatedBy', label: '更新人', minWidth: 120 },
|
||||
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 200 },
|
||||
]
|
||||
const summary = reactive({ latestMonth: '', latestUsdToCnyAverage: null as number | null, updatedBy: '', updatedAt: '' })
|
||||
const form = reactive({ month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 })
|
||||
function params() { const month = String(filters.value.month || ''); return { page: page.value, size: pageSize.value, monthFrom: month || undefined, monthTo: month || undefined, status: (filters.value.status || undefined) as MasterDataStatus | undefined } }
|
||||
async function load() { loading.value = true; error.value = null; try { const [result, latest] = await Promise.all([listExchangeRates(params()), getExchangeRateSummary()]); rows.value = result.records; total.value = result.total; Object.assign(summary, latest) } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '月度汇率加载失败') } finally { loading.value = false } }
|
||||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||||
function openCreate() { editingMonth.value = ''; Object.assign(form, { month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 }); editorOpen.value = true }
|
||||
async function openEdit(row: MonthlyExchangeRate) { try { const value = await getExchangeRate(row.month); editingMonth.value = value.month; Object.assign(form, { month: value.month, usdToCnyAverage: Number(value.usdToCnyAverage), remark: value.remark || '', recordVersion: value.recordVersion }); editorOpen.value = true } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率详情加载失败')) } }
|
||||
async function save() { if (!form.month || form.usdToCnyAverage <= 0) { ElMessage.warning('请选择月份并填写大于 0 的平均汇率'); return } saving.value = true; try { if (editingMonth.value) await updateExchangeRate(editingMonth.value, { recordVersion: form.recordVersion, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); else await createExchangeRate({ month: form.month, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); ElMessage.success(editingMonth.value ? '月度汇率已更新' : '月度汇率已创建'); editorOpen.value = false; await load() } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '月度汇率保存失败')) } finally { saving.value = false } }
|
||||
async function toggle(row: MonthlyExchangeRate) { const next: MasterDataStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'} ${row.month} 汇率吗?`, '汇率状态', { type: 'warning' }); await changeExchangeRateStatus(row.month, next, row.recordVersion, null); ElMessage.success('汇率状态已更新'); await load() } catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '汇率状态更新失败')) } }
|
||||
async function exportCsv() { exporting.value = true; try { const { page: _page, size: _size, ...query } = params(); saveCsv(await exportExchangeRates(query), 'monthly-exchange-rates.csv') } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率导出失败')) } finally { exporting.value = false } }
|
||||
watch([page, pageSize], load); onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<ListPageLayout aria-label="汇率管理">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" aria-label="汇率月份筛选" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" @search="page = 1" /></template>
|
||||
<template #metrics>
|
||||
<section class="rate-summary" aria-label="汇率摘要">
|
||||
<article v-for="item in summaries" :key="item.label" class="surface-panel rate-summary__item"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></article>
|
||||
</section>
|
||||
</template>
|
||||
<template #state><ContractPendingNotice domain="汇率管理" /></template>
|
||||
<section class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-exchange-rates-table" row-key="month" empty-text="月度汇率查询契约待接入">
|
||||
<template #toolbar-start><div><h2>美元兑人民币月平均汇率</h2><p>统一口径:1 USD = X CNY</p></div></template>
|
||||
<template #toolbar-actions><el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />更新月度汇率</el-button></span></el-tooltip></template>
|
||||
<template #actions><span class="muted-value">—</span></template>
|
||||
<template #conversion>—</template>
|
||||
<template #pagination-left>共 0 条月度汇率</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rate-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.rate-summary__item { position: relative; min-width: 0; padding: 16px 18px; overflow: hidden; }
|
||||
.rate-summary__item::before { position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--cc-primary); content: ''; }
|
||||
.rate-summary__item span { display: block; color: var(--cc-muted); font-size: 12px; }
|
||||
.rate-summary__item strong { display: block; margin-top: 8px; color: var(--cc-text); font-size: 22px; }
|
||||
.table-panel { min-width: 0; overflow: hidden; }
|
||||
.muted-value { color: var(--cc-muted); }
|
||||
@media (max-width: 720px) { .rate-summary { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
<template><AdminPage><ListPageLayout aria-label="汇率管理">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" :loading="loading" @search="search" /></template>
|
||||
<template #metrics><section class="rate-summary"><article v-for="item in [{label:'最新维护汇率',value:summary.latestUsdToCnyAverage??'—'},{label:'汇率月份',value:summary.latestMonth||'—'},{label:'最后更新人',value:summary.updatedBy||'—'},{label:'最后更新时间',value:summary.updatedAt||'—'}]" :key="item.label" class="surface-panel"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></article></section></template>
|
||||
<template #state><LoadErrorState v-if="error" title="月度汇率加载失败" :message="error.message" @retry="load" /></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="month" preference-key="overseas-exchange-rates-table" empty-text="暂无月度汇率">
|
||||
<template #toolbar-start><div><h2>美元兑人民币月平均汇率</h2><p>统一口径:1 USD = X CNY</p></div></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.exchangeRateCreate)" type="primary" @click="openCreate"><Plus :size="16" />更新月度汇率</el-button><el-button v-if="access.can(permission.exchangeRateExport)" :loading="exporting" @click="exportCsv"><Download :size="16" />导出</el-button></template>
|
||||
<template #actions="{ row }"><el-button v-if="access.can(permission.exchangeRateEdit)" link type="primary" @click="openEdit(row as MonthlyExchangeRate)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can(permission.exchangeRateStatus)" link :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggle(row as MonthlyExchangeRate)"><Power :size="15" />{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></template>
|
||||
<template #conversion="{ row }">1 USD = {{ (row as MonthlyExchangeRate).usdToCnyAverage }} CNY</template><template #status="{ row }"><el-tag :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
|
||||
</DataTable></section>
|
||||
<AppDialog v-model="editorOpen" :title="editingMonth ? '编辑月度汇率' : '更新月度汇率'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><el-form-item label="汇率月份"><el-date-picker v-model="form.month" type="month" value-format="YYYY-MM" :disabled="!!editingMonth" /></el-form-item><el-form-item label="美元兑人民币平均汇率"><el-input-number v-model="form.usdToCnyAverage" :min="0.00000001" :precision="8" /><p>汇率方向:1 USD = X CNY</p></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" /></el-form-item></el-form></AppDialog>
|
||||
</ListPageLayout></AdminPage></template>
|
||||
<style scoped>.rate-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.rate-summary article{padding:16px 18px}.rate-summary span{display:block;color:var(--cc-muted);font-size:12px}.rate-summary strong{display:block;margin-top:8px;font-size:22px}.table-panel{min-width:0;overflow:hidden}</style>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ExchangeRatesView from './ExchangeRatesView.vue'
|
||||
import OverseasQuotesView from './OverseasQuotesView.vue'
|
||||
import VehicleDetailView from './VehicleDetailView.vue'
|
||||
import VehiclesView from './VehiclesView.vue'
|
||||
|
||||
const push = vi.fn()
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { vehicleId: 'VH-TEST-001' } }), useRouter: () => ({ push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('../api/overseasMasterData', () => ({
|
||||
listQuotes: vi.fn().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }),
|
||||
listQuoteWarehouseOptions: vi.fn().mockResolvedValue([]), getQuote: vi.fn(), createQuote: vi.fn(), updateQuote: vi.fn(), changeQuoteStatus: vi.fn(), exportQuotes: vi.fn(),
|
||||
listExchangeRates: vi.fn().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }),
|
||||
getExchangeRateSummary: vi.fn().mockResolvedValue({ latestMonth: null, latestUsdToCnyAverage: null, updatedBy: null, updatedAt: null }),
|
||||
getExchangeRate: vi.fn(), createExchangeRate: vi.fn(), updateExchangeRate: vi.fn(), changeExchangeRateStatus: vi.fn(), exportExchangeRates: vi.fn(),
|
||||
listVehicles: vi.fn().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }),
|
||||
getVehicle: vi.fn().mockResolvedValue({ id: 'VH-TEST-001', plateNumber: 'CA-TEST', model: 'Truck', fuelConsumptionPer100Km: 12, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'tester', updatedAt: '2026-08-20T10:00:00' }),
|
||||
createVehicle: vi.fn(), updateVehicle: vi.fn(), changeVehicleStatus: vi.fn(), exportVehicles: vi.fn(),
|
||||
}))
|
||||
|
||||
const slot = { template: '<div><slot/><slot name="header"/><slot name="toolbar"/><slot name="metrics"/><slot name="state"/><slot name="toolbar-start"/><slot name="toolbar-actions"/><slot name="actions"/></div>' }
|
||||
const stubs = { AdminPage: slot, ListPageLayout: slot, DetailPageLayout: slot, QueryToolbar: true, DataTable: { template: '<div data-test="data-table"><slot name="toolbar-actions"/></div>' }, FormSection: slot, LoadErrorState: true, AppDialog: slot, ElTooltip: slot, ElButton: slot, ElTag: slot, ElTable: slot, ElTableColumn: true, ElForm: slot, ElFormItem: slot, ElInput: true, ElSelect: slot, ElOption: true, ElDatePicker: true, ElInputNumber: true }
|
||||
function mountView(component: object): VueWrapper { return shallowMount(component, { global: { stubs, directives: { loading: () => undefined } } }) }
|
||||
|
||||
describe('overseas master data real page mounts', () => {
|
||||
beforeEach(() => push.mockReset())
|
||||
it.each([OverseasQuotesView, ExchangeRatesView, VehiclesView])('mounts a real API-backed list page', async (component) => { const wrapper = mountView(component); await flushPromises(); expect(wrapper.find('[data-test="data-table"]').exists()).toBe(true) })
|
||||
it('mounts API-backed vehicle detail without fabricating fulfillment history', async () => { const wrapper = mountView(VehicleDetailView); await flushPromises(); expect(wrapper.text()).toContain('CA-TEST'); expect(wrapper.text()).toContain('当前车辆主数据接口明确不包含派送履约记录') })
|
||||
})
|
||||
@@ -6,7 +6,7 @@ function source(name: string) {
|
||||
return readFileSync(resolve(process.cwd(), `src/modules/overseas-master-data/pages/${name}.vue`), 'utf8')
|
||||
}
|
||||
|
||||
describe('overseas master data pending-contract pages', () => {
|
||||
describe('overseas master data real contract pages', () => {
|
||||
it('gives every list query toolbar a unique stable preference key', () => {
|
||||
const expectedKeys = new Map([
|
||||
['OverseasQuotesView', 'overseas-quotes-query'],
|
||||
@@ -32,37 +32,38 @@ describe('overseas master data pending-contract pages', () => {
|
||||
const quotes = source('OverseasQuotesView')
|
||||
const exchangeRates = source('ExchangeRatesView')
|
||||
const vehicles = source('VehiclesView')
|
||||
expect(quotes.indexOf("key: 'actions'")).toBeLessThan(quotes.indexOf("key: 'name'"))
|
||||
expect(quotes.indexOf("key:'actions'")).toBeLessThan(quotes.indexOf("key:'name'"))
|
||||
for (const label of ['报价名称', '海外仓', '服务商', '币种', '生效日期', '失效日期', '报价版本', '状态', '更新时间', '备注']) {
|
||||
expect(quotes).toContain(`label: '${label}'`)
|
||||
expect(quotes).toContain(`label:'${label}'`)
|
||||
}
|
||||
const exchangeColumns = exchangeRates.slice(exchangeRates.indexOf('const columns'))
|
||||
expect(exchangeColumns.indexOf("key: 'actions'")).toBeLessThan(exchangeColumns.indexOf("key: 'month'"))
|
||||
expect(vehicles.indexOf("key: 'actions'")).toBeLessThan(vehicles.indexOf("key: 'plateNumber'"))
|
||||
for (const label of ['自有车辆', '外派车辆', '当前派送单', '最近更换原因']) expect(vehicles).toContain(label)
|
||||
expect(vehicles).toContain("key: 'region'")
|
||||
expect(vehicles).toContain("placeholder: '全部区域'")
|
||||
expect(vehicles.indexOf("key:'actions'")).toBeLessThan(vehicles.indexOf("key:'plateNumber'"))
|
||||
for (const label of ['自有车辆', '外派车辆', '司机 / 外派单位']) expect(vehicles).toContain(label)
|
||||
expect(vehicles).toContain("key:'region'")
|
||||
expect(vehicles).toContain("placeholder:'全部区域'")
|
||||
expect(vehicles).toContain('class="status-tabs"')
|
||||
expect(vehicles).toContain("'status-tab', { 'is-active': ownership === tab.value }")
|
||||
expect(vehicles).toContain("'status-tab',{'is-active':ownership===tab[1]}")
|
||||
expect(vehicles).not.toContain('<el-segmented')
|
||||
expect(vehicles).not.toContain('aria-label="车辆区域"')
|
||||
})
|
||||
|
||||
it('keeps all write and export entry points disabled with an explicit contract message', () => {
|
||||
it('uses real API operations and canonical action permissions', () => {
|
||||
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
|
||||
const view = source(name)
|
||||
expect(view).toContain('海外区域')
|
||||
expect(view).toMatch(/<el-button[^>]*disabled/)
|
||||
expect(view).not.toContain('ElMessage.success')
|
||||
expect(view).toContain("from '../api/overseasMasterData'")
|
||||
expect(view).toContain('access.can(permission.')
|
||||
expect(view).toContain('ElMessage.success')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses an independent vehicle detail page without inventing a record', () => {
|
||||
it('uses an independent API-backed vehicle detail page without inventing fulfillment history', () => {
|
||||
const detail = source('VehicleDetailView')
|
||||
expect(detail).toContain('车辆信息')
|
||||
expect(detail).toContain('派送订单明细')
|
||||
expect(detail).toContain('派送更换记录')
|
||||
expect(detail).toContain('<ContractPendingNotice domain="车辆详情" detail />')
|
||||
expect(detail).toContain('getVehicle(')
|
||||
expect(detail).toContain('明确不包含派送履约记录')
|
||||
expect(detail).not.toContain('AppDrawer')
|
||||
expect(detail).not.toContain('const vehicle =')
|
||||
})
|
||||
@@ -74,13 +75,11 @@ describe('overseas master data pending-contract pages', () => {
|
||||
expect(matrix).toContain('原型未定义车辆文件预览、审核/确认流程')
|
||||
})
|
||||
|
||||
it('records audited service boundaries without treating truck orders as vehicle master data', () => {
|
||||
it('records the real service boundary and remaining unsupported operations', () => {
|
||||
const gaps = readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/CONTRACT_GAPS.md'), 'utf8')
|
||||
expect(gaps).toContain('/api/platform/v1/warehouse-admin')
|
||||
expect(gaps).toContain('/api/platform/v1/last-leg/us-truck-orders')
|
||||
expect(gaps).toContain('它不是车辆档案')
|
||||
expect(gaps).toContain('/internal/warehouses/overseas-partners')
|
||||
expect(gaps).toContain('前端不得调用该内部端点')
|
||||
expect(gaps).toContain('保持结构化空态')
|
||||
expect(gaps).toContain('/api/platform/v1/last-leg/master-data')
|
||||
expect(gaps).toContain('/overseas-quotes/warehouse-options')
|
||||
expect(gaps).toContain('浏览器不调用国内')
|
||||
expect(gaps).toContain('没有删除端点')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,62 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Download, Plus } from '@lucide/vue'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Download, Eye, Pencil, Plus, Power } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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 QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import type { OverseasWarehouseQuote } from '../types'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { changeQuoteStatus, createQuote, exportQuotes, getQuote, listQuotes, listQuoteWarehouseOptions, updateQuote } from '../api/overseasMasterData'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { saveCsv } from '../downloadCsv'
|
||||
import { masterDataErrorMessage } from '../apiError'
|
||||
import type { MasterDataStatus, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption } from '../types'
|
||||
|
||||
const rows = ref<OverseasWarehouseQuote[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ keyword: '', warehouseId: '', provider: '', currency: '', status: '' })
|
||||
const fields: QueryField[] = [
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<OverseasWarehouseQuote[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false), saving = ref(false), exporting = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), options = ref<PartnerWarehouseOption[]>([]), detail = ref<OverseasWarehouseQuoteDetail | null>(null)
|
||||
const detailOpen = ref(false), editorOpen = ref(false), editingId = ref('')
|
||||
const filters = ref<QueryModel>({ keyword: '', warehouseId: '', providerName: '', currency: '', status: '' })
|
||||
const fields = ref<QueryField[]>([])
|
||||
function syncFields() { fields.value = [
|
||||
{ key: 'keyword', type: 'input', label: '报价名称 / 版本', placeholder: '报价名称 / 版本', width: 180 },
|
||||
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [], width: 180 },
|
||||
{ key: 'provider', type: 'input', label: '服务商', placeholder: '服务商', width: 170 },
|
||||
{ key: 'currency', type: 'select', label: '币种', placeholder: '全部币种', options: [{ label: 'USD', value: 'USD' }, { label: 'CNY', value: 'CNY' }], width: 120 },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '启用', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }], width: 120 },
|
||||
]
|
||||
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: options.value.map(v => ({ label: `${v.warehouseName} · ${v.warehouseCode}`, value: v.warehouseId })), width: 220 },
|
||||
{ key: 'providerName', type: 'input', label: '服务商', placeholder: '服务商', width: 170 },
|
||||
{ key: 'currency', type: 'select', label: '币种', placeholder: '全部币种', options: ['USD','CNY'].map(v => ({ label:v,value:v })), width: 120 },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{label:'启用',value:'ACTIVE'},{label:'停用',value:'INACTIVE'}], width: 120 },
|
||||
] }
|
||||
syncFields()
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 248, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'name', prop: 'name', label: '报价名称', minWidth: 190 },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '海外仓', minWidth: 170 },
|
||||
{ key: 'providerName', prop: 'providerName', label: '服务商', minWidth: 180 },
|
||||
{ key: 'currency', prop: 'currency', label: '币种', width: 74 },
|
||||
{ key: 'effectiveStart', prop: 'effectiveStart', label: '生效日期', width: 112 },
|
||||
{ key: 'effectiveEnd', prop: 'effectiveEnd', label: '失效日期', width: 112 },
|
||||
{ key: 'version', prop: 'version', label: '报价版本', width: 92 },
|
||||
{ key: 'status', prop: 'status', label: '状态', width: 76 },
|
||||
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 142 },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
|
||||
{ key:'actions',label:'操作',width:270,fixed:'left',slotName:'actions' }, { key:'name',prop:'name',label:'报价名称',minWidth:190 },
|
||||
{ key:'warehouseName',prop:'warehouseName',label:'海外仓',minWidth:170 }, { key:'providerName',prop:'providerName',label:'服务商',minWidth:180 },
|
||||
{ key:'currency',prop:'currency',label:'币种',width:74 }, { key:'effectiveStart',prop:'effectiveStart',label:'生效日期',width:112 },
|
||||
{ key:'effectiveEnd',prop:'effectiveEnd',label:'失效日期',width:112 }, { key:'versionCode',prop:'versionCode',label:'报价版本',width:92 },
|
||||
{ key:'status',label:'状态',width:76,slotName:'status' }, { key:'updatedAt',prop:'updatedAt',label:'更新时间',minWidth:150 }, { key:'remark',prop:'remark',label:'备注',minWidth:180 },
|
||||
]
|
||||
const form = reactive({ name:'', warehouseId:'', providerName:'', currency:'USD', effectiveStart:'', effectiveEnd:'', versionCode:'V1.0', remark:'', recordVersion:0, warehouseFees:[] as OverseasWarehouseFee[], transitPrices:[] as OverseasTransitPrice[] })
|
||||
function resetForm(){ Object.assign(form,{name:'',warehouseId:'',providerName:'',currency:'USD',effectiveStart:'',effectiveEnd:'',versionCode:'V1.0',remark:'',recordVersion:0,warehouseFees:[{item:'入库卸货',billingUnit:'托',unitPrice:0,minimumCharge:null,remark:null}],transitPrices:[]}) }
|
||||
function params(){ return { page:page.value,size:pageSize.value,keyword:String(filters.value.keyword||'')||undefined,warehouseId:String(filters.value.warehouseId||'')||undefined,providerName:String(filters.value.providerName||'')||undefined,currency:String(filters.value.currency||'')||undefined,status:(filters.value.status||undefined) as MasterDataStatus|undefined } }
|
||||
async function load(){ loading.value=true;error.value=null;try{const r=await listQuotes(params());rows.value=r.records;total.value=r.total}catch(e){rows.value=[];total.value=0;error.value=getApiErrorDetails(e,'海外仓报价加载失败')}finally{loading.value=false} }
|
||||
async function loadOptions(){ try{options.value=await listQuoteWarehouseOptions();syncFields()}catch(e){ElMessage.error(masterDataErrorMessage(e,'合作仓候选加载失败'))} }
|
||||
function search(){ if(page.value===1) void load(); else page.value=1 }
|
||||
async function openDetail(row:OverseasWarehouseQuote){ try{detail.value=await getQuote(row.id);detailOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价详情加载失败'))} }
|
||||
function fill(d:OverseasWarehouseQuoteDetail){const s=d.summary;Object.assign(form,{name:s.name,warehouseId:s.warehouseId,providerName:s.providerName,currency:s.currency,effectiveStart:s.effectiveStart,effectiveEnd:s.effectiveEnd,versionCode:s.versionCode,remark:s.remark||'',recordVersion:s.recordVersion,warehouseFees:d.warehouseFees.map(v=>({...v})),transitPrices:d.transitPrices.map(v=>({...v}))})}
|
||||
async function openCreate(){editingId.value='';resetForm();if(!options.value.length)await loadOptions();editorOpen.value=true}
|
||||
async function openEdit(row:OverseasWarehouseQuote){try{const d=await getQuote(row.id);editingId.value=row.id;fill(d);editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价详情加载失败'))}}
|
||||
async function save(){if(!form.name||!form.warehouseId||!form.providerName||!form.effectiveStart||!form.effectiveEnd||!form.warehouseFees.length){ElMessage.warning('请完整填写报价必填信息');return} if(form.effectiveEnd<form.effectiveStart){ElMessage.warning('失效日期不能早于生效日期');return} saving.value=true;try{const common={name:form.name,providerName:form.providerName,currency:form.currency as 'USD'|'CNY',effectiveStart:form.effectiveStart,effectiveEnd:form.effectiveEnd,warehouseFees:form.warehouseFees,transitPrices:form.transitPrices,remark:form.remark||null};if(editingId.value)await updateQuote(editingId.value,{...common,recordVersion:form.recordVersion});else await createQuote({...common,warehouseId:form.warehouseId,versionCode:form.versionCode});ElMessage.success(editingId.value?'报价已更新':'报价已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价保存失败'))}finally{saving.value=false}}
|
||||
async function toggle(row:OverseasWarehouseQuote){const next:MasterDataStatus=row.status==='ACTIVE'?'INACTIVE':'ACTIVE';try{await ElMessageBox.confirm(`确认${next==='ACTIVE'?'启用':'停用'}“${row.name}”吗?`,'报价状态',{type:'warning'});await changeQuoteStatus(row.id,next,row.recordVersion);ElMessage.success('报价状态已更新');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(masterDataErrorMessage(e,'报价状态更新失败'))}}
|
||||
async function exportCsv(){exporting.value=true;try{const {page:_,size:__,...q}=params();saveCsv(await exportQuotes(q),'overseas-quotes.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价导出失败'))}finally{exporting.value=false}}
|
||||
watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions()])})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<ListPageLayout aria-label="海外仓报价列表">
|
||||
<template #toolbar>
|
||||
<QueryToolbar v-model="filters" class="surface-panel" :fields="fields" aria-label="海外仓报价筛选" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" @search="page = 1" />
|
||||
</template>
|
||||
<template #state><ContractPendingNotice domain="海外仓报价" /></template>
|
||||
<section class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-quotes-table" row-key="id" empty-text="海外仓报价查询契约待接入">
|
||||
<template #toolbar-actions>
|
||||
<el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />新增报价</el-button></span></el-tooltip>
|
||||
<el-tooltip content="海外区域导出接口待接入"><span><el-button disabled><Download :size="16" />导出Excel</el-button></span></el-tooltip>
|
||||
</template>
|
||||
<template #actions><span class="muted-value">—</span></template>
|
||||
<template #pagination-left>共 0 条海外仓报价</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-panel { min-width: 0; overflow: hidden; }
|
||||
.muted-value { color: var(--cc-muted); }
|
||||
@media (max-width: 720px) { .table-panel :deep(.data-table__business-actions) { width: 100%; } }
|
||||
</style>
|
||||
<template><AdminPage><ListPageLayout aria-label="海外仓报价列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" :loading="loading" @search="search" /></template>
|
||||
<template #state><LoadErrorState v-if="error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-quotes-table" row-key="id" empty-text="暂无海外仓报价">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出Excel</el-button></template>
|
||||
<template #actions="{row}"><el-button link type="primary" @click="openDetail(row as OverseasWarehouseQuote)"><Eye :size="15"/>详情</el-button><el-button v-if="access.can(permission.quoteEdit)" link type="primary" @click="openEdit(row as OverseasWarehouseQuote)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.quoteStatus)" link :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)"><Power :size="15"/>{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}</el-button></template>
|
||||
<template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template>
|
||||
</DataTable></section>
|
||||
<AppDialog v-model="detailOpen" title="海外仓报价详情" width="min(980px, calc(100vw - 28px))" :show-confirm="false"><div v-if="detail" class="detail-grid"><div v-for="item in [['报价名称',detail.summary.name],['海外仓',detail.summary.warehouseName],['服务商',detail.summary.providerName],['币种',detail.summary.currency],['生效日期',detail.summary.effectiveStart],['失效日期',detail.summary.effectiveEnd],['版本',detail.summary.versionCode],['状态',detail.summary.status]]" :key="item[0]"><span>{{item[0]}}</span><strong>{{item[1]}}</strong></div></div><h3>仓储作业费用</h3><el-table :data="detail?.warehouseFees||[]" border><el-table-column prop="item" label="费用项目"/><el-table-column prop="billingUnit" label="计费单位"/><el-table-column prop="unitPrice" label="单价"/><el-table-column prop="minimumCharge" label="最低收费"/><el-table-column prop="remark" label="备注"/></el-table><h3>中转派送价格</h3><el-table :data="detail?.transitPrices||[]" border><el-table-column prop="fbaWarehouseCode" label="目的FBA仓"/><el-table-column prop="deliveryMethod" label="车型/提货方式"/><el-table-column prop="estimatedMiles" label="预估迈数"/><el-table-column prop="price" label="报价"/><el-table-column prop="minimumCharge" label="最低收费"/></el-table></AppDialog>
|
||||
<AppDialog v-model="editorOpen" :title="editingId?'编辑海外仓报价':'新增海外仓报价'" width="min(1180px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存报价" @confirm="save"><el-form label-position="top"><div class="form-grid"><el-form-item label="报价名称"><el-input v-model="form.name"/></el-form-item><el-form-item label="海外仓"><el-select v-model="form.warehouseId" :disabled="!!editingId"><el-option v-for="o in options" :key="o.warehouseId" :label="`${o.warehouseName} · ${o.warehouseCode}`" :value="o.warehouseId"/></el-select></el-form-item><el-form-item label="服务商"><el-input v-model="form.providerName"/></el-form-item><el-form-item label="币种"><el-select v-model="form.currency"><el-option label="USD" value="USD"/><el-option label="CNY" value="CNY"/></el-select></el-form-item><el-form-item label="生效日期"><el-date-picker v-model="form.effectiveStart" type="date" value-format="YYYY-MM-DD"/></el-form-item><el-form-item label="失效日期"><el-date-picker v-model="form.effectiveEnd" type="date" value-format="YYYY-MM-DD"/></el-form-item><el-form-item label="报价版本"><el-input v-model="form.versionCode" :disabled="!!editingId"/></el-form-item><el-form-item label="备注"><el-input v-model="form.remark"/></el-form-item></div><h3>仓储作业费用 <el-button @click="form.warehouseFees.push({item:'',billingUnit:'',unitPrice:0,minimumCharge:null,remark:null})">+ 添加费用</el-button></h3><div v-for="(f,i) in form.warehouseFees" :key="i" class="line-grid"><el-input v-model="f.item" placeholder="费用项目"/><el-input v-model="f.billingUnit" placeholder="计费单位"/><el-input-number v-model="f.unitPrice" :min="0"/><el-input-number v-model="f.minimumCharge" :min="0"/><el-button type="danger" plain @click="form.warehouseFees.splice(i,1)">删除</el-button></div><h3>中转派送价格 <el-button @click="form.transitPrices.push({fbaWarehouseCode:'',deliveryMethod:'',estimatedMiles:null,price:0,minimumCharge:null,remark:null})">+ 添加派送价格</el-button></h3><div v-for="(t,i) in form.transitPrices" :key="i" class="line-grid"><el-input v-model="t.fbaWarehouseCode" placeholder="FBA仓"/><el-input v-model="t.deliveryMethod" placeholder="车型/提货方式"/><el-input-number v-model="t.price" :min="0"/><el-input-number v-model="t.minimumCharge" :min="0"/><el-button type="danger" plain @click="form.transitPrices.splice(i,1)">删除</el-button></div></el-form></AppDialog>
|
||||
</ListPageLayout></AdminPage></template>
|
||||
<style scoped>.table-panel{min-width:0;overflow:hidden}.form-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px 16px}.line-grid{display:grid;grid-template-columns:2fr 1.2fr 1fr 1fr auto;gap:8px;margin:8px 0}.detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;background:var(--cc-border);border:1px solid var(--cc-border)}.detail-grid>div{padding:12px;background:#fff}.detail-grid span{display:block;color:var(--cc-muted);font-size:12px}.detail-grid strong{display:block;margin-top:4px}h3{margin:18px 0 10px;font-size:14px}</style>
|
||||
|
||||
@@ -1,60 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const deliveryColumns: DataTableColumn[] = [
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '派送单号', minWidth: 160 },
|
||||
{ key: 'sourceOrderNo', prop: 'sourceOrderNo', label: 'BOL / 来源单号', minWidth: 170 },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 160 },
|
||||
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 140 },
|
||||
{ key: 'appointmentDate', prop: 'appointmentDate', label: '预约日期', minWidth: 130 },
|
||||
{ key: 'status', prop: 'status', label: '派送状态', minWidth: 110 },
|
||||
{ key: 'relation', prop: 'relation', label: '关联关系', minWidth: 120 },
|
||||
]
|
||||
const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 150 },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '派送单号', minWidth: 160 },
|
||||
{ key: 'action', prop: 'action', label: '动作', minWidth: 110 },
|
||||
{ key: 'relatedVehicle', prop: 'relatedVehicle', label: '关联车辆', minWidth: 140 },
|
||||
{ key: 'reason', prop: 'reason', label: '原因', minWidth: 220 },
|
||||
{ key: 'operatorName', prop: 'operatorName', label: '操作人', minWidth: 120 },
|
||||
]
|
||||
function backToList() {
|
||||
void router.push({ name: 'overseas-master-data-vehicles' })
|
||||
}
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'; import FormSection from '@/shared/components/FormSection.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails,type ApiErrorDetails } from '@/shared/api/http'; import { getVehicle } from '../api/overseasMasterData'; import type { VehicleDetail } from '../types'
|
||||
const route=useRoute(),router=useRouter(),detail=ref<VehicleDetail|null>(null),loading=ref(false),error=ref<ApiErrorDetails|null>(null)
|
||||
async function load(){loading.value=true;error.value=null;try{detail.value=await getVehicle(String(route.params.vehicleId||''))}catch(e){detail.value=null;error.value=getApiErrorDetails(e,'车辆详情加载失败')}finally{loading.value=false}}
|
||||
function back(){void router.push({name:'overseas-master-data-vehicles'})} onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage width="comfortable">
|
||||
<DetailPageLayout aria-label="车辆详情">
|
||||
<template #header>
|
||||
<div class="detail-heading">
|
||||
<div><h1>车辆详情</h1><p>车辆 ID:{{ String(route.params.vehicleId || '—') }}</p></div>
|
||||
<el-button @click="backToList"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<ContractPendingNotice domain="车辆详情" detail />
|
||||
<section class="surface-panel detail-surface">
|
||||
<FormSection title="车辆信息"><div class="detail-empty">没有可展示的真实车辆档案</div></FormSection>
|
||||
<FormSection title="派送订单明细"><DataTable :data="[]" :columns="deliveryColumns" empty-text="车辆详情接口待接入" /></FormSection>
|
||||
<FormSection title="派送更换记录" :bordered="false"><DataTable :data="[]" :columns="historyColumns" empty-text="车辆详情接口待接入" /></FormSection>
|
||||
</section>
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.detail-heading h1 { margin: 0; color: var(--cc-text); font-size: 20px; }
|
||||
.detail-heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-surface { min-width: 0; overflow: hidden; }
|
||||
.detail-empty { padding: 18px; color: var(--cc-muted); text-align: center; }
|
||||
@media (max-width: 720px) { .detail-heading { align-items: stretch; flex-direction: column; } .detail-heading :deep(.el-button) { min-height: 44px; } }
|
||||
</style>
|
||||
<template><AdminPage width="comfortable"><DetailPageLayout aria-label="车辆详情"><template #header><div class="heading"><div><h1>车辆详情</h1><p>{{detail?.plateNumber||String(route.params.vehicleId||'—')}} <el-tag v-if="detail">{{detail.status}}</el-tag></p></div><el-button @click="back"><ArrowLeft :size="16"/>返回列表</el-button></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="surface-panel detail-surface"><FormSection title="车辆信息"><dl v-if="detail" class="detail-grid"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',detail.status],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection title="派送订单明细"><p class="contract-note">当前车辆主数据接口明确不包含派送履约记录。</p></FormSection><FormSection title="派送更换记录" :bordered="false"><p class="contract-note">当前车辆主数据接口明确不包含车辆更换历史。</p></FormSection></section></DetailPageLayout></AdminPage></template>
|
||||
<style scoped>.heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.heading h1{margin:0;font-size:20px}.heading p{margin:4px 0 0;color:var(--cc-muted)}.detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;margin:0;background:var(--cc-border);border:1px solid var(--cc-border)}.detail-grid>div{padding:12px;background:#fff}.detail-grid dt{color:var(--cc-muted);font-size:12px}.detail-grid dd{margin:5px 0 0;overflow-wrap:anywhere}.contract-note{margin:0;padding:16px;color:var(--cc-muted);text-align:center}</style>
|
||||
|
||||
@@ -1,125 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Download, Plus } 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 QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import type { VehicleOwnership, VehicleSummary } from '../types'
|
||||
|
||||
const rows = ref<VehicleSummary[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const ownership = ref<VehicleOwnership>('OWNED')
|
||||
const ownershipTabs: Array<{ label: string; value: VehicleOwnership }> = [
|
||||
{ label: '自有车辆', value: 'OWNED' },
|
||||
{ label: '外派车辆', value: 'DISPATCHED' },
|
||||
]
|
||||
const filters = ref<QueryModel>({ keyword: '', region: '', status: '' })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '车辆关键词', placeholder: '车牌 / 司机 / 车型', width: 220 },
|
||||
{ key: 'region', type: 'select', label: '区域', placeholder: '全部区域', width: 140, options: [
|
||||
{ label: '国内', value: 'DOMESTIC' },
|
||||
{ label: '海外', value: 'OVERSEAS' },
|
||||
] },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: [
|
||||
['在役', 'IN_SERVICE'], ['外派中', 'DISPATCHED'], ['维修中', 'MAINTENANCE'], ['停用', 'INACTIVE'], ['已归还', 'RETURNED'],
|
||||
].map(([label, value]) => ({ label, value })) },
|
||||
]
|
||||
const ownedColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 160, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'plateNumber', prop: 'plateNumber', label: '车牌', minWidth: 130 },
|
||||
{ key: 'model', prop: 'model', label: '车型', minWidth: 180 },
|
||||
{ key: 'fuelConsumptionPer100Km', prop: 'fuelConsumptionPer100Km', label: '油耗(L/100km)', minWidth: 130, align: 'right' },
|
||||
{ key: 'mileageKm', prop: 'mileageKm', label: '当前公里码(km)', minWidth: 140, align: 'right' },
|
||||
{ key: 'driverOrDispatchCompany', prop: 'driverOrDispatchCompany', label: '司机', minWidth: 130 },
|
||||
{ key: 'phone', prop: 'phone', label: '联系电话', minWidth: 145 },
|
||||
{ key: 'status', prop: 'status', label: '状态', width: 100 },
|
||||
{ key: 'region', prop: 'region', label: '区域', width: 100 },
|
||||
]
|
||||
const dispatchedColumns: DataTableColumn[] = [
|
||||
...ownedColumns.slice(0, 5),
|
||||
{ key: 'driverOrDispatchCompany', prop: 'driverOrDispatchCompany', label: '外派单位', minWidth: 160 },
|
||||
{ key: 'phone', prop: 'phone', label: '联系电话', minWidth: 145 },
|
||||
{ key: 'currentDeliveryOrderNo', prop: 'currentDeliveryOrderNo', label: '当前派送单', minWidth: 160 },
|
||||
{ key: 'latestReplacementReason', prop: 'latestReplacementReason', label: '最近更换原因', minWidth: 180 },
|
||||
{ key: 'status', prop: 'status', label: '状态', width: 100 },
|
||||
{ key: 'region', prop: 'region', label: '区域', width: 100 },
|
||||
]
|
||||
const columns = computed(() => ownership.value === 'OWNED' ? ownedColumns : dispatchedColumns)
|
||||
const regionLabel = computed(() => ({ DOMESTIC: '国内', OVERSEAS: '海外' }[String(filters.value.region)] ?? '全部区域'))
|
||||
|
||||
function selectOwnership(value: VehicleOwnership) {
|
||||
if (ownership.value === value) return
|
||||
ownership.value = value
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
function handleOwnershipTabKeydown(event: KeyboardEvent, index: number) {
|
||||
let nextIndex = index
|
||||
if (event.key === 'ArrowRight') nextIndex = (index + 1) % ownershipTabs.length
|
||||
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + ownershipTabs.length) % ownershipTabs.length
|
||||
else if (event.key === 'Home') nextIndex = 0
|
||||
else if (event.key === 'End') nextIndex = ownershipTabs.length - 1
|
||||
else return
|
||||
event.preventDefault()
|
||||
const tab = ownershipTabs[nextIndex]
|
||||
if (!tab) return
|
||||
selectOwnership(tab.value)
|
||||
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
buttons?.[nextIndex]?.focus()
|
||||
}
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Download, Eye, Pencil, Plus, Power } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
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 QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails,type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { changeVehicleStatus,createVehicle,exportVehicles,getVehicle,listVehicles,updateVehicle } from '../api/overseasMasterData'; import { overseasMasterDataPermissions as permission } from '../permissions'; import { saveCsv } from '../downloadCsv'; import type { VehicleDetail,VehicleOwnership,VehicleRegion,VehicleStatus } from '../types'
|
||||
import { masterDataErrorMessage } from '../apiError'
|
||||
const router=useRouter(),access=useAccessPreviewStore(),rows=ref<VehicleDetail[]>([]),total=ref(0),page=ref(1),pageSize=ref(20),loading=ref(false),saving=ref(false),exporting=ref(false),error=ref<ApiErrorDetails|null>(null),editorOpen=ref(false),editingId=ref(''),statusOpen=ref(false),statusRow=ref<VehicleDetail|null>(null),statusTarget=ref<VehicleStatus>('IN_SERVICE')
|
||||
const ownership=ref<VehicleOwnership>('OWNED'),tabs:[string,VehicleOwnership][]=[['自有车辆','OWNED'],['外派车辆','DISPATCHED']]
|
||||
const filters=ref<QueryModel>({keyword:'',region:'',status:''});const fields:QueryField[]=[{key:'keyword',type:'input',label:'车辆关键词',placeholder:'车牌 / 司机 / 车型',width:220},{key:'region',type:'select',label:'区域',placeholder:'全部区域',width:140,options:[{label:'国内',value:'DOMESTIC'},{label:'海外',value:'OVERSEAS'}]},{key:'status',type:'select',label:'状态',placeholder:'全部状态',width:140,options:[['在役','IN_SERVICE'],['外派中','DISPATCHED'],['维修中','MAINTENANCE'],['停用','INACTIVE'],['已归还','RETURNED']].map(([label,value])=>({label,value}))}]
|
||||
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:250,fixed:'left',slotName:'actions'},{key:'plateNumber',prop:'plateNumber',label:'车牌',minWidth:130},{key:'model',prop:'model',label:'车型',minWidth:180},{key:'fuelConsumptionPer100Km',prop:'fuelConsumptionPer100Km',label:'油耗(L/100km)',minWidth:130,align:'right'},{key:'mileageKm',prop:'mileageKm',label:'当前公里码(km)',minWidth:140,align:'right'},{key:'driverOrDispatchCompany',prop:'driverOrDispatchCompany',label:'司机 / 外派单位',minWidth:160},{key:'phone',prop:'phone',label:'联系电话',minWidth:145},{key:'status',label:'状态',width:100,slotName:'status'},{key:'region',label:'区域',width:100,slotName:'region'}]
|
||||
const form=reactive({plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS' as VehicleRegion,ownership:'OWNED' as VehicleOwnership,status:'IN_SERVICE' as VehicleStatus,remark:'',recordVersion:0})
|
||||
function params(){return{page:page.value,size:pageSize.value,keyword:String(filters.value.keyword||'')||undefined,ownership:ownership.value,region:(filters.value.region||undefined) as VehicleRegion|undefined,status:(filters.value.status||undefined) as VehicleStatus|undefined}}
|
||||
async function load(){loading.value=true;error.value=null;try{const r=await listVehicles(params());rows.value=r.records;total.value=r.total}catch(e){rows.value=[];total.value=0;error.value=getApiErrorDetails(e,'车辆加载失败')}finally{loading.value=false}}
|
||||
function search(){if(page.value===1)void load();else page.value=1} function selectOwnership(v:VehicleOwnership){if(ownership.value===v)return;ownership.value=v;page.value=1;void load()}
|
||||
function reset(){Object.assign(form,{plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS',ownership:ownership.value,status:'IN_SERVICE',remark:'',recordVersion:0})}
|
||||
function openCreate(){editingId.value='';reset();editorOpen.value=true} async function openEdit(row:VehicleDetail){try{const d=await getVehicle(row.id);editingId.value=d.id;Object.assign(form,{...d,driverOrDispatchCompany:d.driverOrDispatchCompany||'',phone:d.phone||'',remark:d.remark||''});editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆详情加载失败'))}}
|
||||
async function save(){if(!form.plateNumber||!form.model||form.fuelConsumptionPer100Km<=0){ElMessage.warning('请完整填写车辆必填信息');return}saving.value=true;try{const base={plateNumber:form.plateNumber,model:form.model,fuelConsumptionPer100Km:form.fuelConsumptionPer100Km,mileageKm:form.mileageKm,driverOrDispatchCompany:form.driverOrDispatchCompany||null,phone:form.phone||null,region:form.region,ownership:form.ownership,remark:form.remark||null};if(editingId.value)await updateVehicle(editingId.value,{...base,recordVersion:form.recordVersion});else await createVehicle({...base,status:form.status});ElMessage.success(editingId.value?'车辆已更新':'车辆已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆保存失败'))}finally{saving.value=false}}
|
||||
function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=row.status;statusOpen.value=true} async function submitStatus(){const row=statusRow.value;if(!row)return;saving.value=true;try{await changeVehicleStatus(row.id,statusTarget.value,row.recordVersion);ElMessage.success('车辆状态已更新');statusOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆状态更新失败'))}finally{saving.value=false}}
|
||||
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();saveCsv(await exportVehicles(q),'vehicles.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
|
||||
watch([page,pageSize],load);onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<ListPageLayout aria-label="车辆列表">
|
||||
<template #toolbar>
|
||||
<div class="vehicles-query-panel">
|
||||
<div class="status-tabs" role="tablist" aria-label="车辆归属">
|
||||
<button
|
||||
v-for="(tab, index) in ownershipTabs"
|
||||
:key="tab.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="ownership === tab.value"
|
||||
aria-controls="vehicle-results"
|
||||
:tabindex="ownership === tab.value ? 0 : -1"
|
||||
:class="['status-tab', { 'is-active': ownership === tab.value }]"
|
||||
@click="selectOwnership(tab.value)"
|
||||
@keydown="handleOwnershipTabKeydown($event, index)"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
<QueryToolbar v-model="filters" class="vehicles-query-toolbar" :fields="fields" aria-label="车辆筛选" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" @search="page = 1" />
|
||||
</div>
|
||||
</template>
|
||||
<template #state><ContractPendingNotice domain="车辆管理" /></template>
|
||||
<section id="vehicle-results" class="surface-panel vehicle-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-vehicles-table" row-key="id" empty-text="车辆查询契约待接入">
|
||||
<template #toolbar-actions>
|
||||
<el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />添加车辆</el-button></span></el-tooltip>
|
||||
<el-tooltip content="海外区域导出接口待接入"><span><el-button disabled><Download :size="16" />导出Excel</el-button></span></el-tooltip>
|
||||
</template>
|
||||
<template #actions><span class="muted-value">—</span></template>
|
||||
<template #pagination-left>共 0 条{{ regionLabel }}{{ ownership === 'OWNED' ? '自有' : '外派' }}车辆</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vehicle-panel { min-width: 0; overflow: hidden; }
|
||||
.vehicles-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }
|
||||
.status-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }
|
||||
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
|
||||
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
|
||||
.status-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
|
||||
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
|
||||
.status-tab.is-active::after { background: var(--cc-primary); }
|
||||
.vehicles-query-toolbar { border: 0; }
|
||||
.muted-value { color: var(--cc-muted); }
|
||||
@media (max-width: 720px) { .status-tab { min-height: 46px; padding: 0 14px; } }
|
||||
</style>
|
||||
<template><AdminPage><ListPageLayout aria-label="车辆列表"><template #toolbar><div class="query-panel"><div class="status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])">{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template><template #state><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template><section class="surface-panel vehicle-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆"><template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出Excel</el-button></template><template #actions="{row}"><el-button link type="primary" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})"><Eye :size="15"/>查看</el-button><el-button v-if="access.can(permission.vehicleEdit)" link type="primary" @click="openEdit(row as VehicleDetail)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.vehicleStatus)" link type="primary" @click="openStatus(row as VehicleDetail)"><Power :size="15"/>状态</el-button></template><template #status="{row}"><el-tag>{{(row as VehicleDetail).status}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
|
||||
<AppDialog v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber"/></el-form-item><el-form-item label="车型"><el-input v-model="form.model"/></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2"/></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0"/></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone"/></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany"/></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></el-form-item><el-form-item label="备注"><el-input v-model="form.remark"/></el-form-item></div></el-form></AppDialog><AppDialog v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
|
||||
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#fff}.status-tabs{display:flex;border-bottom:1px solid var(--cc-border);background:#f9fafb}.status-tab{min-height:44px;padding:0 18px;border:0;background:transparent;color:var(--cc-muted)}.status-tab.is-active{border-bottom:3px solid var(--cc-primary);color:var(--cc-primary);font-weight:600}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px}</style>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
export const overseasMasterDataPermissions = {
|
||||
quoteList: 'platform:overseas-master-data:quote:list',
|
||||
quoteCreate: 'platform:overseas-master-data:quote:create',
|
||||
quoteEdit: 'platform:overseas-master-data:quote:edit',
|
||||
quoteStatus: 'platform:overseas-master-data:quote:status',
|
||||
quoteExport: 'platform:overseas-master-data:quote:export',
|
||||
exchangeRateList: 'platform:overseas-master-data:exchange-rate:list',
|
||||
exchangeRateCreate: 'platform:overseas-master-data:exchange-rate:create',
|
||||
exchangeRateEdit: 'platform:overseas-master-data:exchange-rate:edit',
|
||||
exchangeRateStatus: 'platform:overseas-master-data:exchange-rate:status',
|
||||
exchangeRateExport: 'platform:overseas-master-data:exchange-rate:export',
|
||||
vehicleList: 'platform:overseas-master-data:vehicle:list',
|
||||
vehicleCreate: 'platform:overseas-master-data:vehicle:create',
|
||||
vehicleEdit: 'platform:overseas-master-data:vehicle:edit',
|
||||
vehicleStatus: 'platform:overseas-master-data:vehicle:status',
|
||||
vehicleExport: 'platform:overseas-master-data:vehicle:export',
|
||||
} as const
|
||||
|
||||
|
||||
@@ -20,10 +20,9 @@ export interface OverseasWarehouseFee {
|
||||
}
|
||||
|
||||
export interface OverseasTransitPrice {
|
||||
warehouseId: string
|
||||
fbaWarehouseCode: string
|
||||
deliveryMethod: string
|
||||
estimatedMiles: number
|
||||
estimatedMiles: number | null
|
||||
price: number
|
||||
minimumCharge: number | null
|
||||
remark: string | null
|
||||
@@ -34,21 +33,30 @@ export interface OverseasWarehouseQuote {
|
||||
name: string
|
||||
warehouseId: string
|
||||
warehouseName: string
|
||||
warehouseCode: string
|
||||
countryCode: string
|
||||
providerName: string
|
||||
currency: QuoteCurrency
|
||||
effectiveStart: string
|
||||
effectiveEnd: string | null
|
||||
version: string
|
||||
versionCode: string
|
||||
status: MasterDataStatus
|
||||
updatedAt: string
|
||||
updatedBy: string
|
||||
recordVersion: number
|
||||
remark: string | null
|
||||
warehouseFees: OverseasWarehouseFee[]
|
||||
transitPrices: OverseasTransitPrice[]
|
||||
}
|
||||
export interface OverseasWarehouseQuoteDetail { summary: OverseasWarehouseQuote; warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[]; createdBy: string; createdAt: string }
|
||||
|
||||
export interface MonthlyExchangeRate {
|
||||
month: string
|
||||
baseCurrency: string
|
||||
quoteCurrency: string
|
||||
usdToCnyAverage: number
|
||||
status: MasterDataStatus
|
||||
recordVersion: number
|
||||
updatedBy: string
|
||||
updatedAt: string
|
||||
remark: string | null
|
||||
@@ -65,8 +73,10 @@ export interface VehicleSummary {
|
||||
region: VehicleRegion
|
||||
ownership: VehicleOwnership
|
||||
status: VehicleStatus
|
||||
currentDeliveryOrderNo: string | null
|
||||
latestReplacementReason: string | null
|
||||
remark: string | null
|
||||
recordVersion: number
|
||||
updatedBy: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface VehicleDeliveryOrder {
|
||||
@@ -88,8 +98,6 @@ export interface VehicleReplacementHistory {
|
||||
operatorName: string
|
||||
}
|
||||
|
||||
export interface VehicleDetail extends VehicleSummary {
|
||||
remark: string | null
|
||||
deliveryOrders: VehicleDeliveryOrder[]
|
||||
replacementHistory: VehicleReplacementHistory[]
|
||||
}
|
||||
export type VehicleDetail = VehicleSummary
|
||||
|
||||
export interface PartnerWarehouseOption { warehouseId: string; warehouseCode: string; warehouseName: string; countryCode: string }
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
|---|---|---|---|---|
|
||||
| 尾程订单列表 | `fba-last-mile-order` | `GET /platform/v1/last-leg/orders` | 已接入 | 显式 `customerId`;支持 `page/size/orderNo/inboundCode/containerNo/status`。原型其余筛选保留但禁用,不发送无效参数。 |
|
||||
| 尾程订单详情 | `fba-last-mile-order-detail` | `GET /platform/v1/last-leg/orders/{id}` | 已接入 | 展示订单、货物、状态历史和既有单据编号;出库/派送编号只读,不提供离仓动作。 |
|
||||
| 入库单列表/FBA预约 | `fba-inbound-order` | `GET /platform/v1/last-leg/inbound-plans`、`POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | 前端已接入,待后端合入 | 已实现显式客户范围、分页筛选、按入库明细勾选、同客户/同 FBA 仓校验和预约弹窗;后端仓库写权限获批后需合入分页端点及预计装车时间字段。 |
|
||||
| 入库单详情 | `fba-inbound-order-detail` | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | 待接入 | 详情端点已存在,当前任务只处理列表分页与列表勾选预约。 |
|
||||
| 入库单列表/详情/FBA 预约 | `fba-inbound-order*` | `GET /platform/v1/last-leg/inbound-plans`、`GET /{planNo}`、`POST /fba-appointments/batch`、`GET/PUT /fba-appointments/{appointmentNo}` | 已接入 | 显式 `customerId`、真实分页和筛选;支持按入库明细勾选、同客户/同 FBA 仓校验、预计装车时间以及预约编辑。 |
|
||||
| 换标单列表/详情 | `fba-relabel-order*` | `GET /platform/v1/last-leg/relabel-applications`、`GET /{relabelNo}` | 已接入只读 | 列表 DTO 可展示单号、SKU、箱数和状态;详情补产品名、标签要求、文件。预算/费用继续显示空值并记录缺口,未伪造金额。 |
|
||||
| 库存/进销存/详情 | `fba-inventory*` | `GET /platform/v1/last-leg/inventory/summary|balances|ledger` | 阻断 | API 是入库单/包裹余额口径,原型是客户/仓库/SKU/产品口径,缺 SKU、产品名及原型指标。 |
|
||||
| 托盘列表/详情 | `fba-pallet*` | `GET /platform/v1/warehouse-admin/{warehouseId}/pallets`、`GET /{id}` | 阻断 | API 强制 `customerId + warehouseId` 且资源客户绑定;产品契约要求平台公共资源、独立查询全部客户/仓库。字段还缺 FBA 仓和采购价。 |
|
||||
| 箱号列表/详情 | `fba-carton*` | 无平台查询接口 | 阻断 | 只有内部接收事件产生快照,浏览器不得调用内部端点。 |
|
||||
| 产品库列表/详情 | `fba-product*` | 无平台查询接口 | 阻断 | 只有内部一件代发快照写入,未提供平台查询/维护端点。 |
|
||||
| 库存/进销存/详情 | `fba-inventory*` | `GET /platform/v1/last-leg/inventory/summary|balances|balances/{id}/positions|ledger` | 已接入只读 | 列表、汇总、库位/托盘位置与流水已接入;余额返回 SKU/产品名数组,流水返回 SKU 和库位代码。 |
|
||||
| 托盘列表/详情 | `fba-pallet*` | `GET/POST /platform/v1/warehouse-admin/{warehouseId}/pallets`、`GET/PUT/DELETE /{id}` | 已接入 | 所有请求显式携带 `customerId` 和仓库范围;新增、编辑、删除使用真实 DTO 和真实 Controller 端点。 |
|
||||
| 箱号列表/详情 | `fba-carton*` | 无平台查询接口 | 本期暂缓 | 本期已从模块正式路由移除;页面、类型、权限常量与测试源码保留,供后续恢复。只有内部接收事件产生快照,浏览器不得调用内部端点。 |
|
||||
| 产品库列表/详情 | `fba-product*` | 无平台查询接口 | 本期暂缓 | 本期已从模块正式路由移除;页面、类型、权限常量与测试源码保留,供后续恢复。只有内部一件代发快照写入,未提供平台查询/维护端点。 |
|
||||
|
||||
状态映射仅使用服务端已有事实:`PENDING_ASSIGNMENT`(待分配)、`DESTINATION_ASSIGNED`(已分配目的仓)、`INBOUND_PLANNED`(已生成入库计划)。未定义的原型状态不发送到接口。
|
||||
|
||||
入库单菜单与预约按钮已统一使用服务端正式权限 `platform:lastleg:inbound-plan:list`、`platform:lastleg:inbound-plan:appointment`。其余海外页面仍按各自契约缺口处理,不用前端按钮权限替代服务端鉴权。
|
||||
菜单、路由与操作按钮使用真实 Controller 权限:`platform:lastleg:*`、`platform:pallet:*`、`platform:warehouse:*` 和 `platform:location:*`。Admin 未接 PDA、扫码、卸柜照片、内部事件及 `allocate/unallocate/pick/ship` 等离仓命令。
|
||||
|
||||
当前正式路由只保留尾程订单、入库单、换标单、托盘管理和库存管理;箱号和产品库本期暂缓,不恢复正式路由。
|
||||
|
||||
@@ -5,24 +5,21 @@
|
||||
以下前端适配或页面级契约补齐前,页面不得用演示数据或本地成功提示替代真实调用:
|
||||
|
||||
- 尾程订单导入模板、批量导入、异步导出任务及对应独立权限。现有同步 CSV 导出不等同于 Admin 下载中心异步导出。尾程分页与详情已经接入;“待分配”映射为 `PENDING_ASSIGNMENT`,出库单号、派送单号仅展示既有只读关联事实。
|
||||
- 入库单分页查询、详情、创建、编辑、预约、智能排车接口,以及状态机、幂等键和操作权限。
|
||||
- 库存汇总、分页查询、详情、库位库存、托盘箱号和进销存记录接口。
|
||||
- 入库单分页与详情已接入。详情 DTO 仍缺预约状态/时间、排车状态/时间、创建更新时间、备注和应收费用;创建、预约及补托只在原型承载和完整幂等/打印语义冻结后接入。
|
||||
- 库存汇总、SKU 分页、库位位置和进销存已接入。流水 DTO 仍缺 SKU、产品名、客户名和海外仓名称;详情也没有按 `balanceId` 直接查询余额主记录的端点。
|
||||
- 换标单分页、详情、创建、编辑/审核、文件上传与费用明细接口,以及状态机和幂等约定。
|
||||
- 托盘分页、详情、创建、编辑接口,以及海外仓/FBA 仓候选、编码唯一性与占用状态约束。
|
||||
- 箱号分页、详情、创建、编辑接口,以及产品/托盘/入库单关联校验;“已出库”只作为只读库存事实,本模块不承接出库动作。
|
||||
- 产品分页、详情、创建、编辑接口,以及 SKU 唯一性、品类和包装尺寸精度约定。
|
||||
- 托盘分页、详情、新增、编辑已接入公共资源接口。剩余缺口是独立公共仓库候选接口、FBA 仓字段、业务绑定/关联箱数只读汇总;删除未在原型定义,不新增入口。
|
||||
- 箱号、产品库本期暂缓并已从模块正式路由移除;既有源码保留,相关平台查询/写接口不再作为本期交付阻断。
|
||||
- 四期权限资源在 IAM 初始化数据中的正式登记。前端暂按本模块 `permissions.ts` 的稳定编码装配。
|
||||
- 所有分页响应、错误码、`requestId`/`traceId`、时间/金额精度和长整型 ID 字符串传输约定。
|
||||
|
||||
## 当前缺口的精确请求形状
|
||||
|
||||
- 入库单列表:缺 `GET /api/platform/v1/last-leg/inbound-plans`;至少需要 `customerId,page,size,planNo,inboundCode,warehouseId,fbaWarehouseCode,status,appointmentStatus`,响应需返回 `records/page/size/total/pages` 及原型列表字段。现有状态起点为 `PENDING_ASSIGNMENT`,转换端点 `POST /convert` 不能代替列表。
|
||||
- 产品库:缺平台 `GET/POST/PUT /api/platform/v1/last-leg/products[/{id}]`;查询/响应至少覆盖客户、SKU、中英文品名、品类、长宽高、重量,写入需 SKU 唯一性、精度、幂等和状态约束。
|
||||
- 箱号:缺平台 `GET/POST/PUT /api/platform/v1/last-leg/cartons[/{id}]`;查询/响应至少覆盖客户、箱号、FBA 仓、SKU、产品名、数量、重量、库存状态、托盘、入库单及只读出库关联。内部 `/internal/v1/lastleg/dropship/receive` 不得暴露给浏览器。
|
||||
- 库存:现有 `GET /api/platform/v1/last-leg/inventory/balances|ledger` 需增加 SKU/产品快照维度,或另提供与原型一致的 SKU 聚合端点;响应需覆盖现有量、可用量、暂扣、散板、在途、采购、状态和更新时间。状态流转仍只能由入库/库内事实驱动,不增加离仓操作。
|
||||
- 入库详情:`GET /api/platform/v1/last-leg/inbound-plans/{planNo}` 需补预约/排车/时间/费用,或提供对应只读辅助接口;`sourceOrderId` 已按字符串接入。
|
||||
- 库存流水:`GET /api/platform/v1/last-leg/inventory/ledger` 需补 `skuCode/productName/customerName/overseasWarehouseName`,并支持 SKU 过滤,才能完整还原进销存和 SKU 详情流水。
|
||||
- 换标:当前列表响应需补 `productName,labelRequirement,relabelFile,budgetTotal/relabelCost`,或产品明确调整原型字段;写接口必须补正式 `create/edit` 权限。现有状态机为 `PENDING_PROCESSING -> PROCESSING/REJECTED/CANCELLED`、`PROCESSING -> COMPLETED/REJECTED/CANCELLED`。
|
||||
- 托盘:需先收敛资源作用域。产品要求平台公共查询全部客户/仓库,现接口 `GET /api/platform/v1/warehouse-admin/{warehouseId}/pallets?customerId=...` 强制客户绑定;响应还需明确 FBA 仓与采购价如何对应当前 `locationId/unitPrice`。在此冲突解决前不把客户筛选错误地当作公共资源范围。
|
||||
- 权限:尾程、入库、换标、库存、海外仓、库位、托盘相关 Controller 均需为列表/详情使用各资源 `*:list`,写操作分别使用 `*:create`、`*:edit` 等稳定权限;当前仅有应用范围与客户隔离,不能以页面按钮权限替代服务端鉴权。
|
||||
- 托盘:资源接口已固定公共作用域且不再传客户参数;仍缺无需客户遍历即可获取所有实际海外仓的公共候选接口,以及 FBA 仓和业务绑定汇总字段。
|
||||
- 权限:入仓、库存、仓库资源已切换 canonical `platform:overseas-warehouse:*`;前端已同步本模块权限常量。换标写弹框字段与后端 Create DTO 仍不一致,因此不开放提交。
|
||||
|
||||
范围排除:海外仓出库、退货出库、海外派送和其他库存离仓接口。
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/03-overseas-warehouse.html`。
|
||||
|
||||
当前范围冻结:箱号管理、产品库本期暂不实施,已从模块正式路由移除;既有页面、详情、弹框、类型与原型证据保留,后续恢复时继续以本矩阵为准。其余正式范围为尾程订单、入库单、换标单、托盘和库存/进销存。
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式 | 当前实现(开工前) | 冻结结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| 尾程订单 | 列表/筛选 | `page-last-mile-orders`,868–897 行;`lastMileSearch()`、`lastMileReset()` | 独立列表页,状态标签、查询区、横向宽表 | `/prototype/overseas-warehouse-orders/*` 占位 | 建立正式列表路由;操作列第一列,保留原型字段顺序,接口未接时显示明确契约态 |
|
||||
@@ -11,13 +13,13 @@
|
||||
| 尾程订单 | 批量转仓 | `modal-lmo-batch-transfer`,921 行 | 居中弹框,约 720px | 无正式实现 | 属库内操作,但写接口/幂等契约未定义;暂不提供写入口 |
|
||||
| 尾程订单 | 入库计划 | `modal-lmo-inbound-plan`,912 行 | 居中弹框,约 720px | 无正式实现 | 属本期入仓链路;待写接口与状态机冻结后按原型实现 |
|
||||
| 尾程订单 | 出库计划 | `modal-lmo-outbound-plan`,927 行 | 居中弹框,约 760px | 无正式实现 | 明确属于库存离仓,排除,不实现 |
|
||||
| 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 建立正式列表路由;保留筛选与列契约,接口未接时不填充演示数据 |
|
||||
| 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue`;字段和费用空态已落位 | 保持独立页面,不改成抽屉;真实查询与费用数据等待海外区域接口 |
|
||||
| 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 已接真实分页、显式客户、筛选、状态、加载/错误/空态;排车字段后端未提供,保持只读空值 |
|
||||
| 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue` | 已接计划详情和 `sourceOrderId`;接口未提供的预约/排车时间、备注和费用继续显示空值 |
|
||||
| 入库单 | 编辑 | `modal-fbio-edit`,527 行 | 居中弹框,约 880px | 无正式实现 | 待海外区域写接口与状态机冻结后实现 |
|
||||
| 入库单 | 预约 | `modal-fbio-appointment`,526 行 | 居中弹框,约 720px | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 |
|
||||
| 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 |
|
||||
| 库存管理 | 列表/筛选 | `page-inventory-overview`,51–89 行;`unifiedInventorySearch()`、`unifiedInventoryReset()` | 独立列表页,六项紧凑汇总、查询区、横向宽表 | 无正式页面 | 建立正式列表路由;汇总与列表在无接口时保持零/空且明确说明未接契约 |
|
||||
| 库存管理 | 详情 | `page-warehouse-stock-detail`,938–983 行;`renderWarehouseStockDetail()` 6624–6646 行 | 独立详情页;商品/库存汇总左右并列,库位库存、托盘箱号、进销存明细连续分区 | 正式参数路由与 `InventoryDetailView.vue`;字段和三类明细空态已落位 | 保持独立页面;进销存仅为只读库存事实,不提供出库或派送操作 |
|
||||
| 库存管理 | 列表/筛选 | `page-inventory-overview`,51–89 行;`unifiedInventorySearch()`、`unifiedInventoryReset()` | 独立列表页,六项紧凑汇总、查询区、横向宽表 | 无正式页面 | 已接 SKU 余额和汇总;散板指标后端无独立字段,显示空值,不以 receiving 等桶冒充 |
|
||||
| 库存管理 | 详情 | `page-warehouse-stock-detail`,938–983 行;`renderWarehouseStockDetail()` 6624–6646 行 | 独立详情页;商品/库存汇总左右并列,库位库存、托盘箱号、进销存明细连续分区 | 正式参数路由与 `InventoryDetailView.vue` | 已接余额、库位/托盘位置和订单范围流水;流水缺 SKU 时明确提示,只读展示出库关联且不提供离仓操作 |
|
||||
| 库存管理 | 导出 | 列表页“导出Excel”按钮 | 页面工具栏动作,具体浮层未定义 | 无正式实现 | 待 File 导出类型与海外 API 契约补齐,不模拟成功 |
|
||||
|
||||
第一批明确不实现:`overseas-outbound`、`fba-outbound-order*`、`fba-return-outbound`、尾程订单的出库计划及任何海外派送/库存离仓操作。
|
||||
@@ -34,15 +36,15 @@
|
||||
| 换标单 | 详情 | `page-fba-relabel-order-detail`,696–699 行;`openFbaRelabelOrderDetail()` 8605–8613 行 | 独立详情页;单据信息、商品信息、作业要求、费用明细 | 建立正式参数路由,详情复用 `list` 权限;不改为弹框或抽屉 |
|
||||
| 换标单 | 新增 | `modal-fba-relabel-form`,556–566 行;`openFbaRelabelCreate()` 7994–8001 行 | 居中弹框,最大宽度约 760px;订单信息与作业要求分组 | 按原型建立 `create` 权限入口与弹框;写接口未冻结,保存禁用且不模拟成功 |
|
||||
| 换标单 | 编辑/审核 | 同一 `modal-fba-relabel-form`;`openFbaRelabelEdit()` 8003–8022 行 | 居中弹框,约 760px;显示状态和预算报价,提交文案为“提交审核” | 使用独立 `edit` 权限;仅保留承载和字段契约,端点/状态机未冻结前不提交 |
|
||||
| 托盘管理 | 列表/筛选 | `page-fba-pallet`,626–652 行;`renderFbaPallet()` 8319–8335 行 | 独立列表页;编码、类型、仓库、状态筛选;横向表格 | 建立正式列表;操作列第一列;仓库选项等待海外基础资料接口 |
|
||||
| 托盘管理 | 详情 | `page-fba-pallet-detail`,702–705 行;`openFbaPalletDetail()` 8615–8621 行 | 独立详情页;基础信息、使用信息 | 建立正式参数路由,详情复用 `list` 权限 |
|
||||
| 托盘管理 | 新增/编辑 | `modal-fba-pallet-form`,654 行;`openFbaPalletForm()` 8358–8375 行 | 同一居中弹框,最大宽度约 760px;两列字段 | 分别使用 `create`、`edit` 权限;保存禁用,不发明 URL 或本地写入 |
|
||||
| 箱号管理 | 列表/筛选 | `page-fba-carton`,657–681 行;`renderFbaCarton()` 8428–8448 行 | 独立列表页;箱号、FBA 仓、状态筛选 | 建立正式列表;操作列第一列;“已出库”仅作为只读库存事实,不提供离仓动作 |
|
||||
| 箱号管理 | 详情 | `page-fba-carton-detail`,708–711 行;`openFbaCartonDetail()` 8623–8630 行 | 独立详情页;基础信息、商品信息 | 建立正式参数路由,详情复用 `list` 权限;关联出库单只读展示不链接出库流程 |
|
||||
| 箱号管理 | 新增/编辑 | `overlays.html` 的 `modal-fba-carton-form`,1528–1541 行;`openFbaCartonForm()` 8460–8481 行 | 同一居中弹框,最大宽度约 860px;两列字段;已出库不可编辑 | 分别使用 `create`、`edit` 权限;不实现出库状态变更,保存禁用 |
|
||||
| 产品库 | 列表/筛选 | `page-fba-product`,720–745 行;`renderFbaProduct()` 8695–8711 行 | 独立列表页;SKU、中文品名、品类筛选 | 建立正式列表;操作列第一列 |
|
||||
| 产品库 | 详情 | `page-fba-product-detail`,714–717 行;`openFbaProductDetail()` 8683–8690 行 | 独立详情页;基础信息、包装信息、其他信息 | 建立正式参数路由,详情复用 `list` 权限 |
|
||||
| 产品库 | 新增/编辑 | `modal-fbap-add`,748–771 行;`openFbapModal()`、`openFbapEditModal()` 8753–8767 行 | 同一居中弹框,最大宽度约 560px;单列标签/控件布局 | 分别使用 `create`、`edit` 权限;保存禁用,不在前端生成产品数据 |
|
||||
| 托盘管理 | 列表/筛选 | `page-fba-pallet`,626–652 行;`renderFbaPallet()` 8319–8335 行 | 独立列表页;编码、类型、仓库、状态筛选;横向表格 | 已接公共托盘分页;资源请求不传客户参数,仓库候选独立加载,不受页面客户筛选控制 |
|
||||
| 托盘管理 | 详情 | `page-fba-pallet-detail`,702–705 行;`openFbaPalletDetail()` 8615–8621 行 | 独立详情页;基础信息、使用信息 | 已接真实详情,复用 `list` 权限;后端未提供 FBA 仓和业务绑定汇总,显示空值 |
|
||||
| 托盘管理 | 新增/编辑 | `modal-fba-pallet-form`,654 行;`openFbaPalletForm()` 8358–8375 行 | 同一居中弹框,最大宽度约 760px;两列字段 | 已接真实 POST/PUT 和独立权限;FBA 仓不在 DTO 中,原字段只读提示“接口未提供” |
|
||||
| 箱号管理 | 列表/筛选 | `page-fba-carton`,657–681 行;`renderFbaCarton()` 8428–8448 行 | 独立列表页;箱号、FBA 仓、状态筛选 | 本期暂缓并移除正式路由;保留页面源码和操作列首列约束,后续恢复仍不承接离仓动作 |
|
||||
| 箱号管理 | 详情 | `page-fba-carton-detail`,708–711 行;`openFbaCartonDetail()` 8623–8630 行 | 独立详情页;基础信息、商品信息 | 本期暂缓并移除正式参数路由;保留详情源码,关联出库单始终只读 |
|
||||
| 箱号管理 | 新增/编辑 | `overlays.html` 的 `modal-fba-carton-form`,1528–1541 行;`openFbaCartonForm()` 8460–8481 行 | 同一居中弹框,最大宽度约 860px;两列字段;已出库不可编辑 | 本期暂缓;保留禁用弹框源码,不接写接口 |
|
||||
| 产品库 | 列表/筛选 | `page-fba-product`,720–745 行;`renderFbaProduct()` 8695–8711 行 | 独立列表页;SKU、中文品名、品类筛选 | 本期暂缓并移除正式路由;保留页面源码和操作列首列约束 |
|
||||
| 产品库 | 详情 | `page-fba-product-detail`,714–717 行;`openFbaProductDetail()` 8683–8690 行 | 独立详情页;基础信息、包装信息、其他信息 | 本期暂缓并移除正式参数路由;保留详情源码 |
|
||||
| 产品库 | 新增/编辑 | `modal-fbap-add`,748–771 行;`openFbapModal()`、`openFbapEditModal()` 8753–8767 行 | 同一居中弹框,最大宽度约 560px;单列标签/控件布局 | 本期暂缓;保留禁用弹框源码,不接写接口 |
|
||||
|
||||
第二批所有新增/编辑弹框按原型尺寸和字段顺序承载;接口未冻结时只用于展示已确认的交互容器,不提供可成功提交路径。导出接口同样未定义,本批不显示可用导出动作。
|
||||
|
||||
@@ -50,7 +52,7 @@
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式与布局 | 实施结论 |
|
||||
|---|---|---|---|---|
|
||||
| 进销存记录 | 列表/筛选 | `page-inventory-transactions`,986–1007 行;`renderInventoryTransactions()` 6573–6582 行;`inventoryTransactionSearch()`、`inventoryTransactionReset()` 6584–6599 行 | 从库存管理进入的独立列表页;日期、客户/货主、海外仓、SKU、业务类型及业务/入库/出库单号筛选;约 2050px 横向表格 | 建立无独立菜单的正式路由,复用库存 `list` 权限;出库单号与出库数量仅作为只读库存流水事实,不提供出库详情跳转或离仓操作 |
|
||||
| 进销存记录 | 列表/筛选 | `page-inventory-transactions`,986–1007 行;`renderInventoryTransactions()` 6573–6582 行;`inventoryTransactionSearch()`、`inventoryTransactionReset()` 6584–6599 行 | 从库存管理进入的独立列表页;日期、客户/货主、海外仓、SKU、业务类型及业务/入库/出库单号筛选;约 2050px 横向表格 | 已接真实流水分页,复用库存 `list` 权限;DTO 未提供 SKU/品名时对应筛选禁用、列显示空值,出库事实只读且无跳转 |
|
||||
| 进销存记录 | 返回 | `backToInventoryManagement()` 6606–6609 行 | 返回库存管理列表 | 页面工具栏提供明确返回按钮 |
|
||||
| 进销存记录 | 导出 | 页面 989 行“导出Excel”按钮 | 工具栏动作,原型未定义导出任务载体 | File 导出类型与海外接口未冻结,本批不显示可成功导出入口 |
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { getInboundPlan, listInboundPlans } from './inboundPlans'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
|
||||
describe('inbound plan overseas API', () => {
|
||||
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
|
||||
it('pages plans with explicit customer scope', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
|
||||
await listInboundPlans({ customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' })
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans', { params: { customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' } })
|
||||
})
|
||||
it('gets detail by encoded plan number', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { id: '1', planNo: 'IP/1', customerId: '2', sourceOrderId: '190000000000000099', lineCount: 0, palletCount: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] } })
|
||||
await getInboundPlan('IP/1', '2')
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1', { params: { customerId: '2' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { OverseasPageResult } from './lastMileOrders'
|
||||
|
||||
export interface InboundPlanQuery {
|
||||
customerId: string
|
||||
page: number
|
||||
size: number
|
||||
planNo?: string
|
||||
inboundCode?: string
|
||||
status?: string
|
||||
overseasWarehouseId?: string
|
||||
destinationWarehouseCode?: string
|
||||
fbaWarehouseCode?: string
|
||||
appointmentStatus?: string
|
||||
createdFrom?: string
|
||||
createdTo?: string
|
||||
}
|
||||
|
||||
export interface InboundPlanSummaryDto {
|
||||
id: string
|
||||
planNo: string
|
||||
sourceOrderId: string
|
||||
customerId: string
|
||||
customerName?: string | null
|
||||
orderType?: string | null
|
||||
inboundCode?: string | null
|
||||
containerNo?: string | null
|
||||
overseasWarehouseId?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
destinationWarehouseId?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
destinationWarehouseName?: string | null
|
||||
fbaWarehouseCode?: string | null
|
||||
packageCount?: number | null
|
||||
totalVolumeCbm?: number | null
|
||||
palletCount?: number | null
|
||||
status?: string | null
|
||||
appointmentStatus?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface InboundPlanDetailDto {
|
||||
id: string
|
||||
planNo: string
|
||||
customerId: string
|
||||
sourceOrderId: string
|
||||
containerNo?: string | null
|
||||
status?: string | null
|
||||
lineCount: number
|
||||
palletCount: number
|
||||
totalVolumeCbm: number
|
||||
lines: Array<{ id: string; orderId: string; inboundCode?: string | null; fbaWarehouseCode?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | 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 }>
|
||||
allocations: Array<{ lineId: string; palletId: string; allocatedVolumeCbm?: number | null; plannedPackageCount: number; countedPackageCount: number; pickedPackageCount: number; actualVolumeCbm?: number | null; status?: string | null; version: number }>
|
||||
}
|
||||
|
||||
export async function listInboundPlans(query: InboundPlanQuery) {
|
||||
const { customerId, ...params } = query
|
||||
return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPageResult<InboundPlanSummaryDto>>, ApiEnvelope<OverseasPageResult<InboundPlanSummaryDto>>>('/platform/v1/last-leg/inbound-plans', { params: { customerId, ...params } }))
|
||||
}
|
||||
|
||||
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 } }))
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { getInventorySummary, listInventoryBalances } from './inventory'
|
||||
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
|
||||
|
||||
@@ -17,4 +17,14 @@ describe('overseas inventory API', () => {
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances', { params: query })
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/summary', { params: { customerId: '100', skuCode: 'SKU-1', warehouseLocationCode: 'A-01' } })
|
||||
})
|
||||
|
||||
it('uses the read-only position and ledger endpoints with explicit customer scope', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [] })
|
||||
|
||||
await listInventoryPositions('190000000000000099', '100')
|
||||
await listInventoryLedger({ customerId: '100', page: 1, size: 20, skuCode: 'SKU-1' })
|
||||
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/190000000000000099/positions', { params: { customerId: '100' } })
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger', { params: { customerId: '100', page: 1, size: 20, skuCode: 'SKU-1' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,12 +43,66 @@ export interface InventoryDashboardDto {
|
||||
procurementPackages: number
|
||||
}
|
||||
|
||||
export interface InventoryPositionDto {
|
||||
id: string
|
||||
balanceId: string
|
||||
customerId: string
|
||||
overseasWarehouseId?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
inboundOrderId?: string | null
|
||||
inboundOrderNo?: string | null
|
||||
inboundCode?: string | null
|
||||
palletId?: string | null
|
||||
palletNo?: string | null
|
||||
batchNo?: string | null
|
||||
warehouseLocationId?: string | null
|
||||
warehouseLocationCode?: string | null
|
||||
totalPackages: number
|
||||
availablePackages: number
|
||||
holdPackages: number
|
||||
allocatedPackages: number
|
||||
pickedPackages: number
|
||||
receivingPackages: number
|
||||
status?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface InventoryLedgerDto {
|
||||
id: string
|
||||
customerId: string
|
||||
overseasWarehouseId?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
inboundOrderId?: string | null
|
||||
inboundOrderNo?: string | null
|
||||
inboundCode?: string | null
|
||||
palletNo?: string | null
|
||||
batchNo?: string | null
|
||||
warehouseLocationCode?: string | null
|
||||
skuCode?: string | null
|
||||
businessType?: string | null
|
||||
businessOrderNo?: string | null
|
||||
outboundOrderNo?: string | null
|
||||
statusFrom?: string | null
|
||||
statusTo?: string | null
|
||||
beforePackages: number
|
||||
deltaPackages: number
|
||||
afterPackages: number
|
||||
operatorId?: string | null
|
||||
remark?: string | null
|
||||
occurredAt?: string | null
|
||||
}
|
||||
|
||||
export interface InventoryBalanceQuery {
|
||||
customerId: string
|
||||
page: number
|
||||
size: number
|
||||
customerName?: string
|
||||
overseasWarehouseId?: string
|
||||
overseasWarehouseCode?: string
|
||||
destinationWarehouseCode?: string
|
||||
inboundOrderNo?: string
|
||||
inboundCode?: string
|
||||
businessOrderNo?: string
|
||||
skuCode?: string
|
||||
productName?: string
|
||||
batchNo?: string
|
||||
@@ -72,3 +126,42 @@ export async function getInventorySummary(query: InventoryBalanceQuery) {
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listInventoryPositions(
|
||||
balanceId: string,
|
||||
customerId: string,
|
||||
query: { palletNo?: string; batchNo?: string; inventoryStatus?: string } = {},
|
||||
) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<InventoryPositionDto[]>, ApiEnvelope<InventoryPositionDto[]>>(
|
||||
`/platform/v1/last-leg/inventory/balances/${encodeURIComponent(balanceId)}/positions`,
|
||||
{ params: { customerId, ...query } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export interface InventoryLedgerQuery {
|
||||
customerId: string
|
||||
page: number
|
||||
size: number
|
||||
overseasWarehouseId?: string
|
||||
destinationWarehouseCode?: string
|
||||
businessType?: string
|
||||
businessOrderNo?: string
|
||||
inboundOrderNo?: string
|
||||
inboundCode?: string
|
||||
outboundOrderNo?: string
|
||||
palletNo?: string
|
||||
batchNo?: string
|
||||
skuCode?: string
|
||||
warehouseLocationCode?: string
|
||||
occurredFrom?: string
|
||||
occurredTo?: string
|
||||
}
|
||||
|
||||
export async function listInventoryLedger(query: InventoryLedgerQuery) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<InventoryLedgerDto>>, ApiEnvelope<OverseasPageResult<InventoryLedgerDto>>>(
|
||||
'/platform/v1/last-leg/inventory/ledger',
|
||||
{ params: { ...query } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -1,62 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getInboundPlan, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const orderId = computed(() => String(route.params.orderId || '—'))
|
||||
|
||||
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)
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function status(value: unknown) { const labels: Record<string, string> = { PENDING_ASSIGNMENT: '待分配', COMPLETED: '已完成' }; return labels[String(value || '')] || text(value) }
|
||||
const firstLine = computed(() => detail.value?.lines[0])
|
||||
const fieldValues = computed<Record<string, string | number>>(() => ({
|
||||
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: '—', 货主: '—', 关联订单: text(detail.value?.sourceOrderId),
|
||||
海外仓: text(firstLine.value?.destinationWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) || 0,
|
||||
'计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0), 状态: status(detail.value?.status),
|
||||
预约状态: '—', 预约送仓时间: '—', 预计装车时间: '—', 排车状态: '—', 创建时间: '—', 更新时间: '—', 备注: '—',
|
||||
}))
|
||||
const groups = [
|
||||
{ title: '单据信息', fields: ['入库单号', '入仓码', '类型', '货主', '关联订单'] },
|
||||
{ title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '计费体积(m³)', '总托盘数'] },
|
||||
{ title: '预约与排车', fields: ['状态', '预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注'] },
|
||||
]
|
||||
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 },
|
||||
]
|
||||
|
||||
function back() {
|
||||
void router.push({ name: 'overseas-inbound-orders' })
|
||||
}
|
||||
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 }]
|
||||
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)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverseasDetailShell title="入库单详情" reference-label="入库单号" :reference-value="orderId" back-label="返回入库单" domain="入库单详情" @back="back">
|
||||
<section class="detail-section" aria-label="入库单基本信息">
|
||||
<div class="detail-groups">
|
||||
<section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title">
|
||||
<h2>{{ group.title }}</h2>
|
||||
<dl class="detail-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading">
|
||||
<h2 id="inbound-fees-heading">应收费用明细</h2>
|
||||
<DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="海外区域应收费用契约待接入" />
|
||||
<footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer>
|
||||
</section>
|
||||
</OverseasDetailShell>
|
||||
</template>
|
||||
<template><OverseasDetailShell title="入库单详情" reference-label="入库单号" :reference-value="detail?.planNo || planNo" 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 groups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-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 fee-section" aria-labelledby="inbound-fees-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
|
||||
</OverseasDetailShell></template>
|
||||
|
||||
<style scoped>
|
||||
.detail-section { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-groups { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); }
|
||||
.detail-group:last-child { border-right: 0; }
|
||||
.detail-group h2, .fee-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
|
||||
.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
|
||||
.detail-fields > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 120px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; }
|
||||
.detail-fields dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-fields dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
|
||||
.fee-section { overflow: hidden; padding-top: 16px; }
|
||||
.fee-section h2 { margin-right: 18px; margin-left: 18px; }
|
||||
.fee-total { display: flex; min-height: 48px; align-items: center; justify-content: flex-end; gap: 40px; padding: 10px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-text); }
|
||||
.fee-total strong { min-width: 120px; font-size: 16px; }
|
||||
.detail-section { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }.detail-groups { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); }.detail-group:last-child { border-right: 0; }.detail-group h2,.fee-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }.detail-fields>div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 120px minmax(0,1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }.detail-fields dt,.detail-fields dd { display:flex; min-width:0; align-items:center; padding:8px 10px; }.detail-fields dt { background:#f8fafc; color:var(--cc-muted); font-size:12px; }.detail-fields dd { margin:0; color:var(--cc-text); overflow-wrap:anywhere; }.fee-section { overflow:hidden; padding-top:16px; }.fee-section h2 { margin-right:18px; margin-left:18px; }.fee-total { display:flex; min-height:48px; align-items:center; justify-content:flex-end; gap:40px; padding:10px 18px; border-top:1px solid var(--cc-border); }.fee-total strong { min-width:120px; font-size:16px; }
|
||||
</style>
|
||||
|
||||
@@ -88,6 +88,7 @@ function appointmentStatusLabel(value: unknown) { return !value ? '未预约' :
|
||||
function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
|
||||
return {
|
||||
lineId: String(dto.lineId), planId: String(dto.planId), customerId: String(dto.customerId), orderId: String(dto.orderId),
|
||||
warehouseId: text(dto.warehouseId) === '—' ? '' : String(dto.warehouseId), destinationWarehouseCode: text(dto.warehouseCode),
|
||||
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), type: typeLabel(dto.orderType), warehouse: text(dto.warehouseName || dto.warehouseCode),
|
||||
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
|
||||
status: planStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
|
||||
|
||||
@@ -1,88 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { listInventoryBalances, listInventoryLedger, listInventoryPositions, type InventoryBalanceDto, type InventoryLedgerDto, type InventoryPositionDto } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const inventoryId = computed(() => String(route.params.inventoryId || '—'))
|
||||
|
||||
const productFields = ['客户', '海外仓', 'SKU编码', '中文品名', '英文品名', '更新时间']
|
||||
const stockFields = ['在库总量', '可用库存', '暂扣库存', '美本土散版', '在途库存', '采购库存', '预计总量']
|
||||
const locationColumns: DataTableColumn[] = [
|
||||
{ key: 'location', label: '库位', minWidth: 130 },
|
||||
{ key: 'status', label: '库存状态', width: 120 },
|
||||
{ key: 'palletNo', label: '托盘号', minWidth: 150 },
|
||||
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
|
||||
{ key: 'quantity', label: '库存数量', width: 110, align: 'right' },
|
||||
{ key: 'available', label: '可用数量', width: 110, align: 'right' },
|
||||
{ key: 'inboundDate', label: '入库日期', width: 130 },
|
||||
{ key: 'ageDays', label: '库龄(天)', width: 100, align: 'right' },
|
||||
]
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'palletNo', label: '托盘号', minWidth: 150 },
|
||||
{ key: 'cartons', label: '关联箱数', width: 110, align: 'right' },
|
||||
{ key: 'cartonRange', label: '箱号范围', minWidth: 200 },
|
||||
{ key: 'location', label: '库位', minWidth: 130 },
|
||||
{ key: 'quantity', label: '库存数量', width: 110, align: 'right' },
|
||||
]
|
||||
const ledgerColumns: DataTableColumn[] = [
|
||||
{ key: 'occurredAt', label: '发生时间', minWidth: 160 },
|
||||
{ key: 'businessType', label: '业务类型', width: 120 },
|
||||
{ key: 'businessOrderNo', label: '业务订单号', minWidth: 170 },
|
||||
{ key: 'inboundOrderNo', label: '入库单号', minWidth: 160 },
|
||||
{ key: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
|
||||
{ key: 'location', label: '库位', minWidth: 120 },
|
||||
{ key: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' },
|
||||
{ key: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' },
|
||||
{ key: 'balance', label: '结存', width: 100, align: 'right' },
|
||||
{ key: 'operator', label: '操作人', minWidth: 120 },
|
||||
{ key: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
|
||||
function back() {
|
||||
void router.push({ name: 'overseas-inventory' })
|
||||
}
|
||||
const route = useRoute(); const router = useRouter(); const inventoryId = computed(() => String(route.params.inventoryId || '')); const customerId = computed(() => String(route.query.customerId || ''))
|
||||
const balance = ref<InventoryBalanceDto | null>(null); const positions = ref<InventoryPositionDto[]>([]); const ledger = ref<InventoryLedgerDto[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
const productValues = computed(() => ({ 客户: text(balance.value?.customerName), 海外仓: text(balance.value?.overseasWarehouseName), SKU编码: balance.value?.skuCodes?.length ? balance.value.skuCodes.join('、') : '—', 产品名称: balance.value?.productNames?.length ? balance.value.productNames.join('、') : '—', 更新时间: formatOverseasDateTime(balance.value?.updatedAt) }))
|
||||
const stockValues = computed(() => ({ 在库总量: Number(balance.value?.totalPackages || 0), 可用库存: Number(balance.value?.availablePackages || 0), 暂扣库存: Number(balance.value?.holdPackages || 0), 美本土散版: '—', 在途库存: Number(balance.value?.inTransitPackages || 0), 采购库存: Number(balance.value?.procurementPackages || 0), 预计总量: Number(balance.value?.totalPackages || 0) + Number(balance.value?.inTransitPackages || 0) + Number(balance.value?.procurementPackages || 0) }))
|
||||
const productFields = ['客户', '海外仓', 'SKU编码', '产品名称', '更新时间']; const stockFields = ['在库总量', '可用库存', '暂扣库存', '美本土散版', '在途库存', '采购库存', '预计总量']
|
||||
const locationRows = computed(() => positions.value.map((item) => ({ id: String(item.id), location: text(item.warehouseLocationCode), status: text(item.status), palletNo: text(item.palletNo), cartons: Number(item.totalPackages || 0), quantity: Number(item.totalPackages || 0), available: Number(item.availablePackages || 0), inboundDate: formatOverseasDateTime(item.updatedAt), ageDays: '—' })))
|
||||
const palletRows = computed(() => positions.value.map((item) => ({ id: String(item.id), palletNo: text(item.palletNo), cartons: Number(item.totalPackages || 0), cartonRange: '—', location: text(item.warehouseLocationCode), quantity: Number(item.totalPackages || 0) })))
|
||||
const ledgerRows = computed(() => ledger.value.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), location: text(item.warehouseLocationCode), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: text(item.operatorId), remark: text(item.remark) })))
|
||||
const locationColumns: DataTableColumn[] = [{ key: 'location', prop: 'location', label: '库位', minWidth: 130 }, { key: 'status', prop: 'status', label: '库存状态', width: 120 }, { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'cartons', prop: 'cartons', label: '箱数', width: 90, align: 'right' }, { key: 'quantity', prop: 'quantity', label: '库存数量', width: 110, align: 'right' }, { key: 'available', prop: 'available', label: '可用数量', width: 110, align: 'right' }, { key: 'inboundDate', prop: 'inboundDate', label: '最近更新', width: 160 }, { key: 'ageDays', prop: 'ageDays', label: '库龄(天)', width: 100, align: 'right' }]
|
||||
const palletColumns: DataTableColumn[] = [{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'cartons', prop: 'cartons', label: '关联箱数', width: 110, align: 'right' }, { key: 'cartonRange', prop: 'cartonRange', label: '箱号范围', minWidth: 200 }, { key: 'location', prop: 'location', label: '库位', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '库存数量', width: 110, align: 'right' }]
|
||||
const ledgerColumns: DataTableColumn[] = [{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'businessType', prop: 'businessType', label: '业务类型', width: 120 }, { key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 }, { key: 'location', prop: 'location', label: '库位', minWidth: 120 }, { key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' }, { key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' }, { key: 'balance', prop: 'balance', label: '结存', width: 100, align: 'right' }, { key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
|
||||
async function load() { if (!inventoryId.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从库存列表重新进入' }; return }; loading.value = true; loadError.value = null; try { const inboundOrderNo = String(route.query.inboundOrderNo || ''); const inboundCode = String(route.query.inboundCode || ''); const skuCode = String(route.query.skuCode || ''); const overseasWarehouseId = String(route.query.overseasWarehouseId || ''); const result = await listInventoryBalances({ customerId: customerId.value, page: 1, size: 200, inboundOrderNo: inboundOrderNo || undefined, inboundCode: inboundCode || undefined, skuCode: skuCode || undefined, overseasWarehouseId: overseasWarehouseId || undefined }); balance.value = result.records.find((item) => String(item.id) === inventoryId.value) || null; if (!balance.value) throw new Error('Inventory balance not found'); const [positionRows, ledgerPage] = await Promise.all([listInventoryPositions(inventoryId.value, customerId.value), listInventoryLedger({ customerId: customerId.value, page: 1, size: 200, inboundOrderNo: balance.value.inboundOrderNo || undefined, inboundCode: balance.value.inboundCode || undefined })]); positions.value = positionRows; ledger.value = ledgerPage.records } catch (error) { loadError.value = getApiErrorDetails(error, '库存详情加载失败,请重试') } finally { loading.value = false } }
|
||||
function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverseasDetailShell title="库存详情" reference-label="库存记录" :reference-value="inventoryId" back-label="返回库存管理" domain="库存详情" @back="back">
|
||||
<section class="overview-panel" aria-label="库存概览">
|
||||
<section aria-labelledby="inventory-product-heading">
|
||||
<h2 id="inventory-product-heading">商品信息</h2>
|
||||
<dl class="detail-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
|
||||
</section>
|
||||
<section aria-labelledby="inventory-summary-heading">
|
||||
<h2 id="inventory-summary-heading">库存状态汇总</h2>
|
||||
<dl class="detail-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
|
||||
</section>
|
||||
</section>
|
||||
<section class="ledger-section" aria-labelledby="inventory-location-heading">
|
||||
<h2 id="inventory-location-heading">库位库存</h2>
|
||||
<DataTable :data="[]" :columns="locationColumns" column-settings-mode="hide" empty-text="海外区域库位库存契约待接入" />
|
||||
</section>
|
||||
<section class="ledger-section" aria-labelledby="inventory-pallet-heading">
|
||||
<h2 id="inventory-pallet-heading">托盘箱号信息</h2>
|
||||
<DataTable :data="[]" :columns="palletColumns" column-settings-mode="hide" empty-text="海外区域托盘箱号契约待接入" />
|
||||
</section>
|
||||
<section class="ledger-section" aria-labelledby="inventory-ledger-heading">
|
||||
<h2 id="inventory-ledger-heading">进销存单据明细</h2>
|
||||
<DataTable :data="[]" :columns="ledgerColumns" column-settings-mode="hide" empty-text="海外区域进销存契约待接入" />
|
||||
</section>
|
||||
</OverseasDetailShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overview-panel { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); background: #fff; }
|
||||
.overview-panel > section { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); }
|
||||
.overview-panel > section:last-child { border-right: 0; }
|
||||
.overview-panel h2, .ledger-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
|
||||
.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
|
||||
.detail-fields > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 120px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; }
|
||||
.detail-fields dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-fields dd { margin: 0; color: var(--cc-text); }
|
||||
.ledger-section { min-width: 0; overflow: hidden; padding-top: 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.ledger-section h2 { margin-right: 18px; margin-left: 18px; }
|
||||
</style>
|
||||
<template><OverseasDetailShell title="库存详情" reference-label="库存记录" :reference-value="inventoryId" back-label="返回库存管理" domain="库存详情" :pending="false" :status-text="balance ? '库存事实' : '加载中'" @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="overview-panel" aria-label="库存概览"><section aria-labelledby="inventory-product-heading"><h2 id="inventory-product-heading">商品信息</h2><dl class="detail-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>{{ productValues[label as keyof typeof productValues] }}</dd></div></dl></section><section aria-labelledby="inventory-summary-heading"><h2 id="inventory-summary-heading">库存状态汇总</h2><dl class="detail-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>{{ stockValues[label as keyof typeof stockValues] }}</dd></div></dl></section></section>
|
||||
<section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细</h2><p class="ledger-note">按该库存记录的入库单范围展示,出库字段仅为只读事实。</p><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></section></template>
|
||||
</OverseasDetailShell></template>
|
||||
<style scoped>.overview-panel{display:grid;min-width:0;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--cc-border);background:#fff}.overview-panel>section{min-width:0;padding:16px 18px 18px;border-right:1px solid var(--cc-border)}.overview-panel>section:last-child{border-right:0}.overview-panel h2,.ledger-section h2{margin:0 0 12px;color:var(--cc-text);font-size:15px}.detail-fields{margin:0;border-top:1px solid var(--cc-border);border-left:1px solid var(--cc-border)}.detail-fields>div{display:grid;min-width:0;min-height:46px;grid-template-columns:120px minmax(0,1fr);border-right:1px solid var(--cc-border);border-bottom:1px solid var(--cc-border)}.detail-fields dt,.detail-fields dd{display:flex;min-width:0;align-items:center;padding:8px 10px}.detail-fields dt{background:#f8fafc;color:var(--cc-muted);font-size:12px}.detail-fields dd{margin:0;color:var(--cc-text)}.ledger-section{min-width:0;overflow:hidden;padding-top:16px;border:1px solid var(--cc-border);background:#fff}.ledger-section h2,.ledger-note{margin-right:18px;margin-left:18px}.ledger-note{color:var(--cc-muted);font-size:12px}</style>
|
||||
|
||||
@@ -1,60 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import type { InventoryTransactionListItem } from '../types'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.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 { listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import type { InventoryTransactionListItem } from '../types'
|
||||
|
||||
const router = useRouter()
|
||||
const rows = ref<InventoryTransactionListItem[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ occurredDate: [], owner: '', warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundOrderNo: '', outboundOrderNo: '' })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'occurredDate', type: 'daterange', label: '发生日期', width: 260 },
|
||||
{ key: 'owner', type: 'input', label: '客户/货主', placeholder: '请输入客户或货主', width: 190 },
|
||||
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', width: 190, options: [] },
|
||||
{ key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码', width: 170 },
|
||||
{ key: 'businessType', type: 'select', label: '业务类型', placeholder: '全部业务类型', width: 170, options: ['采购库存', '头程入库', '尾程入库', '退货入库', '暂扣库存', '一件代发', '头程出库', '派送出库', '调拨转仓', '库存调整'].map((value) => ({ label: value, value })) },
|
||||
{ key: 'businessOrderNo', type: 'input', label: '业务订单号', placeholder: '请输入业务订单号', width: 180 },
|
||||
{ key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 },
|
||||
{ key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 180 },
|
||||
]
|
||||
const route = useRoute(); const router = useRouter(); const rows = ref<InventoryTransactionListItem[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const customersLoading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const customersError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ occurredDate: [], customerId: String(route.query.customerId || ''), warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundOrderNo: '', outboundOrderNo: '' })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'occurredDate', type: 'daterange', label: '发生日期', width: 260 }, { key: 'customerId', type: 'select', label: '客户名称', placeholder: '请选择客户', width: 240, props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'warehouse', type: 'input', label: '目的仓代码', placeholder: '请输入目的仓代码', width: 190 }, { key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入 SKU 编码', width: 170 },
|
||||
{ key: 'businessType', type: 'input', label: '业务类型', placeholder: '请输入业务类型代码', width: 170 }, { key: 'businessOrderNo', type: 'input', label: '业务订单号', placeholder: '请输入业务订单号', width: 180 },
|
||||
{ key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 }, { key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '只读关联筛选', width: 180 },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'occurredAt', label: '发生时间', minWidth: 160 },
|
||||
{ key: 'owner', label: '客户', minWidth: 160 },
|
||||
{ key: 'warehouse', label: '海外仓', minWidth: 180 },
|
||||
{ key: 'sku', label: 'SKU编码', minWidth: 150 },
|
||||
{ key: 'productName', label: '中文品名', minWidth: 180 },
|
||||
{ key: 'businessType', label: '业务类型', width: 120 },
|
||||
{ key: 'businessOrderNo', label: '业务订单号', minWidth: 170 },
|
||||
{ key: 'inboundOrderNo', label: '入库单号', minWidth: 160 },
|
||||
{ key: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
|
||||
{ key: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' },
|
||||
{ key: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' },
|
||||
{ key: 'balance', label: '结存', width: 100, align: 'right' },
|
||||
{ key: 'operator', label: '操作人', minWidth: 120 },
|
||||
{ key: 'remark', label: '备注', minWidth: 180 },
|
||||
{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'owner', prop: 'owner', label: '客户', minWidth: 160 }, { key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 180 },
|
||||
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 }, { key: 'businessType', prop: 'businessType', label: '业务类型', width: 120 },
|
||||
{ key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
|
||||
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' }, { key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' }, { key: 'balance', prop: 'balance', label: '结存', width: 100, align: 'right' },
|
||||
{ key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
|
||||
function back() { void router.push({ name: 'overseas-inventory' }) }
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function customerName(id: string) { return customers.value.find((item) => String(item.id) === id)?.tenantName || '—' }
|
||||
async function loadCustomers() { customersLoading.value = true; customersError.value = null; try { const records: PlatformTenantResource[] = []; let current = 1; let pages = 1; do { const result = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); current += 1 } while (current <= pages); customers.value = records; if (filters.value.customerId) void load() } catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') } finally { customersLoading.value = false } }
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const customerId = String(filters.value.customerId || '').trim(); if (!customerId) { rows.value = []; total.value = 0; loadError.value = null; return }; const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ customerId, page: page.value, size: pageSize.value, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), owner: customerName(customerId), warehouse: text(item.destinationWarehouseCode), sku: text(item.skuCode), productName: '—', businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: text(item.operatorId), remark: text(item.remark) })); total.value = result.total } 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 reset(model: QueryModel) { page.value = 1; void load(model) }; function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(loadCustomers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="进销存记录列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" @search="page = 1" /></template>
|
||||
<template #state><ContractPendingNotice domain="进销存记录" /><p class="read-only-boundary">出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。</p></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-inventory-transactions" row-key="id" empty-text="海外区域进销存查询契约待接入">
|
||||
<template #toolbar-actions><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template>
|
||||
<template #pagination-left>共 0 条进销存记录</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-only-boundary { margin: 8px 2px 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
</style>
|
||||
<template><AdminPage><ListPageLayout aria-label="进销存记录列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" @search="search" @reset="reset" /></template>
|
||||
<template v-if="customersError || loadError" #state><LoadErrorState title="进销存记录加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template></LoadErrorState></template>
|
||||
<template v-else #state><p class="read-only-boundary">出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。当前流水 DTO 未提供产品名称与海外仓名称,对应列显示空值。</p></template>
|
||||
<section v-if="!customersError && !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-inventory-transactions" row-key="id" :empty-text="filters.customerId ? '暂无进销存记录' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
|
||||
</ListPageLayout></AdminPage></template>
|
||||
<style scoped>.read-only-boundary{margin:8px 2px 0;color:var(--cc-muted);font-size:12px;line-height:1.5}</style>
|
||||
|
||||
@@ -60,7 +60,7 @@ const columns: DataTableColumn[] = [
|
||||
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
|
||||
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
|
||||
function status(dto: InventoryBalanceDto) { if (number(dto.holdPackages) > 0) return '存在暂扣'; if (number(dto.availablePackages) <= 0) return '无可用库存'; return '正常' }
|
||||
function toRow(dto: InventoryBalanceDto): InventoryListItem { return { id: String(dto.id), customerId: String(dto.customerId), owner: text(dto.customerName), warehouse: text(dto.overseasWarehouseName || dto.overseasWarehouseCode), sku: dto.skuCodes?.length ? dto.skuCodes.join('、') : '—', productName: dto.productNames?.length ? dto.productNames.join('、') : '—', location: dto.warehouseLocationCodes?.length ? dto.warehouseLocationCodes.join('、') : '—', onHand: number(dto.totalPackages), available: number(dto.availablePackages), held: number(dto.holdPackages), looseboard: '—', inTransit: number(dto.inTransitPackages), purchase: number(dto.procurementPackages), status: status(dto), updatedAt: text(dto.updatedAt) } }
|
||||
function toRow(dto: InventoryBalanceDto): InventoryListItem { return { id: String(dto.id), customerId: String(dto.customerId), warehouseId: String(dto.overseasWarehouseId || ''), inboundOrderNo: text(dto.inboundOrderNo) === '—' ? '' : String(dto.inboundOrderNo), inboundCode: text(dto.inboundCode) === '—' ? '' : String(dto.inboundCode), owner: text(dto.customerName), warehouse: text(dto.overseasWarehouseName || dto.overseasWarehouseCode), sku: dto.skuCodes?.length ? dto.skuCodes.join('、') : '—', productName: dto.productNames?.length ? dto.productNames.join('、') : '—', location: dto.warehouseLocationCodes?.length ? dto.warehouseLocationCodes.join('、') : '—', onHand: number(dto.totalPackages), available: number(dto.availablePackages), held: number(dto.holdPackages), looseboard: '—', inTransit: number(dto.inTransitPackages), purchase: number(dto.procurementPackages), status: status(dto), updatedAt: text(dto.updatedAt) } }
|
||||
|
||||
async function loadCustomers() {
|
||||
contextLoading.value = true; loadError.value = null
|
||||
@@ -92,7 +92,7 @@ async function load(model: QueryModel = filters.value) {
|
||||
}
|
||||
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function openDetail(source: object) { const row = source as InventoryListItem; void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id }, query: { customerId: row.customerId } }) }
|
||||
function openDetail(source: object) { const row = source as InventoryListItem; void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id }, query: { customerId: row.customerId, overseasWarehouseId: row.warehouseId, inboundOrderNo: row.inboundOrderNo, inboundCode: row.inboundCode, skuCode: row.sku === '—' ? undefined : row.sku.split('、')[0] } }) }
|
||||
watch(() => String(filters.value.customerId || ''), (value, oldValue) => { if (value !== oldValue) void loadWarehouses(value) })
|
||||
onMounted(loadCustomers)
|
||||
</script>
|
||||
|
||||
@@ -101,7 +101,7 @@ onMounted(loadCustomers)
|
||||
<template v-if="customersError || loadError" #state><LoadErrorState title="换标单加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template></LoadErrorState></template>
|
||||
<section v-if="!customersError && !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-relabel-orders" row-key="orderNo" :empty-text="filters.customerId ? '暂无换标单' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderEdit)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderStatus)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
|
||||
<template #pagination-left>共 {{ total }} 条换标单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
@@ -61,6 +61,12 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(value).toContain(editTitle)
|
||||
expect(value).not.toContain('AppDrawer')
|
||||
}
|
||||
const pallets = source('PalletsView.vue')
|
||||
expect(pallets).toContain('<AppDialog')
|
||||
expect(pallets).toContain('760px')
|
||||
expect(pallets).toContain('新增托盘')
|
||||
expect(pallets).toContain('编辑托盘')
|
||||
expect(pallets).not.toContain('AppDrawer')
|
||||
})
|
||||
|
||||
it('implements all second-batch details as formal parameterized pages', () => {
|
||||
@@ -83,6 +89,10 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(value).not.toContain('ElMessage.success')
|
||||
expect(value).not.toMatch(/https?:\/\//)
|
||||
}
|
||||
const pallets = source('PalletsView.vue')
|
||||
expect(pallets).toContain('createPallet')
|
||||
expect(pallets).toContain('updatePallet')
|
||||
expect(pallets).toContain('ElMessage.success')
|
||||
})
|
||||
|
||||
it('implements three prototype-defined details as formal pages', () => {
|
||||
@@ -109,12 +119,16 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(tailLeg).toContain(':data="cargo"')
|
||||
expect(tailLeg).toContain('<LoadErrorState')
|
||||
expect(tailLeg).not.toContain('ElMessage.success')
|
||||
for (const fileName of ['InboundOrderDetailView.vue', 'InventoryDetailView.vue']) {
|
||||
const value = source(fileName)
|
||||
expect(value).toContain(':data="[]"')
|
||||
expect(value).toContain('契约待接入')
|
||||
expect(value).not.toContain('ElMessage.success')
|
||||
}
|
||||
const inbound = source('InboundOrderDetailView.vue')
|
||||
expect(inbound).toContain('getInboundPlan')
|
||||
expect(inbound).toContain('sourceOrderId')
|
||||
expect(inbound).toContain('<LoadErrorState')
|
||||
const inventory = source('InventoryDetailView.vue')
|
||||
expect(inventory).toContain('listInventoryPositions')
|
||||
expect(inventory).toContain(':data="locationRows"')
|
||||
expect(inventory).toContain('balance.value?.skuCodes')
|
||||
expect(inventory).not.toContain('当前流水接口未返回 SKU')
|
||||
expect(inventory).not.toContain('ElMessage.success')
|
||||
})
|
||||
|
||||
it('connects pallet CRUD after the overseas contract is available', () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import { getInboundPlan } from '../api/inboundPlans'
|
||||
import { listInventoryLedger } from '../api/inventory'
|
||||
import InboundOrderDetailView from './InboundOrderDetailView.vue'
|
||||
import InventoryTransactionsView from './InventoryTransactionsView.vue'
|
||||
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn() }))
|
||||
vi.mock('../api/inventory', () => ({ listInventoryLedger: vi.fn() }))
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('overseas warehouse mounted real API pages', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ONT8', destinationWarehouseName: '洛杉矶仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
|
||||
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', destinationWarehouseCode: 'LAX', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
})
|
||||
it('mounts inbound detail with a string-safe source order id', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('9007199254740995'); expect(wrapper.text()).toContain('洛杉矶仓') })
|
||||
it('mounts inventory ledger as read-only facts', async () => { routing.route.query = { customerId: '10001' }; const wrapper = mount(InventoryTransactionsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ customerId: '10001', page: 1, size: 20 })); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('不提供出库单跳转') })
|
||||
})
|
||||
@@ -1,15 +1,28 @@
|
||||
export const overseasWarehousePermissions = {
|
||||
lastMileOrderList: 'platform:overseas-warehouse:last-mile-order:list',
|
||||
lastMileOrderList: 'platform:lastleg:order:list',
|
||||
lastMileOrderExport: 'platform:lastleg:order:export',
|
||||
lastMileOrderTransfer: 'platform:lastleg:order:transfer',
|
||||
inboundOrderList: 'platform:lastleg:inbound-plan:list',
|
||||
inboundOrderCreate: 'platform:lastleg:inbound-plan:create',
|
||||
inboundOrderEdit: 'platform:lastleg:inbound-plan:edit',
|
||||
inboundOrderAppointment: 'platform:lastleg:inbound-plan:appointment',
|
||||
inventoryList: 'platform:lastleg:inventory:list',
|
||||
relabelOrderList: 'platform:overseas-warehouse:relabel-order:list',
|
||||
relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create',
|
||||
relabelOrderEdit: 'platform:overseas-warehouse:relabel-order:edit',
|
||||
inventoryExport: 'platform:lastleg:inventory:export',
|
||||
inventoryEdit: 'platform:lastleg:inventory:manage',
|
||||
inventoryAdjust: 'platform:lastleg:inventory:adjust',
|
||||
relabelOrderList: 'platform:lastleg:relabel:list',
|
||||
relabelOrderCreate: 'platform:lastleg:relabel:create',
|
||||
relabelOrderStatus: 'platform:lastleg:relabel:status',
|
||||
locationList: 'platform:location:list',
|
||||
locationCreate: 'platform:location:create',
|
||||
locationEdit: 'platform:location:edit',
|
||||
locationDelete: 'platform:location:delete',
|
||||
warehouseList: 'platform:warehouse:list',
|
||||
palletList: 'platform:pallet:list',
|
||||
palletCreate: 'platform:pallet:create',
|
||||
palletEdit: 'platform:pallet:edit',
|
||||
palletDelete: 'platform:pallet:delete',
|
||||
// 箱号、产品库本期暂缓;权限常量随保留源码留存,但不再由模块正式路由引用。
|
||||
cartonList: 'platform:overseas-warehouse:carton:list',
|
||||
cartonCreate: 'platform:overseas-warehouse:carton:create',
|
||||
cartonEdit: 'platform:overseas-warehouse:carton:edit',
|
||||
|
||||
@@ -16,22 +16,24 @@ describe('overseas warehouse routes', () => {
|
||||
['overseas-warehouse/relabel-orders/:orderId', permission.relabelOrderList],
|
||||
['overseas-warehouse/pallets', permission.palletList],
|
||||
['overseas-warehouse/pallets/:palletId', permission.palletList],
|
||||
['overseas-warehouse/cartons', permission.cartonList],
|
||||
['overseas-warehouse/cartons/:cartonId', permission.cartonList],
|
||||
['overseas-warehouse/products', permission.productList],
|
||||
['overseas-warehouse/products/:productId', permission.productList],
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps all details as formal parameterized pages', () => {
|
||||
const details = overseasWarehouseRoutes.filter((route) => route.path.includes('/:'))
|
||||
expect(details).toHaveLength(7)
|
||||
expect(details).toHaveLength(5)
|
||||
expect(details.every((route) => route.meta.showPageHeading === false)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps create and edit permissions independent for second-batch resources', () => {
|
||||
it('keeps deferred carton and product page sources outside the formal route set', () => {
|
||||
const paths = overseasWarehouseRoutes.map((route) => route.path)
|
||||
expect(paths.some((path) => path.includes('/cartons'))).toBe(false)
|
||||
expect(paths.some((path) => path.includes('/products'))).toBe(false)
|
||||
})
|
||||
|
||||
it('retains independent write permissions for current and deferred page sources', () => {
|
||||
expect(new Set([
|
||||
permission.relabelOrderCreate, permission.relabelOrderEdit,
|
||||
permission.relabelOrderCreate, permission.relabelOrderStatus,
|
||||
permission.palletCreate, permission.palletEdit,
|
||||
permission.cartonCreate, permission.cartonEdit,
|
||||
permission.productCreate, permission.productEdit,
|
||||
|
||||
@@ -68,28 +68,4 @@ export const overseasWarehouseRoutes = [
|
||||
component: () => import('./pages/PalletDetailView.vue'),
|
||||
meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/cartons',
|
||||
name: 'overseas-cartons',
|
||||
component: () => import('./pages/CartonsView.vue'),
|
||||
meta: { title: '箱号管理', description: '管理海外仓箱号、商品与托盘关联', permission: permission.cartonList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/cartons/:cartonId',
|
||||
name: 'overseas-carton-detail',
|
||||
component: () => import('./pages/CartonDetailView.vue'),
|
||||
meta: { title: '箱号详情', description: '查看箱号基础信息与商品信息', permission: permission.cartonList, showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/products',
|
||||
name: 'overseas-products',
|
||||
component: () => import('./pages/ProductsView.vue'),
|
||||
meta: { title: '产品库', description: '管理海外仓商品与包装信息', permission: permission.productList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/products/:productId',
|
||||
name: 'overseas-product-detail',
|
||||
component: () => import('./pages/ProductDetailView.vue'),
|
||||
meta: { title: '产品详情', description: '查看商品基础信息与包装信息', permission: permission.productList, showPageHeading: false },
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface InboundOrderListItem {
|
||||
planId: string
|
||||
customerId: string
|
||||
orderId: string
|
||||
warehouseId: string
|
||||
destinationWarehouseCode: string
|
||||
orderNo: string
|
||||
inboundCode: string
|
||||
type: string
|
||||
@@ -66,6 +68,9 @@ export interface InboundOrderListItem {
|
||||
export interface InventoryListItem {
|
||||
id: string
|
||||
customerId: string
|
||||
warehouseId: string
|
||||
inboundOrderNo: string
|
||||
inboundCode: string
|
||||
owner: string
|
||||
warehouse: string
|
||||
sku: string
|
||||
@@ -74,7 +79,7 @@ export interface InventoryListItem {
|
||||
onHand: number
|
||||
available: number
|
||||
held: number
|
||||
looseboard: number | string
|
||||
looseboard: number | string | null
|
||||
inTransit: number
|
||||
purchase: number
|
||||
status: string
|
||||
|
||||
@@ -17,9 +17,10 @@ const query = {
|
||||
trackingType: 'CONTAINER_NO',
|
||||
trackingNo: 'CSNU1234567',
|
||||
status: 'MANUAL_REQUIRED',
|
||||
triggerSource: 'OUTBOUND_CONFIRM',
|
||||
latestStandardNode: 'DEPARTED_WAREHOUSE',
|
||||
latestNodeObservedAt: '2026-08-20T10:00:00',
|
||||
accessMode: 'DYNAMIC_IFRAME',
|
||||
attemptCount: '2',
|
||||
lastAttemptStatus: 'MANUAL_REQUIRED',
|
||||
lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED',
|
||||
lastErrorMessage: '必须人工查询',
|
||||
resultPortScheduleId: 404,
|
||||
@@ -30,17 +31,25 @@ describe('platformCarrierScheduleQueries', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('filters by real booking/container relations and preserves long ids as strings', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ query, expired: true, maxAttempts: '3', nextAllowedAt: null }], page: '1', size: '200', total: '1', pages: '1' } })
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ query, expired: true }], page: '1', size: '200', total: '1', pages: '1' } })
|
||||
const result = await listCarrierScheduleQueries({ page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' })
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries', { params: { page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' } })
|
||||
expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', accessMode: 'DYNAMIC_IFRAME', attemptCount: 2, lastAttemptStatus: 'MANUAL_REQUIRED', lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询', version: 3 }, expired: true, maxAttempts: 3 })
|
||||
expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', accessMode: 'DYNAMIC_IFRAME', triggerSource: 'OUTBOUND_CONFIRM', latestStandardNode: 'DEPARTED_WAREHOUSE', latestNodeObservedAt: '2026-08-20T10:00:00', lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询', version: 3 }, expired: true })
|
||||
})
|
||||
|
||||
it('normalizes attempts and immutable history without creating query operations', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query, expired: false, maxAttempts: 3 }, attempts: [{ id: 1, queryId: query.id, windowAttemptNo: '2', carrierCode: 'COSCO', attemptStatus: 'FAILED', coverageGap: true }], histories: [{ id: 2, queryId: query.id, action: 'ATTEMPT', fromVersion: '1', toVersion: '2' }] } })
|
||||
it('normalizes standard node facts and immutable business history', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query: { ...query, status: 'ARRIVED', latestStandardNode: 'ARRIVED' }, expired: false }, nodes: [{ id: 1, providerCode: 'COSCO', providerNodeCode: 'ARR', standardNode: 'ARRIVED', nodeSequence: '50', observedAt: '2026-08-20T12:00:00', estimatedAt: null, actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'provider-event-1', rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T12:01:00' }], histories: [{ id: 2, action: 'NODE_OBSERVED', fromVersion: '1', toVersion: '2', reason: 'ARRIVED', operatorId: 808 }] } })
|
||||
const result = await getCarrierScheduleQuery('9007199254740993')
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/9007199254740993')
|
||||
expect(result.attempts[0]).toMatchObject({ id: '1', queryId: '9007199254740993', windowAttemptNo: 2, attemptStatus: 'FAILED' })
|
||||
expect(result.histories[0]).toMatchObject({ id: '2', fromVersion: 1, toVersion: 2 })
|
||||
expect(result.nodes[0]).toMatchObject({ id: '1', standardNode: 'ARRIVED', nodeSequence: 50, actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX' })
|
||||
expect(result.histories[0]).toMatchObject({ id: '2', action: 'NODE_OBSERVED', fromVersion: 1, toVersion: 2, operatorId: '808' })
|
||||
})
|
||||
|
||||
it('preserves nullable DTO fields instead of fabricating false or zero values', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { query: { query: { ...query, containerBookingId: null, resultPortScheduleId: null, latestStandardNode: null, latestNodeObservedAt: null, stoppedAt: null, stopReason: null }, expired: true }, nodes: [{ id: 1, providerCode: 'FIRSTLEG', providerNodeCode: 'OUTBOUND_CONFIRM', standardNode: 'DEPARTED_WAREHOUSE', nodeSequence: 10, observedAt: '2026-08-20T10:00:00', estimatedAt: null, actualAt: null, locationCode: null, locationName: null, sourceReference: null, rawPayloadHash: 'b'.repeat(64), createdAt: null }], histories: [{ id: 2, action: 'AUTO_CREATE', fromVersion: null, toVersion: 0, reason: null, operatorId: null, operatorName: null, createdAt: null }] } })
|
||||
const result = await getCarrierScheduleQuery('9007199254740993')
|
||||
expect(result.query.query).toMatchObject({ containerBookingId: null, resultPortScheduleId: null, latestStandardNode: null, latestNodeObservedAt: null, stoppedAt: null, stopReason: null })
|
||||
expect(result.nodes[0]).toMatchObject({ estimatedAt: null, actualAt: null, locationCode: null, locationName: null, sourceReference: null, createdAt: null })
|
||||
expect(result.histories[0]).toMatchObject({ fromVersion: null, operatorId: null, operatorName: null, createdAt: null })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
CarrierScheduleQueryAttempt,
|
||||
CarrierScheduleQueryDetail,
|
||||
CarrierScheduleQueryFilter,
|
||||
CarrierScheduleQueryHistory,
|
||||
CarrierScheduleNodeFact,
|
||||
CarrierScheduleQueryPage,
|
||||
CarrierScheduleQueryResource,
|
||||
CarrierScheduleQueryView,
|
||||
@@ -22,21 +22,20 @@ function resource(raw: CarrierScheduleQueryResource): CarrierScheduleQueryResour
|
||||
oceanBookingId: String(raw.oceanBookingId),
|
||||
containerBookingId: optionalId(raw.containerBookingId),
|
||||
resultPortScheduleId: optionalId(raw.resultPortScheduleId),
|
||||
attemptCount: numberValue(raw.attemptCount),
|
||||
version: numberValue(raw.version),
|
||||
}
|
||||
}
|
||||
|
||||
function view(raw: CarrierScheduleQueryView): CarrierScheduleQueryView {
|
||||
return { ...raw, query: resource(raw.query), maxAttempts: numberValue(raw.maxAttempts), nextAllowedAt: raw.nextAllowedAt ?? raw.query.nextAllowedAt ?? null }
|
||||
return { ...raw, query: resource(raw.query) }
|
||||
}
|
||||
|
||||
function attempt(raw: CarrierScheduleQueryAttempt): CarrierScheduleQueryAttempt {
|
||||
return { ...raw, id: String(raw.id), queryId: String(raw.queryId), windowAttemptNo: numberValue(raw.windowAttemptNo), coverageGap: Boolean(raw.coverageGap) }
|
||||
function node(raw: CarrierScheduleNodeFact): CarrierScheduleNodeFact {
|
||||
return { ...raw, id: String(raw.id), nodeSequence: numberValue(raw.nodeSequence) }
|
||||
}
|
||||
|
||||
function history(raw: CarrierScheduleQueryHistory): CarrierScheduleQueryHistory {
|
||||
return { ...raw, id: String(raw.id), queryId: String(raw.queryId), fromVersion: raw.fromVersion == null ? null : numberValue(raw.fromVersion), toVersion: raw.toVersion == null ? null : numberValue(raw.toVersion) }
|
||||
return { ...raw, id: String(raw.id), fromVersion: raw.fromVersion == null ? null : numberValue(raw.fromVersion), toVersion: raw.toVersion == null ? null : numberValue(raw.toVersion), operatorId: optionalId(raw.operatorId) }
|
||||
}
|
||||
|
||||
export async function listCarrierScheduleQueries(filter: CarrierScheduleQueryFilter): Promise<CarrierScheduleQueryPage> {
|
||||
@@ -48,5 +47,5 @@ export async function listCarrierScheduleQueries(filter: CarrierScheduleQueryFil
|
||||
export async function getCarrierScheduleQuery(queryId: string): Promise<CarrierScheduleQueryDetail> {
|
||||
const response = await http.get<ApiEnvelope<CarrierScheduleQueryDetail>, ApiEnvelope<CarrierScheduleQueryDetail>>(`${basePath}/${encodeURIComponent(queryId)}`)
|
||||
const result = unwrapApiResponse(response)
|
||||
return { ...result, query: view(result.query), attempts: (result.attempts || []).map(attempt), histories: (result.histories || []).map(history) }
|
||||
return { ...result, query: view(result.query), nodes: (result.nodes || []).map(node), histories: (result.histories || []).map(history) }
|
||||
}
|
||||
|
||||
@@ -16,13 +16,12 @@ const queryView = {
|
||||
id: '11', queryNo: 'CSQ11', customerId: '1', oceanBookingId: '2', containerBookingId: '3',
|
||||
carrierCode: 'COSCO', trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567',
|
||||
officialUrl: 'https://elines.coscoshipping.com/ebusiness/', accessMode: 'DYNAMIC_IFRAME',
|
||||
status: 'MANUAL_REQUIRED' as const, attemptCount: 2, lastAttemptStatus: 'MANUAL_REQUIRED',
|
||||
status: 'MANUAL_REQUIRED' as const, triggerSource: 'OUTBOUND_CONFIRM',
|
||||
latestStandardNode: 'DEPARTED_WAREHOUSE' as const, latestNodeObservedAt: '2026-08-20T10:00:00',
|
||||
lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询',
|
||||
resultPortScheduleId: '21', version: 1,
|
||||
},
|
||||
expired: true,
|
||||
maxAttempts: 3,
|
||||
nextAllowedAt: '2026-08-22T10:00:00',
|
||||
}
|
||||
|
||||
describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
@@ -40,7 +39,7 @@ describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
expect(getCarrierScheduleQuery).toHaveBeenCalledWith('11')
|
||||
expect(getPortSchedule).toHaveBeenCalledWith('21')
|
||||
expect(wrapper.text()).toContain('CSQ11')
|
||||
expect(wrapper.text()).toContain('查询尝试与变更历史暂不可用')
|
||||
expect(wrapper.text()).toContain('标准节点与变更历史暂不可用')
|
||||
expect(wrapper.text()).toContain('关联船期结果暂不可用')
|
||||
expect(wrapper.text()).toContain('DYNAMIC_IFRAME_REQUIRED')
|
||||
})
|
||||
@@ -50,8 +49,37 @@ describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('不会模拟节点或自动创建任务')
|
||||
expect(wrapper.text()).toContain('本页面不会创建或模拟任务')
|
||||
expect(getCarrierScheduleQuery).not.toHaveBeenCalled()
|
||||
expect(getPortSchedule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the booking/container task when an automatic task has no linked port schedule', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }, nodes: [], histories: [] })
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3', portScheduleId: '99' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('CSQ11')
|
||||
expect(getCarrierScheduleQuery).toHaveBeenCalledWith('11')
|
||||
expect(getPortSchedule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders canonical node location, actual time, source and stopped facts from detail DTO', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, status: 'STOPPED', latestStandardNode: 'PICKUP_READY', stoppedAt: '2026-08-20T18:00:00', stopReason: '目的港可提柜后停止', resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({
|
||||
query: { query: { ...queryView.query, status: 'STOPPED', latestStandardNode: 'PICKUP_READY', stoppedAt: '2026-08-20T18:00:00', stopReason: '目的港可提柜后停止', resultPortScheduleId: null }, expired: false },
|
||||
nodes: [{ id: '31', providerCode: 'COSCO', providerNodeCode: 'PICKUP', standardNode: 'PICKUP_READY', nodeSequence: 60, observedAt: '2026-08-20T17:50:00', estimatedAt: null, actualAt: '2026-08-20T17:45:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: 'provider-event-31', rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T17:51:00' }],
|
||||
histories: [{ id: '41', action: 'STOP', fromVersion: 4, toVersion: 5, reason: '目的港可提柜后停止', operatorId: null, operatorName: '系统', createdAt: '2026-08-20T18:00:00' }],
|
||||
})
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('已停止')
|
||||
expect(wrapper.text()).toContain('可提柜')
|
||||
expect(wrapper.text()).toContain('USLAX · Los Angeles')
|
||||
expect(wrapper.text()).toContain('2026-08-20 17:45')
|
||||
expect(wrapper.text()).toContain('COSCO')
|
||||
expect(wrapper.text()).toContain('目的港可提柜后停止')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,11 +16,11 @@ describe('CarrierScheduleQueryPanel', () => {
|
||||
it('shows source, observation/update times, history, failures and expiry truthfully', () => {
|
||||
expect(source).toContain('最新来源')
|
||||
expect(source).toContain('访问方式')
|
||||
expect(source).toContain('查询次数')
|
||||
expect(source).toContain('观测时间')
|
||||
expect(source).toContain('来源更新时间')
|
||||
expect(source).toContain('任务更新时间')
|
||||
expect(source).toContain('查询尝试')
|
||||
expect(source).toContain('标准节点历史')
|
||||
expect(source).toContain('节点位置')
|
||||
expect(source).toContain('节点实际/预计时间')
|
||||
expect(source).toContain('变更历史')
|
||||
expect(source).toContain('query.lastErrorMessage')
|
||||
expect(source).toContain('query.lastErrorCode')
|
||||
@@ -30,12 +30,16 @@ describe('CarrierScheduleQueryPanel', () => {
|
||||
it('keeps query-history and linked-schedule partial failures independently visible', () => {
|
||||
expect(source).toContain('queryDetailError')
|
||||
expect(source).toContain('scheduleError')
|
||||
expect(source).toContain('查询尝试与变更历史暂不可用')
|
||||
expect(source).toContain('标准节点与变更历史暂不可用')
|
||||
expect(source).toContain('关联船期结果暂不可用')
|
||||
})
|
||||
|
||||
it('keeps the missing node-event contract explicit instead of fabricating data', () => {
|
||||
expect(source).toContain('待接口提供独立节点事实')
|
||||
expect(source).toContain('不会模拟节点或自动创建任务')
|
||||
it('shows canonical arrival, pickup-ready and stopped states without exposing scheduler actions', () => {
|
||||
expect(source).toContain("PICKUP_READY: '可提柜'")
|
||||
expect(source).toContain("ARRIVED: '已到目的港'")
|
||||
expect(source).toContain("return '已停止'")
|
||||
expect(source).toContain('任务仅在确认国内出仓后自动建立')
|
||||
expect(source).not.toContain('/scheduler')
|
||||
expect(source).not.toContain('/attempts')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
|
||||
import { getPortSchedule } from '../api/platformPortSchedules'
|
||||
import type { CarrierScheduleQueryDetail, CarrierScheduleQueryView, PortScheduleView } from '../types'
|
||||
import type { CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, PortScheduleView } from '../types'
|
||||
|
||||
const props = defineProps<{ oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null }>()
|
||||
const loading = ref(false)
|
||||
@@ -20,21 +20,32 @@ const query = computed(() => detail.value?.query.query || selected.value?.query
|
||||
const queryView = computed(() => detail.value?.query || selected.value)
|
||||
const statusText = computed(() => {
|
||||
if (!query.value) return '未建立'
|
||||
if (query.value.status === 'STOPPED') return '已停止'
|
||||
if (query.value.status === 'PICKUP_READY') return '可提柜'
|
||||
if (query.value.status === 'ARRIVED') return '已到目的港'
|
||||
if (queryView.value?.expired) return '结果已过期'
|
||||
return ({ READY: '待查询', MANUAL_REQUIRED: '需人工补充', SUCCESS: '最近查询成功' } as Record<string, string>)[query.value.status] || query.value.status
|
||||
return ({ READY: '查询中', MANUAL_REQUIRED: '需人工补充' } as Record<string, string>)[query.value.status] || query.value.status
|
||||
})
|
||||
const statusType = computed<'success' | 'warning' | 'danger'>(() => queryView.value?.expired || query.value?.status === 'MANUAL_REQUIRED' ? 'danger' : query.value?.status === 'SUCCESS' ? 'success' : 'warning')
|
||||
const latestSource = computed(() => latestSchedule.value?.schedule.sourceName || query.value?.carrierCode || '—')
|
||||
const sourceReference = computed(() => latestSchedule.value?.schedule.sourceRef || query.value?.officialUrl || '—')
|
||||
const latestObservation = computed(() => latestSchedule.value?.schedule.observedAt || query.value?.sourceUpdatedAt || query.value?.lastSuccessAt || null)
|
||||
const statusType = computed<'success' | 'warning' | 'danger' | 'info'>(() => {
|
||||
if (query.value?.status === 'STOPPED') return 'info'
|
||||
if (query.value?.status === 'ARRIVED' || query.value?.status === 'PICKUP_READY') return 'success'
|
||||
if (queryView.value?.expired || query.value?.status === 'MANUAL_REQUIRED') return 'danger'
|
||||
return 'warning'
|
||||
})
|
||||
const nodes = computed(() => detail.value?.nodes || [])
|
||||
const latestNode = computed(() => nodes.value[nodes.value.length - 1] || null)
|
||||
const latestSource = computed(() => latestNode.value?.providerCode || latestSchedule.value?.schedule.sourceName || query.value?.carrierCode || '—')
|
||||
const sourceReference = computed(() => latestNode.value?.sourceReference || latestSchedule.value?.schedule.sourceRef || query.value?.officialUrl || '—')
|
||||
const latestObservation = computed(() => latestNode.value?.observedAt || query.value?.latestNodeObservedAt || latestSchedule.value?.schedule.observedAt || query.value?.sourceUpdatedAt || null)
|
||||
const histories = computed(() => [...(detail.value?.histories || [])].reverse())
|
||||
const attempts = computed(() => [...(detail.value?.attempts || [])].reverse())
|
||||
|
||||
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function trackingTypeText(value: string) { return ({ BOOKING_NO: '订舱号', BILL_OF_LADING: '提单号', CONTAINER_NO: '集装箱号' } as Record<string, string>)[value] || value }
|
||||
function attemptText(status: string) { return ({ SUCCESS: '成功', FAILED: '失败', MANUAL_REQUIRED: '需人工补充' } as Record<string, string>)[status] || status }
|
||||
function historyText(action: string) { return ({ CREATE: '建立查询任务', ATTEMPT: '执行查询', MANUAL_RESULT: '关联人工船期结果' } as Record<string, string>)[action] || action }
|
||||
function latestTimestamp(item: CarrierScheduleQueryView) { return Date.parse(item.query.updatedAt || item.query.createdAt || '') || 0 }
|
||||
function triggerSourceText(value?: string | null) { return ({ OUTBOUND_CONFIRM: '国内出仓确认自动建立', MANUAL: '平台人工建立' } as Record<string, string>)[value || ''] || value || '—' }
|
||||
function nodeText(value?: CarrierScheduleStandardNode | null) { return ({ DEPARTED_WAREHOUSE: '已离开国内仓', AT_ORIGIN_PORT: '已到起运港', DEPARTED_ORIGIN_PORT: '已离开起运港', IN_TRANSIT: '海运在途', ARRIVED: '已到目的港', PICKUP_READY: '可提柜' } as Record<string, string>)[value || ''] || value || '—' }
|
||||
function nodeLocation(item?: CarrierScheduleNodeFact | null) { return item ? [item.locationCode, item.locationName].filter(Boolean).join(' · ') || '—' : '—' }
|
||||
function nodePrimaryTime(item?: CarrierScheduleNodeFact | null) { return item?.actualAt || item?.estimatedAt || item?.observedAt || null }
|
||||
function historyText(action: string) { return ({ AUTO_CREATE: '国内出仓自动建任务', CREATE: '人工建立查询任务', NODE_OBSERVED: '记录标准节点', PROVIDER_QUERY: '船司查询', MANUAL_RESULT: '关联人工船期结果', STOP: '停止查询' } as Record<string, string>)[action] || action }
|
||||
|
||||
async function load() {
|
||||
selected.value = null; detail.value = null; latestSchedule.value = null; error.value = null; queryDetailError.value = null; scheduleError.value = null
|
||||
@@ -43,7 +54,7 @@ async function load() {
|
||||
try {
|
||||
const page = await listCarrierScheduleQueries({ page: 1, size: 200, oceanBookingId: props.oceanBookingId || undefined, containerBookingId: props.containerBookingId || undefined })
|
||||
const matching = props.portScheduleId ? page.records.filter((item) => item.query.resultPortScheduleId === String(props.portScheduleId)) : page.records
|
||||
selected.value = [...matching].sort((left, right) => latestTimestamp(right) - latestTimestamp(left))[0] || null
|
||||
selected.value = matching[0] || page.records[0] || null
|
||||
if (!selected.value) return
|
||||
const [queryResult] = await Promise.allSettled([getCarrierScheduleQuery(selected.value.query.id)])
|
||||
if (queryResult.status === 'fulfilled') detail.value = queryResult.value
|
||||
@@ -65,25 +76,26 @@ watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleI
|
||||
<header><div><h2>国内出仓后船节点</h2><p>沿用 CarrierScheduleQuery,跟踪至目的港到达;本页面不会新建轮询任务。</p></div><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></header>
|
||||
<LoadErrorState v-if="error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-else-if="loading" class="loading-state"><el-skeleton :rows="2" animated /></div>
|
||||
<el-empty v-else-if="!query" :image-size="52" description="尚未建立关联的船节点查询任务;当前页面不会模拟节点或自动创建任务。" />
|
||||
<el-empty v-else-if="!query" :image-size="52" description="尚无关联任务;任务仅在确认国内出仓后自动建立,本页面不会创建或模拟任务。" />
|
||||
<template v-else>
|
||||
<el-alert v-if="query.lastErrorMessage || query.status === 'MANUAL_REQUIRED'" type="error" :closable="false" show-icon :title="query.lastErrorMessage || query.manualReason || '自动查询暂不可用,需要人工补充可靠结果。'" />
|
||||
<el-alert v-if="queryDetailError" type="warning" :closable="false" show-icon title="查询尝试与变更历史暂不可用"><template #default><ApiErrorMeta :details="queryDetailError" /></template></el-alert>
|
||||
<el-alert v-if="query.status === 'STOPPED'" type="info" :closable="false" show-icon :title="query.stopReason || '船节点查询已停止。'" />
|
||||
<el-alert v-if="queryDetailError" type="warning" :closable="false" show-icon title="标准节点与变更历史暂不可用"><template #default><ApiErrorMeta :details="queryDetailError" /></template></el-alert>
|
||||
<el-alert v-if="scheduleError" type="warning" :closable="false" show-icon title="关联船期结果暂不可用"><template #default><ApiErrorMeta :details="scheduleError" /></template></el-alert>
|
||||
<dl class="query-facts">
|
||||
<div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}</dd></div>
|
||||
<div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>访问方式</dt><dd>{{ query.accessMode || '—' }}</dd></div>
|
||||
<div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div><div><dt>查询次数</dt><dd>{{ query.attemptCount }} / {{ queryView?.maxAttempts ?? '—' }}</dd></div>
|
||||
<div><dt>最新节点</dt><dd>{{ latestSchedule ? '船期快照已更新' : '待接口提供独立节点事实' }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div>
|
||||
<div><dt>来源更新时间</dt><dd>{{ formatTime(query.sourceUpdatedAt) }}</dd></div><div><dt>任务更新时间</dt><dd>{{ formatTime(query.updatedAt) }}</dd></div>
|
||||
<div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>最近尝试</dt><dd>{{ formatTime(query.lastAttemptAt) }} · {{ query.lastAttemptStatus ? attemptText(query.lastAttemptStatus) : '—' }}</dd></div>
|
||||
<div><dt>结果过期时间</dt><dd>{{ formatTime(query.expiresAt) }}</dd></div><div><dt>下次允许查询</dt><dd>{{ formatTime(queryView?.nextAllowedAt) }}</dd></div>
|
||||
<div><dt>建立来源</dt><dd>{{ triggerSourceText(query.triggerSource) }}</dd></div><div><dt>船司 / 访问方式</dt><dd>{{ query.carrierCode }} · {{ query.accessMode || '—' }}</dd></div>
|
||||
<div><dt>最新节点</dt><dd>{{ nodeText(query.latestStandardNode) }}</dd></div><div><dt>节点位置</dt><dd>{{ nodeLocation(latestNode) }}</dd></div>
|
||||
<div><dt>节点实际/预计时间</dt><dd>{{ formatTime(nodePrimaryTime(latestNode)) }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div>
|
||||
<div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div>
|
||||
<div><dt>来源更新时间</dt><dd>{{ formatTime(query.sourceUpdatedAt) }}</dd></div><div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div>
|
||||
<div><dt>结果过期时间</dt><dd>{{ formatTime(query.expiresAt) }}</dd></div><div><dt>停止时间</dt><dd>{{ formatTime(query.stoppedAt) }}</dd></div>
|
||||
<div v-if="query.lastErrorCode"><dt>最近错误码</dt><dd>{{ query.lastErrorCode }}</dd></div>
|
||||
</dl>
|
||||
<div v-if="latestSchedule" class="latest-result"><strong>{{ latestSchedule.schedule.vesselName }} / {{ latestSchedule.schedule.voyageNo }}</strong><span>{{ latestSchedule.schedule.departurePortName || latestSchedule.schedule.departurePortCode }} → {{ latestSchedule.schedule.destinationPortName || latestSchedule.schedule.destinationPortCode }}</span><span>预计开船 {{ formatTime(latestSchedule.schedule.etdAt) }} · 预计到港 {{ formatTime(latestSchedule.schedule.etaAt) }}</span></div>
|
||||
<div class="history-grid">
|
||||
<section><h3>查询尝试</h3><p v-if="!attempts.length" class="empty-line">暂无查询尝试</p><ol v-else><li v-for="item in attempts" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ attemptText(item.attemptStatus) }}</strong><small>{{ item.errorMessage || item.manualReason || item.operatorName || item.carrierCode }}</small></li></ol></section>
|
||||
<section><h3>变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.reason || item.operatorName || '系统记录' }}</small></li></ol></section>
|
||||
<section><h3>标准节点历史</h3><p v-if="!nodes.length" class="empty-line">暂无标准节点</p><ol v-else class="node-list"><li v-for="item in nodes" :key="item.id"><span>{{ formatTime(nodePrimaryTime(item)) }}</span><strong>{{ nodeText(item.standardNode) }}</strong><small>{{ nodeLocation(item) }} · {{ item.providerCode }}<template v-if="item.sourceReference"> · {{ item.sourceReference }}</template></small></li></ol></section>
|
||||
<section><h3>变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.operatorName || '系统' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -33,7 +33,8 @@ export interface PortScheduleQuery { page: number; size: number; customerId?: st
|
||||
export type PortScheduleData = Omit<PortScheduleResource, 'id' | 'scheduleNo' | 'version' | 'updatedAt'>
|
||||
export interface PortScheduleSaveRequest { expectedVersion: number; data: PortScheduleData; reason?: string | null }
|
||||
|
||||
export type CarrierScheduleQueryStatus = 'READY' | 'MANUAL_REQUIRED' | 'SUCCESS'
|
||||
export type CarrierScheduleQueryStatus = 'READY' | 'MANUAL_REQUIRED' | 'ARRIVED' | 'PICKUP_READY' | 'STOPPED'
|
||||
export type CarrierScheduleStandardNode = 'DEPARTED_WAREHOUSE' | 'AT_ORIGIN_PORT' | 'DEPARTED_ORIGIN_PORT' | 'IN_TRANSIT' | 'ARRIVED' | 'PICKUP_READY'
|
||||
|
||||
export interface CarrierScheduleQueryResource {
|
||||
id: string
|
||||
@@ -47,62 +48,56 @@ export interface CarrierScheduleQueryResource {
|
||||
officialUrl?: string | null
|
||||
accessMode?: string | null
|
||||
status: CarrierScheduleQueryStatus
|
||||
windowStartedAt?: string | null
|
||||
attemptCount: number
|
||||
nextAllowedAt?: string | null
|
||||
lastAttemptAt?: string | null
|
||||
triggerSource?: string | null
|
||||
latestStandardNode?: CarrierScheduleStandardNode | null
|
||||
latestNodeObservedAt?: string | null
|
||||
lastSuccessAt?: string | null
|
||||
sourceUpdatedAt?: string | null
|
||||
expiresAt?: string | null
|
||||
resultPortScheduleId?: string | null
|
||||
lastAttemptStatus?: string | null
|
||||
manualReason?: string | null
|
||||
lastErrorCode?: string | null
|
||||
lastErrorMessage?: string | null
|
||||
remark?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
stoppedAt?: string | null
|
||||
stopReason?: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface CarrierScheduleQueryView {
|
||||
query: CarrierScheduleQueryResource
|
||||
expired: boolean
|
||||
maxAttempts: number
|
||||
nextAllowedAt?: string | null
|
||||
}
|
||||
|
||||
export interface CarrierScheduleQueryAttempt {
|
||||
export interface CarrierScheduleNodeFact {
|
||||
id: string
|
||||
queryId: string
|
||||
windowAttemptNo: number
|
||||
carrierCode: string
|
||||
accessMode?: string | null
|
||||
officialUrl?: string | null
|
||||
attemptStatus: string
|
||||
coverageGap?: boolean | null
|
||||
coverageGapCode?: string | null
|
||||
manualReason?: string | null
|
||||
errorCode?: string | null
|
||||
errorMessage?: string | null
|
||||
operatorName?: string | null
|
||||
providerCode: string
|
||||
providerNodeCode: string
|
||||
standardNode: CarrierScheduleStandardNode
|
||||
nodeSequence: number
|
||||
observedAt: string
|
||||
estimatedAt?: string | null
|
||||
actualAt?: string | null
|
||||
locationCode?: string | null
|
||||
locationName?: string | null
|
||||
sourceReference?: string | null
|
||||
rawPayloadHash: string
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
export interface CarrierScheduleQueryHistory {
|
||||
id: string
|
||||
queryId: string
|
||||
action: string
|
||||
fromVersion?: number | null
|
||||
toVersion?: number | null
|
||||
reason?: string | null
|
||||
operatorId?: string | null
|
||||
operatorName?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
export interface CarrierScheduleQueryDetail {
|
||||
query: CarrierScheduleQueryView
|
||||
attempts: CarrierScheduleQueryAttempt[]
|
||||
nodes: CarrierScheduleNodeFact[]
|
||||
histories: CarrierScheduleQueryHistory[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user