优化
This commit is contained in:
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user