Compare commits

..
13 Commits
Author SHA1 Message Date
liuyihe e10e079565 优化 2026-11-06 13:33:14 +08:00
ouyangxq 110d52fd5b fix(ci): bound Docker image build resources 2026-11-06 12:52:05 +08:00
liuyihe f5f62af4a7 优化 2026-11-06 10:42:39 +08:00
liuyihe 8d80074266 优化 2026-11-05 17:46:01 +08:00
ouyangxq 40095a749b Merge remote-tracking branch 'origin/release/test' into release/test
# Conflicts:
#	src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts
2026-11-05 16:30:48 +08:00
ouyangxq a1721371e4 fix: preserve required inputs and refine operations screens 2026-11-05 16:30:28 +08:00
liuyihe d075ac7e03 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-11-05 16:03:06 +08:00
liuyihe 525392963c 优化 2026-11-05 16:03:04 +08:00
ouyangxq 2d1bb38ad5 fix(inbounds): restore approved measurement workspace 2026-11-05 14:21:32 +08:00
liuyihe b5f9537e21 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-11-04 19:27:18 +08:00
liuyihe f54d3ae8a5 优化 2026-11-04 19:27:16 +08:00
ouyangxq 1150f245be 优化 2026-11-04 18:08:28 +08:00
ouyangxq c17d355a69 明确私人地址人工报价及预装前置提示 2026-11-04 14:47:39 +08:00
69 changed files with 805 additions and 214 deletions
+4 -3
View File
@@ -6,6 +6,9 @@ FROM ${NODE_IMAGE} AS build
WORKDIR /app
# 为类型检查和打包留出原生内存余量,容器总内存由构建入口限制为 3GiB。
ENV NODE_OPTIONS=--max-old-space-size=2048
# Corepack 与 pnpm 依赖均通过 npmmirror 下载,避免构建时访问 npm 官方源过慢。
ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
@@ -21,9 +24,7 @@ RUN pnpm config set registry https://registry.npmmirror.com && \
COPY . .
ARG VITE_CLIENT_WEB_URL
ENV VITE_CLIENT_WEB_URL=${VITE_CLIENT_WEB_URL}
# Docker 构建时同机还会运行多项服务,限制 Vitest 并发以避免测试超时。
# pnpm 会直接把脚本名后的参数传给 Vitest;额外的 `--` 会让 Vitest
# 把 maxWorkers 当作位置参数,导致并发限制不生效。
# 类型检查通过后才生成静态产物。
RUN pnpm build
# 运行阶段:仅保留构建产物和 Nginx,不包含源码或 node_modules。
Vendored
+6 -2
View File
@@ -3,6 +3,7 @@ pipeline {
options {
timestamps()
disableConcurrentBuilds()
timeout(time: 20, unit: 'MINUTES')
}
stages {
stage('检查测试发布参数') {
@@ -27,8 +28,11 @@ pipeline {
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
steps {
sh '''set -eu
docker compose -f docker-compose.test.yml build --pull=false
docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120
sh scripts/build-image.sh --pull=false \
--build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \
--build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \
-t "admin-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 admin-web wget -qO- http://127.0.0.1/healthz
'''
}
+2
View File
@@ -65,6 +65,8 @@ VITE_CLIENT_WEB_URL=http://localhost:5174
容器启动通过 `CLIENT_WEB_URL` 生成 `/runtime-config.js`;本地 Vite 使用 `VITE_CLIENT_WEB_URL`。本地构建通过不代表容器或测试环境已发布。真实 HTTP 验收入口为 `pnpm test:phase4:test --environment test --gateway <统一入口>`,凭据通过环境变量传入,仅在取得对应测试写入授权后执行。
Jenkins 的测试及灰度镜像构建均通过 `scripts/build-image.sh`,Compose 只部署已构建镜像。构建预算及节点要求见[部署指南](../../docs/测试环境Jenkins部署指南.md)。离线回归为 `node --test scripts/build-resource-contract.test.mjs`,本机仍经统一 `local-check.ps1` 入口运行。
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。订单与客户真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
仓库管理已接入真实平台 Warehouse API,统一维护国内收货仓、FBA 仓和海外仓,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给新业务选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。库存和出入库作业由对应业务模块管理,不在仓库主数据页面中模拟。
+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; }
trap cleanup EXIT HUP INT TERM
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 VITE_CLIENT_WEB_URL="$PUBLIC_WEB_ORIGIN" -t "$image" .
docker push "$image"
@@ -124,13 +124,16 @@ it('preserves real FBA fields and entered values when expanding the create dialo
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
expect(creationBody().text()).toContain('AP-001')
})
it('shows every saved server field as readonly text including confirmed offsets and long remarks', async () => {
it('shows saved dates, confirmed offsets and long remarks without planned time ranges', async () => {
const saved = { ...batchContext().appointment, plannedDate: '2026-10-26', plannedAt: '2026-10-26T08:00:00', plannedEndAt: '2026-10-26T18:00:00', timezone: 'America/Los_Angeles', appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-001', appointmentAt: '2026-11-01T01:30:00', appointmentEndAt: '2026-11-01T01:45:00', appointmentStartUtc: '2026-11-01T08:30:00Z', appointmentEndUtc: '2026-11-01T09:45:00Z', appointmentRemark: '完整预约备注'.repeat(30) }
vi.mocked(createSourceAppointment).mockResolvedValue(saved)
const wrapper = await open(); await create(wrapper)
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
const receipt = creationBody().get('[aria-label="预约保存回执"]')
for (const text of ['AP-001', saved.plannedDate, saved.plannedAt, saved.plannedEndAt, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
for (const text of ['AP-001', saved.plannedDate, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
expect(receipt.text()).not.toMatch(/计划开始时段|计划结束时段/)
expect(receipt.text()).not.toContain(saved.plannedAt)
expect(receipt.text()).not.toContain(saved.plannedEndAt)
expect(receipt.findAll('input, textarea, select')).toHaveLength(0)
expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/)
})
@@ -32,9 +32,9 @@ it('names the missing material and supplements the canonical booking inside the
const wrapper = render(); await flushPromises()
expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup)
expect(wrapper.text()).toContain('IN-TEST'); expect(wrapper.text()).toContain('缺少本票 FBA 货件号或追踪号')
expect(wrapper.text()).toContain('不能代替货件号'); expect(wrapper.text()).not.toContain(source.orderId)
expect(wrapper.text()).not.toContain(source.orderId)
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20')
expect(wrapper.find('input[type=number]').exists()).toBe(false)
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).not.toHaveBeenCalled()
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
@@ -44,6 +44,22 @@ it('names the missing material and supplements the canonical booking inside the
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
})
it('allows missing or changed quantities to be verified before saving shipment references', async () => {
for (const cargo of [
{ ...context, effectivePackages: null },
{ ...context, sourceChanged: true },
]) {
vi.mocked(getCargoVerification).mockResolvedValue(cargo)
vi.mocked(saveCargoVerification).mockClear()
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
await wrapper.get('input[type=number]').setValue('20')
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ packageCount: 20, shipmentReferences: ['FBA-TEST'] }))
wrapper.unmount()
}
})
it('leaves pending arrangements lazy and reads only when formal confirmation is selected', async () => {
const wrapper = render(false); await flushPromises()
expect(resolvePlanningOrder).not.toHaveBeenCalled()
@@ -66,7 +66,6 @@ onBeforeUnmount(() => { ++generation; unregister?.() })
<section v-show="active || editing" class="density-dialog-section" aria-label="正式确认前的货物资料" :aria-busy="loading">
<h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3>
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
<LoadErrorState v-if="error" title="货物资料未核对" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>FBA 货件号 / 追踪号</th><th>需要补充的资料</th></tr></thead><tbody>
<tr v-for="row in rows" :key="key(row)">
@@ -129,7 +129,6 @@ onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsReque
<table class="density-table"><tbody>
<tr><th scope="row">系统单号</th><td>{{ created.appointmentNo || '—' }}</td><th scope="row">目的仓时区</th><td>{{ created.timezone || '待核实' }}</td></tr>
<tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr>
<tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr>
<tr v-if="createdForm.appointmentAt || createdForm.appointmentEndAt"><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
<tr v-if="createdForm.startOffset || createdForm.endOffset"><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
@@ -59,14 +59,13 @@ onMounted(() => load())
<template>
<section aria-label="货物核实资料">
<h3>{{ confirmation ? '补齐本票送仓资料' : '核实货物资料' }}<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
<p v-if="confirmation">请向客户或货物负责人取得本票完整的 FBA 货件号或追踪号,每行一个;不要填写预约外部约号。下方保存只补充货物资料,完成后还需保存预约安排。</p>
<LoadErrorState v-if="error" title="核实资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在读取货物资料…</p>
<template v-if="context">
<dl class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><template v-if="!confirmation"><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></template></dl>
<dl v-if="!confirmation" class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></dl>
<p v-if="context.sourceChanged" role="alert">原货源已变化,请核对当前原票和已分配量后重新保存。</p><p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
<form class="density-form work-form appointment-native-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
<label>{{ confirmation ? '本票核实总箱数(必填)' : '核实箱数(可暂不填)' }}<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
<label v-if="!confirmation || context.effectivePackages == null || context.sourceChanged">{{ confirmation ? '本票核实总箱数(必填)' : '核实箱数(可暂不填)' }}<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" class="workbench-input" /></label>
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></label>
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
@@ -63,7 +63,7 @@ onMounted(load)
</tbody></table>
<p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
</section>
<FbaAppointmentFields continuous hide-confirmation-times ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<FbaAppointmentFields continuous date-only ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
</template>
+7 -4
View File
@@ -43,12 +43,15 @@ describe('LoginView authentication lifecycle', () => {
expect(loginSource).not.toContain('demo-tip')
})
it('uses the shared blue-white theme and preserves the existing compact brand layout', () => {
it('preserves the dark brand panel and white login card independently of workbench typography', () => {
expect(loginSource).toContain("import '@/styles/adminWorkbench.css'")
expect(loginSource).toContain('class="login-page admin-workbench"')
expect(loginSource).toContain('<h1 class="workbench-title">柏悦物流管理后台</h1>')
expect(loginSource).toContain('class="login-mobile-brand workbench-brand-mark"')
expect(loginSource).toContain('class="login-card workbench-panel"')
expect(loginSource).toContain('<h1>柏悦物流管理后台</h1>')
expect(loginSource).toContain('class="login-mobile-brand"')
expect(loginSource).toContain('class="login-card"')
expect(loginSource).toContain('--login-brand-background: #172a46')
expect(loginSource).toContain('background: var(--login-brand-background); color: #fff;')
expect(loginSource).not.toMatch(/class="[^"]*workbench-(?:title|heading|panel|brand-mark)/)
expect(loginSource).toContain('class="login-error workbench-notice workbench-tone" data-tone="rose"')
expect(loginSource).toContain('aria-label="平台员工登录"')
expect(loginSource).toContain('label-position="top" size="large"')
+25 -22
View File
@@ -57,23 +57,23 @@ async function submit() {
<template>
<div class="login-page admin-workbench">
<section class="login-intro">
<div class="login-brand workbench-brand-mark"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div class="workbench-heading">
<h1 class="workbench-title">柏悦物流管理后台</h1>
<p class="login-intro-copy workbench-note">平台内部运营与管理系统</p>
<div class="login-brand"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div>
<h1>柏悦物流管理后台</h1>
<p class="login-intro-copy">平台内部运营与管理系统</p>
</div>
<div class="login-security-note workbench-meta">
<div class="login-security-note">
<ShieldCheck :size="22" aria-hidden="true" />
<span>平台内部系统,请使用授权账号登录</span>
</div>
</section>
<section class="login-panel">
<div class="login-card workbench-panel">
<div class="login-mobile-brand workbench-brand-mark"><img :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div class="login-card-icon workbench-icon"><LockKeyhole :size="24" aria-hidden="true" /></div>
<h2 class="workbench-title">登录管理后台</h2>
<p class="workbench-note">使用平台内部员工账号登录</p>
<div class="login-card">
<div class="login-mobile-brand"><img :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div class="login-card-icon"><LockKeyhole :size="24" aria-hidden="true" /></div>
<h2>登录管理后台</h2>
<p>使用平台内部员工账号登录</p>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" aria-label="平台员工登录" @submit.prevent="submit">
<el-form-item label="账号" prop="account" required>
<el-input v-model.trim="form.account" placeholder="请输入账号" :prefix-icon="UserRound" autocomplete="username" maxlength="100" autofocus />
@@ -92,17 +92,20 @@ async function submit() {
</template>
<style scoped>
.login-page { display: grid; min-width: 0; min-height: 100svh; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: var(--workbench-surface); color: var(--workbench-text); }
.login-intro { display: flex; min-width: 0; flex-direction: column; justify-content: space-between; padding: 46px clamp(42px, 7vw, 100px); }
.login-brand { display: flex; width: fit-content; min-height: 64px; align-items: center; padding: 8px 20px; }
/* Brand layout is local to login; workbench styles only supply shared controls and errors. */
.login-page { --login-brand-background: #172a46; display: grid; min-width: 0; min-height: 100svh; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: var(--cc-surface); color: var(--cc-text); }
.login-intro { display: flex; min-width: 0; flex-direction: column; justify-content: space-between; padding: 46px clamp(42px, 7vw, 100px); background: var(--login-brand-background); color: #fff; }
.login-brand { display: flex; width: fit-content; min-height: 64px; align-items: center; }
.login-brand-logo { display: block; width: 205px; height: 64px; object-fit: contain; object-position: left center; }
.login-intro h1 { max-width: 560px; }
.login-intro-copy { max-width: 560px; }
.login-intro h1 { max-width: 560px; margin: 16px 0 14px; font-size: 40px; line-height: 1.2; overflow-wrap: anywhere; }
.login-intro-copy { max-width: 560px; margin: 0; color: #cbd5e1; font-size: 15px; line-height: 1.7; overflow-wrap: anywhere; }
.login-security-note { display: flex; align-items: center; gap: 10px; color: #cbd5e1; font-size: 13px; line-height: 1.6; }
.login-security-note > svg { flex: 0 0 auto; }
.login-panel { display: grid; min-width: 0; place-items: center; padding: 48px; background: var(--workbench-surface-tint); }
.login-card.workbench-panel { width: min(410px, 100%); min-width: 0; padding: 32px; }
.login-card h2 { margin-top: 22px; }
.login-card > p { margin: 8px 0 30px; }
.login-panel { display: grid; min-width: 0; place-items: center; padding: 48px; background: var(--cc-surface-muted); }
.login-card { width: min(410px, 100%); min-width: 0; padding: 32px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); background: var(--cc-surface); box-shadow: var(--cc-shadow-md); }
.login-card-icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: var(--cc-radius-lg); background: var(--workbench-accent-soft); color: var(--workbench-primary); }
.login-card h2 { margin: 22px 0 8px; font-size: 28px; line-height: 1.3; overflow-wrap: anywhere; }
.login-card > p { margin: 0 0 30px; color: var(--cc-muted); font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }
.login-mobile-brand { display: none; }
.login-submit { width: 100%; min-height: 44px; }
.login-submit :deep(> span) { display: flex; align-items: center; gap: 8px; }
@@ -114,14 +117,14 @@ async function submit() {
.login-page { grid-template-columns: 1fr; }
.login-intro { display: none; }
.login-panel { align-items: start; padding: 24px; }
.login-card.workbench-panel { width: min(440px, 100%); margin: auto; padding: 28px; }
.login-mobile-brand { display: flex; width: fit-content; min-height: 52px; align-items: center; margin-bottom: 28px; padding: 8px 16px; }
.login-card { width: min(440px, 100%); margin: auto; padding: 28px; }
.login-mobile-brand { display: flex; width: fit-content; min-height: 52px; align-items: center; margin-bottom: 28px; padding: 8px 16px; border-radius: var(--cc-radius-lg); background: var(--login-brand-background); }
.login-mobile-brand img { display: block; width: 154px; height: 48px; object-fit: contain; object-position: left center; }
}
@media (max-width: 360px) {
.login-panel { padding: 12px; }
.login-card.workbench-panel { padding: 20px; }
.login-card { padding: 20px; }
.login-mobile-brand { margin-bottom: 24px; }
}
</style>
@@ -69,6 +69,22 @@ function buttonTexts(wrapper: Awaited<ReturnType<typeof render>>) {
}
describe('ContainerCustomsPanel', () => {
it('locks pre-entry uploads after customer confirmation before broker execution', async () => {
const value = customs('DECLARING', true)
value.documents[0]!.receiptStatus = 'CONFIRMED'
api.get.mockResolvedValue(value)
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: value.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(wrapper.text()).toContain('客户已确认预录单,待发送报关行执行')
for (const label of ['选择预录单', '提交预录单']) {
expect(wrapper.findAll('button').find(button => button.text() === label)?.attributes('disabled')).toBeDefined()
}
expect(wrapper.findAll('button').find(button => button.text() === '发送报关行执行')?.attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
beforeEach(() => {
vi.clearAllMocks()
api.getDeclarationBatches.mockResolvedValue([])
@@ -264,7 +280,7 @@ describe('ContainerCustomsPanel', () => {
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '提交预录单')!.trigger('click')
await flushPromises()
expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7)
expect(api.upload).not.toHaveBeenCalled()
wrapper.unmount()
})
@@ -71,6 +71,7 @@ const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.
const brokerSubmission = computed(() => customs.value?.brokerSubmission || null)
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
const allReceiptsConfirmed = computed(() => Boolean(customs.value?.documents.length) && customs.value?.documents.every((document) => document.receipt && document.receiptStatus === 'CONFIRMED') === true)
const receiptUploadLocked = computed(() => brokerSubmitted.value || customs.value?.documents.some((document) => document.receiptStatus === 'CONFIRMED') === true)
const openDeclarationBatch = computed(() => declarationBatches.value.find((item) => ['READY', 'SUBMITTED', 'ACKNOWLEDGED', 'RESULT_PENDING'].includes(item.status)) || null)
const statusText: Record<ContainerCustomsStatus, string> = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
@@ -85,7 +86,7 @@ function formatDateTime(value: string | null | undefined) {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function selectReceipt(upload: UploadFile) {
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || receiptUploadLocked.value || actionLoading.value) return
receiptFile.value = upload.raw || null
actionError.value = null
}
@@ -186,7 +187,7 @@ async function start() {
}
async function uploadReceipt() {
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || receiptUploadLocked.value || actionLoading.value) return
const selected = receiptFile.value
if (!selected) { ElMessage.warning('请选择对应的预录单'); return }
const documents = customs.value.documents
@@ -325,12 +326,12 @@ onMounted(load)
<div class="workbench-evidence"><table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table></div>
</div>
<div v-if="customs.documents.length" class="compact-table-wrap">
<div class="documents-heading"><h3>已冻结退税资料、预录单与完税文件</h3><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="selectReceipt"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传预录单' : '选择预录单' }}</el-button></el-upload><span v-if="receiptFile" class="selected-receipt" :title="receiptFile.name">{{ receiptFile.name }}</span><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFile" :loading="actionLoading" @click="uploadReceipt">提交预录单</el-button></div></div>
<div class="documents-heading"><h3>已冻结退税资料、预录单与完税文件</h3><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="actionLoading || !brokerSubmission || receiptUploadLocked" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="selectReceipt"><el-button class="density-action" size="small" :disabled="actionLoading || !brokerSubmission || receiptUploadLocked">{{ !brokerSubmission ? '首次发送后上传预录单' : '选择预录单' }}</el-button></el-upload><span v-if="receiptFile" class="selected-receipt" :title="receiptFile.name">{{ receiptFile.name }}</span><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="actionLoading || !brokerSubmission || receiptUploadLocked || !receiptFile" :loading="actionLoading" @click="uploadReceipt">提交预录单</el-button></div></div>
<div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>关联入仓码</th><th>报关行状态</th><th>客户退税资料</th><th>预录单</th><th>真实报关完税文件</th><th>客户</th><th>预录单情况</th></tr></thead><tbody>
<tr v-for="document in customs.documents" :key="document.documentId">
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td>
<td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td>
<td>{{ brokerSubmitted ? '已发送报关行执行' : document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单,待发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td>
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载预录单 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td><td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单' : document.receiptStatus === 'REJECTED' ? '客户已打回预录单' : '待客户确认预录单') : '待上传预录单' }}</td>
</tr>
@@ -65,15 +65,16 @@ describe('ContainerDetailView empty return', () => {
expect(value).toBe('2026-10-23(默认推算)')
expect(value).not.toContain('00:00')
})
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
const wrapper = await mountDetail('DOMESTIC')
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
expect(panel.props('container').id).toBe('9001')
vi.mocked(getContainer).mockResolvedValue({ ...base, vesselName: 'REASSIGNED', voyageNo: 'V2', currentPortScheduleId: 'S1' } as never)
panel.vm.$emit('saved'); await flushPromises()
expect(getContainer).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('REASSIGNED / V2')
expect(wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }).props('container').currentPortScheduleId).toBe('S1')
it.each(['DOMESTIC', 'OVERSEAS'] as const)('keeps voyage information without the temporary schedule maintenance entry in %s', async (workbench) => {
vi.mocked(getContainer).mockResolvedValue({ ...base, vesselName: 'VESSEL', voyageNo: 'V2', currentPortScheduleId: 'S1', estimatedSailingAt: '2026-11-05T10:55:00', estimatedPortArrivalAt: '2026-11-08T00:00:00' } as never)
const wrapper = await mountDetail(workbench)
const section = wrapper.get('[aria-labelledby="container-voyage-heading"]')
expect(section.text()).toContain('船名航次信息')
expect(section.text()).toContain('VESSEL / V2')
expect(section.text()).toContain('2026-11-05 10:55')
expect(section.text()).toContain('2026-11-08 00:00')
expect(wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }).exists()).toBe(false)
expect(section.text()).not.toContain('船期维护与历史')
})
beforeEach(() => {
routeState.query = {}
@@ -16,7 +16,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists'
import {
getContainerOutboundDispatch,
@@ -648,7 +648,7 @@ onMounted(async () => {
</section>
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-voyage-heading">
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2><ContainerPortSchedulePanel :container="detail" @saved="load" /></header>
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense hide-technical-details :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
@@ -245,6 +245,7 @@ const allColumns: DataTableColumn[] = [
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常', afterColumn: 'containerNo' },
{ key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' },
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
{ key: 'createdAt', label: '创建时间', width: 165, formatter: (row: ContainerBookingResource) => formatTime(row.createdAt), afterColumn: 'cabinetNo' },
{ key: 'pickupCode', label: 'PU码', minWidth: 140, formatter: (row: ContainerBookingResource) => row.pickupCode || '—' },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
@@ -267,8 +268,8 @@ const allColumns: DataTableColumn[] = [
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' },
]
const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'oceanBooking', 'warehouseName', 'channelGroupCode', 'channelName', 'billOfLadingNo', 'route', 'containerType', 'capacity', 'vessel', 'shippingCompanyName', 'loadingAt', 'estimatedSailingAt'])
const overseasColumnKeys = new Set(['actions', 'emptyReturnWarning', 'containerNo', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode', 'terminalTruckingCompany', 'pickupAppointmentAt', 'actualPickupAt', 'estimatedEmptyReturnAt', 'actualEmptyReturnAt', 'billOfLadingNo', 'route', 'vessel', 'actualSailingAt', 'estimatedPortArrivalAt', 'actualPortArrivalAt'])
const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'createdAt', 'oceanBooking', 'warehouseName', 'channelGroupCode', 'channelName', 'billOfLadingNo', 'route', 'containerType', 'capacity', 'vessel', 'shippingCompanyName', 'loadingAt', 'estimatedSailingAt'])
const overseasColumnKeys = new Set(['actions', 'emptyReturnWarning', 'containerNo', 'cabinetNo', 'createdAt', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode', 'terminalTruckingCompany', 'pickupAppointmentAt', 'actualPickupAt', 'estimatedEmptyReturnAt', 'actualEmptyReturnAt', 'billOfLadingNo', 'route', 'vessel', 'actualSailingAt', 'estimatedPortArrivalAt', 'actualPortArrivalAt'])
const columns = computed(() => allColumns
.filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key)))
// 国内同时展示查看、运输信息、装柜流程和更多菜单,须为四个入口预留空间。
@@ -25,7 +25,8 @@ describe('InboundTaskList measurement scope', () => {
expect(toolbar).toContain('</h1></slot>')
expect(filters).toContain('<QueryToolbar')
expect(filters).not.toContain('name="status-tabs"')
expect(source).toContain('<template v-if="isReview && access.can(actionPermission)" #toolbar-actions>')
expect(source).toContain('<template #toolbar-actions>')
expect(source).toContain('v-if="isReview && access.can(actionPermission)" type="success"')
})
it('supports selecting only pending measurement reviews for batch approval', () => {
@@ -39,7 +39,7 @@ const props = defineProps<{
title?: string
taskLoader?: (pagination: { page: number; size: number }) => Promise<InboundPageResult>
}>()
const emit = defineEmits<{ changed: [] }>()
const emit = defineEmits<{ changed: []; selectionChange: [rows: InboundOrderSummary[]] }>()
const router = useRouter()
const access = useAccessPreviewStore()
const reviewPanelRef = ref<InstanceType<typeof MeasurementReviewPanel> | null>(null)
@@ -169,7 +169,7 @@ async function loadOptions() {
}
async function loadTasks() {
selectedReviewRows.value = []
handleReviewSelectionChange([])
const serial = ++requestSerial
loading.value = true
loadError.value = null
@@ -270,6 +270,7 @@ async function handleTaskChanged() {
function handleReviewSelectionChange(rows: InboundOrderSummary[]) {
selectedReviewRows.value = rows.filter((row) => row.measureReviewStatus === 'PENDING')
emit('selectionChange', selectedReviewRows.value)
}
async function batchApproveMeasurements() {
@@ -399,8 +400,9 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
</template></template>
<template v-if="isReview && access.can(actionPermission)" #toolbar-actions>
<el-button type="success" :loading="batchReviewLoading" :disabled="loading || !selectedReviewRows.length" @click="batchApproveMeasurements"><CircleCheckBig :size="16" />批量审核通过<span v-if="selectedReviewRows.length">({{ selectedReviewRows.length }})</span></el-button>
<template #toolbar-actions>
<el-button v-if="isReview && access.can(actionPermission)" type="success" :loading="batchReviewLoading" :disabled="loading || !selectedReviewRows.length" @click="batchApproveMeasurements"><CircleCheckBig :size="16" />批量审核通过<span v-if="selectedReviewRows.length">({{ selectedReviewRows.length }})</span></el-button>
<slot name="toolbar-actions" :loading="loading || batchReviewLoading" />
</template>
<template #pagination-left>当前显示 {{ result.records.length }} 条 · 共 {{ result.total }} 条</template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions">
@@ -4,7 +4,7 @@ import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
@@ -367,6 +367,7 @@ describe('InboundFeePanel', () => {
it('confirms and then sends a calculated fee through the implemented backend transitions', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
vi.mocked(getInboundMeasurement).mockRejectedValue(new ApiResponseError('无测量查看权限', 'A0403', 403))
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mountPanel()
await flushPromises()
@@ -379,10 +380,47 @@ describe('InboundFeePanel', () => {
await wrapper.get('[data-testid="send-fee-button"]').trigger('click')
await flushPromises()
expect(sendInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-send-/))
expect(getInboundMeasurement).not.toHaveBeenCalled()
expect(wrapper.emitted('changed')).toHaveLength(2)
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
})
it.each(['confirm', 'push'])('shows the backend review rejection for the %s entry point without sending', async (entry) => {
const message = '测量数据尚未审核通过,请先完成测量审核后再确认费用'
const showError = vi.spyOn(ElMessage, 'error')
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
vi.mocked(confirmInboundFee).mockRejectedValue(new ApiResponseError(message, 'A0409', 200))
const wrapper = mountPanel()
await flushPromises()
const button = entry === 'confirm'
? wrapper.get('[data-testid="confirm-fee-button"]')
: wrapper.findAll('button').find(button => button.text().includes('确认推送费用'))!
await button.trigger('click')
await flushPromises()
expect(confirmInboundFee).toHaveBeenCalledTimes(1)
expect(sendInboundFee).not.toHaveBeenCalled()
expect(getInboundMeasurement).not.toHaveBeenCalled()
expect(showError).toHaveBeenCalledWith(expect.objectContaining({ message }))
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
})
it('shows the backend review rejection when sending an already confirmed fee', async () => {
const message = '测量数据尚未审核通过,请先完成测量审核后再发送客户'
const showError = vi.spyOn(ElMessage, 'error')
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CONFIRMED' })
vi.mocked(sendInboundFee).mockRejectedValue(new ApiResponseError(message, 'A0409', 200))
const wrapper = mountPanel()
await flushPromises()
await wrapper.get('[data-testid="send-fee-button"]').trigger('click')
await flushPromises()
expect(sendInboundFee).toHaveBeenCalledTimes(1)
expect(getInboundMeasurement).not.toHaveBeenCalled()
expect(wrapper.emitted('changed')).toBeUndefined()
expect(showError).toHaveBeenCalledWith(expect.objectContaining({ message }))
})
it('does not expose fee transitions without their independent permissions', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
const wrapper = mountPanel(['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust'])
@@ -392,6 +430,20 @@ describe('InboundFeePanel', () => {
expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
})
it('confirms and publishes without requiring measurement-view permission', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
vi.mocked(getInboundMeasurement).mockRejectedValue(new ApiResponseError('无测量查看权限', 'A0403', 403))
const wrapper = mountPanel(['firstleg:fba-pricing:view', 'firstleg:fba-pricing:confirm', 'firstleg:inbound:send-fee'])
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('确认推送费用'))!.trigger('click')
await flushPromises()
expect(confirmInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-confirm-/))
expect(sendInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-send-/))
expect(getInboundMeasurement).not.toHaveBeenCalled()
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('submits a reasoned adjustment and emits the refreshed snapshot', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
const wrapper = mountPanel()
+10 -3
View File
@@ -98,6 +98,13 @@ const canConfirm = computed(() => access.can('firstleg:fba-pricing:confirm'))
const canSend = computed(() => access.can('firstleg:inbound:send-fee'))
const canCalculate = computed(() => access.can('firstleg:fba-pricing:calculate'))
const isPrivateAddress = computed(() => props.destinationType === 'PRIVATE')
const pricingHelp = computed(() => isPrivateAddress.value ? {
workflow: '私人地址完成测量审核后,请先进行人工头程报价,再确认预装。',
quantity: '计费量按客户计费方式和实际测量确定,人工填写单价,金额以服务端快照为准。',
} : {
workflow: '测量审核完成后,系统会自动生成初算费用,人工确认后发布。',
quantity: '实际重量与体积重量取大后计费,金额以服务端快照为准。',
})
const canManualQuote = computed(() => access.can('firstleg:inbound:confirm-send-fee'))
const canRunTrial = computed(() => isPrivateAddress.value
? canManualQuote.value && snapshot.value?.customerConfirmationStatus !== 'CONFIRMED'
@@ -459,11 +466,11 @@ defineExpose({ reload: loadFee })
<template>
<section class="inbound-fee-panel admin-workbench" :aria-busy="loading" aria-labelledby="inbound-fee-title">
<header class="fee-heading" :class="{ 'fee-heading--compact': compact }">
<h3 v-if="compact" id="inbound-fee-title" class="fee-heading-title--compact">头程自动初算 <InlineHelp title="计费说明"><p>测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p><p>实际重量与体积重量取大后计费,金额以服务端快照为准。</p></InlineHelp></h3>
<h3 v-if="compact" id="inbound-fee-title" class="fee-heading-title--compact">{{ isPrivateAddress ? '头程人工报价' : '头程自动初算' }} <InlineHelp title="计费说明"><p>{{ pricingHelp.workflow }}</p><p>{{ pricingHelp.quantity }}</p></InlineHelp></h3>
<div v-else class="fee-heading-copy">
<span class="fee-heading-icon" aria-hidden="true"><CircleDollarSign :size="20" /></span>
<div>
<h3 id="inbound-fee-title">入库费用快照 <InlineHelp title="计费说明"><p>测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p><p>实际重量与体积重量取大后计费,金额以服务端快照为准。</p></InlineHelp></h3>
<h3 id="inbound-fee-title">入库费用快照 <InlineHelp title="计费说明"><p>{{ pricingHelp.workflow }}</p><p>{{ pricingHelp.quantity }}</p></InlineHelp></h3>
</div>
</div>
<div class="fee-heading-actions">
@@ -569,7 +576,7 @@ defineExpose({ reload: loadFee })
<el-button size="small" @click="loadFee()">重新加载</el-button>
</el-alert>
<el-empty v-else-if="notFound" :image-size="64" description="尚未生成费用快照">
<el-empty v-else-if="notFound" :image-size="64" :description="isPrivateAddress ? '私人地址待人工头程报价' : '尚未生成费用快照'">
</el-empty>
<div v-else-if="snapshot" class="fee-content">
@@ -425,7 +425,7 @@ describe('InboundMeasurementPanel', () => {
it('preserves the hidden actual goods name without overriding declaration facts', async () => {
const wrapper = await mountPanel()
expect(wrapper.text()).not.toContain('实际品名')
await wrapper.find('input[aria-label="仓库修改外箱长"]').setValue('55')
await wrapper.get('input[aria-label="仓库修改外箱长"]').setValue('55')
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
await save?.trigger('click')
await flushPromises()
@@ -442,6 +442,60 @@ describe('InboundMeasurementPanel', () => {
expect(payload).not.toHaveProperty('originalVolumeCbm')
})
it('preserves a 255-character goods name without exposing correction controls', async () => {
const actualGoodsName = '品'.repeat(255)
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ actualGoodsName }))
const wrapper = await mountPanel()
expect(wrapper.find('input[aria-label="实际品名"]').exists()).toBe(false)
await wrapper.get('input[aria-label="仓库修改外箱长"]').setValue('55')
await wrapper.findAll('button').find(button => button.text().includes('保存测量数据'))!.trigger('click')
await flushPromises()
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({ actualGoodsName }))
})
it('blocks an overlong or cleared goods name and keeps its correction editable until a successful retry', async () => {
const actualGoodsName = '品'.repeat(256)
vi.mocked(getInboundMeasurement)
.mockResolvedValueOnce(measurement({ actualGoodsName }))
.mockResolvedValueOnce(measurement({ actualGoodsName: '精简后的品名', version: 3 }))
vi.mocked(saveInboundMeasurementDraft).mockRejectedValueOnce(new ApiResponseError('资料保存失败', 'S0503'))
const wrapper = await mountPanel()
const input = () => wrapper.get<HTMLInputElement>('input[aria-label="实际品名"]')
const save = () => wrapper.findAll('button').find(button => button.text().includes('保存测量数据'))!
expect(input().element.value).toBe(actualGoodsName)
expect(wrapper.get('.measurement-panel__footer').text()).toContain('实际品名超过255字')
await save().trigger('click'); await flushPromises()
expect(saveInboundMeasurementDraft).not.toHaveBeenCalled()
await (wrapper.vm as unknown as { submitForReview(): Promise<void> }).submitForReview()
expect(submitInboundMeasurement).not.toHaveBeenCalled()
await input().setValue('')
await save().trigger('click'); await flushPromises()
expect(saveInboundMeasurementDraft).not.toHaveBeenCalled()
expect(wrapper.get('.measurement-panel__footer').text()).toContain('请填写精简后的实际品名')
await input().setValue(' 精简后的品名 ')
expect(wrapper.find('input[aria-label="实际品名"]').exists()).toBe(true)
expect(wrapper.get('.measurement-declaration').text()).toContain('蓝牙音箱')
await save().trigger('click'); await flushPromises()
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({ actualGoodsName: '精简后的品名' }))
expect(input().element.value).toBe(' 精简后的品名 ')
expect(wrapper.emitted('saved')).toBeUndefined()
await save().trigger('click'); await flushPromises()
expect(saveInboundMeasurementDraft).toHaveBeenCalledTimes(2)
expect(updateConfirmedPackageCount).not.toHaveBeenCalled()
expect(wrapper.find('input[aria-label="实际品名"]').exists()).toBe(false)
expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('does not expose goods name correction without measurement edit permission', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ actualGoodsName: '品'.repeat(256) }))
const wrapper = await mountPanel(['platform:firstleg:inbound:measure:view'])
expect(wrapper.find('input[aria-label="实际品名"]').exists()).toBe(false)
expect(saveInboundMeasurementDraft).not.toHaveBeenCalled()
})
it('allows saving one warehouse outer dimension against the measured sequence', async () => {
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('001')
@@ -149,12 +149,21 @@ const packageCountDifference = computed(() => expectedPackageCount.value == null
const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0)
const hasConfirmedWeightResult = computed(() => confirmedWeightAvailable.value)
const isDropship = computed(() => detail.value?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
// 以服务端原值决定修正入口,避免输入缩短后立即收起;保存成功重读后才隐藏。
const needsGoodsNameCorrection = computed(() => (detail.value?.actualGoodsName?.trim().length ?? 0) > 255)
const goodsNameCorrectionError = computed(() => {
const length = form.actualGoodsName.trim().length
if (length > 255) return '实际品名超过255字,请先精简后再保存'
if (needsGoodsNameCorrection.value && !length) return '请填写精简后的实际品名,最多255字'
return ''
})
const expectedPalletCountValid = computed(() => !isDropship.value || (Number.isInteger(form.expectedPalletCount) && Number(form.expectedPalletCount) >= 1))
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !loading.value && !saving.value)
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !goodsNameCorrectionError.value && !loading.value && !saving.value)
const submissionBlockReason = computed(() => {
if (!canSubmit.value || !detail.value || loading.value) return ''
if (detail.value.processStatus === 'PREPARING') return '订单尚待客服审核,审核通过后才能提交测量审核'
if (!submitEditableProcess.value) return `当前${processStatusLabel(detail.value.processStatus)},不能提交测量审核`
if (goodsNameCorrectionError.value) return goodsNameCorrectionError.value
if (!form.measurementId) return hasConfirmedWeightResult.value ? '请先完成箱规测量并保存测量数据' : '请先完成箱规测量,并上传确认至少一张称重图片'
if (!hasConfirmedWeightResult.value) return '即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核'
if (!packageCountMatched.value) return '确认箱数合计必须与订单总箱数一致后才能提交审核'
@@ -416,6 +425,7 @@ async function reloadLatest() {
function validateDraft() {
const errors: Record<string, string[]> = {}
const general: string[] = []
if (goodsNameCorrectionError.value) general.push(goodsNameCorrectionError.value)
if (!form.items.length) general.push('请至少保留一条实际扫描箱测量记录')
if (isDropship.value && (!Number.isInteger(form.expectedPalletCount) || Number(form.expectedPalletCount) < 1 || Number(form.expectedPalletCount) > 1000)) general.push('一件代发预计托数必须是 1 至 1,000 的整数')
else if (isDropship.value && Number(form.expectedPalletCount) > confirmedPackageCount.value) general.push('一件代发预计托数不能超过实测箱数')
@@ -736,7 +746,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<p v-for="warning in measurementDeviationWarnings" :key="warning.key">{{ warning.text }}</p>
</el-alert>
<div class="workbench-evidence"><table class="measurement-comparison" aria-label="客户申报与平台实测对比">
<table class="measurement-comparison" aria-label="客户申报与平台实测对比">
<thead><tr><th scope="col">指标</th><th scope="col">客户申报</th><th scope="col">平台实测</th><th scope="col">差异(实测 − 申报)</th></tr></thead>
<tbody>
<tr v-for="metric in comparisonMetrics" :key="metric.label">
@@ -748,7 +758,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
</td>
</tr>
</tbody>
</table></div>
</table>
<div class="measurement-extra-facts">
<span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span>
<span>计费材积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span>
@@ -782,7 +792,8 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<div class="measurement-section__heading">
<div><h3>平台实测箱规</h3></div>
<div v-if="isDropship" class="measurement-data-grid measurement-platform-fields">
<div v-if="isDropship || (needsGoodsNameCorrection && mayMaintain)" class="measurement-data-grid measurement-platform-fields">
<label v-if="needsGoodsNameCorrection && mayMaintain" class="measurement-goods-name"><span>实际品名(最多255字)</span><el-input v-model="form.actualGoodsName" aria-label="实际品名" placeholder="请精简汇总品名后保存" maxlength="255" show-word-limit :disabled="saving" /></label>
<label v-if="isDropship && mayMaintain" class="measurement-expected-pallets"><span>预计托数</span><el-input-number v-model="form.expectedPalletCount" placeholder="请输入预计托数" :min="1" :max="1000" :step="1" step-strictly controls-position="right" /></label>
<div v-else-if="isDropship" class="measurement-readonly-field measurement-expected-pallets"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
</div>
@@ -927,9 +938,9 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<style scoped>
.measurement-comparison { width: 100%; border-collapse: collapse; table-layout: fixed; background: var(--admin-surface, #fff); font-size: var(--admin-font-body, 13px); }
.measurement-comparison th, .measurement-comparison td { padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); border: 1px solid var(--workbench-border); text-align: left; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.measurement-comparison thead th { background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); }
.measurement-comparison thead th:nth-child(2) { background: #f7f9ff; color: #465fc4; }
.measurement-comparison thead th:nth-child(3) { background: var(--admin-surface, #fff); color: #18795d; }
.measurement-comparison thead th { background: var(--admin-label-bg); color: var(--workbench-text); }
.measurement-comparison thead th:nth-child(2) { background: var(--admin-dialog-source-bg); color: #465fc4; }
.measurement-comparison thead th:nth-child(3) { background: var(--admin-dialog-summary-bg); color: #18795d; }
.measurement-comparison__difference { color: var(--el-color-danger-dark-2); font-weight: 600; }
.measurement-extra-facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
.measurement-extra-facts strong { color: var(--workbench-text); }
@@ -1,14 +1,15 @@
/* Measurement workspace: keep all sections inside the panel's content bounds. */
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-section { min-width: 0; margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
/* Approved measurement-only palette; shared dialog styling must preserve these sections and gutters. */
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-section { min-width: 0; margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-summary-border); border-left: 4px solid #20a477; border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-summary-bg); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header { flex-wrap: wrap; align-items: center; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context { flex: 1 1 auto; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context p > span { flex-shrink: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-status { flex-wrap: wrap; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-work-border); border-left: 4px solid #c79643; border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-work-bg); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert { background: var(--admin-surface, #fff); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-subheading { color: #18795d; font-size: var(--admin-font-section, 14px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-left: 4px solid #647fec; border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-table th.el-table__cell { background: var(--admin-label-bg); color: var(--workbench-muted); }
@media (min-width: 961px) {
.inbound-density.admin-density-dialog .measurement-dialog-weight { padding-bottom: var(--admin-space-4, 10px); }
@@ -19,9 +20,6 @@
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header h4,
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-header h4 { flex-shrink: 0; margin: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-header { display: flex; align-items: baseline; gap: var(--admin-space-6, 16px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-document-number { display: flex; flex: 1; align-items: baseline; gap: var(--admin-space-3, 8px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-document-number > span { flex-shrink: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-document-number strong { min-width: 0; min-height: 0; padding: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section .measurement-section__heading { flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields { display: flex; flex: 1 1 520px; align-items: center; flex-wrap: wrap; margin: 0; gap: var(--admin-space-5, 12px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields > label,
@@ -226,7 +226,7 @@ onMounted(() => {
<ContainerSimulationCreate :dense="isPreload" v-if="canCreate" success-message="模拟柜及预装清单已创建" @created="onSimulationCreated" />
</template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions" :class="{ 'density-actions': isPreload }">
<el-button v-if="canView" :class="{ 'density-action': isPreload }" size="small" :type="isPreload ? undefined : 'primary'" :plain="!isPreload" @click="openDetail(row)">查看</el-button>
<el-button v-if="canView" :class="{ 'density-action': isPreload }" size="small" :type="isPreload ? undefined : 'primary'" :plain="!isPreload" @click="openDetail(row)">{{ isPreload ? '编辑' : '查看' }}</el-button>
<el-button v-if="canView && !isPreload" class="density-action" size="small" @click="printConfirmed(row as LoadingListSummary)">打印</el-button>
</div></template>
<template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></template>
@@ -152,10 +152,10 @@ describe('platform overseas business API adapter', () => {
})
it('supports looseboard list/detail/create and PUT update', async () => {
const detail = { summary: looseboardSummary, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
const detail = { summary: looseboardSummary, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [], dispatchNos: ['PC-1', 'PC-2'] }
overseas.get.mockResolvedValueOnce(envelope({ records: [looseboardSummary], page: 1, size: 20, total: 1, pages: 1 })).mockResolvedValueOnce(envelope(detail))
expect((await listLocalLooseboardOrders({ status: 'PENDING_ACCEPTANCE' })).records[0]?.id).toBe('9007199254741005')
expect((await getLocalLooseboardOrder('LB-1', '10003')).status).toBe('PENDING_ACCEPTANCE')
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ status: 'PENDING_ACCEPTANCE', dispatchNos: ['PC-1', 'PC-2'] })
const create = { productName: '家居用品', estimatedPalletCount: 2, originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'HTTP-20260824-MARKER-looseboard' }
overseas.post.mockResolvedValue(envelope(detail)); await createLocalLooseboardOrder('9007199254742001', create)
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders', create, { params: { customerId: '9007199254742001' } })
@@ -180,7 +180,7 @@ describe('platform overseas business API adapter', () => {
it('preserves unknown pool pallet service and product name in list and detail responses', async () => {
const summary = { ...looseboardSummary, palletRequired: null, productName: null }
overseas.get.mockResolvedValueOnce(envelope({ records: [summary], total: 1, page: 1, size: 20, pages: 1 })).mockResolvedValueOnce(envelope({ summary, histories: [] }))
overseas.get.mockResolvedValueOnce(envelope({ records: [summary], total: 1, page: 1, size: 20, pages: 1 })).mockResolvedValueOnce(envelope({ summary, histories: [], dispatchNos: [] }))
expect((await listLocalLooseboardOrders({})).records[0]).toMatchObject({ palletRequired: null, productName: null })
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ palletRequired: null, productName: null })
expect(overseas.get).toHaveBeenCalledTimes(2)
@@ -27,7 +27,7 @@ interface TruckDetailPayload {
interface LooseboardSummaryPayload extends Omit<LocalLooseboardOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
interface LooseboardDetailPayload {
summary: LooseboardSummaryPayload; originAddress?: string | null; destinationAddress?: string | null
remark?: string | null; createdBy?: string | null; histories?: OverseasBusinessHistory[]
remark?: string | null; createdBy?: string | null; histories?: OverseasBusinessHistory[]; dispatchNos: string[]
}
function normalizePage<TPayload, TResult>(payload: PagePayload<TPayload>, normalize: (value: TPayload) => TResult): OverseasBusinessPage<TResult> {
@@ -49,8 +49,9 @@ function truckDetail(value: TruckDetailPayload): LocalTransportDetail {
function looseboardSummary(value: LooseboardSummaryPayload): LocalLooseboardOrder {
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
}
/** 详情保留按原单查询的全部排车,不依赖车队预订摘要。 */
function looseboardDetail(value: LooseboardDetailPayload): LocalLooseboardDetail {
return { ...looseboardSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, histories: value.histories || [] }
return { ...looseboardSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, histories: value.histories || [], dispatchNos: value.dispatchNos }
}
export async function listOverseasPurchaseCustomerOptions(keyword = '', limit = 200) {
@@ -9,6 +9,8 @@ import type { LocalLooseboardDetail } from '../types'
import LooseboardOrderDetailView from './LooseboardOrderDetailView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } as Record<string, string> } }))
const permissions = vi.hoisted(() => ({ can: vi.fn(() => false) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => permissions }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('../api/platformOverseasBusiness', () => ({ getLocalLooseboardOrder: vi.fn() }))
@@ -21,7 +23,7 @@ function order(overrides: Partial<LocalLooseboardDetail> = {}): LocalLooseboardD
pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', packageCount: 20,
totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
status: 'PENDING_ACCEPTANCE', version: 1, remark: '运输备注', createdBy: '7',
createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00', histories: [], ...overrides,
createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00', histories: [], dispatchNos: [], ...overrides,
}
}
function mountDetail() {
@@ -31,11 +33,33 @@ function mountDetail() {
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.clearAllMocks()
permissions.can.mockReturnValue(false)
routing.route = reactive({ params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
vi.mocked(getLocalLooseboardOrder).mockResolvedValue(order())
})
describe('looseboard document fees', () => {
it('shows all actual dispatches without a fleet booking and links only with dispatch permission', async () => {
vi.mocked(getLocalLooseboardOrder).mockResolvedValue(order({ dispatchNos: ['PC-1', 'PC-2'] }))
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.find('.looseboard-dispatches').text()).toContain('PC-1')
expect(wrapper.find('.looseboard-dispatches').text()).toContain('PC-2')
expect(wrapper.find('.looseboard-dispatches button').exists()).toBe(false)
permissions.can.mockReturnValue(true)
await wrapper.findAll('button').find(button => button.text() === '刷新订单')!.trigger('click')
await flushPromises()
const links = wrapper.findAll('.looseboard-dispatches button')
expect(links).toHaveLength(2)
await links[1]!.trigger('click')
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC-2' } })
})
it('identifies a looseboard order that has not been dispatched', async () => {
const wrapper = mountDetail(); await flushPromises()
expect(wrapper.text()).toContain('排车单号')
expect(wrapper.text()).toContain('暂未排车')
})
it('refreshes the current status when returning to a cached document tab', async () => {
const visible = ref(true)
vi.mocked(getLocalLooseboardOrder).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND' }))
@@ -3,6 +3,7 @@ import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onActivated, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -19,13 +20,14 @@ import { poolOrderStatusLabels } from '../fleetArrangement'
import type { LocalLooseboardDetail, OverseasBusinessHistory } from '../types'
const route = useRoute(), router = useRouter()
const access = useAccessPreviewStore()
const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const detail = ref<LocalLooseboardDetail | null>(null), loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
let requestSequence = 0
const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—'
const statusLabels: Record<string, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', ...poolOrderStatusLabels }
const statusLabels: Record<string, string> = { FUNDING_PENDING: '资金处理中', FUNDING_FAILED: '资金冻结失败', PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', ...poolOrderStatusLabels }
const statusLabel = (value: string | null) => value ? statusLabels[value] || value : '—'
const fields = computed(() => detail.value ? [
['客户编号', detail.value.customerId], ['商品名称', detail.value.productName], ['预计托数', detail.value.estimatedPalletCount],
@@ -81,7 +83,10 @@ onActivated(() => {
<template v-else-if="detail">
<section v-if="detail.fleetArrangement" class="looseboard-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
<section v-if="detail.deliveryMode === 'WAREHOUSE_PICKUP' && detail.transferNo" class="looseboard-detail-section"><el-alert title="上门取货需在对应取货排车的作业节点上传取货及到仓资料。" type="info" :closable="false" /><TransferPickupProgressPanel :transfer-no="detail.transferNo" :customer-id="detail.customerId" /></section>
<section class="looseboard-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section class="looseboard-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border>
<el-descriptions-item label="排车单号" :span="3"><div v-if="detail.dispatchNos.length" class="looseboard-dispatches"><template v-for="dispatchNo in detail.dispatchNos" :key="dispatchNo"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" link type="primary" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo } })">{{ dispatchNo }}</el-button><span v-else>{{ dispatchNo }}</span></template></div><span v-else>暂未排车</span></el-descriptions-item>
<el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item>
</el-descriptions></section>
<section class="looseboard-detail-section"><BusinessDocumentFees business-type="LOCAL_LOOSEBOARD" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
<section class="looseboard-detail-section"><h2>操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template>
@@ -92,4 +97,5 @@ onActivated(() => {
.looseboard-detail-header { flex-wrap: wrap; }
.looseboard-detail-header .density-detail-heading { flex: 1; }
.looseboard-detail-section { margin-bottom: var(--admin-space-6); }
.looseboard-dispatches { display: flex; flex-wrap: wrap; gap: var(--admin-space-2); overflow-wrap: anywhere; }
</style>
@@ -61,7 +61,7 @@ const procurementDetail = (): PlatformProcurement => ({
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', vehicleType: 'PALLET_15', origin: 'Long Beach', destination: 'Ontario', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', originContactName: 'Bob', originContactPhone: '10010', destinationContactName: 'Alice', destinationContactPhone: '10020', pickupAt: '2026-08-28T09:30:00', deliveryAt: '2026-08-28T15:45:00', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 2, packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [], dispatchNos: [] }
const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' }
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '测试操作员', account: 'tester', roles: [] }; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
@@ -42,7 +42,7 @@ const page = ref(1); const pageSize = ref(20); const total = ref(0)
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true }
const filters = ref<QueryModel>({ orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '派送中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { FUNDING_PENDING: '资金处理中', FUNDING_FAILED: '资金冻结失败', PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '派送中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
const statusLabels = computed<Record<string, string>>(() => isLooseboard.value ? looseboardStatusLabels : truckStatusLabels)
const statusTones: Record<LocalTransportStatus | LocalLooseboardStatus, 'warning' | 'primary' | 'success' | 'danger' | 'info'> = {
@@ -75,7 +75,7 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
if (field === 'packageCount' && kind === 'truck') continue
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' ? '送达时间' : '送货时间'
rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, 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 (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 === 'looseboard' && form.budgetAmount == null) return '请输入预算总价'
if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0'
if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种'
return undefined
+2 -1
View File
@@ -66,7 +66,7 @@ export interface UpdateLocalTransportOrderRequest extends Omit<CreateLocalTransp
}
export type PalletRequired = 'YES' | 'NO'
export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
export type LocalLooseboardStatus = 'FUNDING_PENDING' | 'FUNDING_FAILED' | 'PENDING_ACCEPTANCE' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
export interface LocalLooseboardQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; routeKeyword?: string
status?: LocalLooseboardStatus | ''; requestedFrom?: string; requestedTo?: string
@@ -92,6 +92,7 @@ export interface LocalLooseboardOrder {
export interface LocalLooseboardDetail extends LocalLooseboardOrder {
originAddress: string | null; destinationAddress: string | null; remark: string | null; createdBy: string | null
histories: OverseasBusinessHistory[]
dispatchNos: string[]
}
export interface CreateLocalLooseboardOrderRequest {
deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
@@ -6,6 +6,7 @@
- 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。
- 当前散板需求:正式列表使用`pages/DispatchCapacityDemandsView.vue`、`api/dispatchCapacityDemands.ts`,只读消费日常派送排车的`/dispatches/capacity-demands`摘要;发布选项仅在智能排车建单时选择,默认不勾选;维护车辆不提供发布修改,预约日历建单不自动发布,重发先取消原排车再重新创建,规则见拼车主契约当前页面范围。不得以实托数推算标准托位,未知余位明确待确认,不读取客户或司机私人资料。原登记和旧车次入口代码保留隐藏。
- 散板加货从上述列表的 `components/LooseboardCargoDialog.vue` 进入,通过 `../overseas-warehouse` 公开的 `listLooseboardCargo/addLooseboardCargo` 调用 `dispatch/controller/LooseboardCargoController.java`;区别于客户本土散板下单及排车提货/派送执行。
- 保留的内部拼车实现:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
@@ -0,0 +1,32 @@
import { shallowMount, flushPromises } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import Dialog from './LooseboardCargoDialog.vue'
import { candidate, detail } from '../../overseas-warehouse/dispatchTestFixtures'
import * as api from '@/modules/overseas-warehouse'
vi.mock('@/modules/overseas-warehouse', async () => ({
getDispatch: vi.fn(), listLooseboardCargo: vi.fn(), addLooseboardCargo: vi.fn(),
expandSelectionGroups: (await import('../../overseas-warehouse/dispatchContracts')).expandSelectionGroups,
}))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() } }))
beforeEach(() => { vi.clearAllMocks() })
it('selects one looseboard order and submits all original pallet selections together', async () => {
const first = candidate({ orderNo: 'LB1', availablePackages: 9 })
const second = candidate({ sourceKey: 'source-2', sourceVersion: 'version-2', selection: { ...first.selection, planPalletId: '2', sourceVersion: 'version-2' } })
first.selectionGroup = { ...first.selectionGroup, sources: [...first.selectionGroup.sources, ...second.selectionGroup.sources], pallets: [...first.selectionGroup.pallets, { warehousePalletId: null, palletNo: 'P2', sourceKeys: [second.sourceKey], packageCount: 2 }] }
const dispatch = detail(); dispatch.actions.ADD_CARGO = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(api.getDispatch).mockResolvedValue(dispatch)
vi.mocked(api.listLooseboardCargo).mockResolvedValue({ candidates: [first], total: 1, page: 1, size: 20, pages: 1, complete: true, snapshotAt: '2026-11-05T00:00:00Z' })
const wrapper = shallowMount(Dialog, { props: { modelValue: false, dispatchNo: 'PC/1' }, global: { renderStubDefaultSlot: true, stubs: { ElAlert: true } } })
await wrapper.setProps({ modelValue: true }); await flushPromises()
const table = wrapper.getComponent({ name: 'DataTable' })
expect(table.props('data')).toHaveLength(1)
expect(table.props('columns').map((column: { label?: string }) => column.label)).toContain('托数')
expect(table.props('columns').map((column: { label?: string }) => column.label)).not.toContain('计划 / 实物托盘')
table.vm.$emit('selection-change', [first]); await flushPromises()
wrapper.getComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.addLooseboardCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ cargo: [first.selection, second.selection] }))
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
wrapper.unmount()
})
@@ -15,7 +15,7 @@ const page=ref(1), size=ref(20), total=ref(0), loading=ref(false), saving=ref(fa
const columns:DataTableColumn[]=[
{key:'selection',type:'selection',width:45}, {key:'orderNo',label:'订单号',minWidth:180},
{key:'customerName',label:'客户',minWidth:140}, {key:'inboundCode',label:'入仓号',minWidth:150},
{key:'sourceStage',label:'货物阶段',width:110,formatter:(row:Candidate)=>!row.selection.warehousePalletId ? '申报待到仓' : '已实收'}, {key:'palletNo',label:'计划 / 实物托盘',minWidth:150}, {key:'destination',label:'目的地',minWidth:180,formatter:(row:Candidate)=>row.destination.warehouseCode || row.destination.address || '—'},
{key:'sourceStage',label:'货物阶段',width:110,formatter:(row:Candidate)=>row.selectionGroup?.sources.every(source=>source.selection.warehousePalletId) ? '已实收' : '申报待到仓'}, {key:'palletCount',label:'托数',width:80,formatter:(row:Candidate)=>row.selectionGroup?.pallets.length ?? '—'}, {key:'destination',label:'目的地',minWidth:180,formatter:(row:Candidate)=>row.destination.warehouseCode || row.destination.address || '—'},
{key:'availablePackages',label:'计划 / 可用箱数',width:100},
{key:'blockReasons',label:'资格',minWidth:200,formatter:(row:Candidate)=>row.blockReasons.length?row.blockReasons.join(';'):'可添加'},
]
@@ -19,7 +19,7 @@ export interface OutsourcedBusinessQuery { page?: number; size?: number; planned
export interface OutsourcedBusinessSummary { dispatchCount: number; afterLoadingCount: number; deliveringCount: number; deliveredCount: number }
export interface OutsourcedBusinessPage<T> { records: T[]; page: number; size: number; total: number }
export interface OutsourcedBusinessList extends OutsourcedBusinessPage<OutsourcedBusinessRow> { summary: OutsourcedBusinessSummary }
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] }
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string | null; destinationKey: string; destinationKeys?: string[]; packages: string | null }[]; photos: OutsourcedBusinessPhoto[] }
export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null }
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
@@ -20,7 +20,7 @@ export interface OwnedBusinessQuery { page?: number; size?: number; plannedDepar
export interface OwnedBusinessSummary { dispatchCount: number; afterLoadingCount: number; deliveringCount: number; deliveredCount: number }
export interface OwnedBusinessPage<T> { records: T[]; page: number; size: number; total: number }
export interface OwnedBusinessList extends OwnedBusinessPage<OwnedBusinessRow> { summary: OwnedBusinessSummary }
export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OwnedBusinessPhoto[] }
export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string | null; destinationKey: string; destinationKeys?: string[]; packages: string | null }[]; photos: OwnedBusinessPhoto[] }
export interface OwnedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OwnedBusinessOperations; reason: string | null }
export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
@@ -23,7 +23,7 @@ const history = ref<OutsourcedBusinessHistory[]>([]), historyPage = ref(1), hist
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
const previewUrls = new Set<string>()
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '关联托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
const taskFields = computed(() => {
const row = detail.value?.row
@@ -68,7 +68,7 @@ onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previ
<template #actions><el-button v-if="detail && access.can(permission.outsourcedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.outsourcedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
<template #state><LoadErrorState v-if="error" title="外配任务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
<template #task><dl v-if="detail" class="density-fields outsourced-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="outsourced-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable></div><section class="outsourced-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="outsourced-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ (row.destinationKeys || [row.destinationKey]).map(destinationLabel).join('、') }}</template></DataTable></div><section class="outsourced-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><div class="workbench-table"><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ outsourcedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车承运金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></div></template>
</OutsourcedBusinessDetailLayout>
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
@@ -30,6 +30,19 @@ beforeEach(() => {
})
describe('outsourced business views', () => {
it('shows the complete destination set on a grouped looseboard order', async () => {
const detail = outsourcedBusinessFixture()
detail.row.destinations.push({ ...detail.row.destinations[0]!, destinationKey: 'destination-2', warehouseCode: 'FBA-2' })
detail.cargo = [{ ...detail.cargo[0]!, orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', destinationKeys: ['destination-1', 'destination-2'], packages: '9' }]
calls.detail.mockResolvedValue(detail)
const wrapper = mount(OutsourcedBusinessDetailView, { global }); await flushPromises()
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'route'); await flushPromises()
const table = wrapper.findAllComponents(Table).find(component => component.props('columns').some((column: { prop?: string }) => column.prop === 'orderNo'))!
expect(table.props('data')).toHaveLength(1)
expect(table.props('data')[0]).toMatchObject({ orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', packages: '9' })
expect(table.text()).toContain('FBA-1、FBA-2')
wrapper.unmount()
})
it('keeps an owned vehicle with pooled cargo in the outsourced task list', async () => {
const detail = outsourcedBusinessFixture()
detail.row.vehicle.ownership = 'OWNED'
@@ -23,7 +23,7 @@ const history = ref<OwnedBusinessHistory[]>([]), historyPage = ref(1), historySi
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
const previewUrls = new Set<string>()
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '关联托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
const taskFields = computed(() => {
const row = detail.value?.row
@@ -68,7 +68,7 @@ onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previ
<template #actions><el-button v-if="detail && access.can(permission.ownedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.ownedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
<template #state><LoadErrorState v-if="error" title="自有业务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
<template #task><dl v-if="detail" class="density-fields owned-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="owned-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable></div><section class="owned-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="owned-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ (row.destinationKeys || [row.destinationKey]).map(destinationLabel).join('、') }}</template></DataTable></div><section class="owned-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><div class="workbench-table"><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ ownedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><p>里程 {{ row.operations.actualDistanceMiles ?? '未登记' }} mile · 油耗 {{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }} L/100km</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></div></template>
</OwnedBusinessDetailLayout>
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
@@ -30,6 +30,19 @@ beforeEach(() => {
})
describe('owned business views', () => {
it('shows one looseboard order with all associated destinations and pallets', async () => {
const detail = ownedBusinessFixture()
detail.row.destinations.push({ ...detail.row.destinations[0]!, destinationKey: 'destination-2', warehouseCode: 'FBA-2' })
detail.cargo = [{ ...detail.cargo[0]!, orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', destinationKeys: ['destination-1', 'destination-2'], packages: '9' }]
calls.detail.mockResolvedValue(detail)
const wrapper = mount(OwnedBusinessDetailView, { global }); await flushPromises()
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'route'); await flushPromises()
const table = wrapper.findAllComponents(Table).find(component => component.props('columns').some((column: { prop?: string }) => column.prop === 'orderNo'))!
expect(table.props('data')).toHaveLength(1)
expect(table.props('data')[0]).toMatchObject({ orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', packages: '9' })
expect(table.text()).toContain('FBA-1、FBA-2')
wrapper.unmount()
})
it('keeps split prices authoritative and arrival times associated with each destination', async () => {
const detail = ownedBusinessFixture()
detail.row.operations.money = { currency: 'USD', amount: '0.00' }; detail.row.unitPrice = '0.00'
+4 -2
View File
@@ -25,8 +25,10 @@
| 仓储转运审核、审核通过 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `permissions.ts` 的 `warehouseTransferApprove`;仅 `WAREHOUSE_TRANSFER + PENDING_REVIEW` 行开放。当前弹框仅提交 `APPROVE`,携带该行客户和转运单号;无驳回按钮 |
| 转运驳回、REJECT 接口能力 | `api/warehouseTransfers.ts` 的 `reviewWarehouseTransfer` | API 支持 `APPROVE/REJECT` 与 `remark`;不据此推定当前弹框提供驳回操作,界面能力沿上一行组件核对 |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | `api/lastMileOrders.ts`;[入仓计划页面约束](../../../../../docs/业务实现边界.md#admin-入仓计划页面约束),仅计划/状态/请求行为变化时查 |
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 入仓单、私人地址计划与 FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询、整车草稿、审核与装车资料 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
| 散板提货/派送执行与取证 | `components/DispatchDocumentsDialog.vue` → `DispatchLooseboardExecution.vue` | `api/dispatchLooseboardExecution.ts`;Backend `dispatch/controller/DispatchLooseboardExecutionController.java`,区别于客户散板下单 |
| 派送异常回仓、退回货物与实收入库 | `components/DispatchReturnCargo.vue`、`pages/ExceptionOrdersView.vue` | 候选 `api/dispatches.ts`,退回建单 `api/exceptionOrders.ts` 的 `generateExceptionReturn`;PDA 实收查 `ExceptionReturnWork.vue` 与 Lastleg 异常回仓契约 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;[出库与派送页面约束](../../../../../docs/业务实现边界.md#admin-出库与派送页面约束),候选选择另查下表 |
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
@@ -41,7 +43,7 @@
- 托盘标签从 `pages/PalletsView.vue` → `api/pallets.ts` 的 `getPalletLabelPdf` 下载服务端 PDF,传该行仓库与托盘 ID,复用列表权限;不在前端生成标签或维护模板、尺寸、编码。模板/接口变化才查 [Print 指引](../../../../../baiyue-firstleg/print/AGENTS.md)的托盘入口及[标签维护](../../../../../docs/打印标签维护/README.md),纯按钮文案不追加打印资料。
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。出库/派送建单时机按[排车现行契约](../../../../../docs/海外仓业务/排车列表统一作业改造契约.md)执行;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
- 异常回仓先从原出库事实选择可退货物,由平台生成关联异常的退回尾程单/计划;建单不恢复库存。PDA 经异常回仓任务分批实收并恢复原来源库存,不走普通接柜或客户退货预报链。版本、证据、目标资源及完成状态以工作区 `baiyue-firstleg/lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml` 为准;回访仍走 `api/customerVisits.ts`。
## 作业细则按需入口
@@ -476,3 +476,13 @@
2026-10-29 用户截图调整:排车列表查看路线更名查看轨迹。已完成本土卡车详情补充货物数量卡片、起讫地址与联系人、司机/车辆及证照照片、实际发车和完成时间。维护车辆/司机候选排除其他车次实际占用,当前车次已选资源允许保留。尾程查询占位提示字体12px。自动检查与人工视觉验收分别记录;待人工1920×1080、100%截图确认。
## 2026-11-05 PDA拆柜收货与维护车辆问题修复
| 页面/问题 | 修复 | 验收 |
|---|---|---|
| PDA拆柜收货按钮重叠 | 底部操作区改为随内容正常排列,解析、校验、保存及结束按钮全部保留;照片上传后返回页面不再清理同一签收会话的未签收草稿。 | PDA定向35项通过,App资源构建通过;真实设备截图待确认 |
| 维护车辆字段与附件 | 移除不可维护的发车时间及主档拖车公司展示;保留可编辑的外配拖车公司字段;预约号回显已登记编号;驾驶证展示优先本趟上传文件,避免保存后重复主档或空白图。 | Admin定向67项通过,修改文件Scoped TypeScript检查通过;真实页面截图待确认 |
| 后端预约号 | 已登记预约号可回显,只有有效收货确认提供确认时间。 | Lastleg定向8项通过;未部署 |
本轮修改未部署到测试或生产环境;需在真实PDA和Admin页面进行人工截图及业务数据验收。
@@ -54,7 +54,7 @@ it('hides the single delivery selector and saves receipt files to that delivery
await wrapper.get('.save').trigger('click'); await flushPromises()
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101', expect.objectContaining({ bolNo: 'BOL-ORIGINAL', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: [] }, { category: 'RECEIPT', fileIds: ['99'] }] }))
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('modelValue')).toBe(true)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
wrapper.unmount()
})
it('switches customer scope exactly and keeps unsaved delivery edits when switching tabs', async () => {
@@ -91,6 +91,18 @@ it('switches customer scope exactly and keeps unsaved delivery edits when switch
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
confirmation.mockRestore(); wrapper.unmount()
})
it('keeps the management dialog and draft open when saving fails', async () => {
vi.mocked(saveDeliveryDocuments).mockRejectedValue(new Error('保存失败'))
const wrapper = setup(); await flushPromises()
const receipt = wrapper.getComponent(DeliveryDocumentsDialog)
await receipt.findAll('button').find(button => button.text() === '删除')!.trigger('click')
await wrapper.get('.save').trigger('click'); await flushPromises()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(receipt.text()).not.toContain('existing-pod.pdf')
expect(wrapper.text()).toContain('保存失败')
wrapper.unmount()
})
it('does not request delivery documents without delivery list permission', async () => {
can.mockImplementation((value?: string) => value !== 'platform:overseas-warehouse:delivery:list')
const wrapper = setup(); await flushPromises()
@@ -55,7 +55,7 @@ async function canLeave() {
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
async function beforeClose(done: () => void) { if (await canLeave()) done() }
function selectTab(value: string) { if (!busy.value) { tab.value = value } }
function saved() { emit('saved') }
function saved() { open.value = false; emit('saved') }
function pickupCompleted() { open.value = false; emit('saved') }
async function loadingSaved() { emit('saved'); await load() }
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
@@ -23,13 +23,33 @@ it.each(['VEHICLE', 'DRIVER_PHOTO'] as const)('does not count a PDF or failed up
expect(wrapper.emitted('busy')?.at(-1)).toEqual([false])
wrapper.unmount()
})
it('previews an unsaved master license through its driver and keeps uploaded license files on the dispatch route', async () => {
it('uses the uploaded license before the master fallback and restores the fallback after removal', async () => {
vi.mocked(downloadDriverLicense).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['301'], referenceIds: ['33'], masterLicense: { driverId: '2', fileId: '33' } } }); await flushPromises()
expect(downloadDriverLicense).toHaveBeenCalledWith('2', '33')
expect(downloadDriverLicense).not.toHaveBeenCalled()
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER', '301')
expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
expect(wrapper.findAll('figure img')).toHaveLength(2); wrapper.unmount()
expect(wrapper.findAll('figure img')).toHaveLength(1)
await wrapper.setProps({ modelValue: [] }); await flushPromises()
expect(downloadDriverLicense).toHaveBeenCalledWith('2', '33')
expect(wrapper.findAll('figure img')).toHaveLength(1); wrapper.unmount()
})
it('keeps saved trip licenses without an additional master image or changes to stored references', async () => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['33', '301'], referenceIds: ['33'], fallbackFileId: '33' } }); await flushPromises()
expect(wrapper.findAll('figure img')).toHaveLength(1)
expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
it.each(['tiny', 'broken'])('shows a recovery message instead of a blank %s thumbnail', async failure => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true } }); await flushPromises()
const image = wrapper.get('figure img')
Object.defineProperties(image.element, { naturalWidth: { value: 1 }, naturalHeight: { value: 1 } })
await image.trigger(failure === 'tiny' ? 'load' : 'error')
expect(wrapper.find('figure img').exists()).toBe(false)
expect(wrapper.text()).toContain(failure === 'tiny' ? '原图仅 1 × 1 像素' : '照片加载失败')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
async function selectFile(wrapper: ReturnType<typeof mount>, file: File) { const input = wrapper.get('input[type="file"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() }
beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getDispatchEvidence).mockResolvedValue(metadata()); vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })); URL.createObjectURL = vi.fn().mockReturnValue('blob:authenticated'); URL.revokeObjectURL = vi.fn() })
@@ -11,14 +11,21 @@ import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/sh
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
import { downloadDriverLicense, getResourcePhoto, downloadResourcePhoto, type ResourcePhotoKind } from '@/modules/overseas-master-data'
import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts'
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; masterResource?: { kind: ResourcePhotoKind; id: string } | null; masterLicense?: { driverId: string; fileId: string } | null }>()
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; fallbackFileId?: string | null; masterResource?: { kind: ResourcePhotoKind; id: string } | null; masterLicense?: { driverId: string; fileId: string } | null }>()
const ids = defineModel<string[]>({ required: true })
const profilePhotoId = ref<string | null>(null)
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value, ...(profilePhotoId.value ? [profilePhotoId.value] : [])])])
// 主档仅作缺图时的默认来源;已有本趟驾驶证时不再额外展示主档图片。
const selectedIds = computed(() => {
const values = [...new Set([...(props.referenceIds || []), ...ids.value])]
const fallback = props.fallbackFileId || props.masterLicense?.fileId
return props.kind === 'DRIVER' && fallback && values.some(id => id !== fallback) ? values.filter(id => id !== fallback) : values
})
const allIds = computed(() => [...new Set([...selectedIds.value, ...(profilePhotoId.value ? [profilePhotoId.value] : [])])])
const emit = defineEmits<{ busy: [value: boolean]; available: [value: boolean] }>()
const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiErrorDetails | null>(null), uploading = ref(false), input = ref<HTMLInputElement | null>(null)
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
const thumbnails = ref<Record<string, string>>({})
const thumbnailFailures = ref<Record<string, string>>({})
let masterBlob: { fileId: string; blob: Blob } | null = null
const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('')
const previewBusy = computed(() => previewLoading.value || imageDecoding.value)
@@ -45,9 +52,9 @@ function imageFailed(event: Event) {
imageFailure.value = '图片无法解码或加载,请重新预览;若仍失败,请核对原附件是否损坏。'
}
async function load() {
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...new Set([...(props.referenceIds || []), ...ids.value])]
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...selectedIds.value]
for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) }
thumbnails.value = {}
thumbnails.value = {}; thumbnailFailures.value = {}
closePreview(); metadata.value = {}; error.value = null; masterBlob = null; profilePhotoId.value = null; emit('available', false)
if (!no) return
// 未保存且没有本趟照片时展示档案图片;保存由服务端复制,不提交跨归属文件ID。
@@ -89,6 +96,14 @@ async function load() {
} catch (cause) { if (current === generation) fail(cause, '照片读取失败,请重试') }
}))
}
function thumbnailLoaded(id: string, event: Event) {
const image = event.target as HTMLImageElement
if (image.getAttribute('src') !== thumbnails.value[id]) return
if (image.naturalWidth <= 1 && image.naturalHeight <= 1) thumbnailFailures.value[id] = '原图仅 1 × 1 像素,请替换有效照片'
}
function thumbnailFailed(id: string, event: Event) {
if ((event.target as HTMLImageElement).getAttribute('src') === thumbnails.value[id]) thumbnailFailures.value[id] = '照片加载失败,请重新读取或替换'
}
function choose(id: string | null = null) { if (props.readonly || props.disabled || uploading.value || !props.dispatchNo) return; replacing = id; input.value?.click() }
async function upload(event: Event) {
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
@@ -133,7 +148,7 @@ async function preview(id: string) {
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
finally { if (current === previewGeneration) previewLoading.value = false }
}
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value, props.referenceIds, props.masterLicense, props.masterResource]), load, { immediate: true })
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value, props.referenceIds, props.fallbackFileId, props.masterLicense, props.masterResource]), load, { immediate: true })
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
</script>
<template>
@@ -142,7 +157,7 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
<figure v-if="photoCard" class="evidence-photo-card">
<div v-if="allIds.length" class="evidence-photo-list">
<div v-for="id in allIds" :key="id" class="evidence-photo-item">
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName"><span v-else>{{ metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载' }}</span></button>
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id] && !thumbnailFailures[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName" @load="thumbnailLoaded(id, $event)" @error="thumbnailFailed(id, $event)"><span v-else>{{ thumbnailFailures[id] || (metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载') }}</span></button>
<p class="density-dialog-note evidence-photo-name" :title="metadata[id]?.fileName">{{ metadata[id]?.fileName || '附件资料待核实' }}</p>
<div v-if="!readonly && ids.includes(id)" class="density-actions evidence-photo-actions"><el-button size="small" :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button size="small" :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
</div>
@@ -22,11 +22,24 @@ it.each([['本趟承运公司', '555-0123'], [null, null]])('shows continuous ma
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'; value.vehicle.carrierName = carrierName; value.driver.mode = 'MASTER'; value.driver.driverId = 'driver-1'; value.driver.phone = phone
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } }); await flushPromises()
const field = (label: string) => wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!.getComponent({ name: 'ElInput' })
expect(field('拖车公司名称').props('modelValue')).toBe(carrierName || '—'); expect(field('拖车公司名称').props('readonly')).toBe(true)
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).map(item => item.props('label'))).not.toContain('拖车公司名称')
expect(field('联系电话').props('modelValue')).toBe(phone || '—'); expect(field('联系电话').props('readonly')).toBe(true)
expect(wrapper.text()).toContain('预计任务结束')
expect(listVehicles).toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(wrapper.emitted('update:driver')).toBeUndefined(); wrapper.unmount()
})
it.each(['MASTER', 'EXTERNAL'] as const)('lets a %s driver fill a missing trip end and respects the disabled state', async mode => {
const value = resources(); value.driver.mode = mode; value.driver.evidence!.expectedTaskEndAt = null
const wrapper = mount(DispatchResourceFields, { props: { ...value, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } })
const endField = wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '预计任务结束')!
const picker = endField.getComponent({ name: 'ElDatePicker' })
picker.vm.$emit('update:modelValue', '2030-01-01T17:00:00Z'); await flushPromises()
expect(value.driver.evidence!.expectedTaskEndAt).toBe('2030-01-01T17:00:00Z')
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).some(item => item.props('label') === '驾驶证到期日')).toBe(mode === 'EXTERNAL')
await wrapper.setProps({ disabled: true })
expect(picker.props('disabled')).toBe(true)
wrapper.unmount()
})
it('keeps master company and phone fields out of the existing non-continuous creation form', async () => {
const value = resources(); value.vehicle.mode = 'MASTER'; value.driver.mode = 'MASTER'
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } }); await flushPromises()
@@ -43,8 +56,8 @@ it('shows the newly selected authorized vehicle company without copying it into
const selected = wrapper.emitted('update:vehicle')!.at(-1)![0] as typeof value.vehicle
expect(selected).toMatchObject({ vehicleId: 'vehicle-2', carrierName: null })
await wrapper.setProps({ vehicle: selected })
const company = wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '拖车公司名称')!.getComponent({ name: 'ElInput' })
expect(company.props('modelValue')).toBe('新承运公司'); expect(company.props('readonly')).toBe(true); wrapper.unmount()
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).map(item => item.props('label'))).not.toContain('拖车公司名称')
wrapper.unmount()
})
it.each([false, true])('renders known business labels without internal IDs (continuous=%s)', async continuous => {
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = '900719925474099301'; value.driver.mode = 'MASTER'; value.driver.driverId = '900719925474099302'
@@ -10,7 +10,6 @@ const driver = defineModel<Driver>('driver', { required: true })
const props = defineProps<{ disabled: boolean; continuous?: boolean; dispatchNo?: string }>()
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
/** 维护弹窗只读展示本趟快照或已读取的当前主档资料,不回写主档字段。 */
const masterCarrierName = computed(() => vehicle.value.carrierName?.trim() || vehicles.value.find(row => row.id === vehicle.value.vehicleId)?.driverOrDispatchCompany?.trim() || '—')
const masterDriverPhone = computed(() => driver.value.phone?.trim() || drivers.value.find(row => row.id === driver.value.driverId)?.details.phone?.trim() || '—')
const currentVehicle = ref<VehicleDetail | null>(null), vehicleUnavailable = ref(false)
const vehicleOptions = computed(() => [...vehicles.value, ...(currentVehicle.value && !vehicles.value.some(row => row.id === currentVehicle.value?.id) ? [currentVehicle.value] : [])])
@@ -63,7 +62,6 @@ function selectDriver(id: string) {
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
</template>
<tr v-if="vehicle.mode === 'MASTER'"><td colspan="2"><el-form-item label="拖车公司名称"><el-input :model-value="masterCarrierName" readonly /></el-form-item></td></tr>
<tr><td><el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item><el-form-item v-else label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item></td></tr>
<tr v-if="driver.mode === 'MASTER'"><td colspan="2"><el-form-item label="联系电话"><el-input :model-value="masterDriverPhone" readonly /></el-form-item></td></tr>
<tr v-if="driver.mode === 'EXTERNAL'"><td><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item></td><td><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item></td></tr>
@@ -0,0 +1,77 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchReturnCargo from './DispatchReturnCargo.vue'
import { getDispatchReturnOptions } from '../api/dispatches'
import type { DispatchReturnCandidate, DispatchReturnSelection } from '../dispatchContracts'
vi.mock('../api/dispatches', () => ({ getDispatchReturnOptions: vi.fn() }))
const candidates: DispatchReturnCandidate[] = [
{ customerId: '101', customerName: '客户甲', deliveryNo: 'DL-1', lastMileOrderId: '11', lastMileOrderNo: '同号订单', sources: [
{ outboundPalletItemId: '21', inventoryPositionId: '31', returnCartonId: null, palletNo: 'P-1', skuCode: 'SKU-A', cartonMark: 'BOX-A', maxReturnPackages: 3 },
] },
{ customerId: '102', customerName: '客户乙', deliveryNo: 'DL-2', lastMileOrderId: '12', lastMileOrderNo: '同号订单', sources: [
{ outboundPalletItemId: '22', inventoryPositionId: '32', returnCartonId: null, palletNo: 'P-2', skuCode: 'SKU-B', cartonMark: 'BOX-B', maxReturnPackages: 5 },
] },
]
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(getDispatchReturnOptions).mockResolvedValue(structuredClone(candidates))
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
})
it('selects multiple customer orders, preserves independent quantities and removes only the deselected order', async () => {
const wrapper = mount(DispatchReturnCargo, { props: { dispatchNo: 'PC-1', disabled: false }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getDispatchReturnOptions).toHaveBeenCalledExactlyOnceWith('PC-1')
const select = wrapper.getComponent({ name: 'ElSelect' })
expect(select.props('multiple')).toBe(true)
const values = wrapper.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))
expect(new Set(values).size).toBe(2)
/** 模拟选择控件的真实双向绑定和 change,验证用户操作后的提交模型。 */
async function choose(selected: unknown[]) {
select.vm.$emit('update:modelValue', selected)
select.vm.$emit('change', selected)
await flushPromises()
}
/** 数量控件先更新绑定,再触发提交模型重算。 */
async function count(index: number, value: number) {
const input = wrapper.findAllComponents({ name: 'ElInputNumber' })[index]!
input.vm.$emit('update:modelValue', value)
input.vm.$emit('change', value)
await flushPromises()
}
/** 只核对公开的 v-model 输出,不依赖组件内部状态。 */
function selected() { return wrapper.emitted('update:modelValue')!.at(-1)![0] as DispatchReturnSelection[] }
await choose([values[0]])
await count(0, 1)
await choose(values)
expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).map(input => input.props('modelValue'))).toEqual([1, undefined])
await count(1, 2)
expect(selected()).toEqual([
{ deliveryNo: 'DL-1', lastMileOrderId: '11', items: [{ outboundPalletItemId: '21', inventoryPositionId: '31', returnCartonId: null, packageCount: 1 }] },
{ deliveryNo: 'DL-2', lastMileOrderId: '12', items: [{ outboundPalletItemId: '22', inventoryPositionId: '32', returnCartonId: null, packageCount: 2 }] },
])
await choose([values[1]])
expect(selected()).toEqual([{ deliveryNo: 'DL-2', lastMileOrderId: '12', items: [{ outboundPalletItemId: '22', inventoryPositionId: '32', returnCartonId: null, packageCount: 2 }] }])
await choose(values)
expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).map(input => input.props('modelValue'))).toEqual([undefined, 2])
wrapper.unmount()
})
it('clears stale selections and exposes a retry when return sources cannot be loaded', async () => {
vi.mocked(getDispatchReturnOptions).mockRejectedValueOnce(new Error('来源读取失败'))
const wrapper = mount(DispatchReturnCargo, { props: { dispatchNo: 'PC-1', disabled: false, modelValue: [{ deliveryNo: 'OLD', lastMileOrderId: 'OLD', items: [] }] }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('来源货物加载失败')
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([[]])
expect(wrapper.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
await wrapper.get('[role="alert"] button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.findAllComponents({ name: 'ElOption' })).toHaveLength(2)
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([[]])
wrapper.unmount()
})
@@ -6,20 +6,24 @@ import { getApiErrorDetails } from '@/shared/api/http'
const props = defineProps<{ dispatchNo: string; disabled: boolean }>()
const selections = defineModel<DispatchReturnSelection[]>({ default: () => [] })
const candidates = ref<DispatchReturnCandidate[]>([]), loading = ref(false), error = ref('')
const sourceId = ref(''), quantities = ref<number[]>([])
const source = computed(() => candidates.value.find(row => row.lastMileOrderId === sourceId.value))
/** 原单切换时清空旧货物,避免把另一单数量提交到当前来源。 */
function choose() { quantities.value = []; selections.value = [] }
/** 仅提交用户明确填写的原出库来源与退回箱数。 */
const sourceIds = ref<string[]>([]), quantities = ref<Record<string, number[]>>({})
/** 客户、派送单和原订单共同标识选项,避免同号货物串单。 */
function sourceKey(row: DispatchReturnCandidate) { return JSON.stringify([row.customerId, row.deliveryNo, row.lastMileOrderId]) }
const sources = computed(() => candidates.value.filter(row => sourceIds.value.includes(sourceKey(row))))
/** 增选保留已填数量,取消选中的订单立即清除其退回数量与提交内容。 */
function choose() {
quantities.value = Object.fromEntries(sourceIds.value.map(key => [key, quantities.value[key] || []]))
update()
}
/** 每单分别提交用户填写的原出库来源和退回箱数,不把其他订单数量带入本单。 */
function update() {
const row = source.value
selections.value = row ? [{ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId,
items: row.sources.flatMap((item, index) => quantities.value[index]! > 0 ? [{ outboundPalletItemId: item.outboundPalletItemId,
inventoryPositionId: item.inventoryPositionId, returnCartonId: item.returnCartonId, packageCount: quantities.value[index]! }] : []) }] : []
selections.value = sources.value.map(row => ({ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId,
items: row.sources.flatMap((item, index) => quantities.value[sourceKey(row)]?.[index]! > 0 ? [{ outboundPalletItemId: item.outboundPalletItemId,
inventoryPositionId: item.inventoryPositionId, returnCartonId: item.returnCartonId, packageCount: quantities.value[sourceKey(row)]![index]! }] : []) }))
}
/** 每次打开按本车最新派送事实加载候选,失败明确阻断退回提交。 */
async function load() {
loading.value = true; error.value = ''; selections.value = []
loading.value = true; error.value = ''; candidates.value = []; sourceIds.value = []; quantities.value = {}; selections.value = []
try { candidates.value = await getDispatchReturnOptions(props.dispatchNo) }
catch (cause) { error.value = getApiErrorDetails(cause, '来源货物加载失败').message }
finally { loading.value = false }
@@ -29,11 +33,14 @@ onMounted(load)
<template>
<section v-loading="loading" aria-label="选择退回货物">
<p v-if="error" role="alert">{{ error }} <el-button link @click="load">重试</el-button></p>
<el-form-item label="来源订单" required><el-select v-model="sourceId" :disabled="disabled || loading" placeholder="请选择本车来源订单" @change="choose">
<el-option v-for="row in candidates" :key="row.lastMileOrderId" :value="row.lastMileOrderId" :label="`${row.lastMileOrderNo} · ${row.customerName || row.customerId} · ${row.deliveryNo}`" />
<el-form-item label="来源订单" required><el-select v-model="sourceIds" multiple :disabled="disabled || loading" placeholder="请选择本车来源订单(可多选)" @change="choose">
<el-option v-for="row in candidates" :key="sourceKey(row)" :value="sourceKey(row)" :label="`${row.lastMileOrderNo} · ${row.customerName || row.customerId} · ${row.deliveryNo}`" />
</el-select></el-form-item>
<table v-if="source" class="density-table density-table--editable"><thead><tr><th>原托盘 / 箱唛</th><th>SKU</th><th>可退箱数</th><th>本次退回</th></tr></thead><tbody>
<tr v-for="(item, index) in source.sources" :key="`${item.outboundPalletItemId}:${item.inventoryPositionId}:${item.returnCartonId}`"><td>{{ item.cartonMark || item.palletNo || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.maxReturnPackages }}</td><td><el-input-number v-model="quantities[index]" :min="0" :max="item.maxReturnPackages" :precision="0" :disabled="disabled" @change="update" /></td></tr>
</tbody></table>
<section v-for="source in sources" :key="sourceKey(source)" :aria-label="`退回货物 ${source.lastMileOrderNo} ${source.deliveryNo}`">
<h4>{{ source.lastMileOrderNo }} · {{ source.customerName || source.customerId }} · {{ source.deliveryNo }}</h4>
<table class="density-table density-table--editable"><thead><tr><th>原托盘 / 箱唛</th><th>SKU</th><th>可退箱数</th><th>本次退回</th></tr></thead><tbody>
<tr v-for="(item, index) in source.sources" :key="`${item.outboundPalletItemId}:${item.inventoryPositionId}:${item.returnCartonId}`"><td>{{ item.cartonMark || item.palletNo || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.maxReturnPackages }}</td><td><el-input-number v-model="quantities[sourceKey(source)]![index]" :min="0" :max="item.maxReturnPackages" :precision="0" :disabled="disabled" @change="update" /></td></tr>
</tbody></table>
</section>
</section>
</template>
@@ -31,7 +31,7 @@ function confirmedAt(appointment: DispatchDestinationAppointment) {
<div class="density-dialog-sections">
<section class="density-dialog-section" aria-label="本车资源登记">
<h3 class="density-dialog-section__title">车辆与司机</h3>
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td><td><el-form-item label="发车时间"><el-input :model-value="formatOverseasDateTime(detail.dispatchInfo.departedAt)" readonly /><DispatchTimeZonePreview :value="detail.dispatchInfo.departedAt" label="发车时间" /></el-form-item></td></tr></tbody></table>
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td colspan="2"><el-form-item label="排车单号"><el-input :model-value="detail.dispatchNo" readonly /></el-form-item></td></tr></tbody></table>
<DispatchResourceFields :dispatch-no="detail.dispatchNo" continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
</section>
<section class="density-dialog-section" aria-label="本车时间登记">
@@ -60,6 +60,7 @@ describe('dispatch workflow native layout', () => {
const fields = dialog.findComponent(DispatchResourceFields)
const selected = { ...resources().driver, mode: 'MASTER' as const, driverId: '22', evidence: { ...resources().driver.evidence!, fileIds: [], photoFileIds: [], masterLicenseFileId: '33' } }
fields.vm.$emit('update:driver', selected); await flushPromises()
expect(dialog.text()).toContain('预计任务结束')
const license = () => dialog.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === 'DRIVER')!
expect(license().find('img').attributes('alt')).toBe('驾驶证照片:驾驶证照片')
expect(downloadDriverLicense).toHaveBeenCalledExactlyOnceWith('22', '33')
@@ -70,6 +71,17 @@ describe('dispatch workflow native layout', () => {
expect(license().find('img').exists()).toBe(false)
expect(license().text()).toContain('暂无照片')
})
it('saves the entered task end for a master driver in the maintenance command', async () => {
const saved = resources()
saved.driver = { ...saved.driver, mode: 'MASTER', driverId: '22', evidence: { ...saved.driver.evidence!, expectedTaskEndAt: null } }
const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: saved })
field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2030-01-01T17:00:00Z')
await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({
driver: expect.objectContaining({ mode: 'MASTER', driverId: '22', evidence: expect.objectContaining({ expectedTaskEndAt: '2030-01-01T17:00:00Z' }) }),
}))
})
it('reads an already frozen master license through the dispatch permission without a new driver directory request', async () => {
const saved = resources()
saved.driver = { ...saved.driver, mode: 'MASTER', driverId: '22', evidence: { ...saved.driver.evidence!, fileIds: ['33'], masterLicenseFileId: '33' } }
@@ -112,6 +124,18 @@ describe('dispatch workflow native layout', () => {
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: saved }))
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt')
})
it('shows the server grouped looseboard orders and their aggregated pallet and volume totals', async () => {
const base = detail().orderDetails[0]!
const orderDetails = [{ ...base, orderNo: 'LB202611050003', inboundCode: null, palletCount: 2, volumeCbm: 10, containerNo: null, sourceBusinessType: 'LOCAL_LOOSEBOARD', sourceBusinessNo: 'LB202611050003', sourceKeys: ['PLAN:11', 'PLAN:12'] },
{ ...base, sourceKey: 'PLAN:13', orderNo: 'LB202611050002', inboundCode: null, palletCount: 1, volumeCbm: 4, containerNo: null, sourceBusinessType: 'LOCAL_LOOSEBOARD', sourceBusinessNo: 'LB202611050002', sourceKeys: ['PLAN:13'] }]
const { dialog } = await render('EDIT', undefined, false, { orderDetails })
expect(dialog.text()).toContain('关联订单 (2)')
await dialog.findAll('[role="tab"]').find(tab => tab.text().includes('关联订单'))!.trigger('click')
const rows = dialog.get('.maintenance-orders').findAll('tbody tr')
expect(rows).toHaveLength(2)
expect(rows[0]!.findAll('td').map(cell => cell.text())).toEqual(['客户甲', 'LB202611050003', '—', '—', '2', 'ONT8', '10', '—'])
expect(rows[1]!.text()).toContain('LB202611050002')
})
it('edits one number per destination and can save an incomplete second destination', async () => {
const destinations = ['ONT8', 'LGB8'].map(code => ({ batchKey: null, batchNo: null, customerId: null, appointmentNo: null, destinationKey: `FBA:${code}`, destinationWarehouseCode: code, status: 'PENDING', confirmedAt: null, blockReason: null, shipmentNumbers: [] }))
const initial = detail()
@@ -151,7 +175,8 @@ describe('dispatch workflow native layout', () => {
const { dialog } = await render('EDIT', undefined, false, { dispatchInfo: { ...source.dispatchInfo,
departedAt: '2026-07-01T15:00:00Z', plannedDepartureAt: '2026-07-01T16:00:00Z', estimatedArrivalAt: '2026-07-01T17:00:00Z',
} })
expect(field(dialog, '发车时间').get('[aria-label="发车时间时区换算"]').text()).toBe('美东2026-07-01 11:00:00美西2026-07-01 08:00:00')
expect(dialog.find('[aria-label="发车时间时区换算"]').exists()).toBe(false)
expect(field(dialog, '发车时间')).toBeUndefined()
expect(field(dialog, '计划发车时间').get('[aria-label="计划发车时间时区换算"]').text()).toBe('美东2026-07-01 12:00:00美西2026-07-01 09:00:00')
expect(field(dialog, '预计到达时间').get('[aria-label="预计到达时间时区换算"]').text()).toBe('美东2026-07-01 13:00:00美西2026-07-01 10:00:00')
expect(field(dialog, '预计任务结束').get('[aria-label="预计任务结束时区换算"]').text()).toBe('美东2026-11-01 05:00:00美西2026-11-01 02:00:00')
@@ -178,7 +203,8 @@ describe('dispatch workflow native layout', () => {
for (const [label, value] of [['派送方式', '外配'], ['提货方式', '一提多卸'], ['装车类型', '混装'], ['车辆类型', '厢式货车']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
await field(dialog, '车队邮箱').get('input').setValue(' fleet@example.test ')
await field(dialog, '预约账号').get('input').setValue(' fleet-account ')
expect(field(dialog, '发车时间').get('input').attributes('readonly')).toBeDefined()
expect(field(dialog, '发车时间')).toBeUndefined()
expect(field(dialog, '排车单号').get('input').attributes('readonly')).toBeDefined()
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
@@ -202,7 +228,7 @@ describe('dispatch workflow native layout', () => {
await field(dialog,'车牌号').get('input').setValue('CA-PRESERVED')
dialog.vm.$emit('confirm');await flushPromises()
const original=structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
expect(dialog.text()).toContain('workflow-request')
expect((wrapper.vm as unknown as { error: { requestId: string } }).error.requestId).toBe('workflow-request')
expect(field(dialog,'车牌号').get('input').attributes('disabled')).toBeUndefined()
expect((field(dialog,'车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
await field(dialog,'车牌号').get('input').setValue('CA-CHANGED')
@@ -275,7 +275,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车牌图片" :master-resource="vehicle.mode === 'MASTER' && vehicle.vehicleId ? { kind: 'VEHICLE', id: vehicle.vehicleId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driver.mode === 'MASTER' && driver.driverId ? { kind: 'DRIVER', id: driver.driverId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :fallback-file-id="masterLicenseReference" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
</div>
</template>
</DispatchVehicleMaintenanceForm>
@@ -3,9 +3,9 @@ import { expect, it } from 'vitest'
import { createAppointmentForm } from '../appointmentForm'
import FbaAppointmentFields from './FbaAppointmentFields.vue'
it('removes confirmation time and offset inputs only for the new appointment flow while retaining planned dates', async () => {
it('keeps only the planned date for new appointments and allows time fields in other flows', async () => {
const wrapper = mount(FbaAppointmentFields, {
props: { model: createAppointmentForm(), continuous: true, hideConfirmationTimes: true, timezone: 'America/New_York' },
props: { model: createAppointmentForm(), continuous: true, dateOnly: true, timezone: 'America/New_York' },
global: { stubs: {
ElForm: { template: '<form><slot /></form>' },
ElFormItem: { props: ['label'], template: '<label>{{ label }}<slot /></label>' },
@@ -16,10 +16,11 @@ it('removes confirmation time and offset inputs only for the new appointment flo
})
expect(wrapper.text()).toContain('外部约号')
expect(wrapper.text()).toContain('计划送仓日期')
expect(wrapper.text()).toContain('补充计划时段')
expect(wrapper.findAll('[data-picker]')).toHaveLength(3)
expect(wrapper.text()).not.toMatch(/对方确认开始时间|对方确认结束时间|确认开始时差|确认结束时差|夏令时/)
await wrapper.setProps({ hideConfirmationTimes: false })
expect(wrapper.findAll('[data-picker]')).toHaveLength(1)
expect(wrapper.text()).not.toMatch(/补充计划时段|计划开始时段|计划结束时段|对方确认开始时间|对方确认结束时间|确认开始时差|确认结束时差|夏令时/)
await wrapper.setProps({ dateOnly: false })
expect(wrapper.text()).toContain('计划开始时段')
expect(wrapper.text()).toContain('计划结束时段')
expect(wrapper.text()).toContain('对方确认开始时间')
expect(wrapper.findAll('[data-picker]')).toHaveLength(5)
wrapper.unmount()
@@ -3,7 +3,7 @@ import InlineHelp from '@/shared/components/InlineHelp.vue'
import { AppointmentConfirmationCargo, type PlanningSourceLookup } from '@/modules/appointment-calendar'
import { ref, watchEffect } from 'vue'
import type { AppointmentForm } from '../appointmentForm'
const props = defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; hideConfirmationTimes?: boolean; sources?: PlanningSourceLookup[] }>()
const props = defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean; dateOnly?: boolean; sources?: PlanningSourceLookup[] }>()
const emit = defineEmits<{ confirmationReady: [ready: boolean] }>()
const cargo = ref<InstanceType<typeof AppointmentConfirmationCargo> | null>(null)
watchEffect(() => {
@@ -13,7 +13,7 @@ watchEffect(() => {
defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true) })
</script>
<template>
<p v-if="!hideConfirmationTimes && model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
<p v-if="!dateOnly && model.appointmentStatus === 'SUCCESS' && timezone && /^(UTC|GMT|Etc\/(GMT|UTC)|[+-])/.test(timezone)" role="alert">目的仓当前只有固定时差,尚未设置地区时区。请到“仓库管理”编辑本次目的 FBA 仓,按实际地址设置地区时区(如 America/New_York),再重新打开预约;固定时差无法判断夏令时。</p>
<AppointmentConfirmationCargo v-if="sources?.length" ref="cargo" :sources="sources" :active="model.appointmentStatus === 'SUCCESS'" :disabled="disabled" @ready="emit('confirmationReady', $event)" />
<div v-if="continuous" class="density-dialog-sections">
<section class="density-dialog-section" aria-label="预约来源信息"><h3 class="density-dialog-section__title">预约信息</h3><table class="density-table"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
@@ -22,13 +22,13 @@ defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true)
<tr><td><el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item></td><td><el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item></td></tr>
<template v-if="model.appointmentStatus === 'SUCCESS'">
<tr><td colspan="2"><el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item></td></tr>
<tr v-if="!hideConfirmationTimes"><td><el-form-item label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item></td><td><el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item></td></tr>
<tr v-if="!dateOnly"><td><el-form-item label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item></td><td><el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item></td></tr>
</template>
<tr><td colspan="2"><el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" aria-label="预约备注" maxlength="1000" /></el-form-item></td></tr>
</tbody></table>
<details><summary>{{ hideConfirmationTimes ? '补充计划时段' : '补充计划时段与夏令时信息' }}</summary><table class="density-table density-table--editable appointment-editor-table"><tbody>
<details v-if="!dateOnly"><summary>补充计划时段与夏令时信息</summary><table class="density-table density-table--editable appointment-editor-table"><tbody>
<tr><td><el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item></td><td><el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr>
<template v-if="!hideConfirmationTimes && model.appointmentStatus === 'SUCCESS'"><tr><td colspan="2"><span class="density-dialog-note">当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</span><InlineHelp title="夏令时填写说明">仅重叠时刻需填写对方确认的时差;修改确认时间后须重新核对时差。</InlineHelp></td></tr><tr><td><el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item></td><td><el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item></td></tr></template>
<template v-if="model.appointmentStatus === 'SUCCESS'"><tr><td colspan="2"><span class="density-dialog-note">当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</span><InlineHelp title="夏令时填写说明">仅重叠时刻需填写对方确认的时差;修改确认时间后须重新核对时差。</InlineHelp></td></tr><tr><td><el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item></td><td><el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item></td></tr></template>
</tbody></table></details></el-form>
</section>
</div>
@@ -39,15 +39,15 @@ defineExpose({ canLeave: () => cargo.value?.canLeave() ?? Promise.resolve(true)
<el-form-item label="预约状态"><el-input :model-value="({ SUCCESS: '已确认', CANCELLED: '已取消', FAILED: '未约成', PENDING: '待确认' })[model.appointmentStatus]" readonly /></el-form-item>
<template v-if="model.appointmentStatus === 'SUCCESS'">
<el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item>
<el-form-item v-if="!hideConfirmationTimes" label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
<el-form-item v-if="!hideConfirmationTimes" label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
<el-form-item v-if="!dateOnly" label="对方确认开始时间"><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
<el-form-item v-if="!dateOnly" label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
</template>
<el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" aria-label="预约备注" maxlength="1000" /></el-form-item>
<details class="appointment-fields__extra"><summary>{{ hideConfirmationTimes ? '补充计划时段' : '补充计划时段与夏令时信息' }}</summary>
<details v-if="!dateOnly" class="appointment-fields__extra"><summary>补充计划时段与夏令时信息</summary>
<div class="appointment-fields__extra-grid">
<el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item>
<el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<template v-if="!hideConfirmationTimes && model.appointmentStatus === 'SUCCESS'"><p>当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</p>
<template v-if="model.appointmentStatus === 'SUCCESS'"><p>当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</p>
<el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item>
<el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item>
</template>
@@ -31,7 +31,7 @@ export interface DispatchPage { records: DispatchListRow[]; total: number; page:
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
export interface FbaAppointment { batchKey: string | null; batchNo: string | null; customerId: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null; shipmentNumbers: string[] }
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null; sourceKeys?: string[] }
export type DispatchExceptionType = 'REFUSED' | 'PARTIAL_RECEIPT' | 'VEHICLE_EXCEPTION'
export type DispatchReturnDirection = 'OVERSEAS_WAREHOUSE' | 'OTHER'
export interface DispatchExceptionRecord { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[]; occurredAt: string }
@@ -58,6 +58,19 @@ describe('DeliveryOrderDetailView', () => {
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
wrapper.unmount()
})
it('renders document and receipt operation nodes in Chinese', async () => {
const value = await vi.mocked(getDeliveryOrder)('DEL/1', '100')
value.operations = ['FIRSTLEG_COMPLETION_PENDING', 'SAVE_DOCUMENTS', 'RECEIPT_AUTO_COMPLETE'].map((operationNode, index) => ({ id: String(index), operationNode, operatedAt: '2026-11-05T16:59:55', result: '已保存' }))
vi.mocked(getDeliveryOrder).mockResolvedValue(value)
const wrapper = mount(DeliveryOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.find('.el-tabs__item:nth-child(4)').trigger('click')
expect(wrapper.text()).toContain('来源货物签收待齐')
expect(wrapper.text()).toContain('保存派送资料')
expect(wrapper.text()).toContain('签收文件自动完成派送')
for (const operation of value.operations) expect(wrapper.text()).not.toContain(operation.operationNode)
wrapper.unmount()
})
it('shows frozen original coverage separately from new fees', async () => {
const value = await vi.mocked(getDeliveryOrder)('DEL/1', '100')
value.feeCoverage = { cargoHash: 'hash', cargo: [], confirmedAt: '2026-10-03T00:00:00Z', references: [{
@@ -29,7 +29,7 @@ const methodLabels: Record<string, string> = { PICKUP: '普通自提', AMAZON_PI
const pickupLabels: Record<string, string> = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' }
const loadingLabels: Record<string, string> = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库', CANCEL_OUTBOUND_REASON: '取消原因', CONFIRM_EXTERNAL_FREIGHT: '确认外接运费' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库', CANCEL_OUTBOUND_REASON: '取消原因', CONFIRM_EXTERNAL_FREIGHT: '确认外接运费', SAVE_DOCUMENTS: '保存派送资料', RECEIPT_AUTO_COMPLETE: '签收文件自动完成派送', FIRSTLEG_COMPLETION_PENDING: '来源货物签收待齐' }
const resultLabels: Record<string, string> = { CREATED: '已创建' }
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
@@ -57,7 +57,6 @@ beforeEach(() => {
})
async function listPage() { const wrapper = mount(InboundOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
function popup(wrapper: VueWrapper, title: string) { return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith(title))! }
async function openEditPlan() { const wrapper = await listPage(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '编辑')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '编辑入库单') } }
async function openEditAppointment() { const wrapper = await listPage(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '预约')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '编辑预约') } }
async function openMove() { const wrapper = mount(InboundOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '移库')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '托盘移库') } }
@@ -140,40 +139,17 @@ describe('inbound appointment native dialogs', () => {
})
describe('inbound plan and pallet move native dialogs', () => {
it('shows plan identity and saves the selected master warehouse with the internal version through fullscreen', async () => {
const { dialog } = await openEditPlan()
expect(dialog.get('[aria-label="入库计划来源"]').findAll('input')).toHaveLength(0)
expect(dialog.get('[aria-label="入库计划来源"]').text()).toContain('入库单号')
expect(dialog.get('[aria-label="入库计划来源"]').text()).not.toContain('版本')
const select = dialog.findComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(updateInboundPlan).toHaveBeenCalledExactlyOnceWith('IP-1', { customerId: 'C-1', overseasWarehouseId: warehouse.id, warehouseCode: 'LA2', warehouseName: '洛杉矶二仓', version: 0 })
})
it('locks a pending plan edit and keeps the warehouse and diagnostic context after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(updateInboundPlan).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const { dialog } = await openEditPlan()
const select = dialog.findComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(updateInboundPlan).toHaveBeenCalledTimes(1)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(select.find('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('版本已变化', 'A0409', 409, null, 'plan-popup-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(select.props('modelValue')).toBe(warehouse.id)
expect(dialog.text()).toContain('plan-popup-request')
})
it('does not save while the plan warehouse candidates are loading', async () => {
let resolve!: (value: Awaited<ReturnType<typeof listReceivingOverseasWarehouses>>) => void
vi.mocked(listReceivingOverseasWarehouses).mockReturnValue(new Promise(done => { resolve = done }))
const { dialog } = await openEditPlan()
expect(dialog.props('loading')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(updateInboundPlan).not.toHaveBeenCalled()
resolve({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 }); await flushPromises()
expect(dialog.props('loading')).toBe(false)
it.each(['ONE_CLICK_DROPSHIP', 'FIRST_LEG', 'RELABEL_APPLICATION', 'WAREHOUSE_TRANSFER', 'RETURN_RELABEL', 'DELIVERY_RETURN', 'LOCAL_LOOSEBOARD', 'CARGO_DISCARD', 'REJECTED', 'EXPRESS', 'FUTURE_TYPE', null])('hides plan editing for inbound order type %s even with edit permission', async (orderType) => {
vi.mocked(listInboundOrders).mockResolvedValue({ records: [{ ...order, orderType }], total: 1, page: 1, size: 10, pages: 1 })
const wrapper = await listPage()
const actions = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text())
expect(actions).not.toContain('编辑')
expect(actions).toContain('查看')
expect(actions).toContain('查看预约')
expect(actions).toContain('预约')
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('title').startsWith('编辑入库单'))).toBe(false)
expect(listReceivingOverseasWarehouses).not.toHaveBeenCalled()
expect(updateInboundPlan).not.toHaveBeenCalled()
})
it('loads only physical usable locations and preserves a valid zero pallet version in the command', async () => {
const { dialog } = await openMove()
@@ -19,8 +19,7 @@ import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import FbaAppointmentFields from '../components/FbaAppointmentFields.vue'
import type { PlanningSourceLookup } from '@/modules/appointment-calendar'
import { createAppointmentForm, appointmentPayload, appointmentFormError } from '../appointmentForm'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
@@ -68,7 +67,6 @@ const appointmentViewOnly = ref(false)
const appointmentReplacementNos = ref<string[]>([])
const appointmentReadOnly = computed(() => appointmentViewOnly.value || !access.can(permission.inboundOrderAppointment))
const appointmentTitle = computed(() => `${appointmentReadOnly.value ? '查看预约' : appointmentMode.value === 'edit' ? '编辑预约' : '预约'} · ${editingAppointmentNo.value || `${appointmentSelectedCount.value} 条入库单`}`)
const planEditOpen = ref(false); const planEditLoading = ref(false); const planEditSaving = ref(false); const planEditTarget = ref<InboundOrderListItem | null>(null); const planEditForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', version: 0 }); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
function selectableRow(source: object) {
const row = source as InboundOrderListItem
@@ -234,8 +232,6 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as InboundOrderListItem; void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
async function openPlanEdit(source: object) { const row = source as InboundOrderListItem; planEditTarget.value = row; planEditOpen.value = true; planEditLoading.value = true; mutationError.value = null; try { Object.assign(planEditForm, { warehouseId: row.warehouseId, warehouseCode: row.destinationWarehouseCode === '—' ? '' : row.destinationWarehouseCode, warehouseName: row.destinationWarehouseName === '—' ? '' : row.destinationWarehouseName, version: row.version }); if (!warehouses.value.length) warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { planEditOpen.value = false; mutationError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') } finally { planEditLoading.value = false } }
async function submitPlanEdit() { if (planEditSaving.value || planEditLoading.value || !access.can(permission.inboundOrderEdit)) return; const target = planEditTarget.value; if (!target || !planEditForm.warehouseId) { ElMessage.warning('请选择目的仓'); return } planEditSaving.value = true; mutationError.value = null; try { await updateInboundPlan(target.orderNo, { customerId: target.customerId, overseasWarehouseId: planEditForm.warehouseId, warehouseCode: planEditForm.warehouseCode, warehouseName: planEditForm.warehouseName, version: planEditForm.version }); planEditOpen.value = false; ElMessage.success('入库单已更新'); await load() } catch (error) { mutationError.value = getApiErrorDetails(error, '入库单更新失败,请重试') } finally { planEditSaving.value = false } }
onMounted(() => { void load() })
</script>
@@ -250,7 +246,7 @@ onMounted(() => { void load() })
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="canCreateDispatch" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" @click="openPlanEdit(row)">编辑</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderList)" size="small" @click="openAppointmentEdit(row, true)">查看预约</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button><el-button v-if="(row as InboundOrderListItem).appointmentStatus === '多批预约' && access.can('platform:appointment:list')" class="density-action" @click="openMultipleAppointments(row as InboundOrderListItem)">管理多批预约</el-button></div></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderList)" size="small" @click="openAppointmentEdit(row, true)">查看预约</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button><el-button v-if="(row as InboundOrderListItem).appointmentStatus === '多批预约' && access.can('platform:appointment:list')" class="density-action" @click="openMultipleAppointments(row as InboundOrderListItem)">管理多批预约</el-button></div></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></div></section>
</ListPageLayout>
@@ -263,11 +259,6 @@ onMounted(() => { void load() })
</div>
</AppDialog>
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="入库计划来源"><h3 class="density-dialog-section__title">计划信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ planEditTarget?.orderNo || '—' }}</td></tr></tbody></table></section>
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<section class="density-dialog-section" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
</div></AppDialog>
</AdminPage>
</template>
@@ -297,7 +297,7 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 330, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110, afterColumn: 'orderNo' },
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '出库排车状态', width: 110, afterColumn: 'orderNo' },
{ key: 'relabelStatus', slotName: 'relabelStatus', label: '换标子状态', minWidth: 180, afterColumn: 'orderNo' },
{ key: 'status', prop: 'status', label: '当前状态', width: 110, afterColumn: 'orderNo' },
{ key: 'abnormal', prop: 'abnormal', label: '是否异常', width: 100, afterColumn: 'orderNo' },
@@ -91,7 +91,7 @@ function feePreview(overrides: Partial<InboundFeePreview> = {}): InboundFeePrevi
}
function savedOuterMeasurement() {
return { actualPackageCount: 22, actualGrossWeightKg: 570.5, actualVolumeCbm: 3.6894,
return { measureReviewStatus: 'APPROVED', actualPackageCount: 22, actualGrossWeightKg: 570.5, actualVolumeCbm: 3.6894,
items: [{ id: '9001', itemNo: 'B1', packageCount: 22, lengthCm: 62, widthCm: 52, heightCm: 52,
outerLengthCm: 63, outerWidthCm: 53, outerHeightCm: 53,
calculatedOuterLengthCm: 63, calculatedOuterWidthCm: 53, calculatedOuterHeightCm: 53,
@@ -266,6 +266,22 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
wrapper.unmount()
})
it.each(['NOT_SUBMITTED', 'PENDING', 'REJECTED'])('blocks fee publication before measurement approval: %s', async (measureReviewStatus) => {
const wrapper = mountDetail()
await flushPromises()
const vm = wrapper.vm as unknown as FeeConfirmVm & { feeConfirmOpen: boolean }
vm.measurementDetail = { ...savedOuterMeasurement(), measureReviewStatus }
await vm.openFeeConfirmDialog()
expect(vm.feeConfirmOpen).toBe(false)
await vm.executeConfirmAndSendFee()
expect(previewInboundFee).not.toHaveBeenCalled()
expect(calculateInboundFee).not.toHaveBeenCalled()
expect(confirmInboundFee).not.toHaveBeenCalled()
expect(sendInboundFee).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each([3.894, 4.25])('defaults billing to the saved outer volume and sends the confirmed value %s', async (confirmedVolume) => {
const fee = { unitPrice: 13.6, chargeableQuantity: 650.298, chargeUnit: 'KG', roundingScale: 4, rateCode: 'RATE', feeStatus: 'CALCULATED' }
vi.mocked(getLatestInboundFee).mockResolvedValue(fee as never)
@@ -359,7 +375,7 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
await flushPromises()
const vm = wrapper.vm as unknown as FeeConfirmVm
vm.measurementDetail = {
actualPackageCount: 10, actualGrossWeightKg: 100, actualVolumeCbm: 0.81,
measureReviewStatus: 'APPROVED', actualPackageCount: 10, actualGrossWeightKg: 100, actualVolumeCbm: 0.81,
items: [{ id: '9001', itemNo: 'B1', packageCount: 10, lengthCm: 50, widthCm: 40, heightCm: 30,
outerLengthCm: null, outerWidthCm: null, outerHeightCm: null,
calculatedOuterLengthCm: 54, calculatedOuterWidthCm: 44, calculatedOuterHeightCm: 34,
@@ -23,7 +23,7 @@ vi.mock('@/modules/inbounds', async () => {
name: 'InboundTaskList', props: ['mode', 'title', 'taskLoader'],
methods: { reload() { return this.taskLoader({ page: 1, size: 20 }) } },
mounted() { void this.reload() },
template: '<section><slot name="filters" :loading="false" /><slot name="status-tabs" /></section>',
template: '<section><slot name="filters" :loading="false" /><slot name="status-tabs" /><slot name="toolbar-actions" :loading="false" /></section>',
}),
}
})
@@ -77,6 +77,32 @@ function mountView() {
}
describe('UnifiedOrdersView list request coordination', () => {
it('shares fulfillment actions with measurement reviews and uses only that tab selection', async () => {
const wrapper = mountView()
await flushPromises()
const reviewPage = { ...emptyPage(), records: [
{ orderId: 'review-1', inboundId: 'inbound-1', tenantId: '8', mainStatus: 'WAIT_MEASURE', receivingWarehouseId: '9', channelCode: 'CHANNEL' },
{ orderId: 'review-2', inboundId: 'inbound-2', tenantId: '8', mainStatus: 'WAIT_MEASURE', receivingWarehouseId: '9', channelCode: 'CHANNEL' },
], total: 2, pages: 1 } as Awaited<ReturnType<typeof listPlatformUnifiedOrders>>
vi.mocked(listPlatformUnifiedOrders).mockResolvedValueOnce(reviewPage)
await wrapper.findAll('[role="tab"]').find(tab => tab.text().startsWith('测量数据审核'))!.trigger('click')
await flushPromises()
const action = (label: string) => wrapper.findAll('button').find(button => button.text().startsWith(label))!
expect(action('合柜报关').attributes('disabled')).toBeDefined()
expect(action('打印箱唛').attributes('disabled')).toBeDefined()
expect(action('智能装柜').exists()).toBe(true)
const reviewList = wrapper.getComponent({ name: 'InboundTaskList' })
reviewList.vm.$emit('selection-change', [{ id: 'inbound-1' }, { id: 'inbound-2' }])
await flushPromises()
expect(action('合柜报关').attributes('disabled')).toBeUndefined()
expect(action('打印箱唛').text()).toContain('2')
expect(action('打印箱唛').attributes('disabled')).toBeUndefined()
reviewList.vm.$emit('selection-change', [])
await flushPromises()
expect(action('打印箱唛').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
beforeEach(() => {
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockReset().mockResolvedValue({ counts: {} })
vi.mocked(listPlatformUnifiedOrders).mockReset().mockResolvedValue(emptyPage())
@@ -747,7 +747,7 @@ function selectStatus(status: UnifiedOrderMainStatus | '' | 'MEASUREMENT_REVIEW'
if (selectedTab.value === status) return
ordersRequestSerial += 1
measurementReviewSelected.value = status === 'MEASUREMENT_REVIEW'
if (measurementReviewSelected.value) selectedOrders.value = []
handleOrderSelectionChange([])
if (status === 'MEASUREMENT_REVIEW') {
return
}
@@ -1670,11 +1670,23 @@ function tabCount(value: UnifiedOrderMainStatus | '' | 'MEASUREMENT_REVIEW') {
return value ? statusCounts.value[value] : undefined
}
const measurementReviewOrders = ref<UnifiedOrderResource[]>([])
let measurementReviewRequestSerial = 0
/** 保留审核列表对应的原始订单,工具栏操作复用同一客户、入库单和业务资格。 */
async function loadMeasurementReviewTasks(pagination: { page: number; size: number }) {
const serial = ++measurementReviewRequestSerial
const result = await listPlatformUnifiedOrders({ ...orderCountFilters(), mainStatus: 'MEASUREMENT_REVIEW', ...pagination })
if (serial === measurementReviewRequestSerial) measurementReviewOrders.value = result.records
return { ...result, records: result.records.map(toMeasurementReviewTask) }
}
/** 审核列表勾选映射回原始订单,避免混用其他页签的勾选数据。 */
function handleMeasurementReviewSelection(rows: Array<{ id: string }>) {
const ids = new Set(rows.map(row => String(row.id)))
handleOrderSelectionChange(measurementReviewOrders.value.filter(row => ids.has(String(row.inboundId))))
}
async function loadOrders(refreshCounts = true) {
const serial = ++ordersRequestSerial
if (refreshCounts) void loadStatusCounts()
@@ -1741,6 +1753,10 @@ async function openFeeConfirmDialog() {
ElMessage.warning('当前单据缺少入仓信息,暂不能发送费用')
return
}
if (measurementDetail.value?.measureReviewStatus !== 'APPROVED') {
ElMessage.warning('测量数据尚未审核通过,请先完成测量审核后再发送客户')
return
}
if (!hasActualMeasurementData.value) {
ElMessage.warning('暂无实际测量数据,不能确认并发送费用')
return
@@ -1866,6 +1882,10 @@ async function executeConfirmAndSendFee() {
if (confirmFeeSending.value) return
const summary = detail.value?.summary
if (!summary?.inboundId) return
if (measurementDetail.value?.measureReviewStatus !== 'APPROVED') {
ElMessage.warning('测量数据尚未审核通过,请先完成测量审核后再发送客户')
return
}
const selectedWeightKg = Number(feeConfirmWeight.value)
const selectedVolumeCbm = Number(feeConfirmVolume.value)
const selectedUnitPrice = Number(feeConfirmUnitPrice.value)
@@ -2854,7 +2874,14 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
<template v-else>
<div v-if="measurementReviewSelected" id="unified-orders-results" class="orders-status-toolbar" role="tabpanel">
<InboundTaskList ref="measurementReviewList" :task-loader="loadMeasurementReviewTasks" mode="review" title="订单列表" class="is-embedded" @changed="loadStatusCounts">
<InboundTaskList ref="measurementReviewList" :task-loader="loadMeasurementReviewTasks" mode="review" title="订单列表" class="is-embedded" @changed="loadStatusCounts" @selection-change="handleMeasurementReviewSelection">
<template #toolbar-actions="{ loading: reviewLoading }">
<div v-if="can('platform:firstleg:smart-loading:assign')" class="coload-toolbar-action">
<el-button type="primary" plain :loading="coLoadSaving" :disabled="reviewLoading || !canCreateCoLoadGroup" aria-label="合柜报关" @click="openCoLoadCreate"><Link2 :size="16" />合柜报关<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
</div>
<el-button v-if="can('platform:firstleg:inbound:view')" :loading="printingCartonLabels" :disabled="reviewLoading || printingCartonLabels || !selectedCartonLabelOrders.length" @click="printSelectedCartonLabels"><Printer :size="16" />打印箱唛<span v-if="selectedCartonLabelOrders.length">({{ selectedCartonLabelOrders.length }})</span></el-button>
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
</template>
<template #status-tabs>
<div ref="statusTabList" class="status-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="订单主状态" :aria-busy="statusCountsLoading">
<button
+14 -3
View File
@@ -431,7 +431,11 @@ describe('approved Admin density ownership', () => {
expect(rules.get(':is(.admin-density, .admin-density-dialog) .density-table--editable .el-form-item.is-error')).toMatchObject({ 'margin-bottom': 'var(--admin-space-5)' })
for (const selector of rules.keys()) if (selector.includes('density-dialog-section')) expect(selector.startsWith(':is(.admin-density-dialog, .admin-density-drawer:where(.admin-density-drawer--detail, .admin-density-drawer--editor)) ')).toBe(true)
const measured = read('modules/inbounds/measurement/measurementDensity.css')
expect(measured).not.toMatch(/border-left(?:-color|-width)?\s*:/)
cssRoot(measured).walkRules(rule => {
rule.walkDecls(/^border-left/, () => {
expect(rule.selector).toMatch(/^\.inbound-measurement-workspace-dialog\.inbound-density \.measurement-(summary-section|declaration|items-section)$/)
})
})
for (const color of ['#f4fbf8', '#f7f9ff', '#fffbf4']) expect(measured).not.toContain(color)
for (const file of ['unified-orders/pages/UnifiedOrdersView.vue', 'unified-orders/components/OrderDeclarationEditorDialog.vue', 'unified-orders/components/InboundFeeDetailManager.vue', 'inbounds/measurement/InboundMeasurementPanel.vue', 'inbounds/weight/components/WeightOcrPanel.vue', 'domestic-containers/pages/DomesticContainersView.vue', 'sailing-calendar/pages/SailingCalendarView.vue', 'ocean-bookings/components/OceanBookingPrintDialog.vue', 'ocean-bookings/pages/OceanBookingDetailView.vue', 'trucking/components/TruckAttachmentDialog.vue', 'container-loading/pages/ContainersView.vue', 'container-loading/pages/ContainerDetailView.vue', 'container-loading/components/OceanBookingLinkDialog.vue']) {
const source = read('modules/' + file)
@@ -1302,20 +1306,27 @@ describe('approved Admin density ownership', () => {
sectionStyles.set(rule.selector, declarations)
})
for (const [section, background] of [
['summary-section', 'var(--admin-surface, #fff)'], ['declaration', 'var(--admin-dialog-source-bg)'], ['items-section', 'var(--admin-surface, #fff)'],
['summary-section', 'var(--admin-dialog-summary-bg)'], ['declaration', 'var(--admin-dialog-source-bg)'], ['items-section', 'var(--admin-dialog-work-bg)'],
]) {
expect(sectionStyles.get(`.inbound-measurement-workspace-dialog.inbound-density .measurement-${section}`)).toMatchObject({
padding: 'var(--admin-space-4, 10px) var(--admin-space-5, 12px)',
background,
})
}
for (const [section, accent] of [['summary-section', '#20a477'], ['declaration', '#647fec'], ['items-section', '#c79643']]) {
expect(sectionStyles.get(`.inbound-measurement-workspace-dialog.inbound-density .measurement-${section}`)?.['border-left']).toBe(`4px solid ${accent}`)
}
expect(differenceStyles.get('.measurement-comparison thead th')).toMatchObject({ background: 'var(--admin-label-bg)' })
expect(differenceStyles.get('.measurement-comparison thead th:nth-child(2)')).toMatchObject({ background: 'var(--admin-dialog-source-bg)' })
expect(differenceStyles.get('.measurement-comparison thead th:nth-child(3)')).toMatchObject({ background: 'var(--admin-dialog-summary-bg)' })
expect(sectionStyles.get('.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-table th.el-table__cell')).toMatchObject({ background: 'var(--admin-label-bg)', color: 'var(--workbench-muted)' })
expect(sectionStyles.get('.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert')?.background).toBe('var(--admin-surface, #fff)')
// Shared density must not reset the sections' horizontal gutters.
expect(sectionStyles.get('.inbound-density.admin-density-dialog .measurement-section')).toEqual({
'padding-block': 'var(--admin-space-4, 10px)',
})
expect(layout).toContain('.measurement-document-number { display: flex; flex: 1;')
expect(layout).toContain('.measurement-platform-fields { display: flex; flex: 1 1 520px;')
expect(layout).toContain('.measurement-platform-fields > .measurement-goods-name { flex: 1 1 340px; }')
expect(layout).toContain('.measurement-platform-fields > .measurement-expected-pallets { flex: 0 0 180px; }')
for (const style of dialog.styles) {
expect(style.content).not.toMatch(/\.measurement-(summary-section|metrics|declaration|items-section)\b/)