23 changed files with 195 additions and 69 deletions
+3
View File
@@ -6,6 +6,9 @@ FROM ${NODE_IMAGE} AS build
WORKDIR /app WORKDIR /app
# 为类型检查和打包留出原生内存余量,容器总内存由构建入口限制为 3GiB。
ENV NODE_OPTIONS=--max-old-space-size=2048
# Corepack 与 pnpm 依赖均通过 npmmirror 下载,避免构建时访问 npm 官方源过慢。 # Corepack 与 pnpm 依赖均通过 npmmirror 下载,避免构建时访问 npm 官方源过慢。
ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
Vendored
+7 -2
View File
@@ -3,6 +3,7 @@ pipeline {
options { options {
timestamps() timestamps()
disableConcurrentBuilds() disableConcurrentBuilds()
timeout(time: 20, unit: 'MINUTES')
} }
stages { stages {
stage('检查测试发布参数') { stage('检查测试发布参数') {
@@ -27,8 +28,12 @@ pipeline {
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
steps { steps {
sh '''set -eu sh '''set -eu
docker compose -f docker-compose.test.yml build --pull=false sh scripts/build-image.sh --pull=false \
docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120 --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \
--build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \
--build-arg VITE_ADMIN_WEB_ORIGIN="$VITE_ADMIN_WEB_ORIGIN" \
-t "client-web-test:$IMAGE_TAG" .
docker compose -f docker-compose.test.yml up -d --no-build --pull never --wait --wait-timeout 120
docker compose -f docker-compose.test.yml exec -T client-web wget -qO- http://127.0.0.1/healthz docker compose -f docker-compose.test.yml exec -T client-web wget -qO- http://127.0.0.1/healthz
''' '''
} }
+2
View File
@@ -54,3 +54,5 @@ pnpm test
仅保留 `docker-compose.test.yml` 与 test Jenkins Job。发布前注入 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`VITE_ADMIN_WEB_ORIGIN`(管理后台 origin,构建时用于安全代登录)与 `IMAGE_TAG`(Jenkins 从当前提交生成)。`HOST_PORT` 默认 9002 且仅绑定 loopback;宿主机反向代理负责外部入口。可选 `NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE`,私有镜像认证由构建节点管理。不得把本地构建结果描述为测试环境已发布。 仅保留 `docker-compose.test.yml` 与 test Jenkins Job。发布前注入 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`VITE_ADMIN_WEB_ORIGIN`(管理后台 origin,构建时用于安全代登录)与 `IMAGE_TAG`(Jenkins 从当前提交生成)。`HOST_PORT` 默认 9002 且仅绑定 loopback;宿主机反向代理负责外部入口。可选 `NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE`,私有镜像认证由构建节点管理。不得把本地构建结果描述为测试环境已发布。
同机目标默认值:`API_UPSTREAM=http://gateway:9000`、`TEST_NETWORK=firstleg-test-network`;必须与后端同批 test Compose 配置一致。 同机目标默认值:`API_UPSTREAM=http://gateway:9000`、`TEST_NETWORK=firstleg-test-network`;必须与后端同批 test Compose 配置一致。
Jenkins 的测试及灰度镜像构建均通过 `scripts/build-image.sh`,Compose 只部署已构建镜像。构建预算及节点要求见[部署指南](../../docs/测试环境Jenkins部署指南.md)。离线回归为 `node --test scripts/build-resource-contract.test.mjs`,本机仍经统一 `local-check.ps1` 入口运行。
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -eu
# 构建前至少保留 1GiB 给业务服务,避免在宿主机内存不足时继续启动构建。
available_kib=$(awk '$1 == "MemAvailable:" { print $2 }' /proc/meminfo)
case "$available_kib" in
''|*[!0-9]*) echo 'Cannot read host available memory; build refused.' >&2; exit 69 ;;
esac
if [ "$available_kib" -lt 4194304 ]; then
echo 'Docker build requires at least 4GiB available host memory; retry after resources recover.' >&2
exit 75
fi
# 当前节点使用 Docker 26 的经典构建器。显式固定后端,确保资源参数实际生效。
# 限制覆盖每个 RUN 容器,失败时也清理中间容器,超时由宿主机独立终止。
exec env DOCKER_BUILDKIT=0 timeout --signal=TERM --kill-after=30s 15m \
docker build --memory=3g --memory-swap=3g \
--cpu-period=100000 --cpu-quota=200000 --force-rm "$@"
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
const root = fileURLToPath(new URL('../', import.meta.url))
const wrapper = join(root, 'scripts/build-image.sh')
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
const shellAvailable = process.platform !== 'win32' || existsSync(bash)
// 使用替身验证参数、内存预检和退出码,不启动 Docker 或访问部署环境。
function runBuild(availableMemory, childStatus = '0') {
const directory = mkdtempSync(join(tmpdir(), 'frontend-build-resource-'))
try {
writeFileSync(join(directory, 'awk'), '#!/bin/sh\nprintf "%s\\n" "$TEST_AVAILABLE_MEMORY"\n', { mode: 0o755 })
writeFileSync(join(directory, 'timeout'), '#!/bin/sh\nprintf "%s\\n" watchdog "$@"\nshift 3\nexec "$@"\n', { mode: 0o755 })
writeFileSync(join(directory, 'docker'), '#!/bin/sh\nprintf "%s\\n" "builder=$DOCKER_BUILDKIT" "$@"\nexit "$TEST_CHILD_STATUS"\n', { mode: 0o755 })
return spawnSync(bash, ['-c',
'testbin=$(cd "$1" && pwd); shift; PATH="$testbin:$PATH"; export PATH; exec sh "$@"',
'fixture', directory.replaceAll('\\', '/'), wrapper.replaceAll('\\', '/'),
'--build-arg', 'VALUE=with space', '-t', 'web:test', '.'], {
encoding: 'utf8', timeout: 10000,
env: { ...process.env, DOCKER_BUILDKIT: '1', TEST_AVAILABLE_MEMORY: availableMemory, TEST_CHILD_STATUS: childStatus }
})
} finally {
assert.equal(dirname(resolve(directory)), resolve(tmpdir()))
rmSync(directory, { recursive: true })
}
}
test('TEST and gray builds share the resource gate and deployment cannot rebuild implicitly', () => {
const pipeline = readFileSync(join(root, 'Jenkinsfile'), 'utf8')
const production = readFileSync(join(root, 'scripts/deploy-production.sh'), 'utf8')
const dockerfile = readFileSync(join(root, 'Dockerfile'), 'utf8')
for (const source of [pipeline, production]) {
assert.match(source, /sh scripts\/build-image\.sh/)
assert.doesNotMatch(source, /docker (?:compose[^\n]* build|build\s)/)
}
assert.match(pipeline, /timeout\(time: 20, unit: 'MINUTES'\)/)
assert.match(pipeline, /up -d --no-build --pull never --wait/)
assert.ok(pipeline.includes(`-t "${basename(root)}-test:$IMAGE_TAG"`))
assert.match(dockerfile, /^ENV NODE_OPTIONS=--max-old-space-size=2048$/m)
})
test('build forwards quoted arguments, enforces container budgets and propagates failure', { skip: !shellAvailable }, () => {
const result = runBuild('4194304', '23')
assert.equal(result.status, 23, result.stderr)
assert.match(result.stdout, /watchdog\r?\n--signal=TERM\r?\n--kill-after=30s\r?\n15m\r?\ndocker\r?\nbuild/)
assert.match(result.stdout, /builder=0\r?\nbuild/)
for (const argument of ['--memory=3g', '--memory-swap=3g', '--cpu-period=100000', '--cpu-quota=200000', '--force-rm']) {
assert.ok(result.stdout.split(/\r?\n/).includes(argument), argument)
}
assert.match(result.stdout, /--build-arg\r?\nVALUE=with space\r?\n-t\r?\nweb:test\r?\n\./)
assert.equal(runBuild('8388608').status, 0)
assert.equal(runBuild('8388608', '124').status, 124)
})
test('low or unreadable host memory refuses to start Docker', { skip: !shellAvailable }, () => {
for (const [memory, status] of [['4194303', 75], ['', 69], ['unknown', 69]]) {
const result = runBuild(memory)
assert.equal(result.status, status, result.stderr)
assert.equal(result.stdout, '')
}
})
+1 -1
View File
@@ -35,7 +35,7 @@ DOCKER_CONFIG=$(mktemp -d)
cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
trap cleanup EXIT HUP INT TERM trap cleanup EXIT HUP INT TERM
printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry" printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry"
docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ sh scripts/build-image.sh --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \
--build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \
--build-arg VITE_ADMIN_WEB_ORIGIN="$PUBLIC_WEB_ORIGIN" -t "$image" . --build-arg VITE_ADMIN_WEB_ORIGIN="$PUBLIC_WEB_ORIGIN" -t "$image" .
docker push "$image" docker push "$image"
@@ -70,6 +70,7 @@ export interface TransportDetail {
destinationAddress: string | null destinationAddress: string | null
remark: string | null remark: string | null
histories?: Array<{ operationType: string; toStatus: string; remark: string | null }> histories?: Array<{ operationType: string; toStatus: string; remark: string | null }>
dispatchNos?: string[]
} }
export interface TransportCreate { export interface TransportCreate {
+2 -2
View File
@@ -2,9 +2,9 @@ import { apiRequest } from '@/api/http'
export interface BudgetResult { status: string; usable: boolean; amountCny: number | string; message: string; orderNo: string | null } export interface BudgetResult { status: string; usable: boolean; amountCny: number | string; message: string; orderNo: string | null }
const base = '/merchant/v1/last-leg/order-budgets' const base = '/merchant/v1/last-leg/order-budgets'
// 创建订单时后端在同一事务记录冻结请求,客户端只在详情核对状态和重试明确失败。 // 创建订单时后端在同一事务记录冻结请求,客户端只在详情核对状态和重试明确失败。
export function readOrderFunding(kind: 'procurement' | 'trucking', orderNo: string) { export function readOrderFunding(kind: 'procurement' | 'trucking' | 'looseboard', orderNo: string) {
return apiRequest<BudgetResult>({ method: 'GET', url: base + '/' + kind + '/orders/' + encodeURIComponent(orderNo) }) return apiRequest<BudgetResult>({ method: 'GET', url: base + '/' + kind + '/orders/' + encodeURIComponent(orderNo) })
} }
export function retryOrderFunding(kind: 'procurement' | 'trucking', orderNo: string) { export function retryOrderFunding(kind: 'procurement' | 'trucking' | 'looseboard', orderNo: string) {
return apiRequest<BudgetResult>({ method: 'POST', url: base + '/' + kind + '/orders/' + encodeURIComponent(orderNo) + '/retry' }) return apiRequest<BudgetResult>({ method: 'POST', url: base + '/' + kind + '/orders/' + encodeURIComponent(orderNo) + '/retry' })
} }
@@ -18,7 +18,7 @@ test('all current truck statuses display Chinese labels in lists and details', (
const { poolOrderStatusLabels } = runInNewContext(`${labelsCode}\n({ poolOrderStatusLabels })`) const { poolOrderStatusLabels } = runInNewContext(`${labelsCode}\n({ poolOrderStatusLabels })`)
const helpers = view.slice(view.indexOf('function read('), view.indexOf('function idempotencyKey(')) const helpers = view.slice(view.indexOf('function read('), view.indexOf('function idempotencyKey('))
const compiled = ts.transpileModule(helpers, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText const compiled = ts.transpileModule(helpers, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const { statusLabel, displayCell } = runInNewContext(`${compiled}\n({ statusLabel, displayCell })`, { poolOrderStatusLabels }) const { statusLabel, displayCell } = runInNewContext(`${compiled}\n({ statusLabel, displayCell })`, { poolOrderStatusLabels, props: { kind: 'trucking' } })
for (const status of ['PENDING_REVIEW', 'FUNDING_PENDING', 'FUNDING_FAILED', 'WAITING_INBOUND', 'RECEIVING', 'WAITING_CONSOLIDATION', 'PALLET_OVERAGE_REVIEW', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH', 'DISPATCHED', 'IN_TRANSIT', 'COMPLETED', 'REJECTED', 'CANCELLED']) { for (const status of ['PENDING_REVIEW', 'FUNDING_PENDING', 'FUNDING_FAILED', 'WAITING_INBOUND', 'RECEIVING', 'WAITING_CONSOLIDATION', 'PALLET_OVERAGE_REVIEW', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH', 'DISPATCHED', 'IN_TRANSIT', 'COMPLETED', 'REJECTED', 'CANCELLED']) {
assert.match(statusLabel(status), /[\u4e00-\u9fff]/, status) assert.match(statusLabel(status), /[\u4e00-\u9fff]/, status)
assert.equal(displayCell({ status }, 'status'), statusLabel(status)) assert.equal(displayCell({ status }, 'status'), statusLabel(status))
@@ -29,7 +29,7 @@ test('all current truck statuses display Chinese labels in lists and details', (
}) })
test('truck search excludes warehouse and dispatch preparation stages', () => { test('truck search excludes warehouse and dispatch preparation stages', () => {
const truckingOptions = view.split("options: props.kind === 'trucking' ? [")[1].split('] : [')[0] const truckingOptions = view.split("options: props.kind === 'trucking' ? [")[1].split('] : Object.entries')[0]
for (const status of ['WAITING_INBOUND', 'RECEIVING', 'WAITING_CONSOLIDATION', 'PALLET_OVERAGE_REVIEW', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH']) { for (const status of ['WAITING_INBOUND', 'RECEIVING', 'WAITING_CONSOLIDATION', 'PALLET_OVERAGE_REVIEW', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH']) {
assert.ok(!truckingOptions.includes(status), status) assert.ok(!truckingOptions.includes(status), status)
} }
@@ -149,7 +149,7 @@ function transportHarness(kind = 'trucking', failFirst = false, budgetCheck = as
const script = view.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1] const script = view.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const compiled = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText const compiled = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
.replace(/^import .*?;$/gm, '').replace(/^export \{\};$/gm, '') .replace(/^import .*?;$/gm, '').replace(/^export \{\};$/gm, '')
const harness = runInNewContext(`${compiled}\n({ form, formRef, submit, detail, detailFields, configuration })`, { const harness = runInNewContext(`${compiled}\n({ form, formRef, submit, detail, detailStatus, detailFields, configuration, queryFields, statusLabel, displayCell })`, {
defineProps: () => ({ kind, createPage: true, detailPage: false }), defineProps: () => ({ kind, createPage: true, detailPage: false }),
withDefaults: props => props, withDefaults: props => props,
computed: getter => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() } }), computed: getter => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() } }),
@@ -164,6 +164,23 @@ function transportHarness(kind = 'trucking', failFirst = false, budgetCheck = as
return { ...harness, calls, rules: { get value() { return demandHelpers.transportDemandRules(kind === 'trucking' ? 'truck' : 'looseboard', harness.form) } } } return { ...harness, calls, rules: { get value() { return demandHelpers.transportDemandRules(kind === 'trucking' ? 'truck' : 'looseboard', harness.form) } } }
} }
test('looseboard dropdown and displayed labels use only the actual review and transport workflow', () => {
const current = transportHarness('looseboard')
const options = current.queryFields.value.find(field => field.key === 'status').options
assert.deepEqual(Array.from(options, item => [item.value, item.label]), [
['FUNDING_PENDING', '订单受理中'], ['FUNDING_FAILED', '订单处理失败'],
['PENDING_ACCEPTANCE', '待审核'], ['PENDING_DISPATCH', '待排车'],
['DISPATCHED', '待取货'], ['PICKING_UP', '取货中'], ['IN_TRANSIT', '派送中'],
['COMPLETED', '已完成'], ['CANCELLED', '已取消'],
])
for (const item of options) {
assert.equal(current.statusLabel(item.value), item.label)
assert.equal(current.displayCell({ status: item.value }, 'status'), item.label)
current.detail.value = { summary: { status: item.value } }
assert.equal(current.detailStatus.value, item.label)
}
})
test('truck requires both contacts vehicle and minute times without inheriting looseboard fields', () => { test('truck requires both contacts vehicle and minute times without inheriting looseboard fields', () => {
const truck = transportHarness() const truck = transportHarness()
for (const key of ['vehicleType', 'originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) assert.equal(truck.rules.value[key][0].required, true) for (const key of ['vehicleType', 'originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) assert.equal(truck.rules.value[key][0].required, true)
@@ -217,7 +234,7 @@ test('truck details render saved minute times and unknown pool demand without ti
test('looseboard saves split contacts and both local minute times without obsolete fields', async () => { test('looseboard saves split contacts and both local minute times without obsolete fields', async () => {
const current = transportHarness('looseboard', true) const current = transportHarness('looseboard', true)
Object.assign(current.form, { originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, origin: 'LA', destination: 'ONT', pickupAt: '2026-10-20T09:00:00', deliveryAt: '2026-10-20T15:30:00' }) Object.assign(current.form, { originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, budgetAmount: 500, origin: 'LA', destination: 'ONT', pickupAt: '2026-10-20T09:00:00', deliveryAt: '2026-10-20T15:30:00' })
await current.submit(); await current.submit() await current.submit(); await current.submit()
const payload = current.calls[1].payload const payload = current.calls[1].payload
assert.equal(payload.estimatedPalletCount, 3) assert.equal(payload.estimatedPalletCount, 3)
@@ -237,6 +254,17 @@ test('looseboard saves split contacts and both local minute times without obsole
for (const field of ['palletRequired', 'transportType', 'transportMode', 'containerType', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(payload, field), false) for (const field of ['palletRequired', 'transportType', 'transportMode', 'containerType', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(payload, field), false)
}) })
test('looseboard details show every dispatch without a fleet booking and retain the unassigned state', () => {
const looseboard = transportHarness('looseboard')
looseboard.detail.value = { summary: { fleetArrangement: null }, dispatchNos: ['PC-1', 'PC-2'] }
assert.equal(new Map(looseboard.detailFields.value).get('排车单号'), 'PC-1、PC-2')
looseboard.detail.value.dispatchNos = []
assert.equal(new Map(looseboard.detailFields.value).get('排车单号'), '暂未排车')
const truck = transportHarness('trucking')
truck.detail.value = { summary: {} }
assert.equal(new Map(truck.detailFields.value).has('排车单号'), false)
})
test('looseboard requires both times and rejects backwards schedule', () => { test('looseboard requires both times and rejects backwards schedule', () => {
const current = transportHarness('looseboard') const current = transportHarness('looseboard')
assert.equal(current.rules.value.pickupAt[0].required, true) assert.equal(current.rules.value.pickupAt[0].required, true)
@@ -70,7 +70,7 @@ function transportColumns(looseboard: boolean): ColumnDefinition[] {
{ key: 'pickupAt', label: pickupLabel, width: 165 }, { key: 'deliveryAt', label: deliveryLabel, width: 165 }, { key: 'pickupAt', label: pickupLabel, width: 165 }, { key: 'deliveryAt', label: deliveryLabel, width: 165 },
]), ]),
{ key: 'status', label: '状态', width: 130 }, { key: 'status', label: '状态', width: 130 },
...(!looseboard ? [{ key: 'failureReason', label: '失败 / 拒绝原因', width: 240 }] : []), { key: 'failureReason', label: looseboard ? '处理失败原因' : '失败 / 拒绝原因', width: 240 },
...(looseboard ...(looseboard
? [{ key: 'fleetArrangement', label: '承运安排', width: 190 }] ? [{ key: 'fleetArrangement', label: '承运安排', width: 190 }]
: [{ key: 'createdAt', label: '创建时间', width: 180 }]), : [{ key: 'createdAt', label: '创建时间', width: 180 }]),
@@ -90,6 +90,18 @@ const formRef = ref<FormInstance>()
const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }) const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' })
const total = ref(0) const total = ref(0)
const looseboardStatusLabels: Record<string, string> = {
FUNDING_PENDING: '订单受理中',
FUNDING_FAILED: '订单处理失败',
PENDING_ACCEPTANCE: '待审核',
PENDING_DISPATCH: '待排车',
DISPATCHED: '待取货',
PICKING_UP: '取货中',
IN_TRANSIT: '派送中',
COMPLETED: '已完成',
CANCELLED: '已取消',
}
const queryModel = computed<QueryModel>({ const queryModel = computed<QueryModel>({
get: () => ({ get: () => ({
orderNo: query.orderNo, orderNo: query.orderNo,
@@ -120,19 +132,7 @@ const queryFields = computed<QueryField[]>(() => [
{ label: '已完成', value: 'COMPLETED' }, { label: '已完成', value: 'COMPLETED' },
{ label: '已拒绝', value: 'REJECTED' }, { label: '已拒绝', value: 'REJECTED' },
{ label: '已取消', value: 'CANCELLED' }, { label: '已取消', value: 'CANCELLED' },
] : [ ] : Object.entries(looseboardStatusLabels).map(([value, label]) => ({ value, label })),
{ label: '订单受理中', value: 'FUNDING_PENDING' },
{ label: '订单处理失败', value: 'FUNDING_FAILED' },
{ label: '待审核', value: 'PENDING_REVIEW' },
{ label: '已通过', value: 'APPROVED' },
{ label: '已拒绝', value: 'REJECTED' },
{ label: '待接单', value: 'PENDING_ACCEPTANCE' },
...Object.entries(poolOrderStatusLabels).filter(([value]) => !['IN_TRANSIT', 'COMPLETED', 'CANCELLED'].includes(value)).map(([value, label]) => ({ value, label })),
{ label: '处理中', value: 'PROCESSING' },
{ label: '派送中', value: 'IN_TRANSIT' },
{ label: '已完成', value: 'COMPLETED' },
{ label: '已取消', value: 'CANCELLED' },
],
}, },
]) ])
@@ -145,6 +145,7 @@ const detailFields = computed(() => {
const current = detail.value as TransportDetail const current = detail.value as TransportDetail
return [ return [
...(props.kind === 'looseboard' ? [ ...(props.kind === 'looseboard' ? [
['排车单号', current.dispatchNos?.length ? current.dispatchNos.join('、') : '暂未排车'],
['商品名称', summary.productName], ['预计托数', summary.estimatedPalletCount], ['商品名称', summary.productName], ['预计托数', summary.estimatedPalletCount],
] : [['车辆类型', vehicleTypeLabel(summary.vehicleType)]]), ] : [['车辆类型', vehicleTypeLabel(summary.vehicleType)]]),
[props.kind === 'trucking' ? '取货地' : '起运地', read(summary, 'origin')], [props.kind === 'trucking' ? '取货地址' : '起运地址', current.originAddress], [props.kind === 'trucking' ? '取货地' : '起运地', read(summary, 'origin')], [props.kind === 'trucking' ? '取货地址' : '起运地址', current.originAddress],
@@ -199,11 +200,12 @@ function vehicleTypeLabel(value: unknown) { return ({ PALLET_15: '15拖', PALLET
function localMinute(value: unknown) { return value ? String(value).replace('T', ' ').slice(0, 16) : '—' } function localMinute(value: unknown) { return value ? String(value).replace('T', ' ').slice(0, 16) : '—' }
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 statusLabel(value: unknown) { function statusLabel(value: unknown) {
if (props.kind === 'looseboard') return looseboardStatusLabels[String(value)] || text(value)
if (poolOrderStatusLabels[String(value)]) return poolOrderStatusLabels[String(value)] if (poolOrderStatusLabels[String(value)]) return poolOrderStatusLabels[String(value)]
return ({ FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '派送中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value) return ({ FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '派送中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
} }
function statusTone(value: unknown) { function statusTone(value: unknown) {
return ({ APPROVED: 'success', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info', FUNDING_PENDING: 'warning', FUNDING_FAILED: 'danger', PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', PROCESSING: 'primary', IN_TRANSIT: 'primary' } as Record<string, 'success' | 'warning' | 'danger' | 'info' | 'primary'>)[String(value)] || 'info' return ({ APPROVED: 'success', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info', FUNDING_PENDING: 'warning', FUNDING_FAILED: 'danger', PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', PENDING_DISPATCH: 'warning', DISPATCHED: 'primary', PICKING_UP: 'primary', PROCESSING: 'primary', IN_TRANSIT: 'primary' } as Record<string, 'success' | 'warning' | 'danger' | 'info' | 'primary'>)[String(value)] || 'info'
} }
function displayCell(candidate: unknown, key: string) { function displayCell(candidate: unknown, key: string) {
const row = candidate as BusinessRow const row = candidate as BusinessRow
@@ -253,8 +255,9 @@ function getRowActions(candidate: object): TableAction[] {
return [{ key: 'detail', label: '详情', run: () => openDetail(candidate) }] return [{ key: 'detail', label: '详情', run: () => openDetail(candidate) }]
} }
/** 长地址和多张排车单保留完整单号,按详情已有网格换行展示。 */
function detailFieldClass(label: unknown) { function detailFieldClass(label: unknown) {
if (label === '备注') return 'is-full' if (label === '备注' || label === '排车单号') return 'is-full'
return [props.kind === 'trucking' ? '取货地址' : '起运地址', props.kind === 'trucking' ? '送达地址' : '目的地址'].includes(String(label)) ? 'is-wide' : '' return [props.kind === 'trucking' ? '取货地址' : '起运地址', props.kind === 'trucking' ? '送达地址' : '目的地址'].includes(String(label)) ? 'is-wide' : ''
} }
@@ -338,7 +341,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 #failureReason="{ row }"><OrderFundingStatus v-if="['FUNDING_FAILED', 'REJECTED'].includes((row as BusinessRow).status)" kind="trucking" :order-no="(row as BusinessRow).orderNo" :status="(row as BusinessRow).status" :can-retry="false" compact /><span v-else>—</span></template> <template #failureReason="{ row }"><OrderFundingStatus v-if="(row as BusinessRow).status === 'FUNDING_FAILED' || (kind === 'trucking' && (row as BusinessRow).status === 'REJECTED')" :kind="kind" :order-no="(row as BusinessRow).orderNo" :status="(row as BusinessRow).status" :can-retry="false" compact /><span v-else>—</span></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> <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>
@@ -349,7 +352,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
<div class="business-detail-heading-main"><el-button link type="primary" @click="returnToList">← 返回列表</el-button><div><h2>{{ detail?.summary.orderNo || '订单详情' }}</h2></div></div> <div class="business-detail-heading-main"><el-button link type="primary" @click="returnToList">← 返回列表</el-button><div><h2>{{ detail?.summary.orderNo || '订单详情' }}</h2></div></div>
<div class="business-detail-heading-actions"><el-button v-if="canCancelTruck" type="danger" :loading="submitting" :disabled="loading" @click="cancelTruck">取消订单</el-button><el-tag :type="statusTone(detail?.summary.status)" effect="light">{{ detailStatus }}</el-tag><el-button :disabled="loading" @click="loadDetail">刷新</el-button></div> <div class="business-detail-heading-actions"><el-button v-if="canCancelTruck" type="danger" :loading="submitting" :disabled="loading" @click="cancelTruck">取消订单</el-button><el-tag :type="statusTone(detail?.summary.status)" effect="light">{{ detailStatus }}</el-tag><el-button :disabled="loading" @click="loadDetail">刷新</el-button></div>
</header> </header>
<OrderFundingStatus v-if="kind === 'trucking' && detail" kind="trucking" :order-no="detail.summary.orderNo" :status="detail.summary.status" :can-retry="canCreate" @changed="loadDetail" /> <OrderFundingStatus v-if="detail" :kind="kind" :order-no="detail.summary.orderNo" :status="detail.summary.status" :can-retry="canCreate" @changed="loadDetail" />
<div v-if="error" class="business-detail-error" role="alert"><strong>{{ error }}</strong><el-button size="small" @click="loadDetail">重新加载</el-button></div> <div v-if="error" class="business-detail-error" role="alert"><strong>{{ error }}</strong><el-button size="small" @click="loadDetail">重新加载</el-button></div>
<section v-else class="business-detail-section"> <section v-else class="business-detail-section">
<h3>订单信息</h3> <h3>订单信息</h3>
@@ -3,7 +3,7 @@ import { onActivated, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import { readOrderFunding, retryOrderFunding } from '../api/orderBudgets' import { readOrderFunding, retryOrderFunding } from '../api/orderBudgets'
import { getBusinessOrder, type TransportDetail } from '../api/businessOrders' import { getBusinessOrder, type TransportDetail } from '../api/businessOrders'
const props = defineProps<{ kind: 'procurement' | 'trucking'; orderNo: string; status: string; canRetry: boolean; compact?: boolean }>() const props = defineProps<{ kind: 'procurement' | 'trucking' | 'looseboard'; orderNo: string; status: string; canRetry: boolean; compact?: boolean }>()
const emit = defineEmits<{ changed: [] }>() const emit = defineEmits<{ changed: [] }>()
const message = ref(''), busy = ref(false) const message = ref(''), busy = ref(false)
let generation = 0, timer: ReturnType<typeof setTimeout> | undefined let generation = 0, timer: ReturnType<typeof setTimeout> | undefined
@@ -39,6 +39,19 @@ test('truck rejection uses latest review reason and never customer remarks', asy
assert.equal(missing.message.value, '未填写拒绝原因') assert.equal(missing.message.value, '未填写拒绝原因')
}) })
test('looseboard funding failures show the server reason and retry the original order', async () => {
const calls = []
const h = harness({ status: 'FUNDING_FAILED', read: async (kind, no) => {
calls.push([kind, no]); return { status: 'FAILED', message: '可用余额和授信额度不足' }
}, retry: async (kind, no) => { calls.push([kind, no]); return { status: 'FREEZE_PENDING' } } })
h.props.kind = 'looseboard'; h.props.orderNo = 'LB1'
await h.check(0, 0)
assert.equal(h.message.value, '可用余额和授信额度不足')
await h.retry()
assert.deepEqual(calls, [['looseboard', 'LB1'], ['looseboard', 'LB1']])
assert.equal(h.events.join(','), 'changed')
})
test('funding status polls for a bounded time and refreshes detail only on a result', async () => { test('funding status polls for a bounded time and refreshes detail only on a result', async () => {
let count = 0 let count = 0
const h = harness({ read: async () => { count++; return { status: 'FREEZE_PENDING' } } }) const h = harness({ read: async () => { count++; return { status: 'FREEZE_PENDING' } } })
@@ -323,7 +323,7 @@ test('a lost create response recovers the original order after review releases i
}) })
test('funding APIs preserve server results and retry the same order only when requested', async () => { test('funding APIs preserve server results and retry the same order only when requested', async () => {
for (const kind of ['procurement', 'trucking']) for (const status of ['FREEZE_PENDING', 'FROZEN', 'RELEASE_PENDING', 'RELEASED', 'RELEASE_FAILED', 'FAILED']) { for (const kind of ['procurement', 'trucking', 'looseboard']) for (const status of ['FREEZE_PENDING', 'FROZEN', 'RELEASE_PENDING', 'RELEASED', 'RELEASE_FAILED', 'FAILED']) {
const calls = [] const calls = []
const result = { status, usable: false, orderNo: 'CG/EXISTING', message: '资金校验失败' } const result = { status, usable: false, orderNo: 'CG/EXISTING', message: '资金校验失败' }
const budgets = moduleFrom(readFileSync(new URL('./api/orderBudgets.ts', import.meta.url), 'utf8'), name => { const budgets = moduleFrom(readFileSync(new URL('./api/orderBudgets.ts', import.meta.url), 'utf8'), name => {
@@ -35,6 +35,8 @@ test('initial form leaves contacts and times blank, zeroes cargo and distinguish
assert.equal(form.currency, 'USD') assert.equal(form.currency, 'USD')
assert.throws(() => buildTransportDemand('truck', { ...valid(), budgetAmount: undefined }), /预算/) assert.throws(() => buildTransportDemand('truck', { ...valid(), budgetAmount: undefined }), /预算/)
assert.throws(() => buildTransportDemand('truck', { ...valid(), budgetAmount: 0 }), /预算/) assert.throws(() => buildTransportDemand('truck', { ...valid(), budgetAmount: 0 }), /预算/)
assert.throws(() => buildTransportDemand('looseboard', { ...valid(), budgetAmount: undefined }), /预算/)
assert.equal(buildTransportDemand('looseboard', { ...valid(), budgetAmount: 0 }).budgetAmount, 0)
}) })
for (const kind of ['truck', 'looseboard']) { for (const kind of ['truck', 'looseboard']) {
@@ -75,7 +75,7 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
if (field === 'packageCount' && kind === 'truck') continue if (field === 'packageCount' && kind === 'truck') continue
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }] rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
} }
rules.budgetAmount = [{ required: kind === 'truck', type: 'number', min: kind === 'truck' ? 0.01 : 0, message: '请输入大于 0 的预算总价', trigger: 'change' }] rules.budgetAmount = [{ required: true, type: 'number', min: kind === 'truck' ? 0.01 : 0, message: kind === 'truck' ? '请输入大于 0 的预算总价' : '请输入预算总价,不能小于 0', trigger: 'change' }]
const pickupLabel = kind === 'truck' ? '取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? '送达时间' : '送货时间' const pickupLabel = kind === 'truck' ? '取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? '送达时间' : '送货时间'
rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, trigger: 'change' }] rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, trigger: 'change' }]
rules.deliveryAt = [{ required: true, message: `请选择${deliveryLabel}`, trigger: 'change' }, { rules.deliveryAt = [{ required: true, message: `请选择${deliveryLabel}`, trigger: 'change' }, {
@@ -110,6 +110,7 @@ export function transportDemandError(kind: TransportDemandKind, form: TransportD
if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数' if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数'
if (kind === 'truck' && (!Number.isSafeInteger(form.estimatedPalletCount) || form.estimatedPalletCount <= 0)) return '请输入大于 0 的托数' if (kind === 'truck' && (!Number.isSafeInteger(form.estimatedPalletCount) || form.estimatedPalletCount <= 0)) return '请输入大于 0 的托数'
if (kind === 'truck' && (form.budgetAmount == null || !Number.isFinite(form.budgetAmount) || form.budgetAmount <= 0)) return '请输入大于 0 的预算总价' if (kind === 'truck' && (form.budgetAmount == null || !Number.isFinite(form.budgetAmount) || form.budgetAmount <= 0)) return '请输入大于 0 的预算总价'
if (kind === 'looseboard' && form.budgetAmount == null) return '请输入预算总价'
if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0' if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0'
if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种' if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种'
return undefined return undefined
@@ -279,7 +279,7 @@ async function confirmReceipt() {
if (!documents.length) return if (!documents.length) return
let confirmedCount = 0 let confirmedCount = 0
try { try {
await ElMessageBox.confirm(`确认已核对 ${documents.length} 份报关行回执吗?确认后平台会把确认结果发送给报关行执行。`, '确认报关行回执', { type: 'warning' }) await ElMessageBox.confirm('确认报关行回执吗?确认后平台会把确认结果发送给报关行执行。', '确认报关行回执', { type: 'warning' })
mutating.value = true mutating.value = true
for (const document of documents) { for (const document of documents) {
await confirmMerchantCustomsReceipt(document) await confirmMerchantCustomsReceipt(document)
@@ -320,19 +320,6 @@ function formatDateTime(value: string | null | undefined) {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
} }
function receiptHistoryLabel(document: MerchantCustomsDocument) {
return (document.receiptHistory || []).map((item) => `V${item.version}`).join('、') || '—'
}
function confirmationActionLabel(action: MerchantCustomsDocument['confirmationHistory'][number]['action']) {
return action === 'CONFIRM' ? '确认' : '打回'
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function clearanceResultLabel(value: MerchantClearanceResult) { function clearanceResultLabel(value: MerchantClearanceResult) {
return ({ INSPECTION: '查验', SUPPLEMENT_REQUIRED: '补充资料', RELEASED: '放行', DETAINED: '扣留', OTHER: '其他' } as Record<string, string>)[value.resultType] || value.resultType return ({ INSPECTION: '查验', SUPPLEMENT_REQUIRED: '补充资料', RELEASED: '放行', DETAINED: '扣留', OTHER: '其他' } as Record<string, string>)[value.resultType] || value.resultType
} }
@@ -418,7 +405,7 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
<section class="customs-section"> <section class="customs-section">
<div class="customs-section-title"> <div class="customs-section-title">
<h3>资料版本历史</h3> <h3>资料文件</h3>
<div v-if="pendingReceipts.length && detail.status === 'DECLARING'" class="customs-receipt-actions"> <div v-if="pendingReceipts.length && detail.status === 'DECLARING'" class="customs-receipt-actions">
<el-button type="success" :disabled="mutating" @click="confirmReceipt">确认全部回执</el-button> <el-button type="success" :disabled="mutating" @click="confirmReceipt">确认全部回执</el-button>
<el-button type="danger" plain :disabled="mutating" @click="rejectReceipt">打回全部回执</el-button> <el-button type="danger" plain :disabled="mutating" @click="rejectReceipt">打回全部回执</el-button>
@@ -431,10 +418,6 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
<FileText :size="20" /> <FileText :size="20" />
<div> <div>
<strong>{{ item.source.fileName }}</strong> <strong>{{ item.source.fileName }}</strong>
<span>版本 V{{ item.source.version }} · {{ formatSize(item.source.sizeBytes) }} · {{ formatDateTime(item.source.uploadedAt) }}</span>
<small v-if="item.receiptHistory?.length">报关行回执历史:{{ receiptHistoryLabel(item) }}</small>
<small v-for="history in item.confirmationHistory" :key="history.id">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small>
<small>关联入仓码:{{ item.inboundCodes.join('、') || '—' }}</small>
</div> </div>
</div> </div>
<div class="customs-document-actions"> <div class="customs-document-actions">
@@ -18,7 +18,6 @@ const emit = defineEmits<{
<section v-if="page" class="first-leg-editor-page"> <section v-if="page" class="first-leg-editor-page">
<div class="editor-page-nav"> <div class="editor-page-nav">
<el-button link type="primary" @click="emit('back')"><ArrowLeft :size="17" />返回订单列表</el-button> <el-button link type="primary" @click="emit('back')"><ArrowLeft :size="17" />返回订单列表</el-button>
<span>可先保存草稿,确认无误后再提交平台审核</span>
</div> </div>
<div class="editor-page-body"><slot /></div> <div class="editor-page-body"><slot /></div>
<footer class="editor-page-footer"><slot name="footer" /></footer> <footer class="editor-page-footer"><slot name="footer" /></footer>
@@ -44,7 +43,6 @@ const emit = defineEmits<{
<style scoped> <style scoped>
.first-leg-editor-page { min-width: 0; padding-bottom: 72px; border: 1px solid #edf0f5; background: #fff; box-shadow: 0 5px 18px rgba(15, 31, 58, .04); } .first-leg-editor-page { min-width: 0; padding-bottom: 72px; border: 1px solid #edf0f5; background: #fff; box-shadow: 0 5px 18px rgba(15, 31, 58, .04); }
.editor-page-nav { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 5px 16px; border-bottom: 1px solid #edf0f5; } .editor-page-nav { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 5px 16px; border-bottom: 1px solid #edf0f5; }
.editor-page-nav span { color: var(--muted); font-size: 12px; }
.editor-page-body { padding: 16px 20px 24px; } .editor-page-body { padding: 16px 20px 24px; }
.editor-page-footer { position: fixed; z-index: 18; right: 0; bottom: 0; left: 224px; display: flex; min-height: 68px; align-items: center; justify-content: center; padding: 10px 24px; border-top: 1px solid #dfe6ee; background: rgba(255, 255, 255, 0.97); box-shadow: 0 -6px 18px rgba(15, 31, 58, .08); backdrop-filter: blur(10px); } .editor-page-footer { position: fixed; z-index: 18; right: 0; bottom: 0; left: 224px; display: flex; min-height: 68px; align-items: center; justify-content: center; padding: 10px 24px; border-top: 1px solid #dfe6ee; background: rgba(255, 255, 255, 0.97); box-shadow: 0 -6px 18px rgba(15, 31, 58, .08); backdrop-filter: blur(10px); }
.editor-page-footer :deep(.editor-actions) { width: min(100%, 900px); justify-content: center; } .editor-page-footer :deep(.editor-actions) { width: min(100%, 900px); justify-content: center; }
@@ -70,7 +68,6 @@ const emit = defineEmits<{
@media (max-width: 720px) { @media (max-width: 720px) {
.first-leg-editor-page { padding-bottom: 0; } .first-leg-editor-page { padding-bottom: 0; }
.editor-page-nav { align-items: flex-start; flex-direction: column; gap: 2px; padding: 10px 16px; } .editor-page-nav { align-items: flex-start; flex-direction: column; gap: 2px; padding: 10px 16px; }
.editor-page-nav span { padding-left: 1px; line-height: 1.5; }
.editor-page-body { padding: 18px 16px 24px; } .editor-page-body { padding: 18px 16px 24px; }
.editor-page-footer { position: sticky; bottom: 0; left: auto; padding: 10px 16px; } .editor-page-footer { position: sticky; bottom: 0; left: auto; padding: 10px 16px; }
:global(.first-leg-editor-dialog.el-dialog) { width: calc(100vw - 24px) !important; height: 96vh; margin-top: 2vh; } :global(.first-leg-editor-dialog.el-dialog) { width: calc(100vw - 24px) !important; height: 96vh; margin-top: 2vh; }
@@ -56,7 +56,8 @@ test('supports a standalone dialog and keeps the unpushed billing-weight state v
}) })
test('shows the published merchant inbound, measurement details and customer confirmation entry', () => { test('shows the published merchant inbound, measurement details and customer confirmation entry', () => {
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '换算与精度', '最终计费量', '单价', '基础费用', '附加费用', '费用合计', '平台备注']) { assert.doesNotMatch(source, /平台备注|inbound\.publicRemark/)
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '换算与精度', '最终计费量', '单价', '基础费用', '附加费用', '费用合计']) {
assert.ok(source.includes(label), `missing label: ${label}`) assert.ok(source.includes(label), `missing label: ${label}`)
} }
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/) assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
@@ -113,7 +113,7 @@ async function confirmFee() {
</dl> </dl>
<div class="inbound-result-section"> <div class="inbound-result-section">
<div class="inbound-section-title"><h4>测量数据</h4><el-tag size="small" type="warning" effect="light">后台推送</el-tag></div> <div class="inbound-section-title"><h4>测量数据</h4></div>
<dl class="inbound-measurements"> <dl class="inbound-measurements">
<div><dt>实际箱数</dt><dd>{{ formatInboundNumber(props.data.inbound.summary.totalPackageCount, 0) }}</dd></div> <div><dt>实际箱数</dt><dd>{{ formatInboundNumber(props.data.inbound.summary.totalPackageCount, 0) }}</dd></div>
<div><dt>实际毛重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.actualGrossWeightKg, 'kg') }}</dd></div> <div><dt>实际毛重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.actualGrossWeightKg, 'kg') }}</dd></div>
@@ -139,7 +139,7 @@ async function confirmFee() {
<div class="inbound-result-section fee-result"> <div class="inbound-result-section fee-result">
<div class="inbound-section-title"> <div class="inbound-section-title">
<h4>费用结果</h4> <h4>费用结果</h4>
<span>{{ props.data.latestFee.feeNo }}</span>
</div> </div>
<dl class="fee-calculation-grid"> <dl class="fee-calculation-grid">
<div><dt>计费方式</dt><dd>{{ getChargeModeLabel(props.data.latestFee.chargeMode, props.data.latestFee.actualWeightKg, props.data.latestFee.actualVolumeCbm, props.data.latestFee.convertedQuantity) }} · {{ getTaxModeLabel(props.data.latestFee.taxMode) }}</dd></div> <div><dt>计费方式</dt><dd>{{ getChargeModeLabel(props.data.latestFee.chargeMode, props.data.latestFee.actualWeightKg, props.data.latestFee.actualVolumeCbm, props.data.latestFee.convertedQuantity) }} · {{ getTaxModeLabel(props.data.latestFee.taxMode) }}</dd></div>
@@ -167,10 +167,6 @@ async function confirmFee() {
</div> </div>
</div> </div>
<div v-if="props.data.inbound.publicRemark" class="inbound-public-remark">
<strong>平台备注</strong>
<p>{{ props.data.inbound.publicRemark }}</p>
</div>
</template> </template>
</section> </section>
@@ -22,7 +22,6 @@ function stageState(index: number) {
<div class="progress-heading"> <div class="progress-heading">
<div> <div>
<h3 id="order-progress-title">履约进度</h3> <h3 id="order-progress-title">履约进度</h3>
<span>订单主节点与关联柜次统一展示</span>
</div> </div>
</div> </div>
@@ -63,6 +63,10 @@ function rowActions(row: object): TableAction[] {
return [{ key: 'documents', label: '查看资料', disabled: loading.value, run: () => openDetail(row) }] return [{ key: 'documents', label: '查看资料', disabled: loading.value, run: () => openDetail(row) }]
} }
function rowClassName({ row }: { row: CustomsDocumentRow }) {
return row.detail.merchantStatus === 'DECLARING' ? 'customs-row-declaring' : ''
}
function statusLabel(detail: MerchantCustomsContainer) { function statusLabel(detail: MerchantCustomsContainer) {
return detail.merchantStatus ? MERCHANT_CUSTOMS_STATUS_LABELS[detail.merchantStatus] ?? '—' : '—' return detail.merchantStatus ? MERCHANT_CUSTOMS_STATUS_LABELS[detail.merchantStatus] ?? '—' : '—'
} }
@@ -98,7 +102,7 @@ onMounted(() => { void load() })
<div class="customs-page"> <div class="customs-page">
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="报关资料查询条件" :fields="queryFields" @search="search" /> <QueryToolbar v-model="queryModel" layout="grid" ariaLabel="报关资料查询条件" :fields="queryFields" @search="search" />
<section class="customs-card"> <section class="customs-card">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRecords" :columns="columns" :loading="loading" :total="filteredRecords.length" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="order.id" :empty-text="emptyText"> <DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRecords" :columns="columns" :loading="loading" :total="filteredRecords.length" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" :row-class-name="rowClassName" row-key="order.id" :empty-text="emptyText">
<template #toolbar><strong class="customs-list-heading">资料列表<CustomsHelp title="资料统计口径"><p>统计为本客户同柜退税货物汇总,资料覆盖按入仓码计。</p></CustomsHelp></strong></template> <template #toolbar><strong class="customs-list-heading">资料列表<CustomsHelp title="资料统计口径"><p>统计为本客户同柜退税货物汇总,资料覆盖按入仓码计。</p></CustomsHelp></strong></template>
<template #toolbar-actions><el-button :loading="loading" @click="load"><RefreshCcw :size="16" />刷新</el-button></template> <template #toolbar-actions><el-button :loading="loading" @click="load"><RefreshCcw :size="16" />刷新</el-button></template>
<template v-if="errorMessage" #error><div class="state-error" role="alert"><span>{{ errorMessage }}</span><el-button :loading="loading" @click="load">重新加载</el-button></div></template> <template v-if="errorMessage" #error><div class="state-error" role="alert"><span>{{ errorMessage }}</span><el-button :loading="loading" @click="load">重新加载</el-button></div></template>
@@ -117,5 +121,7 @@ onMounted(() => { void load() })
.customs-page { display: grid; gap: var(--client-section-gap); } .customs-page { display: grid; gap: var(--client-section-gap); }
.customs-list-heading { display: flex; align-items: center; gap: 4px; } .customs-list-heading { display: flex; align-items: center; gap: 4px; }
.customs-card { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; } .customs-card { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
.customs-card :deep(.el-table__row.customs-row-declaring > .el-table__cell) { background-color: #eff8ff; }
.customs-card :deep(.el-table__row.customs-row-declaring:hover > .el-table__cell) { background-color: #dbeafe; }
.state-error { display: flex; min-height: 84px; align-items: center; justify-content: center; gap: 12px; padding: 14px; color: #b42318; } .state-error { display: flex; min-height: 84px; align-items: center; justify-content: center; gap: 12px; padding: 14px; color: #b42318; }
</style> </style>
@@ -226,7 +226,7 @@ test('shows one unified progress line and C02 business summaries without detail
assert.match(source, /label: '后续履约', value: orderProgressLabel\(store\.orderProgress\[order\.id\]\)/) assert.match(source, /label: '后续履约', value: orderProgressLabel\(store\.orderProgress\[order\.id\]\)/)
assert.doesNotMatch(source, /待海外同步/) assert.doesNotMatch(source, /待海外同步/)
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/) assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
assert.match(progressSource, /订单主节点与关联柜次统一展示/) assert.doesNotMatch(progressSource, /订单主节点与关联柜次统一展示/)
assert.match(progressSource, /履约进度/) assert.match(progressSource, /履约进度/)
assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/) assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/)
assert.doesNotMatch(source, /<h3>报关回执<\/h3>/) assert.doesNotMatch(source, /<h3>报关回执<\/h3>/)
@@ -312,7 +312,7 @@ test('requires an enabled platform receiving warehouse in the order form', () =>
assert.match(editorShellSource, /first-leg-editor-dialog \.el-dialog__body\) \{[^}]*min-height: 0;[^}]*overflow-y: auto;[^}]*overscroll-behavior: contain/) assert.match(editorShellSource, /first-leg-editor-dialog \.el-dialog__body\) \{[^}]*min-height: 0;[^}]*overflow-y: auto;[^}]*overscroll-behavior: contain/)
assert.match(editorShellSource, /first-leg-editor-dialog \.el-dialog__footer\) \{[^}]*border-top: 1px solid var\(--border\);[^}]*background: #fff/) assert.match(editorShellSource, /first-leg-editor-dialog \.el-dialog__footer\) \{[^}]*border-top: 1px solid var\(--border\);[^}]*background: #fff/)
assert.match(source, /detailOrder\.receivingWarehouseSnapshot\?\.name/) assert.match(source, /detailOrder\.receivingWarehouseSnapshot\?\.name/)
assert.match(source, /detailOrder\.receivingWarehouseSnapshot\.code/) assert.doesNotMatch(source, /detailOrder\.receivingWarehouseSnapshot\.code/)
assert.match(source, /loadSelectedWarehouse\(detailedOrder\.receivingWarehouseId\)/) assert.match(source, /loadSelectedWarehouse\(detailedOrder\.receivingWarehouseId\)/)
}) })
@@ -549,7 +549,7 @@ test('shows fulfillment courier information as read-only client detail', () => {
assert.match(source, /履约快递信息/) assert.match(source, /履约快递信息/)
assert.match(source, /detailOrder\.expressCompanyName/) assert.match(source, /detailOrder\.expressCompanyName/)
assert.match(source, /detailOrder\.trackingNumber/) assert.match(source, /detailOrder\.trackingNumber/)
assert.match(source, /快递公司由平台维护/) assert.doesNotMatch(source, /快递公司由平台维护/)
assert.doesNotMatch(source, /v-model="form\.expressCompanyName"/) assert.doesNotMatch(source, /v-model="form\.expressCompanyName"/)
}) })
@@ -751,7 +751,8 @@ test('supports the frozen customs document workflow and customer-safe downloads'
assert.match(customsDialogSource, /v-if="detail.status === 'COMPLETED'"[^>]*><strong>报关已完成/) assert.match(customsDialogSource, /v-if="detail.status === 'COMPLETED'"[^>]*><strong>报关已完成/)
assert.match(customsDialogSource, /真实报关完税文件已齐全/) assert.match(customsDialogSource, /真实报关完税文件已齐全/)
assert.match(customsDialogSource, /item\.finalDocument/) assert.match(customsDialogSource, /item\.finalDocument/)
assert.match(customsDialogSource, /资料版本历史/) assert.match(customsDialogSource, /资料文件/)
assert.doesNotMatch(customsDialogSource, /资料版本历史|报关行回执历史|item\.confirmationHistory|版本 V/)
assert.match(customsDialogSource, /尚未上传退税报关资料/) assert.match(customsDialogSource, /尚未上传退税报关资料/)
assert.match(customsDialogSource, /退税报关资料加载失败/) assert.match(customsDialogSource, /退税报关资料加载失败/)
assert.doesNotMatch(customsDialogSource, /accept=/) assert.doesNotMatch(customsDialogSource, /accept=/)
@@ -2401,8 +2401,8 @@ async function searchOrders(model: QueryModel) {
<div><dt>发货方式</dt><dd>{{ pickupMethodLabels[detailOrder.pickupMethod] }}</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> <div><dt>预约提货时间</dt><dd>{{ detailOrder.pickupMethod === 'PICKUP' && detailOrder.requestedPickupAt ? formatDateTime(detailOrder.requestedPickupAt) : '-' }}</dd></div>
<div><dt>报关方式</dt><dd>{{ taxModeLabels[detailOrder.taxMode] }}</dd></div> <div><dt>报关方式</dt><dd>{{ taxModeLabels[detailOrder.taxMode] }}</dd></div>
<div><dt>运输渠道</dt><dd>{{ detailOrder.channelName || detailOrder.channelCode || '-' }}<span v-if="detailOrder.channelName && detailOrder.channelCode" class="detail-subline">{{ detailOrder.channelCode }}</span></dd></div> <div><dt>运输渠道</dt><dd>{{ detailOrder.channelName || detailOrder.channelCode || '-' }}</dd></div>
<div><dt>国内收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName || '—' }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code || '—' }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div> <div><dt>国内收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName || '—' }}</dd></div>
<div><dt>收货要求</dt><dd>{{ displayValue(detailOrder.receivingTime) }}</dd></div> <div><dt>收货要求</dt><dd>{{ displayValue(detailOrder.receivingTime) }}</dd></div>
<div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[detailOrder.destinationType] }}</dd></div> <div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[detailOrder.destinationType] }}</dd></div>
<div><dt>目的国家 / 仓库</dt><dd>{{ detailOrder.destinationCountryCode || '-' }} · {{ detailOrder.destinationWarehouseName || '-' }}</dd></div> <div><dt>目的国家 / 仓库</dt><dd>{{ detailOrder.destinationCountryCode || '-' }} · {{ detailOrder.destinationWarehouseName || '-' }}</dd></div>
@@ -2417,7 +2417,7 @@ async function searchOrders(model: QueryModel) {
<h3>收发货信息</h3> <h3>收发货信息</h3>
<dl class="party-detail-grid"> <dl class="party-detail-grid">
<div><dt>发货方</dt><dd><strong>{{ detailOrder.shipperPartySnapshot?.name || detailOrder.shipper || '未提供' }}</strong><span v-if="detailOrder.shipperPartySnapshot">{{ detailOrder.shipperPartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.shipperPartySnapshot.phone || '未填写电话' }} · {{ detailOrder.shipperPartySnapshot.email || '未填写邮箱' }}<br />{{ detailOrder.shipperPartySnapshot.countryRegion }} {{ detailOrder.shipperPartySnapshot.province }} {{ detailOrder.shipperPartySnapshot.city }} {{ detailOrder.shipperPartySnapshot.address }} {{ detailOrder.shipperPartySnapshot.postalCode }}</span></dd></div> <div><dt>发货方</dt><dd><strong>{{ detailOrder.shipperPartySnapshot?.name || detailOrder.shipper || '未提供' }}</strong><span v-if="detailOrder.shipperPartySnapshot">{{ detailOrder.shipperPartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.shipperPartySnapshot.phone || '未填写电话' }} · {{ detailOrder.shipperPartySnapshot.email || '未填写邮箱' }}<br />{{ detailOrder.shipperPartySnapshot.countryRegion }} {{ detailOrder.shipperPartySnapshot.province }} {{ detailOrder.shipperPartySnapshot.city }} {{ detailOrder.shipperPartySnapshot.address }} {{ detailOrder.shipperPartySnapshot.postalCode }}</span></dd></div>
<div><dt>收货方</dt><dd><strong>{{ detailOrder.consigneePartySnapshot?.name || detailOrder.consignee || detailOrder.destinationWarehouseName || '未提供' }}</strong><span v-if="detailOrder.consigneePartySnapshot">{{ detailOrder.consigneePartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.consigneePartySnapshot.phone || '未填写电话' }} · {{ detailOrder.consigneePartySnapshot.email || '未填写邮箱' }}<br />{{ detailOrder.consigneePartySnapshot.countryRegion }} {{ detailOrder.consigneePartySnapshot.province }} {{ detailOrder.consigneePartySnapshot.city }} {{ detailOrder.consigneePartySnapshot.address }} {{ detailOrder.consigneePartySnapshot.postalCode }}</span><span v-else>平台目的仓地址以最终履约安排为准</span></dd></div> <div><dt>收货方</dt><dd><strong>{{ detailOrder.consigneePartySnapshot?.name || detailOrder.consignee || detailOrder.destinationWarehouseName || '未提供' }}</strong><span v-if="detailOrder.consigneePartySnapshot">{{ detailOrder.consigneePartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.consigneePartySnapshot.phone || '未填写电话' }} · {{ detailOrder.consigneePartySnapshot.email || '未填写邮箱' }}<br />{{ detailOrder.consigneePartySnapshot.countryRegion }} {{ detailOrder.consigneePartySnapshot.province }} {{ detailOrder.consigneePartySnapshot.city }} {{ detailOrder.consigneePartySnapshot.address }} {{ detailOrder.consigneePartySnapshot.postalCode }}</span></dd></div>
</dl> </dl>
<div v-if="detailOrder.customerRemark" class="customer-remark"><strong>客户备注</strong><p>{{ detailOrder.customerRemark }}</p></div> <div v-if="detailOrder.customerRemark" class="customer-remark"><strong>客户备注</strong><p>{{ detailOrder.customerRemark }}</p></div>
</section> </section>
@@ -2445,7 +2445,7 @@ async function searchOrders(model: QueryModel) {
<section v-if="detailOrder.status !== 'CANCELLED' || detailOrder.expressCompanyName || detailOrder.trackingNumber" class="detail-section"> <section v-if="detailOrder.status !== 'CANCELLED' || detailOrder.expressCompanyName || detailOrder.trackingNumber" class="detail-section">
<h3>履约快递信息</h3> <h3>履约快递信息</h3>
<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> <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> <div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>暂无快递信息</strong></div></div>
</section> </section>
<section class="detail-section"> <section class="detail-section">
<h3>货物明细</h3> <h3>货物明细</h3>