This commit is contained in:
2026-08-20 09:10:36 +08:00
parent 033b5df6b6
commit 508f76924e
58 changed files with 1790 additions and 628 deletions
@@ -0,0 +1,18 @@
# 海外仓真实接口接入矩阵
浏览器只通过共享 `overseasHttp` 请求海外网关,模块路径统一从 `/platform/v1/...` 开始。长整型 ID 全程按字符串处理。以下结论来自 `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service` 当前 Controller/DTO;三者当前没有静态 OpenAPI YAML,本轮未把 Springdoc 注解之外的路径推断为正式接口。
| 原型页面 | 页面 ID | 当前真实接口 | 接入状态 | 说明 |
|---|---|---|---|---|
| 尾程订单列表 | `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-inbound-order*` | 仅有 `GET /platform/v1/last-leg/inbound-plans/{planNo}` | 阻断 | 没有分页列表,无法由详情接口反推列表;当前页面继续明确显示契约待接入。 |
| 换标单列表/详情 | `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*` | 无平台查询接口 | 阻断 | 只有内部一件代发快照写入,未提供平台查询/维护端点。 |
状态映射仅使用服务端已有事实:`PENDING_ASSIGNMENT`(待分配)、`DESTINATION_ASSIGNED`(已分配目的仓)、`INBOUND_PLANNED`(已生成入库计划)。未定义的原型状态不发送到接口。
当前所有上述海外业务 Controller 均未声明与前端 `platform:overseas-warehouse:*` 对应的服务端 `@PreAuthorize`。因此本轮只接入尾程只读查询;已有写端点在服务端权限、幂等和原型字段未一致前不向 Admin 暴露。
@@ -1,11 +1,10 @@
# 四期海外仓前端待补契约
第一批正式页面只冻结路由、权限、字段与操作承载方式。后端服务边界现已确认:浏览器请求统一进入 `gateway-overseas/`(RS256/JWKS,Audience=`lastleg-api`);尾程订单、入仓计划、换标、产品/箱号快照和库存业务归 `lastleg-service/`;海外仓库、地址、能力、库位、实物托盘和资源锁归 `warehouse-overseas-service/`。国内 `warehouse/` 只维护“海外仓合作仓”公共主数据类型。
正式页面已开始接入真实海外接口。后端服务边界现已确认:浏览器请求统一进入 `gateway-overseas/`(RS256/JWKS,Audience=`lastleg-api`);尾程订单、入仓计划、换标、产品/箱号快照和库存业务归 `lastleg-service/`;海外仓库、地址、能力、库位、实物托盘和资源锁归 `warehouse-overseas-service/`。国内 `warehouse/` 只维护“海外仓合作仓”公共主数据类型。逐页现状见 `API_INTEGRATION_MATRIX.md`。
以下前端适配或页面级契约补齐前,页面不得用演示数据或本地成功提示替代真实调用:
- 统一认证/HTTP 层对海外 API Base、短时海外 Access Token 的存储、刷新和自动选择,以及与现有国内 `http` 客户端一致的错误处理;业务页面不得自行读写 Token,也不得把请求发往国内 `gateway/`、`firstleg/` 或 `warehouse/`。
- 尾程订单分页查询、详情、导入模板、批量导入、异步导出、批量转仓和入库计划接口;分页查询需明确“全部 / 待分配”页签的请求映射,以及出库单号、派送单号只读关联字段。
- 尾程订单导入模板、批量导入、异步导出任务及对应独立权限。现有同步 CSV 导出不等同于 Admin 下载中心异步导出。尾程分页与详情已经接入;“待分配”映射为 `PENDING_ASSIGNMENT`,出库单号、派送单号仅展示既有只读关联事实。
- 入库单分页查询、详情、创建、编辑、预约、智能排车接口,以及状态机、幂等键和操作权限。
- 库存汇总、分页查询、详情、库位库存、托盘箱号和进销存记录接口。
- 换标单分页、详情、创建、编辑/审核、文件上传与费用明细接口,以及状态机和幂等约定。
@@ -15,6 +14,16 @@
- 四期权限资源在 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 聚合端点;响应需覆盖现有量、可用量、暂扣、散板、在途、采购、状态和更新时间。状态流转仍只能由入库/库内事实驱动,不增加离仓操作。
- 换标:当前列表响应需补 `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` 等稳定权限;当前仅有应用范围与客户隔离,不能以页面按钮权限替代服务端鉴权。
范围排除:海外仓出库、退货出库、海外派送和其他库存离仓接口。
`lastleg-service` 已提供尾程订单、入仓计划、库存查询等 Controller,`warehouse-overseas-service` 已提供平台海外仓、库位和托盘 Controller;接入时以这些服务的 Controller/DTO/OpenAPI 为准。当前 `types.ts` 只冻结原型派生的页面展示字段,不是海外 API DTO;不得据此推导未覆盖页面的分页包络、URL、请求参数、时间/金额精度或写接口。统一区域客户端和对应页面契约完成后再建立实际 API 适配层,并逐页替换空态数据源。
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { getLastMileOrder, listLastMileOrders } from './lastMileOrders'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
describe('last-mile order overseas API', () => {
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
it('uses the gateway-relative platform list endpoint and explicit customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 20, total: 0, pages: 0 } })
await expect(listLastMileOrders({ customerId: '190000000000000001', page: 2, size: 20, status: 'PENDING_ASSIGNMENT' })).resolves.toMatchObject({ page: 2 })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', {
params: { customerId: '190000000000000001', page: 2, size: 20, status: 'PENDING_ASSIGNMENT' },
})
})
it('preserves a long order id as a string in the detail path', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { order: { id: '190000000000000099' }, cargo: [], statusHistory: [] } })
await getLastMileOrder('190000000000000099', '190000000000000001')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/190000000000000099', {
params: { customerId: '190000000000000001' },
})
})
it('does not call outbound or delivery endpoints', () => {
const source = `${listLastMileOrders}${getLastMileOrder}`
expect(source).not.toMatch(/outbound|delivery|dispatch/)
})
})
@@ -0,0 +1,110 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface OverseasPageResult<T> {
records: T[]
page: number
size: number
total: number
pages: number
}
export interface LastMileOrderQuery {
customerId: string
page: number
size: number
orderNo?: string
inboundCode?: string
containerNo?: string
status?: string
}
export interface LastMileOrderSummaryDto {
id: string
orderNo: string
orderType?: string | null
inboundCode?: string | null
sourceOrderNo?: string | null
customerName?: string | null
overseasWarehouseName?: string | null
transferWarehouseName?: string | null
fbaWarehouseCode?: string | null
containerNo?: string | null
containerStatus?: string | null
abnormal?: boolean | null
detained?: boolean | null
palletCount?: number | null
packageCount?: number | null
volumeCbm?: number | null
productQuantity?: number | null
volumeWeightKg?: number | null
estimatedArrivalAt?: string | null
actualArrivalAt?: string | null
estimatedWarehouseAt?: string | null
inboundOrderNo?: string | null
outboundOrderNo?: string | null
deliveryOrderNo?: string | null
relabelOrderNo?: string | null
relabelStatus?: string | null
palletMergeFlag?: string | null
currentStatus?: string | null
privateRemark?: string | null
customerRemark?: string | null
warehouseRemark?: string | null
}
export interface LastMileOrderDto extends LastMileOrderSummaryDto {
customerId: string
sourceOrderNo?: string | null
subOrderStatus?: string | null
createdAt?: string | null
updatedAt?: string | null
}
export interface LastMileCargoDto {
id: string
skuCode?: string | null
productNameZh?: string | null
productNameEn?: string | null
packageCount?: number | null
productQuantity?: number | null
palletCount?: number | null
grossWeightKg?: number | null
dimensionalWeightKg?: number | null
volumeCbm?: number | null
destinationWarehouseName?: string | null
fbaTrackingNo?: string | null
shipmentNo?: string | null
}
export interface LastMileStatusHistoryDto {
id: string
fromStatus?: string | null
toStatus?: string | null
eventId?: string | null
remark?: string | null
createdAt?: string | null
}
export interface LastMileOrderDetailDto {
order: LastMileOrderDto
cargo: LastMileCargoDto[]
statusHistory: LastMileStatusHistoryDto[]
}
export async function listLastMileOrders(query: LastMileOrderQuery) {
const { customerId, ...params } = query
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>, ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>>(
'/platform/v1/last-leg/orders',
{ params: { customerId, ...params } },
)
return unwrapApiResponse(response)
}
export async function getLastMileOrder(orderId: string, customerId: string) {
const response = await overseasHttp.get<ApiEnvelope<LastMileOrderDetailDto>, ApiEnvelope<LastMileOrderDetailDto>>(
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}`,
{ params: { customerId } },
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,21 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { getRelabelOrder, listRelabelOrders } from './relabelOrders'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
describe('relabel overseas API', () => {
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
it('uses explicit customer scope for the real page endpoint', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
await listRelabelOrders({ customerId: '190000000000000001', page: 1, size: 20, skuCode: 'SKU-1' })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', { params: { customerId: '190000000000000001', page: 1, size: 20, skuCode: 'SKU-1' } })
})
it('encodes the relabel number and does not call a mutation endpoint', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { application: { id: '1', relabelNo: 'RL/1' }, items: [], history: [] } })
await getRelabelOrder('RL/1', '190000000000000001')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL%2F1', { params: { customerId: '190000000000000001' } })
})
})
@@ -0,0 +1,71 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OverseasPageResult } from './lastMileOrders'
export interface RelabelOrderQuery {
customerId: string
page: number
size: number
skuCode?: string
status?: string
}
export interface RelabelSummaryDto {
id: string
relabelNo: string
lastlegOrderId?: string | null
lastlegOrderNo?: string | null
customerOrderNo?: string | null
overseasWarehouseId?: string | null
labelDeadline?: string | null
contactName?: string | null
contactPhone?: string | null
status?: string | null
itemCount?: number | null
totalPackageCount?: number | null
notifiedPackageCount?: number | null
skuCodes?: string | null
createdAt?: string | null
}
export interface RelabelItemDto {
id: string
skuCode?: string | null
productNameZh?: string | null
totalPackageCount?: number | null
notifiedPackageCount?: number | null
newSkuCode?: string | null
labelRequirement?: string | null
relabelFileName?: string | null
relabelFileUrl?: string | null
}
export interface RelabelHistoryDto {
fromStatus?: string | null
toStatus?: string | null
operatorId?: string | null
remark?: string | null
createdAt?: string | null
}
export interface RelabelDetailDto {
application: RelabelSummaryDto
remark?: string | null
items: RelabelItemDto[]
history: RelabelHistoryDto[]
}
export async function listRelabelOrders(query: RelabelOrderQuery) {
const { customerId, ...params } = query
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>, ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>>(
'/platform/v1/last-leg/relabel-applications', { params: { customerId, ...params } },
)
return unwrapApiResponse(response)
}
export async function getRelabelOrder(relabelNo: string, customerId: string) {
const response = await overseasHttp.get<ApiEnvelope<RelabelDetailDto>, ApiEnvelope<RelabelDetailDto>>(
`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`, { params: { customerId } },
)
return unwrapApiResponse(response)
}
@@ -3,13 +3,15 @@ import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ContractPendingNotice from './ContractPendingNotice.vue'
defineProps<{
withDefaults(defineProps<{
title: string
referenceLabel: string
referenceValue: string
backLabel: string
domain: string
}>()
pending?: boolean
statusText?: string
}>(), { pending: true, statusText: '接口待接入' })
defineEmits<{ back: [] }>()
</script>
@@ -22,10 +24,10 @@ defineEmits<{ back: [] }>()
<h1>{{ title }}</h1>
<p>{{ referenceLabel }} <strong>{{ referenceValue }}</strong></p>
</div>
<span class="overseas-detail-status" role="status">接口待接入</span>
<span class="overseas-detail-status" :class="{ 'is-live': !pending }" role="status">{{ statusText }}</span>
</header>
<ContractPendingNotice :domain="domain" />
<ContractPendingNotice v-if="pending" :domain="domain" />
<slot />
</AdminPage>
</template>
@@ -38,4 +40,5 @@ defineEmits<{ back: [] }>()
.overseas-detail-title p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.overseas-detail-title strong { color: var(--cc-text); overflow-wrap: anywhere; }
.overseas-detail-status { display: inline-flex; min-height: 26px; align-items: center; padding: 3px 10px; border: 1px solid #f3d19e; border-radius: 999px; background: #fdf6ec; color: #b88230; font-size: 12px; font-weight: 600; white-space: nowrap; }
.overseas-detail-status.is-live { border-color: var(--el-color-success-light-7); background: var(--el-color-success-light-9); color: var(--el-color-success-dark-2); }
</style>
@@ -13,6 +13,11 @@ defineProps<{
backLabel: string
domain: string
groups: DetailGroup[]
fieldValues?: Record<string, string | number>
pending?: boolean
statusText?: string
loading?: boolean
showContent?: boolean
}>()
defineEmits<{ back: [] }>()
@@ -25,12 +30,15 @@ defineEmits<{ back: [] }>()
:reference-value="referenceValue"
:back-label="backLabel"
:domain="domain"
:pending="pending"
:status-text="statusText"
@back="$emit('back')"
>
<section class="read-only-detail" :aria-label="title">
<slot name="state" />
<section v-if="showContent !== false" v-loading="loading" class="read-only-detail" :aria-label="title" :aria-busy="loading">
<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="field in group.fields" :key="field"><dt>{{ field }}</dt><dd>—</dd></div></dl>
<dl class="detail-fields"><div v-for="field in group.fields" :key="field"><dt>{{ field }}</dt><dd>{{ fieldValues?.[field] ?? '—' }}</dd></div></dl>
</section>
</section>
</OverseasDetailShell>
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => {
it('maps backend codes, associations and long ids without inventing data', () => {
const row = toLastMileOrderListItem({
id: '190000000000000099', orderNo: 'LL-001', orderType: 'ONE_CLICK_DROPSHIP', currentStatus: 'PENDING_ASSIGNMENT',
abnormal: true, detained: false, packageCount: 12, deliveryOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY',
}, '190000000000000001')
expect(row).toMatchObject({
id: '190000000000000099', customerId: '190000000000000001', orderType: '一件代发', status: '待分配',
warning: '异常', abnormal: '是', held: '否', cartons: 12, dispatchOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY',
})
expect(row.rejectedAt).toBe('—')
})
})
@@ -0,0 +1,87 @@
import type { LastMileOrderSummaryDto } from './api/lastMileOrders'
import type { LastMileOrderListItem } from './types'
const orderTypeLabels: Record<string, string> = {
ONE_CLICK_DROPSHIP: '一件代发',
FIRST_LEG: '头程',
REJECTED: '拒收订单',
EXPRESS: '快递订单',
}
const statusLabels: Record<string, string> = {
PENDING_ASSIGNMENT: '待分配',
DESTINATION_ASSIGNED: '已分配目的仓',
INBOUND_PLANNED: '已生成入库计划',
PENDING_PROCESSING: '待处理',
PROCESSING: '处理中',
COMPLETED: '已完成',
CANCELLED: '已取消',
}
function text(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized || '—'
}
function count(value: number | null | undefined) {
return Number.isFinite(value) ? Number(value) : 0
}
function yesNo(value: boolean | null | undefined) {
return value == null ? '—' : value ? '是' : '否'
}
export function lastMileStatusLabel(value: unknown) {
const code = String(value ?? '').trim()
return statusLabels[code] || text(code)
}
export function lastMileOrderTypeLabel(value: unknown) {
const code = String(value ?? '').trim()
return orderTypeLabels[code] || text(code)
}
export function formatOverseasDateTime(value: unknown) {
const raw = String(value ?? '').trim()
if (!raw) return '—'
const date = new Date(raw)
return Number.isNaN(date.getTime()) ? raw : date.toLocaleString('zh-CN', { hour12: false })
}
export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto, customerId: string): LastMileOrderListItem {
return {
id: String(dto.id),
customerId,
orderNo: text(dto.orderNo),
warning: dto.abnormal ? '异常' : '—',
orderType: lastMileOrderTypeLabel(dto.orderType),
rejectedAt: '—',
inboundCode: text(dto.inboundCode),
owner: text(dto.customerName),
warehouse: text(dto.overseasWarehouseName),
transferWarehouse: text(dto.transferWarehouseName),
fbaWarehouse: text(dto.fbaWarehouseCode),
containerNo: text(dto.containerNo),
containerStatus: text(dto.containerStatus),
abnormal: yesNo(dto.abnormal),
held: yesNo(dto.detained),
pallets: count(dto.palletCount),
cartons: count(dto.packageCount),
volume: count(dto.volumeCbm),
quantity: count(dto.productQuantity),
volumeWeight: count(dto.volumeWeightKg),
portEta: formatOverseasDateTime(dto.estimatedArrivalAt),
portAta: formatOverseasDateTime(dto.actualArrivalAt),
warehouseEta: formatOverseasDateTime(dto.estimatedWarehouseAt),
inboundOrderNo: text(dto.inboundOrderNo),
outboundOrderNo: text(dto.outboundOrderNo),
dispatchOrderNo: text(dto.deliveryOrderNo),
relabelOrderNo: text(dto.relabelOrderNo),
relabelStatus: lastMileStatusLabel(dto.relabelStatus),
consolidated: text(dto.palletMergeFlag),
status: lastMileStatusLabel(dto.currentStatus),
internalRemark: text(dto.privateRemark),
customerRemark: text(dto.customerRemark),
warehouseRemark: text(dto.warehouseRemark),
}
}
@@ -1,76 +1,146 @@
<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 { getLastMileOrder, type LastMileOrderDetailDto } from '../api/lastMileOrders'
import { formatOverseasDateTime, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
interface DetailField { label: string; value: string | number }
interface DocumentRow { type: string; number: string; createdAt: string; quote: string }
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
interface LogRow { id: string; occurredAt: string; node: string; operator: string; result: string }
const route = useRoute()
const router = useRouter()
const orderId = computed(() => String(route.params.orderId || '—'))
const orderId = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<LastMileOrderDetailDto | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
const referenceValue = computed(() => text(detail.value?.order.orderNo || orderId.value))
const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order
return [
{ label: '订单类型', value: lastMileOrderTypeLabel(order?.orderType) },
{ label: '拒收时间', value: '—' },
{ label: '实际入库时间', value: '—' },
{ label: '头程单号', value: text(order?.sourceOrderNo) },
{ label: '货主', value: text(order?.customerName) },
{ label: '海外仓', value: text(order?.overseasWarehouseName) },
{ label: '转仓目的仓', value: text(order?.transferWarehouseName) },
{ label: 'FBA仓库', value: text(order?.fbaWarehouseCode) },
{ label: '集装箱号', value: text(order?.containerNo) },
{ label: '货柜异常', value: yesNo(order?.abnormal) },
{ label: '异常原因', value: order?.abnormal ? text(order.subOrderStatus) : '—' },
{ label: '运输方式', value: '—' },
{ label: '预计到仓', value: formatOverseasDateTime(order?.estimatedWarehouseAt) },
{ label: '清关状态', value: text(order?.containerStatus) },
]
})
const detailFields = computed<DetailField[]>(() => {
const order = detail.value?.order
return [
{ label: '订单类型', value: lastMileOrderTypeLabel(order?.orderType) },
{ label: '入仓码', value: text(order?.inboundCode) },
{ label: '目的仓', value: text(order?.transferWarehouseName) },
{ label: '托数', value: Number(order?.palletCount || 0) },
{ label: '箱数', value: Number(order?.packageCount || 0) },
{ label: '数量', value: Number(order?.productQuantity || 0) },
{ label: '体积重量', value: Number(order?.volumeWeightKg || 0) },
{ label: '预计到港时间', value: formatOverseasDateTime(order?.estimatedArrivalAt) },
{ label: '实际到港时间', value: formatOverseasDateTime(order?.actualArrivalAt) },
{ label: '预计到仓', value: formatOverseasDateTime(order?.estimatedWarehouseAt) },
{ label: '是否暂扣', value: yesNo(order?.detained) },
{ label: '备注(仅自己)', value: text(order?.privateRemark) },
{ label: '备注(客户)', value: text(order?.customerRemark) },
{ label: '备注(仓库)', value: text(order?.warehouseRemark) },
]
})
const planFields = computed<DetailField[]>(() => {
const order = detail.value?.order
return [
{ label: '入仓码', value: text(order?.inboundCode) },
{ label: '计划入库仓', value: text(order?.transferWarehouseName || order?.overseasWarehouseName) },
{ label: '合托批次', value: text(order?.palletMergeFlag) },
{ label: '合托后托数', value: Number(order?.palletCount || 0) },
{ label: 'FBA预约号', value: '—' },
{ label: '预约状态', value: '—' },
{ label: '换标状态', value: lastMileStatusLabel(order?.relabelStatus) },
]
})
const documents = computed<DocumentRow[]>(() => {
const order = detail.value?.order
if (!order) return []
return [
['入库单', order.inboundOrderNo],
['出库单(只读关联)', order.outboundOrderNo],
['派送单(只读关联)', order.deliveryOrderNo],
['换标单', order.relabelOrderNo],
].filter((item) => item[1]).map(([type, number]) => ({ type: String(type), number: String(number), createdAt: '—', quote: '—' }))
})
const cargo = computed<CargoRow[]>(() => (detail.value?.cargo || []).map((item) => ({
id: String(item.id), sku: text(item.skuCode), productName: text(item.productNameZh), pallets: Number(item.palletCount || 0),
cartons: Number(item.packageCount || 0), quantity: Number(item.productQuantity || 0), weight: Number(item.grossWeightKg || 0), volume: Number(item.volumeCbm || 0),
})))
const logs = computed<LogRow[]>(() => (detail.value?.statusHistory || []).map((item) => ({
id: String(item.id), occurredAt: formatOverseasDateTime(item.createdAt), node: lastMileStatusLabel(item.toStatus), operator: '—',
result: text(item.remark || `${lastMileStatusLabel(item.fromStatus)} → ${lastMileStatusLabel(item.toStatus)}`),
})))
const orderFields = ['订单类型', '拒收时间', '实际入库时间', '头程单号', '货主', '海外仓', '转仓目的仓', 'FBA仓库', '集装箱号', '货柜异常', '异常原因', '运输方式', '预计到仓', '清关状态']
const detailFields = ['订单类型', '入仓码', '目的仓', '托数', '箱数', '数量', '体积重量', '预计到港时间', '实际到港时间', '预计到仓', '是否暂扣', '备注(仅自己)', '备注(客户)', '备注(仓库)']
const planFields = ['入仓码', '计划入库仓', '合托批次', '合托后托数', 'FBA预约号', '预约状态', '换标状态']
const documentColumns: DataTableColumn[] = [
{ key: 'type', label: '单据类型', minWidth: 120 },
{ key: 'number', label: '单据编号', minWidth: 180 },
{ key: 'createdAt', label: '生成时间', minWidth: 160 },
{ key: 'quote', label: '报价', minWidth: 140 },
{ key: 'type', prop: 'type', label: '单据类型', minWidth: 120 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 180 },
{ key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 160 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 140 },
]
const cargoColumns: DataTableColumn[] = [
{ key: 'sku', label: 'SKU', minWidth: 150 },
{ key: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'pallets', label: '托数', width: 90, align: 'right' },
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
{ key: 'quantity', label: '件数', width: 90, align: 'right' },
{ key: 'weight', label: '重量(kg)', width: 110, align: 'right' },
{ key: 'volume', label: '体积(m³)', width: 110, align: 'right' },
{ key: 'sku', prop: 'sku', label: 'SKU', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'pallets', prop: 'pallets', label: '托数', width: 90, align: 'right' }, { key: 'cartons', prop: 'cartons', label: '箱数', width: 90, align: 'right' },
{ key: 'quantity', prop: 'quantity', label: '件数', width: 90, align: 'right' }, { key: 'weight', prop: 'weight', label: '重量(kg)', width: 110, align: 'right' },
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 110, align: 'right' },
]
const logColumns: DataTableColumn[] = [
{ key: 'occurredAt', label: '时间', minWidth: 160 },
{ key: 'node', label: '操作节点', minWidth: 140 },
{ key: 'operator', label: '操作人', minWidth: 130 },
{ key: 'result', label: '处理结果', minWidth: 220 },
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 160 }, { key: 'node', prop: 'node', label: '操作节点', minWidth: 140 },
{ key: 'operator', prop: 'operator', label: '操作人', minWidth: 130 }, { key: 'result', prop: 'result', label: '处理结果', minWidth: 220 },
]
function back() {
void router.push({ name: 'overseas-last-mile-orders' })
async function load() {
if (!orderId.value || !customerId.value) {
loadError.value = { message: '详情缺少客户范围,请从尾程订单列表重新进入' }
return
}
loading.value = true
loadError.value = null
try { detail.value = await getLastMileOrder(orderId.value, customerId.value) }
catch (error) { loadError.value = getApiErrorDetails(error, '尾程订单详情加载失败,请重试') }
finally { loading.value = false }
}
function back() { void router.push({ name: 'overseas-last-mile-orders' }) }
onMounted(load)
</script>
<template>
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="orderId" back-label="返回尾程订单" domain="尾程订单详情" @back="back">
<div class="detail-grid">
<section class="detail-section" aria-labelledby="last-mile-order-heading">
<h2 id="last-mile-order-heading">订单信息</h2>
<dl class="detail-fields"><div v-for="label in orderFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
</section>
<section class="detail-section" aria-labelledby="last-mile-lines-heading">
<h2 id="last-mile-lines-heading">订单明细</h2>
<dl class="detail-fields"><div v-for="label in detailFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
</section>
<section class="detail-section" aria-labelledby="last-mile-plan-heading">
<h2 id="last-mile-plan-heading">计划信息</h2>
<dl class="detail-fields"><div v-for="label in planFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
</section>
<section class="detail-section table-section" aria-labelledby="last-mile-documents-heading">
<h2 id="last-mile-documents-heading">关联单据</h2>
<DataTable :data="[]" :columns="documentColumns" column-settings-mode="hide" empty-text="海外区域关联单据契约待接入" />
</section>
<section class="detail-section table-section is-wide" aria-labelledby="last-mile-cargo-heading">
<h2 id="last-mile-cargo-heading">货物信息</h2>
<DataTable :data="[]" :columns="cargoColumns" column-settings-mode="hide" empty-text="海外区域货物明细契约待接入" />
</section>
<section class="detail-section table-section" aria-labelledby="last-mile-logs-heading">
<h2 id="last-mile-logs-heading">操作记录</h2>
<DataTable :data="[]" :columns="logColumns" column-settings-mode="hide" empty-text="海外区域操作记录契约待接入" />
</section>
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="detail-grid" :aria-busy="loading">
<section class="detail-section" aria-labelledby="last-mile-order-heading"><h2 id="last-mile-order-heading">订单信息</h2><dl class="detail-fields"><div v-for="field in orderFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section" aria-labelledby="last-mile-lines-heading"><h2 id="last-mile-lines-heading">订单明细</h2><dl class="detail-fields"><div v-for="field in detailFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section" aria-labelledby="last-mile-plan-heading"><h2 id="last-mile-plan-heading">计划信息</h2><dl class="detail-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section table-section" aria-labelledby="last-mile-documents-heading"><h2 id="last-mile-documents-heading">关联单据</h2><DataTable :data="documents" :columns="documentColumns" column-settings-mode="hide" empty-text="暂无关联单据" /></section>
<section class="detail-section table-section is-wide" aria-labelledby="last-mile-cargo-heading"><h2 id="last-mile-cargo-heading">货物信息</h2><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" empty-text="暂无货物明细" /></section>
<section class="detail-section table-section" aria-labelledby="last-mile-logs-heading"><h2 id="last-mile-logs-heading">操作记录</h2><DataTable :data="logs" :columns="logColumns" column-settings-mode="hide" row-key="id" empty-text="暂无操作记录" /></section>
</div>
</OverseasDetailShell>
</template>
<style scoped>
.detail-grid { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); background: #fff; }
.detail-grid { display: grid; min-width: 0; min-height: 240px; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); background: #fff; }
.detail-section { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-section:nth-child(even) { border-right: 0; }
.detail-section.is-wide { grid-column: 1 / -1; border-right: 0; }
@@ -1,32 +1,48 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { Eye } from '@lucide/vue'
import { 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 ContractPendingNotice from '../components/ContractPendingNotice.vue'
import { listLastMileOrders } from '../api/lastMileOrders'
import { toLastMileOrderListItem } from '../lastMilePresentation'
import type { LastMileOrderListItem } from '../types'
const rows = ref<LastMileOrderListItem[]>([])
const customers = ref<PlatformTenantResource[]>([])
const loading = ref(false)
const customersLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const customersError = ref<ApiErrorDetails | null>(null)
const router = useRouter()
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const statusTab = ref<'全部' | '待分配'>('全部')
const filters = ref<QueryModel>({ orderNo: '', inboundCode: '', warehouse: '', fbaWarehouse: '', orderType: '', owner: '', containerNo: '', status: '', consolidated: '', portEta: [] })
const fields: QueryField[] = [
const filters = ref<QueryModel>({ customerId: '', orderNo: '', inboundCode: '', warehouse: '', fbaWarehouse: '', orderType: '', owner: '', containerNo: '', status: '', consolidated: '', portEta: [] })
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户名称', width: 240, placeholder: '请选择客户', props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
{ key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' },
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [] },
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '全部FBA仓库', options: [] },
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '全部订单类型', options: ['一件代发', '头程', '拒收订单', '快递订单'].map((value) => ({ label: value, value })) },
{ key: 'owner', type: 'input', label: '货主', placeholder: '请输入货主' },
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '接口暂不支持', options: [], props: { disabled: true } },
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '接口暂不支持', options: [], props: { disabled: true } },
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '接口暂不支持', options: ['一件代发', '头程', '拒收订单', '快递订单'].map((value) => ({ label: value, value, disabled: true })), props: { disabled: true } },
{ key: 'owner', type: 'input', label: '货主', placeholder: '请通过客户名称选择', props: { disabled: true } },
{ key: 'containerNo', type: 'input', label: '集装箱号', placeholder: '请输入集装箱号' },
{ key: 'status', type: 'select', label: '当前状态', placeholder: '全部状态', options: ['待入库计划', '待清关', '清关完成', '已合托', '待预约', '预约中', '预约成功'].map((value) => ({ label: value, value })) },
{ key: 'consolidated', type: 'select', label: '是否合托', placeholder: '全部', options: [{ label: '是', value: 'yes' }, { label: '否', value: 'no' }] },
{ key: 'portEta', type: 'daterange', label: '预计到港时间' },
]
{ key: 'status', type: 'select', label: '当前状态', placeholder: '全部状态', options: [
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
{ label: '已分配目的仓', value: 'DESTINATION_ASSIGNED' },
{ label: '已生成入库计划', value: 'INBOUND_PLANNED' },
] },
{ key: 'consolidated', type: 'select', label: '是否合托', placeholder: '接口暂不支持', options: [{ label: '是', value: 'yes', disabled: true }, { label: '否', value: 'no', disabled: true }], props: { disabled: true } },
{ key: 'portEta', type: 'daterange', label: '预计到港时间', props: { disabled: true, startPlaceholder: '接口暂不支持', endPlaceholder: '接口暂不支持' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
@@ -62,27 +78,90 @@ const columns: DataTableColumn[] = [
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
]
async function loadCustomers() {
customersLoading.value = true
customersError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} 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
}
loading.value = true
loadError.value = null
try {
const result = await listLastMileOrders({
customerId,
page: page.value,
size: pageSize.value,
orderNo: String(filters.value.orderNo || '').trim() || undefined,
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
containerNo: String(filters.value.containerNo || '').trim() || undefined,
status: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined,
})
rows.value = result.records.map((item) => toLastMileOrderListItem(item, customerId))
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 changeStatusTab() { page.value = 1; void load() }
function openDetail(source: object) {
const row = source as LastMileOrderListItem
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.orderNo } })
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id }, query: { customerId: row.customerId } })
}
onMounted(loadCustomers)
</script>
<template>
<AdminPage><ListPageLayout aria-label="尾程订单列表">
<template #toolbar>
<section class="surface-panel last-mile-toolbar" aria-label="尾程订单状态与筛选">
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="page = 1">
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
<el-radio-button value="全部">全部</el-radio-button>
<el-radio-button value="待分配">待分配</el-radio-button>
</el-radio-group>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="page = 1" />
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="search" @reset="reset" />
</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-last-mile-orders" row-key="orderNo" empty-text="海外区域尾程订单查询契约待接入">
<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-last-mile-orders" row-key="id" :empty-text="filters.customerId ? '暂无尾程订单' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #pagination-left>共 0 条尾程订单</template>
<template #pagination-left>共 {{ total }} 条尾程订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
@@ -1,18 +1,60 @@
<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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getRelabelOrder, type RelabelDetailDto } from '../api/relabelOrders'
import { formatOverseasDateTime } from '../lastMilePresentation'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
const route = useRoute()
const router = useRouter()
const orderId = computed(() => String(route.params.orderId || '—'))
const orderId = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<RelabelDetailDto | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const groups = [
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '创建时间', '更新时间'] },
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '预算总价'] },
{ title: '作业要求', fields: ['贴标要求', '上传文件', '备注'] },
{ title: '费用明细', fields: ['开箱费', '挑货费', '换标费', '换箱唛标费', '换标费总计'] },
]
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function status(value: unknown) {
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
return labels[String(value || '')] || text(value)
}
const referenceValue = computed(() => text(detail.value?.application.relabelNo || orderId.value))
const fieldValues = computed<Record<string, string | number>>(() => {
const value = detail.value
const items = value?.items || []
return {
换标单号: text(value?.application.relabelNo),
关联订单: text(value?.application.lastlegOrderNo || value?.application.customerOrderNo),
状态: status(value?.application.status),
创建时间: formatOverseasDateTime(value?.application.createdAt),
更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—',
商品SKU: items.map((item) => text(item.skuCode)).join('、') || '—',
中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—',
箱数: Number(value?.application.totalPackageCount || 0),
预算总价: '—',
贴标要求: items.map((item) => text(item.labelRequirement)).join(';') || '—',
上传文件: items.map((item) => text(item.relabelFileName)).filter((item) => item !== '—').join('、') || '—',
备注: text(value?.remark),
开箱费: '—', 挑货费: '—', 换标费: '—', 换箱唛标费: '—', 换标费总计: '—',
}
})
async function load() {
if (!orderId.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从换标单列表重新进入' }; return }
loading.value = true; loadError.value = null
try { detail.value = await getRelabelOrder(orderId.value, customerId.value) }
catch (error) { loadError.value = getApiErrorDetails(error, '换标单详情加载失败,请重试') }
finally { loading.value = false }
}
function back() { void router.push({ name: 'overseas-relabel-orders' }) }
onMounted(load)
</script>
<template><OverseasReadOnlyDetail title="换标单详情" reference-label="换标单号" :reference-value="orderId" back-label="返回换标单" domain="换标单详情" :groups="groups" @back="back" /></template>
<template><OverseasReadOnlyDetail title="换标单详情" reference-label="换标单号" :reference-value="referenceValue" back-label="返回换标单" domain="换标单详情" :groups="groups" :field-values="fieldValues" :pending="false" :status-text="detail ? status(detail.application.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back"><template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></OverseasReadOnlyDetail></template>
@@ -1,27 +1,38 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, Plus, Upload } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
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 ContractPendingNotice from '../components/ContractPendingNotice.vue'
import ContractPendingFormDialog from '../components/ContractPendingFormDialog.vue'
import { listRelabelOrders } from '../api/relabelOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { RelabelOrderListItem } from '../types'
const access = useAccessPreviewStore()
const router = useRouter()
const rows = ref<RelabelOrderListItem[]>([])
const customers = ref<PlatformTenantResource[]>([])
const loading = ref(false)
const customersLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const customersError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ sku: '', status: '' })
const fields: QueryField[] = [
const total = ref(0)
const filters = ref<QueryModel>({ customerId: '', sku: '', status: '' })
const fields = computed<QueryField[]>(() => [
{ 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: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: ['待处理', '处理中', '已完成'].map((value) => ({ label: value, value })) },
]
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', label: '换标单号', minWidth: 170 },
@@ -48,18 +59,50 @@ function openForm(mode: 'create' | 'edit', source?: object) {
function openDetail(source: object) {
const row = source as RelabelOrderListItem
void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) } })
void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) }, query: { customerId: row.customerId } })
}
function relabelStatus(value: unknown) {
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
return labels[String(value || '')] || String(value || '—')
}
async function loadCustomers() {
customersLoading.value = true; customersError.value = null
try {
const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1
do { const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages)
customers.value = records
} 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 }
loading.value = true; loadError.value = null
try {
const result = await listRelabelOrders({ customerId, page: page.value, size: pageSize.value, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
rows.value = result.records.map((item) => ({
customerId, orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), 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) }
onMounted(loadCustomers)
</script>
<template>
<AdminPage><ListPageLayout aria-label="换标单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-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-relabel-orders" row-key="orderNo" empty-text="海外区域换标单查询契约待接入">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-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>
<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 #pagination-left>共 0 条换标单</template>
<template #pagination-left>共 {{ total }} 条换标单</template>
</DataTable></section>
</ListPageLayout>
<ContractPendingFormDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" domain="换标单" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'">
@@ -104,8 +104,13 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).not.toContain('海外派送')
})
it('shows contract-pending empty states instead of demo data or fake success actions', () => {
for (const fileName of ['LastMileOrderDetailView.vue', 'InboundOrderDetailView.vue', 'InventoryDetailView.vue']) {
it('connects the tail-leg detail to the real overseas API and keeps missing contracts pending', () => {
const tailLeg = source('LastMileOrderDetailView.vue')
expect(tailLeg).toContain('getLastMileOrder')
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('契约待接入')
+4 -1
View File
@@ -4,6 +4,8 @@
* identifiers, timestamps, money precision, or mutation request contracts.
*/
export interface LastMileOrderListItem {
id: string
customerId: string
orderNo: string
warning: string
orderType: string
@@ -71,12 +73,13 @@ export interface InventoryListItem {
}
export interface RelabelOrderListItem {
customerId: string
orderNo: string
relatedOrderNo: string
sku: string
productName: string
cartons: number
budgetTotal: number
budgetTotal: number | null
labelRequirement: string
relabelCost?: number
fileName: string