Merge pull request 'Release internal fleet capacity pool to gray' (#12) from release/test into main

This commit was merged in pull request #12.
This commit is contained in:
2026-10-08 08:13:46 +08:00
4 changed files with 89 additions and 6 deletions
@@ -75,10 +75,11 @@ export interface ProcurementCreate {
} }
export interface TransportSummary { export interface TransportSummary {
fleetArrangement: import('../fleetArrangement').FleetArrangement | null
orderNo: string orderNo: string
transportType: string transportType: string
transportMode?: string transportMode?: string
palletRequired?: string palletRequired?: string | null
origin: string origin: string
destination: string destination: string
containerType: string | null containerType: string | null
@@ -1,11 +1,38 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { runInNewContext } from 'node:vm'
import ts from 'typescript'
const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8') const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8')
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8') const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8')
test('renders unknown pallet service and container type as dashes while preserving explicit values', () => {
const helpers = view.slice(view.indexOf('function read('), view.indexOf('function idempotencyKey('))
const compiled = ts.transpileModule(helpers, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const { displayCell, yesNo, text } = runInNewContext(`${compiled}\n({ displayCell, yesNo, text })`)
assert.equal(displayCell({ palletRequired: null }, 'palletRequired'), '—')
assert.equal(displayCell({ palletRequired: 'NO' }, 'palletRequired'), '否')
assert.equal(displayCell({ palletRequired: 'YES' }, 'palletRequired'), '是')
assert.equal(displayCell({ containerType: null }, 'containerType'), '—')
assert.equal(displayCell({ containerType: '40HQ' }, 'containerType'), '40HQ')
assert.equal(yesNo(null), '—')
assert.equal(text(null), '—')
assert.match(api, /palletRequired\?: string \| null/)
const create = api.slice(api.indexOf('export interface TransportCreate'), api.indexOf('export interface TransportUpdate'))
assert.doesNotMatch(create, /palletRequired\?: string \| null/)
})
test('shows merchant-owned arrangement facts without a pool request or editable status control', () => {
assert.match(api, /fleetArrangement: import\('\.\.\/fleetArrangement'\)\.FleetArrangement \| null/)
assert.match(view, /aria-label="承运安排"/)
for (const field of ['signedPackages', 'unsignedPackages', 'returnedPackages', 'readOnlyReason']) assert.ok(view.includes(`fleetArrangement.${field}`))
assert.doesNotMatch(api, /capacity-offers|fleet\/trips/)
assert.match(view, /if \(query\.page !== 1\) query\.page = 1\s+else void load\(\)/)
assert.match(view, /if \(reloadDirectly\) void load\(\)/)
})
test('activates all three customer cross-border pages with frozen permissions', () => { test('activates all three customer cross-border pages with frozen permissions', () => {
for (const [path, permission] of [ for (const [path, permission] of [
['cross-border/procurement', 'merchant:cross-border:procurement:list'], ['cross-border/procurement', 'merchant:cross-border:procurement:list'],
@@ -18,6 +18,7 @@ import {
type TransportSummary, type TransportSummary,
} from '../api/businessOrders' } from '../api/businessOrders'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '../containerSpecifications' import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '../containerSpecifications'
import { poolOrderStatusLabels } from '../fleetArrangement'
const props = withDefaults(defineProps<{ kind: CrossBorderBusinessKind; createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false }) const props = withDefaults(defineProps<{ kind: CrossBorderBusinessKind; createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false })
const auth = useAuthStore() const auth = useAuthStore()
@@ -105,6 +106,7 @@ function transportColumns(looseboard: boolean): ColumnDefinition[] {
{ key: 'budgetAmount', label: '预算总价', width: 110 }, { key: 'budgetAmount', label: '预算总价', width: 110 },
{ key: 'requestedDate', label: '要求时间', width: 120 }, { key: 'requestedDate', label: '要求时间', width: 120 },
{ key: 'status', label: '状态', width: 130 }, { key: 'status', label: '状态', width: 130 },
{ key: 'fleetArrangement', label: '承运安排', width: 190 },
] ]
} }
@@ -115,6 +117,7 @@ const submitKey = ref('')
const error = ref('') const error = ref('')
const rows = ref<BusinessRow[]>([]) const rows = ref<BusinessRow[]>([])
const detail = ref<BusinessDetail | null>(null) const detail = ref<BusinessDetail | null>(null)
const fleetArrangement = computed(() => detail.value && props.kind !== 'procurement' ? (detail.value as TransportDetail).summary.fleetArrangement : null)
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' }) const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' })
const total = ref(0) const total = ref(0)
@@ -157,6 +160,7 @@ const queryFields = computed<QueryField[]>(() => [
{ label: '部分出库', value: 'PARTIALLY_OUTBOUND' }, { label: '部分出库', value: 'PARTIALLY_OUTBOUND' },
{ label: '全部出库', value: 'OUTBOUNDED' }, { label: '全部出库', value: 'OUTBOUNDED' },
{ label: '待接单', value: 'PENDING_ACCEPTANCE' }, { label: '待接单', value: 'PENDING_ACCEPTANCE' },
...(props.kind === 'procurement' ? [] : Object.entries(poolOrderStatusLabels).filter(([value]) => !['IN_TRANSIT', 'COMPLETED', 'CANCELLED'].includes(value)).map(([value, label]) => ({ value, label }))),
{ label: '处理中', value: 'PROCESSING' }, { label: '处理中', value: 'PROCESSING' },
{ label: '运输中', value: 'IN_TRANSIT' }, { label: '运输中', value: 'IN_TRANSIT' },
{ label: '已完成', value: 'COMPLETED' }, { label: '已完成', value: 'COMPLETED' },
@@ -198,7 +202,7 @@ const detailFields = computed(() => {
} }
const current = detail.value as TransportDetail const current = detail.value as TransportDetail
return [ return [
['运输方式', read(summary, 'transportMode')], ['运输方式', transportModeLabel(read(summary, 'transportMode'))],
['是否打盘', yesNo(read(summary, 'palletRequired'))], ['运输类型', transportTypeLabel(read(summary, 'transportType'))], ['是否打盘', yesNo(read(summary, 'palletRequired'))], ['运输类型', transportTypeLabel(read(summary, 'transportType'))],
['起运地', read(summary, 'origin')], ['起运地址', current.originAddress], ['起运地', read(summary, 'origin')], ['起运地址', current.originAddress],
['目的地', read(summary, 'destination')], ['目的地址', current.destinationAddress], ['目的地', read(summary, 'destination')], ['目的地址', current.destinationAddress],
@@ -215,7 +219,7 @@ const tableColumns = computed<DataTableColumn[]>(() => [
...configuration.value.columns.map((column) => ({ ...configuration.value.columns.map((column) => ({
...column, ...column,
prop: column.key, prop: column.key,
slotName: ['orderNo', 'status'].includes(column.key) ? column.key : undefined, slotName: ['orderNo', 'status', 'fleetArrangement'].includes(column.key) ? column.key : undefined,
formatter: (row: object) => displayCell(row, column.key), formatter: (row: object) => displayCell(row, column.key),
})), })),
]) ])
@@ -242,6 +246,7 @@ function yesNo(value: unknown) { return value === 'YES' ? '是' : value === 'NO'
function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } as Record<string, string>)[String(value)] || text(value) } function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } as Record<string, string>)[String(value)] || text(value) }
function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } as Record<string, string>)[String(value)] || text(value) } function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } as Record<string, string>)[String(value)] || text(value) }
function statusLabel(value: unknown) { function statusLabel(value: unknown) {
if (poolOrderStatusLabels[String(value)]) return poolOrderStatusLabels[String(value)]
return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value) return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
} }
function statusTone(value: unknown) { function statusTone(value: unknown) {
@@ -283,13 +288,14 @@ async function load() {
} }
function resetQuery() { function resetQuery() {
const reloadDirectly = query.page === 1
Object.assign(query, { page: 1, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' }) Object.assign(query, { page: 1, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' })
void load() if (reloadDirectly) void load()
} }
function searchOrders() { function searchOrders() {
query.page = 1 if (query.page !== 1) query.page = 1
void load() else void load()
} }
function getRowActions(candidate: object): TableAction[] { function getRowActions(candidate: object): TableAction[] {
@@ -397,6 +403,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
<template v-if="error" #error><div class="business-load-error" role="alert"><span>{{ error }}</span><el-button size="small" @click="load">重新加载</el-button></div></template> <template v-if="error" #error><div class="business-load-error" role="alert"><span>{{ error }}</span><el-button size="small" @click="load">重新加载</el-button></div></template>
<template #orderNo="{ row }"><button class="business-order-link" type="button" @click="openDetail(row)">{{ (row as BusinessRow).orderNo }}</button></template> <template #orderNo="{ row }"><button class="business-order-link" type="button" @click="openDetail(row)">{{ (row as BusinessRow).orderNo }}</button></template>
<template #status="{ row }"><el-tag :type="statusTone((row as BusinessRow).status)" effect="light" size="small">{{ statusLabel((row as BusinessRow).status) }}</el-tag></template> <template #status="{ row }"><el-tag :type="statusTone((row as BusinessRow).status)" effect="light" size="small">{{ statusLabel((row as BusinessRow).status) }}</el-tag></template>
<template #fleetArrangement="{ row }"><template v-if="(row as TransportSummary).fleetArrangement"><div>{{ poolOrderStatusLabels[(row as TransportSummary).fleetArrangement!.fulfillmentStage] || '待核对履约进度' }}</div><small>{{ dateTime((row as TransportSummary).fleetArrangement!.plannedDepartureAt) }}</small></template><span v-else>—</span></template>
</DataTable> </DataTable>
</div> </div>
</template> </template>
@@ -413,6 +420,24 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
<div v-for="field in detailFields" :key="String(field[0])" :class="detailFieldClass(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div> <div v-for="field in detailFields" :key="String(field[0])" :class="detailFieldClass(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div>
</dl> </dl>
</section> </section>
<section v-if="!error && fleetArrangement" class="business-detail-section" aria-label="承运安排">
<h3>承运安排</h3><p>{{ fleetArrangement.readOnlyReason }}</p>
<dl class="detail-grid arrangement-grid">
<div><dt>预订号</dt><dd>{{ fleetArrangement.bookingNo }}</dd></div>
<div><dt>计划发车</dt><dd>{{ dateTime(fleetArrangement.plannedDepartureAt) }}</dd></div>
<div><dt>起运仓</dt><dd>{{ text(fleetArrangement.originWarehouseName) }}</dd></div>
<div><dt>目的地</dt><dd>{{ text(fleetArrangement.destinationLabel) }}</dd></div>
<div><dt>交货方式</dt><dd>{{ fleetArrangement.deliveryMode === 'SELF_DELIVERY' ? '自行送至起运仓' : '线下安排提货至起运仓' }}</dd></div>
<div><dt>履约进度</dt><dd>{{ poolOrderStatusLabels[fleetArrangement.fulfillmentStage] || '待核对履约进度' }}</dd></div>
<div><dt>更新时间</dt><dd>{{ dateTime(fleetArrangement.stageOccurredAt) }}</dd></div>
<div><dt>约定运费(非实收)</dt><dd>{{ money(fleetArrangement.agreedFreightAmount, fleetArrangement.currency) }}</dd></div>
<div><dt>来货单号</dt><dd>{{ text(fleetArrangement.incomingNo) }}</dd></div>
<div><dt>派送单号</dt><dd>{{ text(fleetArrangement.deliveryNo) }}</dd></div>
<div><dt>已签 / 未签 / 退回箱数</dt><dd>{{ fleetArrangement.signedPackages ?? '—' }} / {{ fleetArrangement.unsignedPackages ?? '—' }} / {{ fleetArrangement.returnedPackages ?? '—' }}</dd></div>
<div class="is-wide"><dt>交货说明</dt><dd>{{ text(fleetArrangement.deliveryInstructions) }}</dd></div>
<div v-if="fleetArrangement.exceptionSummary" class="is-wide"><dt>履约说明</dt><dd>{{ fleetArrangement.exceptionSummary }}</dd></div>
</dl>
</section>
</section> </section>
<el-card v-else shadow="never" class="form-card"> <el-card v-else shadow="never" class="form-card">
@@ -472,6 +497,8 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
.detail-grid div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); } .detail-grid div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.detail-grid div.is-wide { grid-column: span 2; } .detail-grid div.is-wide { grid-column: span 2; }
.detail-grid dt { color: var(--muted); font-size: 10px; white-space: nowrap; } .detail-grid dt { color: var(--muted); font-size: 10px; white-space: nowrap; }
.arrangement-grid div { grid-template-columns: 124px minmax(0, 1fr); }
.arrangement-grid dt { white-space: normal; overflow-wrap: anywhere; }
.detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 600; line-height: 1.4; } .detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 600; line-height: 1.4; }
.form-card :deep(.el-form) { max-width: 860px; } .form-card :deep(.el-form) { max-width: 860px; }
.form-card :deep(.el-input), .form-card :deep(.el-textarea), .form-card :deep(.el-select) { width: 100%; } .form-card :deep(.el-input), .form-card :deep(.el-textarea), .form-card :deep(.el-select) { width: 100%; }
@@ -0,0 +1,28 @@
export interface FleetArrangement {
sourceType: 'FLEET_CAPACITY_OFFER'
businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK'
businessOrderNo: string
bookingNo: string
tripNo: string
plannedDepartureAt: string | null
originWarehouseName: string
destinationLabel: string
deliveryMode: 'SELF_DELIVERY' | 'MANUAL_PICKUP_TO_WAREHOUSE'
deliveryInstructions: string | null
fulfillmentStage: string
stageOccurredAt: string | null
agreedFreightAmount: number | null
currency: string | null
incomingNo: string | null
dispatchNo: string | null
deliveryNo: string | null
signedPackages: number | null
unsignedPackages: number | null
returnedPackages: number | null
exceptionSummary: string | null
readOnlyReason: string
}
export const poolOrderStatusLabels: Record<string, string> = {
WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托',
PENDING_DISPATCH: '待派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消',
}