fix(first-leg): show cancelled orders as terminal details

This commit is contained in:
2026-10-02 22:26:42 +08:00
parent dae119a1e8
commit 187deb5047
8 changed files with 53 additions and 18 deletions
+1
View File
@@ -92,6 +92,7 @@ export const FIRST_LEG_ORDER_STATUSES = [
{ code: 'LOADED', label: '装柜完成' },
{ code: 'DELIVERED', label: '派送完成' },
{ code: 'COMPLETED', label: '已完成' },
{ code: 'CANCELLED', label: '已取消' },
] as const
export type FirstLegOrderStatus = (typeof FIRST_LEG_ORDER_STATUSES)[number]['code']
@@ -26,14 +26,15 @@ function stageState(index: number) {
</div>
</div>
<ol class="progress-stages" aria-label="订单主状态阶段">
<p v-if="order.status === 'CANCELLED'" class="draft-progress-note">订单已取消,不再继续履约。</p>
<ol v-else class="progress-stages" aria-label="订单主状态阶段">
<li v-for="(stage, index) in FIRST_LEG_PROGRESS_STAGES" :key="stage.code" :class="stageState(index)" :aria-current="index === currentIndex ? 'step' : undefined">
<span class="stage-marker" aria-hidden="true"><Check v-if="index < currentIndex" :size="13" /><CircleDot v-else :size="13" /></span>
<span class="stage-label">{{ stage.label }}</span>
</li>
</ol>
<p v-if="order.status === 'DRAFT'" class="draft-progress-note">订单提交后开始记录主状态进度。</p>
<div class="progress-cabinet-detail"><slot name="cabinet" /></div>
<div v-if="order.status !== 'CANCELLED'" class="progress-cabinet-detail"><slot name="cabinet" /></div>
</section>
</template>
@@ -28,7 +28,10 @@ function harness(t) {
const module = compile('./firstLegOrders.ts', {
vue, pinia,
'@/modules/first-leg/api/firstLeg': api,
'@/modules/first-leg/api/unifiedOrders': {},
'@/modules/first-leg/api/unifiedOrders': {
getMerchantUnifiedOrder: async () => ({ summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse' } }, timeline: [] }),
isUnifiedOrderQueryUnavailable: () => false,
},
'@/modules/first-leg/utils/firstLegOrder': { canClientEditFirstLegOrder: status => status === 'DRAFT', getFirstLegStatusLabel: value => value },
'@/modules/first-leg/utils/warehousePagination': {},
})
@@ -38,6 +41,28 @@ function harness(t) {
return { store, api, calls, behavior, draft }
}
test('raw and unified cancelled detail remain CANCELLED even when unified mainStatus is null', async t => {
const h = harness(t)
h.behavior.status = 'CANCELLED'
const raw = await h.store.loadDraftDetail('one')
assert.equal(raw.status, 'CANCELLED')
const unified = await h.store.loadDetail('one')
assert.equal(unified.status, 'CANCELLED')
h.behavior.status = 'APPROVED'
const unknown = await h.store.loadDetail('one')
assert.notEqual(unknown.status, 'CANCELLED', 'unknown summary must not be reclassified as cancellation')
})
test('cancelled detail summary has no pending overseas synchronization entries', () => {
const source = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8')
const script = source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const ast = ts.createSourceFile('view.ts', script, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
const declaration = ast.statements.flatMap(node => ts.isVariableStatement(node) ? [...node.declarationList.declarations] : []).find(node => node.name.getText(ast) === 'progressSummaryItems')
const code = ts.transpileModule('const progressSummaryItems = ' + declaration.initializer.getText(ast), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const summary = new Function('computed', 'detailOrder', code + '; return progressSummaryItems.value')(vue.computed, { value: { status: 'CANCELLED' } })
assert.deepEqual(summary, [])
})
test('edit freezes detail version rather than cached list version and replays same key/body after uncertain result', async t => {
const h = harness(t)
const original = await h.store.loadDraftDetail('one')
@@ -47,7 +47,7 @@ function clientStatus(status: ApiFirstLegStatus): FirstLegOrder['status'] {
if (status === 'REJECTED') return 'PENDING_REVIEW'
if (status === 'APPROVED' || status === 'ACCEPTED' || status === 'PROCESSING') return 'WAIT_MEASURE'
if (status === 'COMPLETED') return 'COMPLETED'
if (status === 'CANCELLED') return 'PENDING_REVIEW'
if (status === 'CANCELLED') return 'CANCELLED'
return 'PENDING_REVIEW'
}
@@ -384,7 +384,7 @@ function mapUnifiedOrderDetail(detail: MerchantUnifiedOrderDetail, warehouses: P
if (!detail?.summary || !detail.order?.order) throw new Error('订单详情数据不完整,请刷新列表后重试')
const mapped = mapOrder(detail.order.order, warehouses, detail.order)
mapped.id = String(detail.summary.orderId)
mapped.status = detail.summary.mainStatus
mapped.status = detail.order.order.status === 'CANCELLED' ? 'CANCELLED' : detail.summary.mainStatus
mapped.fulfillmentType = detail.summary.fulfillmentType ?? mapped.fulfillmentType
mapped.lxCode = detail.summary.lxCode ?? mapped.lxCode
mapped.businessSummary = businessSummary(detail.summary)
@@ -60,6 +60,12 @@ function validDraft() {
}
test('uses the confirmed status and transport labels', () => {
assert.equal(getFirstLegStatusLabel('CANCELLED'), '已取消')
assert.equal(getFirstLegStatusLabel('NOT_A_STATUS'), '未知状态')
assert.equal(getFirstLegStatusTone('CANCELLED'), 'info')
assert.equal(getFirstLegProgressIndex('CANCELLED'), -1)
assert.equal(canClientEditFirstLegOrder('CANCELLED'), false)
assert.equal(canClientCancelFirstLegOrder('CANCELLED'), false)
assert.equal(getFirstLegStatusLabel('WAIT_MEASURE'), '待测量数据')
assert.equal(getFirstLegStatusLabel('WAIT_PALLETIZE'), '待组托')
assert.equal(getFirstLegStatusLabel('WAIT_SPLIT'), '待分柜')
+2 -2
View File
@@ -41,11 +41,11 @@ export function canClientCancelFirstLegOrder(status: FirstLegOrderStatus) {
}
export const FIRST_LEG_PROGRESS_STAGES = FIRST_LEG_ORDER_STATUSES.filter(
(item): item is (typeof FIRST_LEG_ORDER_STATUSES)[number] & { code: Exclude<FirstLegOrderStatus, 'DRAFT'> } => item.code !== 'DRAFT',
(item): item is (typeof FIRST_LEG_ORDER_STATUSES)[number] & { code: Exclude<FirstLegOrderStatus, 'DRAFT' | 'CANCELLED'> } => item.code !== 'DRAFT' && item.code !== 'CANCELLED',
)
export function getFirstLegProgressIndex(status: FirstLegOrderStatus) {
if (status === 'DRAFT') return -1
if (status === 'DRAFT' || status === 'CANCELLED') return -1
return FIRST_LEG_PROGRESS_STAGES.findIndex((item) => item.code === status)
}
@@ -245,7 +245,7 @@ test('uses only fields present on C02 rows before loading full detail', () => {
assert.doesNotMatch(source, /key: 'operator'/)
assert.doesNotMatch(source, /key: 'transportMode', type: 'select'/)
assert.match(source, /store\.draftLoadError/)
assert.match(source, /mainStatus: statusValue && statusValue !== 'DRAFT' \? statusValue : undefined/)
assert.match(source, /mainStatus: statusValue && statusValue !== 'DRAFT' && statusValue !== 'CANCELLED' \? statusValue : undefined/)
assert.match(source, /draftOnly: statusValue === 'DRAFT'/)
assert.match(source, /createdFrom: range\[0\] \? `\$\{range\[0\]\}T00:00:00`/)
})
@@ -137,7 +137,7 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'receivingWarehouseId', label: '收货仓', type: 'select', placeholder: '全部', options: availableWarehouses.value.map((item) => ({ label: item.name, value: item.id })), props: { 'aria-label': '收货仓' } },
{ key: 'channelName', label: '渠道名称', type: 'select', placeholder: '全部', options: [...new Set(store.orders.map((item) => item.channelName).filter(Boolean))].map((name) => ({ label: name, value: name })), props: { 'aria-label': '渠道名称' } },
{ key: 'createdRange', label: '创建时间', type: 'daterange', placeholder: '请选择创建时间', props: { 'aria-label': '创建时间' } },
{ key: 'status', label: '当前状态', type: 'select', placeholder: '全部', options: FIRST_LEG_ORDER_STATUSES.map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '当前状态' } },
{ key: 'status', label: '当前状态', type: 'select', placeholder: '全部', options: FIRST_LEG_ORDER_STATUSES.filter(item => item.code !== 'CANCELLED').map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '当前状态' } },
{ key: 'destinationType', label: '目的仓类型', type: 'select', placeholder: '全部', options: [{ label: 'FBA 仓', value: 'FBA' }, { label: '海外仓', value: 'OVERSEAS' }, { label: '私人地址', value: 'PRIVATE' }], props: { 'aria-label': '目的仓类型' } },
{ key: 'trackingNumber', label: '快递单号', type: 'input', placeholder: '请输入快递单号', props: { 'aria-label': '快递单号' } },
])
@@ -597,7 +597,7 @@ const pagedDetailCargoItems = computed(() => {
})
const progressSummaryItems = computed(() => {
const order = detailOrder.value
if (!order) return []
if (!order || order.status === 'CANCELLED') return []
const inbound = inboundBilling.value?.inbound.summary
const summary = order.businessSummary
const formatMetric = (value: number | null | undefined, unit = '') => value == null ? '待更新' : `${value.toLocaleString()}${unit ? ` ${unit}` : ''}`
@@ -1445,7 +1445,8 @@ async function loadDetailPage(orderId: string) {
detailError.value = ''
try {
const detailedOrder = await store.loadDetail(orderId)
void loadInboundBilling(detailedOrder)
if (detailedOrder.status === 'CANCELLED') resetInboundBillingState()
else void loadInboundBilling(detailedOrder)
// Load previews from the persisted file IDs after the store has replaced the detail row.
await Promise.all((detailOrder.value?.items ?? detailedOrder.items).map(ensureProductImagePreview))
} catch (error) {
@@ -2084,7 +2085,7 @@ async function searchOrders(model: QueryModel) {
keyword: undefined,
inboundCode: cargoKeyword || undefined,
receivingWarehouseId: typeof model.receivingWarehouseId === 'string' && model.receivingWarehouseId ? model.receivingWarehouseId : undefined,
mainStatus: statusValue && statusValue !== 'DRAFT' ? statusValue : undefined,
mainStatus: statusValue && statusValue !== 'DRAFT' && statusValue !== 'CANCELLED' ? statusValue : undefined,
draftOnly: statusValue === 'DRAFT',
includeDrafts: !statusValue,
createdFrom: range[0] ? `${range[0]}T00:00:00` : undefined,
@@ -2411,16 +2412,16 @@ async function searchOrders(model: QueryModel) {
<div class="order-detail-heading">
<div class="detail-heading-main">
<el-button link type="primary" class="detail-back-action" @click="router.push({ name: 'first-leg-orders' })">← 返回列表</el-button>
<div><h2>{{ detailOrder.businessSummary.inboundCode || (detailOrder.status === 'DRAFT' ? '入仓码提交后生成' : '入仓码待生成') }}</h2><p>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }} · {{ detailOrder.origin || '起运地待更新' }} → {{ detailOrder.destination || '目的地待更新' }}</p></div>
<div><h2>{{ detailOrder.businessSummary.inboundCode || (detailOrder.status === 'CANCELLED' ? (detailOrder.orderNo || '—') : detailOrder.status === 'DRAFT' ? '入仓码提交后生成' : '入仓码待生成') }}</h2><p v-if="detailOrder.status === 'CANCELLED'">订单已取消。</p><p v-else>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }} · {{ detailOrder.origin || '起运地待更新' }} → {{ detailOrder.destination || '目的地待更新' }}</p></div>
</div>
<div class="detail-heading-actions"><el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" type="primary" plain :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button><el-button v-if="canOfferCancellation(detailOrder)" type="danger" plain :loading="cancellingOrderId === detailOrder.id" :disabled="!!cancellingOrderId" @click="cancelOrder(detailOrder)">{{ pendingCancellation?.id === detailOrder.id ? '重试取消' : '取消订单' }}</el-button></div>
</div>
<section class="detail-section">
<h3>订单与运输</h3>
<dl class="order-detail-grid">
<div><dt>入仓码</dt><dd><button v-if="detailOrder.businessSummary.inboundCode" class="inbound-code-link table-code" type="button" @click="openInboundCodeAssociation(detailOrder.businessSummary.inboundCode)">{{ detailOrder.businessSummary.inboundCode }}</button><span v-else>{{ detailOrder.status === 'DRAFT' ? '提交后生成' : '待生成' }}</span></dd></div>
<div><dt>入仓码</dt><dd><button v-if="detailOrder.businessSummary.inboundCode" class="inbound-code-link table-code" type="button" @click="openInboundCodeAssociation(detailOrder.businessSummary.inboundCode)">{{ detailOrder.businessSummary.inboundCode }}</button><span v-else>{{ detailOrder.status === 'CANCELLED' ? '—' : detailOrder.status === 'DRAFT' ? '提交后生成' : '待生成' }}</span></dd></div>
<div><dt>创建时间</dt><dd>{{ formatDateTime(detailOrder.createdAt) }}</dd></div>
<div><dt>运输方式</dt><dd>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</dd></div>
<div><dt>运输方式</dt><dd>{{ detailOrder.status === 'CANCELLED' && !detailOrder.transportMode ? '—' : getFirstLegTransportModeLabel(detailOrder.transportMode) }}</dd></div>
<div><dt>贸易条款</dt><dd>{{ displayValue(detailOrder.tradeTerm) }}</dd></div>
<div><dt>发货方式</dt><dd>{{ pickupMethodLabels[detailOrder.pickupMethod] }}</dd></div>
<div><dt>预约提货时间</dt><dd>{{ detailOrder.pickupMethod === 'PICKUP' && detailOrder.requestedPickupAt ? formatDateTime(detailOrder.requestedPickupAt) : '-' }}</dd></div>
@@ -2445,7 +2446,7 @@ async function searchOrders(model: QueryModel) {
</dl>
<div v-if="detailOrder.customerRemark" class="customer-remark"><strong>客户备注</strong><p>{{ detailOrder.customerRemark }}</p></div>
</section>
<section v-if="detailOrder.status !== 'DRAFT'" class="detail-section">
<section v-if="detailOrder.status !== 'DRAFT' && detailOrder.status !== 'CANCELLED'" class="detail-section">
<h3>关键业务摘要</h3>
<dl class="fulfillment-summary">
<div v-for="item in progressSummaryItems" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div>
@@ -2465,9 +2466,9 @@ async function searchOrders(model: QueryModel) {
</div>
</template>
</OrderProgressPanel>
<section class="detail-section">
<section v-if="detailOrder.status !== 'CANCELLED' || detailOrder.expressCompanyName || detailOrder.trackingNumber" class="detail-section">
<h3>履约快递信息</h3>
<dl v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><div><dt>快递公司</dt><dd>{{ detailOrder.expressCompanyName || '待平台补充' }}</dd></div><div><dt>快递单号</dt><dd class="table-code">{{ detailOrder.trackingNumber || '待平台补充' }}</dd></div></dl>
<dl v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><div><dt>快递公司</dt><dd>{{ detailOrder.expressCompanyName || (detailOrder.status === 'CANCELLED' ? '—' : '待平台补充') }}</dd></div><div><dt>快递单号</dt><dd class="table-code">{{ detailOrder.trackingNumber || (detailOrder.status === 'CANCELLED' ? '—' : '待平台补充') }}</dd></div></dl>
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
</section>
<section class="detail-section">
@@ -2517,6 +2518,7 @@ async function searchOrders(model: QueryModel) {
<el-empty v-else description="暂无货物明细" :image-size="56" />
</section>
<InboundBillingPanel
v-if="detailOrder.status !== 'CANCELLED'"
:data="inboundBilling"
:loading="inboundBillingLoading"
:unavailable="inboundBillingUnavailable"