Compare commits

...
15 Commits
Author SHA1 Message Date
liuyihe f8408b8076 优化 2026-11-02 17:15:37 +08:00
ouyangxq 7704532e8f 修复财务页面竞态并整理采购与运输展示 2026-10-30 18:50:53 +08:00
liuyihe 19a618c9d2 优化 2026-10-30 18:47:19 +08:00
liuyihe 51d0877a45 优化 2026-10-30 18:15:26 +08:00
liuyihe 09a766c2c8 Merge origin/release/test and reconcile overseas business tests 2026-10-30 18:03:35 +08:00
liuyihe bef074097a 优化 2026-10-30 17:59:39 +08:00
ouyangxq 4abd6ffd5d 修复采购审核竞态并整理管理页面展示 2026-10-30 15:42:37 +08:00
liuyihe 8cc8b4fb94 优化 2026-10-30 11:19:57 +08:00
liuyihe a2ef3542f4 优化 2026-10-29 15:49:53 +08:00
liuyihe 07a160e843 优化 2026-10-29 14:31:47 +08:00
liuyihe 8970ad52fa 优化 2026-10-29 13:37:22 +08:00
ouyangxq ac2ecb4663 优化 2026-10-28 17:18:01 +08:00
ouyangxq d990637b38 统一履约状态和节点日志展示并整理装柜与尾程界面 2026-10-28 16:56:32 +08:00
liuyihe a52a6620fc Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-28 14:45:12 +08:00
liuyihe 1fc806ea7f 优化 2026-10-28 14:45:10 +08:00
111 changed files with 2755 additions and 540 deletions
+2 -2
View File
@@ -75,7 +75,7 @@
| 费用项目、计费科目 | `modules/fee-items/AGENTS.md` | | 费用项目、计费科目 | `modules/fee-items/AGENTS.md` |
| 操作日志、审计日志、操作人 | `modules/audit/AGENTS.md` | | 操作日志、审计日志、操作人 | `modules/audit/AGENTS.md` |
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/AGENTS.md` | | 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/AGENTS.md` |
| 海外采购、卡车、散板 | `modules/overseas-business/AGENTS.md` | | 海外采购、卡车、散板、海外费用明细与仓租 | `modules/overseas-business/AGENTS.md` |
| 海外仓业务合作仓与通用资料 | `modules/warehouses/AGENTS.md`、`modules/overseas-master-data/AGENTS.md` | | 海外仓业务合作仓与通用资料 | `modules/warehouses/AGENTS.md`、`modules/overseas-master-data/AGENTS.md` |
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/AGENTS.md`、`modules/ocean-bookings/` | | 海外仓业务国内出仓后船节点 | `modules/port-schedules/AGENTS.md`、`modules/ocean-bookings/` |
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | | 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` |
@@ -91,7 +91,7 @@
- 新建客户时,公司联系电话同时初始化首个管理员登录手机号;创建后公司联系电话只属于企业资料,与首个管理员、成员及平台用户登录手机号独立。编辑客户资料不得提交账号手机号变更,账号手机号改动统一走用户管理并由 IAM/Auth 撤销对应会话。 - 新建客户时,公司联系电话同时初始化首个管理员登录手机号;创建后公司联系电话只属于企业资料,与首个管理员、成员及平台用户登录手机号独立。编辑客户资料不得提交账号手机号变更,账号手机号改动统一走用户管理并由 IAM/Auth 撤销对应会话。
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。 - 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
- 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。 - 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
- 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。 - 国内离仓状态按真实货柜展示:`ContainerDetailView.vue` 将权威状态传给 `ContainerCustomsPanel.vue`,页面不提供独立确认离仓按钮;报关完成不等于已离仓。不逐入仓提交离仓、不把拖车状态当阻断门禁或重复追加实际船节点。
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。 - `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。 - `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。
- 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。 - 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
@@ -135,7 +135,6 @@ onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsReque
<tr v-if="createdForm.startOffset || createdForm.endOffset"><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</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>
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr> <tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
</tbody></table> </tbody></table>
<p class="density-dialog-note">预约已保存,请继续在原业务安排车辆。</p>
</section> </section>
<div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div> <div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div>
</template> </template>
@@ -212,7 +212,7 @@ describe('ContainerCustomsPanel', () => {
wrapper = await render('DECLARING', true, true) wrapper = await render('DECLARING', true, true)
expect(wrapper.text()).toContain('发送报关行执行') expect(wrapper.text()).toContain('发送报关行执行')
expect(wrapper.text()).toContain('覆盖预录单') expect(wrapper.text()).toContain('选择预录单')
expect(wrapper.text()).toContain('报关行回执.pdf') expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf') expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).not.toContain('确认 · 2026/8/29 11:15:00 · 客户一') expect(wrapper.text()).not.toContain('确认 · 2026/8/29 11:15:00 · 客户一')
@@ -262,12 +262,35 @@ describe('ContainerCustomsPanel', () => {
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
onChange({ raw: selected }) onChange({ raw: selected })
await flushPromises() await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '提交')!.trigger('click') await wrapper.findAll('button').find(button => button.text() === '提交预录单')!.trigger('click')
await flushPromises() await flushPromises()
expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7) expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7)
wrapper.unmount() wrapper.unmount()
}) })
it('一次选择的预录单会关联柜内全部客户资料', async () => {
const value = customs('DECLARING', false)
value.documents.push({ ...value.documents[0]!, documentId: '2092438843630391414', source: { ...value.documents[0]!.source, fileId: '2092438843630391415', fileName: '另一份客户资料.pdf', version: 1 }, receipt: null, confirmationHistory: [] })
value.brokerSubmission = { id: '2092438843630391412', customsContainerId: value.aggregateId, loadingPlanId: value.loadingPlanId, batchNo: 'BS-20260829-001', status: 'SENT', sourceVersion: 3, sentByName: '平台员', sentAt: '2026-08-29T10:45:00', version: 1 }
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()
const selected = new File(['receipt'], '整柜预录单.pdf', { type: 'application/pdf' })
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
onChange({ raw: selected })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '提交预录单')!.trigger('click')
await flushPromises()
expect(api.upload).toHaveBeenCalledTimes(2)
expect(api.upload).toHaveBeenNthCalledWith(1, value.documents[0]!.documentId, selected, 0)
expect(api.upload).toHaveBeenNthCalledWith(2, value.documents[1]!.documentId, selected, 0)
wrapper.unmount()
})
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => { it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
const wrapper = await render('COMPLETED', true, true) const wrapper = await render('COMPLETED', true, true)
expect(wrapper.text()).toContain('客户退税资料.pdf') expect(wrapper.text()).toContain('客户退税资料.pdf')
@@ -341,7 +364,7 @@ describe('ContainerCustomsPanel', () => {
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
onChange({ raw: selected }) onChange({ raw: selected })
await flushPromises() await flushPromises()
const submit = wrapper.findAll('button').find(button => button.text() === '提交')! const submit = wrapper.findAll('button').find(button => button.text() === '提交预录单')!
await submit.trigger('click') await submit.trigger('click')
await flushPromises() await flushPromises()
@@ -48,7 +48,7 @@ const actionLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null) const loadError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null) const actionError = ref<ApiErrorDetails | null>(null)
const downloadingKey = ref('') const downloadingKey = ref('')
const receiptFiles = reactive<Record<string, File | null>>({}) const receiptFile = ref<File | null>(null)
const finalFiles = reactive<Record<string, File | null>>({}) const finalFiles = reactive<Record<string, File | null>>({})
const clearanceDialogVisible = ref(false) const clearanceDialogVisible = ref(false)
const clearanceBatch = ref<DeclarationBatch | null>(null) const clearanceBatch = ref<DeclarationBatch | null>(null)
@@ -84,9 +84,9 @@ function formatDateTime(value: string | null | undefined) {
const date = new Date(value) const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
} }
function selectReceipt(documentId: string, upload: UploadFile) { 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 || brokerSubmitted.value) return
receiptFiles[documentId] = upload.raw || null receiptFile.value = upload.raw || null
actionError.value = null actionError.value = null
} }
function selectFinal(documentId: string, upload: UploadFile) { function selectFinal(documentId: string, upload: UploadFile) {
@@ -185,18 +185,26 @@ async function start() {
finally { actionLoading.value = false } finally { actionLoading.value = false }
} }
async function uploadReceipt(document: ContainerCustomsDocument) { async function uploadReceipt() {
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
const selected = receiptFiles[document.documentId] const selected = receiptFile.value
if (!selected) { ElMessage.warning('请选择对应的预录单'); return } if (!selected) { ElMessage.warning('请选择对应的预录单'); return }
const documents = customs.value.documents
let uploadedCount = 0
actionLoading.value = true; actionError.value = null actionLoading.value = true; actionError.value = null
try { try {
await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0) for (const document of documents) {
receiptFiles[document.documentId] = null await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0)
uploadedCount += 1
}
receiptFile.value = null
await load() await load()
emit('refreshed') emit('refreshed')
ElMessage.success('预录单已上传,等待客户确认') ElMessage.success(`预录单已上传并关联 ${documents.length} 份客户资料`)
} catch (error) { actionError.value = getApiErrorDetails(error, '预录单上传失败') } } catch (error) {
if (uploadedCount) await load()
actionError.value = getApiErrorDetails(error, uploadedCount ? `预录单已关联 ${uploadedCount}/${documents.length} 份资料,后续上传失败` : '预录单上传失败')
}
finally { actionLoading.value = false } finally { actionLoading.value = false }
} }
@@ -317,10 +325,10 @@ 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 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>
<div v-if="customs.documents.length" class="compact-table-wrap"> <div v-if="customs.documents.length" class="compact-table-wrap">
<h3>已冻结退税资料、预录单与完税文件</h3> <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="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> <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"> <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="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传预录单' : document.receipt ? '覆盖预录单' : '上传预录单' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><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><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>{{ document.inboundCodes.join('、') || '—' }}</td>
<td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td> <td>{{ brokerSubmitted ? '已发送报关行执行' : 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 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>
@@ -361,4 +369,5 @@ onMounted(load)
<style scoped> <style scoped>
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--workbench-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--workbench-border);background:var(--workbench-surface-soft)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--workbench-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--workbench-muted)} .container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--workbench-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--workbench-border);background:var(--workbench-surface-soft)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--workbench-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--workbench-muted)}
.documents-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-4)}.selected-receipt{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--workbench-muted);font-size:var(--admin-font-caption)}
</style> </style>
@@ -10,7 +10,7 @@ vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: v
const overview: ContainerOverview = { const overview: ContainerOverview = {
container: { id: '7', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 }, container: { id: '7', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 },
loadingPlanId: '9', loadingPlanId: '9',
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] }, pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_1', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_1', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }], documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }],
exception: { active: true, reason: '码头查验', markedBy: '13', markedByName: '操作员', markedAt: '2026-08-13T11:00:00' }, exception: { active: true, reason: '码头查验', markedBy: '13', markedByName: '操作员', markedAt: '2026-08-13T11:00:00' },
exceptionHistory: [{ id: '14', action: 'MARK', reason: '码头查验', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T11:00:00' }], exceptionHistory: [{ id: '14', action: 'MARK', reason: '码头查验', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T11:00:00' }],
@@ -26,14 +26,19 @@ describe('ContainerOverviewPanel', () => {
}) })
it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', async () => { it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) const types = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
const pda = { ...overview.pda!, photoCounts: types.map(evidenceType => ({ evidenceType, count: 1 })), photos: types.map((evidenceType, index) => ({
...overview.pda!.photos[0]!, evidenceId: String(20 + index), fileId: String(23 + index), evidenceType,
downloadPath: `/api/platform/v1/containers/7/pda/photos/${23 + index}/download`,
})) }
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('DO 资料') expect(wrapper.text()).toContain('DO 资料')
expect(wrapper.text()).toContain('do.pdf') expect(wrapper.text()).toContain('do.pdf')
expect(wrapper.text()).toContain('PDA 作业进度') expect(wrapper.text()).toContain('PDA 作业进度')
expect(wrapper.text()).toContain('待人工复核') expect(wrapper.text()).toContain('待人工复核')
expect(wrapper.text()).toContain('空柜照片') expect(wrapper.findAll('.photo-caption strong').map(caption => caption.text())).toEqual(['空仓照片', '半仓照片', '满仓照片', '锁仓照片', '带锁码照'])
expect(wrapper.text()).not.toContain('PHOTO_STEP_EMPTY_CONTAINER') expect(wrapper.text()).not.toContain('PHOTO_STEP_')
expect(wrapper.text()).toContain('一项待复核') expect(wrapper.text()).toContain('一项待复核')
expect(wrapper.text()).toContain('码头查验') expect(wrapper.text()).toContain('码头查验')
expect(wrapper.text()).not.toContain('更新预计到港时间') expect(wrapper.text()).not.toContain('更新预计到港时间')
@@ -77,8 +77,8 @@ const workflowStatusLabels: Record<string, string> = {
LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成', LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成',
} }
const evidenceTypeLabels: Record<string, string> = { const evidenceTypeLabels: Record<string, string> = {
PHOTO_STEP_EMPTY_CONTAINER: '空柜照片', PHOTO_STEP_HALF_LOADED: '半柜照片', PHOTO_STEP_FULL_CONTAINER: '满柜照片', PHOTO_STEP_1: '空仓照片', PHOTO_STEP_2: '半仓照片', PHOTO_STEP_3: '满仓照片',
EMPTY_CONTAINER: '空柜照片', HALF_LOADED: '半柜照片', FULL_CONTAINER: '满柜照片', SEAL: '封条照片', PHOTO_STEP_4: '锁仓照片', SEAL: '带锁码照',
} }
function workflowStatusLabel(workflowStatus: string | null | undefined, taskStatus: string) { return workflowStatusLabels[workflowStatus || taskStatus] || '作业状态待同步' } function workflowStatusLabel(workflowStatus: string | null | undefined, taskStatus: string) { return workflowStatusLabels[workflowStatus || taskStatus] || '作业状态待同步' }
function evidenceTypeLabel(value: string) { return evidenceTypeLabels[value] || '装柜照片' } function evidenceTypeLabel(value: string) { return evidenceTypeLabels[value] || '装柜照片' }
@@ -245,6 +245,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getContainer).mockResolvedValueOnce(base as never).mockResolvedValue({ ...base, workbenchStage: 'EMPTY_RETURNED', statusSummary: { terminalDrayageStatus: 'EMPTY_RETURNED' }, allowedActions: [], terminalDrayage: { ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } } as never) vi.mocked(getContainer).mockResolvedValueOnce(base as never).mockResolvedValue({ ...base, workbenchStage: 'EMPTY_RETURNED', statusSummary: { terminalDrayageStatus: 'EMPTY_RETURNED' }, allowedActions: [], terminalDrayage: { ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } } as never)
const wrapper = await mountDetail() const wrapper = await mountDetail()
expect(wrapper.text()).toContain('待还柜') expect(wrapper.text()).toContain('待还柜')
expect(wrapper.findAll('dt').find(item => item.text() === '码头拖车状态')!.element.nextElementSibling!.textContent).toBe('待还柜')
expect(wrapper.text()).not.toContain('装柜完成') expect(wrapper.text()).not.toContain('装柜完成')
await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click') await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click')
await flushPromises() await flushPromises()
@@ -257,6 +258,7 @@ describe('ContainerDetailView empty return', () => {
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', expect.objectContaining({ expectedVersion: 3, actualAt: '2026-09-09T12:00:00', remark: undefined }), expect.any(String)) expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', expect.objectContaining({ expectedVersion: 3, actualAt: '2026-09-09T12:00:00', remark: undefined }), expect.any(String))
expect(getContainer).toHaveBeenCalledTimes(2) expect(getContainer).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('已还柜') expect(wrapper.text()).toContain('已还柜')
expect(wrapper.findAll('dt').find(item => item.text() === '码头拖车状态')!.element.nextElementSibling!.textContent).toBe('已还柜')
expect(wrapper.find('.detail-section__heading').findAll('button').some((item) => item.text() === '确认还柜')).toBe(false) expect(wrapper.find('.detail-section__heading').findAll('button').some((item) => item.text() === '确认还柜')).toBe(false)
}) })
@@ -319,6 +321,27 @@ describe('domestic container loading list and booking link', () => {
return wrapper return wrapper
} }
it('keeps customs and PDA information only in the overview when switching to the loading list and back', async () => {
canAccess.mockImplementation((code) => ['platform:firstleg:container:list', 'platform:firstleg:loading-list:list', 'platform:firstleg:customs:list'].includes(code))
vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus: 'COMPLETED' } as never)
const wrapper = await mountDomestic()
const pdaPanels = () => wrapper.findAllComponents({ name: 'ContainerOverviewPanel' }).filter(panel => panel.props('sections')?.includes('pda'))
expect(pdaPanels()).toHaveLength(1)
expect(wrapper.findAllComponents({ name: 'ContainerCustomsPanel' })).toHaveLength(1)
await wrapper.findAll('[role="tab"]').find(tab => accessibleText(tab) === '装柜清单')!.trigger('click')
await flushPromises()
const list = wrapper.get('[aria-labelledby="container-loading-list-heading"]')
expect(list.findComponent({ name: 'LoadingListDetailDrawer' }).exists()).toBe(true)
expect(list.findComponent({ name: 'ContainerOverviewPanel' }).exists()).toBe(false)
expect(list.findComponent({ name: 'ContainerCustomsPanel' }).exists()).toBe(false)
expect(pdaPanels()).toHaveLength(1)
expect(wrapper.findAllComponents({ name: 'ContainerCustomsPanel' })).toHaveLength(1)
await wrapper.findAll('[role="tab"]').find(tab => accessibleText(tab) === '送柜报关')!.trigger('click')
await flushPromises()
expect(pdaPanels()[0]!.isVisible()).toBe(true)
expect(wrapper.findComponent({ name: 'ContainerCustomsPanel' }).isVisible()).toBe(true)
})
it.each(['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'])('opens the loading-list tab using the correct API for %s', async (planStatus) => { it.each(['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'])('opens the loading-list tab using the correct API for %s', async (planStatus) => {
vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus } as never) vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus } as never)
const wrapper = await mountDomestic() const wrapper = await mountDomestic()
@@ -462,6 +485,11 @@ describe('destination document automatic upload', () => {
await flushPromises() await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())! const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())!
expect(dialog.props('showConfirm')).toBe(false) expect(dialog.props('showConfirm')).toBe(false)
const documentName = type === 'DELIVERY_ORDER' ? 'DO 资料' : '目的港清关资料'
expect(dialog.props('title')).toContain(documentName)
expect(dialog.find('.el-form-item__label').exists()).toBe(false)
expect(dialog.text()).not.toContain(`请选择${documentName}。`)
expect(dialog.find(`[aria-label="选择${documentName}文件"]`).exists()).toBe(true)
const file = new File(['data'], '资料.pdf', { type: 'application/pdf' }) const file = new File(['data'], '资料.pdf', { type: 'application/pdf' })
const select = dialog.findComponent(ElUpload).props('onChange')! const select = dialog.findComponent(ElUpload).props('onChange')!
select({ raw: file } as never, []) select({ raw: file } as never, [])
@@ -302,16 +302,17 @@ const oceanBookingLinkOpen = ref(false)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null) const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentTitle = computed(() => 'DO 资料') const documentTitle = computed(() => 'DO 资料')
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情') const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const overseasStageLabels: Record<string, string> = {
WAIT_LOADING: '待装柜', LOADING: '装柜中', WAIT_CUSTOMS: '待出口报关', WAIT_OUTBOUND: '待国内出仓',
DEPARTED_WAREHOUSE: '已从国内仓出仓', IN_TRANSIT: '海运在途', ARRIVED_DESTINATION_PORT: '已到目的港',
WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_READY: '可安排提柜', PICKUP_SCHEDULED: '已预约提柜',
PICKED_UP: '已提柜', UNLOADED: '待还柜', EMPTY_RETURNED: '已还柜',
}
const overseasStageText = computed(() => { const overseasStageText = computed(() => {
const status = workbenchDetail.value?.statusSummary?.terminalDrayageStatus const status = workbenchDetail.value?.statusSummary?.terminalDrayageStatus
|| workbenchDetail.value?.terminalDrayage?.status || workbenchDetail.value?.terminalDrayage?.status
|| workbenchDetail.value?.workbenchStage || workbenchDetail.value?.workbenchStage
return ({ return overseasStageLabels[status || ''] || '海外履约中'
WAIT_LOADING: '待装柜', LOADING: '装柜中', WAIT_CUSTOMS: '待出口报关', WAIT_OUTBOUND: '待国内出仓',
DEPARTED_WAREHOUSE: '已从国内仓出仓', IN_TRANSIT: '海运在途', ARRIVED_DESTINATION_PORT: '已到目的港',
WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_READY: '可安排提柜', PICKUP_SCHEDULED: '已预约提柜',
PICKED_UP: '已提柜', UNLOADED: '待还柜', EMPTY_RETURNED: '已还柜',
} as Record<string, string>)[status || ''] || '海外履约中'
}) })
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value) const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload') const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
@@ -664,13 +665,11 @@ onMounted(async () => {
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading"> <section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header> <header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div> <div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ overseasStageLabels[workbenchDetail?.statusSummary?.terminalDrayageStatus || ''] || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section> </section>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading"> <section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2></div></header> <header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2></div></header>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" /> <LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
</section> </section>
@@ -687,9 +686,9 @@ onMounted(async () => {
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" /> <OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog> <AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" :show-confirm="!!documentError && !!documentFile" confirm-text="重试上传" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :disabled="documentSaving" :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button :loading="documentSaving"><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog> <AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" :show-confirm="!!documentError && !!documentFile" confirm-text="重试上传" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item><el-upload :disabled="documentSaving" :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button :loading="documentSaving" :aria-label="`选择${documentTitle}文件`"><FileUp :size="16" />选择文件</el-button><template v-if="selectedDocument" #tip><div class="el-upload__tip">上传新文件将替换当前资料并保留历史。</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog> <AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="false" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :key="clearanceUploadKey" :disabled="clearanceSaving || clearanceLoading" :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button :loading="clearanceSaving"><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="clearanceError && clearanceFile && !clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading" @click="uploadClearance">重试上传</el-button></div></template></AppDialog> <AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="false" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item><el-upload :key="clearanceUploadKey" :disabled="clearanceSaving || clearanceLoading" :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button :loading="clearanceSaving" aria-label="选择目的港清关资料文件"><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="clearanceError && clearanceFile && !clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading" @click="uploadClearance">重试上传</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect"> <AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState> <LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
@@ -702,7 +701,7 @@ onMounted(async () => {
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal"> <AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState> <LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p> <p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p> <template v-if="terminal">
<section class="density-dialog-section"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></div></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" /> <section class="density-dialog-section"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></div></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
@@ -60,7 +60,7 @@ function setFee(overrides: Partial<FinanceFeeSourceDetail> = {}) {
vi.mocked(api.listFinanceFeeSources).mockResolvedValue({ records: [current], total: 1, pages: 1 }) vi.mocked(api.listFinanceFeeSources).mockResolvedValue({ records: [current], total: 1, pages: 1 })
vi.mocked(api.getFinanceFeeSource).mockResolvedValue(current) vi.mocked(api.getFinanceFeeSource).mockResolvedValue(current)
} }
async function mountPage(kind: 'arap' | 'statements') { async function mountPage(kind: 'arap' | 'statements', cachedWorkspace = false) {
const router = createRouter({ history: createMemoryHistory(), routes: [ const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView }, { path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView },
{ path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView }, { path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView },
@@ -69,7 +69,10 @@ async function mountPage(kind: 'arap' | 'statements') {
] }) ] })
await router.push(kind === 'arap' ? '/finance/customer-arap' : '/finance/customer-statements') await router.push(kind === 'arap' ? '/finance/customer-arap' : '/finance/customer-statements')
await router.isReady() await router.isReady()
const wrapper = mount(kind === 'arap' ? FinanceCustomerArapView : FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) const component = cachedWorkspace
? { template: '<router-view v-slot="{ Component, route }"><keep-alive><component :is="Component" :key="route.name" /></keep-alive></router-view>' }
: kind === 'arap' ? FinanceCustomerArapView : FinanceCustomerStatementsView
const wrapper = mount(component, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises() await flushPromises()
return { wrapper, router } return { wrapper, router }
} }
@@ -99,17 +102,6 @@ async function fillCreation(dialog: VueWrapper, selected = false) {
await flushPromises() await flushPromises()
await dialog.get('textarea').setValue('测试备注') await dialog.get('textarea').setValue('测试备注')
} }
async function openAdjustment() {
const { wrapper, router } = await mountPage('statements')
await rowClick(wrapper, '调整')
return { wrapper, router, dialog: activeDialog(wrapper) }
}
async function fillAdjustment(dialog: VueWrapper) {
await dialog.get('textarea[placeholder="请输入整体调整原因"]').setValue(' 整体调整 ')
await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 88.12)
await dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').setValue(' 行说明 ')
}
enableAutoUnmount(afterEach) enableAutoUnmount(afterEach)
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks() vi.resetAllMocks()
@@ -233,6 +225,30 @@ describe('fee source and statement native dialogs', () => {
await click(dialog, '确定') await click(dialog, '确定')
expect(api.createReconciliationStatement).toHaveBeenCalledWith(expect.objectContaining({ generationMode: 'SELECTED', feeSourceIds: [fee.feeSourceId] })) expect(api.createReconciliationStatement).toHaveBeenCalledWith(expect.objectContaining({ generationMode: 'SELECTED', feeSourceIds: [fee.feeSourceId] }))
}) })
it.each([false, true])('opens selected statement generation directly from arap with a cached statement page=%s', async cached => {
setFee({ reviewStatus: 'APPROVED' })
const { wrapper, router } = await mountPage(cached ? 'statements' : 'arap', true)
const cachedPage = cached ? wrapper.getComponent(FinanceCustomerStatementsView).element : null
if (cached) {
await click(wrapper, '生成对账单')
await click(activeDialog(wrapper), '取消')
await router.push({ name: 'finance-customer-arap' })
await flushPromises()
}
await wrapper.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
await click(wrapper, '生成所选对账单')
const page = wrapper.getComponent(FinanceCustomerStatementsView)
if (cachedPage) expect(page.element).toBe(cachedPage)
const dialog = activeDialog(wrapper)
expect(dialog.props('title')).toBe('生成客户对账单')
expect(dialog.getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('SELECTED')
expect(dialog.findAllComponents({ name: 'ElSelect' })[0]!.props('modelValue')).toBe(String(customer.id))
expect(dialog.findAllComponents({ name: 'ElDatePicker' }).map(item => item.props('modelValue'))).toEqual(['2026-10-01', '2026-10-01'])
expect(dialog.get('.selected-source-summary').text()).toContain('已选 1 笔,合计 100.00 CNY')
expect(router.currentRoute.value.query).toEqual({})
expect(api.createReconciliationStatement).not.toHaveBeenCalled()
})
it('preserves customer and period validation before creating', async () => { it('preserves customer and period validation before creating', async () => {
const { dialog } = await openCreate() const { dialog } = await openCreate()
await click(dialog, '确定') await click(dialog, '确定')
@@ -271,48 +287,14 @@ describe('fee source and statement native dialogs', () => {
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('测试备注') expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('测试备注')
expect(dialog.props('modelValue')).toBe(true) expect(dialog.props('modelValue')).toBe(true)
}) })
it('keeps all adjustment rows in the main dialog flow with plain statement facts', async () => { it.each(['APPROVED', 'ISSUED', 'PAID'])('does not offer adjustment or void after statement approval: %s', async status => {
vi.mocked(api.getReconciliationStatement).mockResolvedValue({ ...detail, lines: Array.from({ length: 13 }, (_, index) => ({ ...detail.lines[0]!, statementLineId: 'line-' + index, feeItemName: '费用' + (index + 1) })) }) vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, status }])
const { dialog } = await openAdjustment() const { wrapper } = await mountPage('statements')
expect(dialog.props('width')).toContain('900px') const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(dialog.props('title')).toContain(statement.billNo) expect(labels).toContain('详情')
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false) expect(labels).not.toContain('调整')
expect(dialog.getComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined() expect(labels).not.toContain('作废')
expect(dialog.findAll('.el-table__body-wrapper tr')).toHaveLength(13)
expect(dialog.get('.el-table__body-wrapper').text()).toContain('费用13')
})
it('saves only checked lines with full IDs and the freshly loaded version', async () => {
const { dialog } = await openAdjustment()
await fillAdjustment(dialog)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await click(dialog, '确定')
expect(api.actionReconciliationStatement).toHaveBeenCalledWith(statement.id, { action: 'ADJUST', reason: '整体调整', expectedVersion: 8, adjustmentLines: [{ statementLineId: '9223372036854775011', adjustedAmount: 88.12, reason: '行说明' }] })
})
it('requires an overall reason, selection and each selected line reason', async () => {
const { dialog } = await openAdjustment()
await click(dialog, '确定')
expect(api.actionReconciliationStatement).not.toHaveBeenCalled() expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
await dialog.get('textarea').setValue('整体调整')
await click(dialog, '确定')
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
await click(dialog, '确定')
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
})
it('locks adjustment rows and retains their state with visible failure diagnostics', async () => {
const pending = deferred<FinanceReconciliationDetail>()
vi.mocked(api.actionReconciliationStatement).mockReturnValue(pending.promise)
const { dialog } = await openAdjustment()
await fillAdjustment(dialog)
await click(dialog, '确定')
expect(dialog.props('showClose')).toBe(false)
expect(dialog.get('.el-table__body-wrapper input[type="checkbox"]').attributes('disabled')).toBeDefined()
pending.reject(failure())
await flushPromises()
expect(dialog.getComponent(OperationErrorToast).props('details')).toMatchObject({ message: '测试拒绝操作', requestId: 'finance-dialog-test' })
expect((dialog.get('.el-table__body-wrapper input[type="checkbox"]').element as HTMLInputElement).checked).toBe(true)
expect((dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').element as HTMLInputElement).value).toBe(' 行说明 ')
expect(dialog.props('modelValue')).toBe(true)
}) })
it.each([ it.each([
['驳回', 'PENDING_REVIEW', 'REJECT'], ['作废', 'DRAFT', 'VOID'], ['重开', 'REJECTED', 'REOPEN'], ['驳回', 'PENDING_REVIEW', 'REJECT'], ['作废', 'DRAFT', 'VOID'], ['重开', 'REJECTED', 'REOPEN'],
@@ -576,7 +576,7 @@ onBeforeUnmount(clearVoucherPreview)
<tr><th scope="row">原消费金额</th><td>{{ money(refundSource?.entry.amount) }}</td></tr> <tr><th scope="row">原消费金额</th><td>{{ money(refundSource?.entry.amount) }}</td></tr>
</tbody></table></section> </tbody></table></section>
<el-alert title="退款会生成独立流水并关联原消费;累计可退金额以服务端校验为准。" type="info" :closable="false" /> <el-alert title="退款会生成独立流水并关联原消费;累计可退金额以服务端校验为准。" type="info" :closable="false" />
<div v-if="refundError"><el-alert :title="refundError.message" type="error" :closable="false" /><ApiErrorMeta :details="refundError" /></div> <el-alert v-if="refundError" :title="refundError.message" type="error" :closable="false" />
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="退款登记"><el-form class="density-form" label-position="left" label-width="100px" :disabled="refundSaving"><table class="density-table density-table--editable"><tbody> <section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="退款登记"><el-form class="density-form" label-position="left" label-width="100px" :disabled="refundSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="退款金额" required><el-input v-model="refundForm.amount" inputmode="decimal" placeholder="请输入退款金额" /></el-form-item></td></tr> <tr><td><el-form-item label="退款金额" required><el-input v-model="refundForm.amount" inputmode="decimal" placeholder="请输入退款金额" /></el-form-item></td></tr>
<tr><td><el-form-item label="退款说明" required><el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写退款说明" /></el-form-item></td></tr> <tr><td><el-form-item label="退款说明" required><el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写退款说明" /></el-form-item></td></tr>
@@ -1,8 +1,9 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { defineComponent, h, KeepAlive, type VNode } from 'vue'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router' import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -53,7 +54,7 @@ const eligibleFeeSource: FinanceFeeSourceSummary = {
sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15', sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15',
} }
async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') { async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements', cached = false) {
const pinia = createPinia(); setActivePinia(pinia) const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin() useAccessPreviewStore().initializeAfterLogin()
@@ -63,7 +64,10 @@ async function mountPage(permissions = ['platform:finance:customer-statement:lis
{ path: '/downloads', name: 'download-center', component: { template: '<div>下载中心</div>' } }, { path: '/downloads', name: 'download-center', component: { template: '<div>下载中心</div>' } },
] }) ] })
await router.push(initialPath); await router.isReady() await router.push(initialPath); await router.isReady()
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } }) const cachedPage = defineComponent({ setup: () => () => h(RouterView, {}, {
default: ({ Component }: { Component: VNode }) => h(KeepAlive, {}, { default: () => Component }),
}) })
const wrapper = mount(cached ? cachedPage : FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises() await flushPromises()
return { wrapper, router } return { wrapper, router }
} }
@@ -121,6 +125,25 @@ describe('FinanceCustomerStatementsView', () => {
expect(wrapper.find('[aria-label="支付历史"]').isVisible()).toBe(true) expect(wrapper.find('[aria-label="支付历史"]').isVisible()).toBe(true)
}) })
it('shows overseas procurement as the source type in statement detail fee lines', async () => {
vi.mocked(getReconciliationStatement).mockResolvedValue({
...detail,
lines: ['开箱费', '提柜-额外费用'].map((feeItemName, index) => ({
...detail.lines[0]!, statementLineId: `procurement-line-${index}`, sourceType: 'OVERSEAS_PROCUREMENT_FEE',
sourceNo: 'CG-TEST-001', feeItemName,
})),
})
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'], '/finance/customer-statements/501')
const table = wrapper.get('[aria-label="客户对账单账单明细"]')
const rows = table.findAll('.el-table__body tbody tr')
expect(rows).toHaveLength(2)
expect(rows.map(row => row.findAll('td')[1]!.text())).toEqual(['海外采购费用', '海外采购费用'])
expect(table.text()).toContain('CG-TEST-001')
expect(table.text()).toContain('开箱费')
expect(table.text()).toContain('提柜-额外费用')
expect(table.text()).not.toMatch(/未知来源类型|OVERSEAS_PROCUREMENT_FEE/)
})
it('collapses advanced criteria without losing values and resets hidden filters', async () => { it('collapses advanced criteria without losing values and resets hidden filters', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; appliedFilters: Record<string, unknown> } const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; appliedFilters: Record<string, unknown> }
@@ -160,31 +183,33 @@ describe('FinanceCustomerStatementsView', () => {
expect(source).toContain('<RowActions text-actions :actions="statementActions(row as FinanceStatementReadModel)"') expect(source).toContain('<RowActions text-actions :actions="statementActions(row as FinanceStatementReadModel)"')
}) })
it('keeps three available row actions visible with primary and danger semantics', async () => { it.each(['APPROVED', 'ISSUED', 'PAID'])('hides adjustment and void for reviewed statement %s even with write permissions', async status => {
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status, customerConfirmationStatus: 'PENDING_CONFIRM' }])
const { wrapper } = await mountPage([ const { wrapper } = await mountPage([
'platform:finance:customer-statement:list', 'platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void',
'platform:finance:reconciliation:adjust',
'platform:finance:reconciliation:void',
]) ])
const actionButtons = [...document.querySelectorAll('button')].filter((button) => ['详情', '调整', '作废'].includes(button.textContent?.trim() || '')) const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
const detailButton = actionButtons.find((button) => button.textContent?.trim() === '详情') expect(labels).toContain('详情')
const adjustButton = actionButtons.find((button) => button.textContent?.trim() === '调整') expect(labels).not.toContain('调整')
const voidButton = actionButtons.find((button) => button.textContent?.trim() === '作废') expect(labels).not.toContain('作废')
expect(new Set(actionButtons.map((button) => button.textContent?.trim()))).toEqual(new Set(['详情', '调整', '作废']))
expect(detailButton?.classList).toContain('el-button--small')
expect(detailButton?.classList).toContain('density-action')
expect(detailButton?.getAttribute('data-tone')).toBe('neutral')
expect(adjustButton?.classList).toContain('density-action')
expect(voidButton?.getAttribute('data-tone')).toBe('danger')
const vm = wrapper.vm as unknown as { actionBusy: boolean; adjustBusy: boolean }
vm.actionBusy = true
vm.adjustBusy = true
await flushPromises()
expect(adjustButton?.hasAttribute('disabled')).toBe(true)
expect(voidButton?.hasAttribute('disabled')).toBe(true)
}) })
it.each(['DRAFT', 'PENDING_REVIEW'])('keeps void before approval with the independent permission: %s', async status => {
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status }])
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:void'])
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text().trim() === '作废')!
expect(button.attributes('data-tone')).toBe('danger')
const vm = wrapper.vm as unknown as { actionBusy: boolean }
vm.actionBusy = true
await flushPromises()
expect(button.attributes('disabled')).toBeDefined()
})
it('does not offer void without permission even before approval', async () => {
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status: 'DRAFT' }])
const { wrapper } = await mountPage()
expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())).not.toContain('作废')
})
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => { it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> } const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
@@ -224,10 +249,6 @@ describe('FinanceCustomerStatementsView', () => {
expect(text).toContain('2026-08-14 03:27') expect(text).toContain('2026-08-14 03:27')
expect(text).not.toContain('2026-08-14T03:27:34Z') expect(text).not.toContain('2026-08-14T03:27:34Z')
await (wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise<void> }).openAdjustment(internalStatement)
await flushPromises()
expect(document.body.textContent).toContain('调整客户对账单')
expect(document.body.textContent).toContain('运费')
}) })
it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => { it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => {
@@ -373,6 +394,67 @@ describe('FinanceCustomerStatementsView', () => {
expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } }) expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } })
}) })
const selectedCreatePermissions = ['platform:finance:customer-statement:list', 'platform:finance:customer-arap:list', 'platform:finance:reconciliation:create', 'platform:customer:list']
const selectedCreatePath = '/finance/customer-statements?createMode=SELECTED&customerId=10001&feeSourceIds=42&periodStart=2026-08-15&periodEnd=2026-08-15'
type SourcePage = Awaited<ReturnType<typeof listFinanceFeeSources>>
it.each(['success', 'failure'] as const)('ignores a late %s after leaving a reactivated statement page', async (outcome) => {
const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true)
const pageVm = wrapper.getComponent(FinanceCustomerStatementsView).vm as unknown as {
createOpen: boolean; createSourcesError: unknown; createSourcesLoading: boolean
}
await router.push('/downloads'); await flushPromises()
let resolve!: (value: SourcePage) => void, reject!: (reason: Error) => void
vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise((yes, no) => { resolve = yes; reject = no }))
await router.push(selectedCreatePath); await flushPromises()
expect(listFinanceFeeSources).toHaveBeenCalledTimes(1)
const replace = vi.spyOn(router, 'replace')
await router.push('/downloads'); await flushPromises()
if (outcome === 'success') resolve({ records: [eligibleFeeSource], total: 2, pages: 2 })
else reject(new Error('旧候选查询失败'))
await flushPromises()
expect(router.currentRoute.value.name).toBe('download-center')
expect(replace).not.toHaveBeenCalled()
expect(pageVm.createOpen).toBe(false)
expect(pageVm.createSourcesError).toBeNull()
expect(pageVm.createSourcesLoading).toBe(false)
expect(listFinanceFeeSources).toHaveBeenCalledTimes(1)
})
it('opens the current selection after reactivation without letting an older response reopen it', async () => {
const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true)
const pageVm = wrapper.getComponent(FinanceCustomerStatementsView).vm as unknown as {
createOpen: boolean; createForm: { feeSourceIds: string[] }; createSourceOptions: FinanceFeeSourceSummary[]
}
await router.push('/downloads'); await flushPromises()
let resolve!: (value: SourcePage) => void
vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise(yes => { resolve = yes }))
await router.push(selectedCreatePath); await flushPromises()
await router.push('/downloads'); await flushPromises()
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [{ ...eligibleFeeSource, feeSourceId: '43' }], total: 1, pages: 1 })
await router.push(selectedCreatePath.replace('feeSourceIds=42', 'feeSourceIds=43')); await flushPromises()
expect(pageVm.createOpen).toBe(true)
expect(pageVm.createForm.feeSourceIds).toEqual(['43'])
const replace = vi.spyOn(router, 'replace')
resolve({ records: [eligibleFeeSource], total: 1, pages: 1 }); await flushPromises()
expect(replace).not.toHaveBeenCalled()
expect(pageVm.createForm.feeSourceIds).toEqual(['43'])
expect(pageVm.createSourceOptions.map(item => item.feeSourceId)).toEqual(['43'])
})
it('does not open or navigate after the statement page is unmounted during candidate loading', async () => {
const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true)
await router.push('/downloads'); await flushPromises()
let resolve!: (value: SourcePage) => void
vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise(yes => { resolve = yes }))
await router.push(selectedCreatePath); await flushPromises()
const replace = vi.spyOn(router, 'replace')
wrapper.unmount()
resolve({ records: [eligibleFeeSource], total: 1, pages: 1 }); await flushPromises()
expect(replace).not.toHaveBeenCalled()
expect(document.body.textContent).not.toContain('对账单生成设置')
})
it('keeps the generation dialog open with a customer-friendly message when no billable fees exist', async () => { it('keeps the generation dialog open with a customer-friendly message when no billable fees exist', async () => {
const permissions = ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create', 'platform:customer:list'] const permissions = ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create', 'platform:customer:list']
const { wrapper } = await mountPage(permissions) const { wrapper } = await mountPage(permissions)
@@ -2,7 +2,7 @@
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import '../financeDensity.css' import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue' import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue'
@@ -29,15 +29,6 @@ import { actionReconciliationStatement, createReconciliationStatement, getReconc
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status' import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types' import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
selected: boolean
statementLineId: string
feeItemName: string
originalAmount: number
adjustedAmount: number
reason: string
}
type StatementRow = Omit<FinanceStatementReadModel, 'creditDays' | 'settled'> & { type StatementRow = Omit<FinanceStatementReadModel, 'creditDays' | 'settled'> & {
periodStart: string periodStart: string
periodEnd: string periodEnd: string
@@ -85,12 +76,9 @@ const createForm = ref({
remark: '', remark: '',
}) })
let createSourceLoadSequence = 0 let createSourceLoadSequence = 0
const adjustOpen = ref(false) let selectedCreateGeneration = 0
const adjustBusy = ref(false) let pageActive = true
const adjustError = ref<ApiErrorDetails | null>(null) let initialLoadCompleted = false
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
const adjustReason = ref('')
const adjustLines = ref<AdjustmentRow[]>([])
const canView = computed(() => access.can('platform:finance:customer-statement:list')) const canView = computed(() => access.can('platform:finance:customer-statement:list'))
const canListCustomers = computed(() => access.can('platform:customer:list')) const canListCustomers = computed(() => access.can('platform:customer:list'))
@@ -100,7 +88,6 @@ const canCreate = computed(() => access.can('platform:finance:reconciliation:cre
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE')) const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const canReview = computed(() => access.can('platform:finance:reconciliation:review')) const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue')) const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust'))
const canVoid = computed(() => access.can('platform:finance:reconciliation:void')) const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
const canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list')) const canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list'))
const canGenerate = computed(() => canCreate.value && canListCustomers.value) const canGenerate = computed(() => canCreate.value && canListCustomers.value)
@@ -407,6 +394,7 @@ function defaultCreateForm() {
function openCreate() { function openCreate() {
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单') if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
invalidateCreateLoads()
createForm.value = defaultCreateForm() createForm.value = defaultCreateForm()
createSourceOptions.value = [] createSourceOptions.value = []
createSourcesError.value = null createSourcesError.value = null
@@ -440,6 +428,7 @@ async function loadEligibleCreateSources(preferredIds: string[] = createForm.val
businessDateFrom: createForm.value.periodStart, businessDateFrom: createForm.value.periodStart,
businessDateTo: createForm.value.periodEnd, businessDateTo: createForm.value.periodEnd,
}) })
if (sequence !== createSourceLoadSequence) return
records.push(...result.records) records.push(...result.records)
pages = Math.max(1, result.pages) pages = Math.max(1, result.pages)
currentPage += 1 currentPage += 1
@@ -467,7 +456,9 @@ function queryText(value: unknown) {
} }
async function openSelectedCreateFromRoute() { async function openSelectedCreateFromRoute() {
if (route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return if (!pageActive || route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
const generation = ++selectedCreateGeneration
const sourcePath = route.fullPath
if (!canGenerate.value || !canSelectSources.value) { if (!canGenerate.value || !canSelectSources.value) {
ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单') ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单')
return return
@@ -484,8 +475,12 @@ async function openSelectedCreateFromRoute() {
ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用') ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用')
return return
} }
createError.value = null
createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' } createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' }
await loadEligibleCreateSources(feeSourceIds) await loadEligibleCreateSources(feeSourceIds)
// 带入候选只属于当前激活页面及本次路由,旧响应不能重新打开弹框或覆盖用户导航。
if (!pageActive || generation !== selectedCreateGeneration || route.fullPath !== sourcePath
|| !canGenerate.value || !canSelectSources.value) return
createOpen.value = true createOpen.value = true
await router.replace({ name: 'finance-customer-statements' }) await router.replace({ name: 'finance-customer-statements' })
} }
@@ -535,18 +530,6 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
finally { actionBusy.value = false } finally { actionBusy.value = false }
} }
async function openAdjustment(row: FinanceStatementReadModel) {
adjustError.value = null
adjustBusy.value = true
try {
const current = await fetchDetail(row.id, true)
adjustStatement.value = { ...row, version: current.version }
adjustReason.value = ''
adjustLines.value = current.lines.flatMap((line) => line.statementLineId ? [{ selected: false, statementLineId: line.statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : [])
adjustOpen.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
finally { adjustBusy.value = false }
}
function statementActions(row: FinanceStatementReadModel): RowActionItem[] { function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
return [ return [
{ key: 'detail', label: '详情', priority: 300, disabled: loading.value }, { key: 'detail', label: '详情', priority: 300, disabled: loading.value },
@@ -554,14 +537,13 @@ function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
{ key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value }, { key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value }, { key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value }, { key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value },
{ key: 'adjust', label: '调整', priority: 200, visible: row.status === 'ISSUED' && canAdjust.value, loading: adjustBusy.value },
{ key: 'reopen', label: '重开', priority: 150, visible: ['REJECTED', 'VOIDED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value }, { key: 'reopen', label: '重开', priority: 150, visible: ['REJECTED', 'VOIDED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW', 'APPROVED', 'ISSUED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value }, { key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
] ]
} }
function handleStatementAction(command: string, row: FinanceStatementReadModel) { function handleStatementAction(command: string, row: FinanceStatementReadModel) {
if (!statementActions(row).some(item => item.key === command && item.visible !== false)) return
if (command === 'detail') void openDetail(row) if (command === 'detail') void openDetail(row)
else if (command === 'adjust') void openAdjustment(row)
else { else {
const actions: Record<string, ReconciliationActionPayload['action']> = { const actions: Record<string, ReconciliationActionPayload['action']> = {
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID', submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID',
@@ -571,35 +553,39 @@ function handleStatementAction(command: string, row: FinanceStatementReadModel)
} }
} }
async function submitAdjustment() { function invalidateCreateLoads() {
if (adjustBusy.value) return selectedCreateGeneration += 1
const statement = adjustStatement.value createSourceLoadSequence += 1
const selected = adjustLines.value.filter((line) => line.selected) createSourcesLoading.value = false
if (!statement || !adjustReason.value.trim() || !selected.length) return ElMessage.warning('请填写调整原因并至少选择一条明细') }
if (selected.some((line) => line.adjustedAmount <= 0 || !line.reason.trim())) return ElMessage.warning('请填写所选明细的新金额和调整说明')
adjustBusy.value = true function deactivateCreateLoads() {
adjustError.value = null pageActive = false
try { invalidateCreateLoads()
await actionReconciliationStatement(statement.id, {
action: 'ADJUST', reason: adjustReason.value.trim(), expectedVersion: statement.version,
adjustmentLines: selected.map((line) => ({ statementLineId: line.statementLineId, adjustedAmount: line.adjustedAmount, reason: line.reason.trim() })),
})
adjustOpen.value = false
ElMessage.success('对账单调整已保存')
await load(appliedFilters.value)
await openDetail(statement)
} catch (cause) { adjustError.value = getApiErrorDetails(cause, '对账单调整失败'); error.value = adjustError.value }
finally { adjustBusy.value = false }
} }
onMounted(async () => { onMounted(async () => {
if (canListCustomers.value) await loadCustomers() await (canListCustomers.value ? loadCustomers() : undefined)
initialLoadCompleted = true
await openSelectedCreateFromRoute() await openSelectedCreateFromRoute()
}) })
onActivated(() => {
pageActive = true
if (initialLoadCompleted) void openSelectedCreateFromRoute()
})
onDeactivated(deactivateCreateLoads)
onBeforeUnmount(deactivateCreateLoads)
watch(() => route.name, (name) => {
if (name !== 'finance-customer-statements') invalidateCreateLoads()
}, { flush: 'sync' })
watch( watch(
() => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd], () => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd],
([open, mode]) => { ([open, mode], [wasOpen]) => {
if (!open || mode !== 'SELECTED') return if (!open) {
if (wasOpen === true) invalidateCreateLoads()
return
}
if (mode !== 'SELECTED') return
void loadEligibleCreateSources() void loadEligibleCreateSources()
}, },
) )
@@ -728,7 +714,7 @@ watch(detailRouteId, (statementId) => {
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="createStatement"> <AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="createStatement">
<el-alert v-if="createError" type="warning" :closable="true" show-icon title="暂时无法生成对账单" @close="createError = null"><p>{{ createError.message }}</p></el-alert> <el-alert v-if="createError" type="warning" :closable="true" show-icon title="暂时无法生成对账单" @close="createError = null"><p>{{ createError.message }}</p></el-alert>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="对账单生成设置"> <section class="density-dialog-section workbench-evidence" aria-label="对账单生成设置">
<h3 class="density-dialog-section__title">生成设置 <InlineHelp title="对账单生成说明">按账期自动汇总,或指定审核通过、已发布且未出账的应收费用;生成币种为CNY。</InlineHelp></h3> <h3 class="density-dialog-section__title">生成设置 <InlineHelp title="对账单生成说明">按账期自动汇总,或指定审核通过、已发布且未出账的应收费用;生成币种为CNY。</InlineHelp></h3>
<el-form class="density-form" label-position="left" label-width="90px" :disabled="createBusy"><table class="density-table density-table--editable statement-create-table"><tbody> <el-form class="density-form" label-position="left" label-width="90px" :disabled="createBusy"><table class="density-table density-table--editable statement-create-table"><tbody>
<tr><td colspan="2"><el-form-item label="生成方式"><el-radio-group v-model="createForm.generationMode"><el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button><el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button></el-radio-group></el-form-item></td></tr> <tr><td colspan="2"><el-form-item label="生成方式"><el-radio-group v-model="createForm.generationMode"><el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button><el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button></el-radio-group></el-form-item></td></tr>
@@ -744,16 +730,7 @@ watch(detailRouteId, (statementId) => {
</section> </section>
</AppDialog> </AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="adjustOpen" :title="`调整客户对账单 · ${adjustStatement?.billNo || '—'}`" width="min(900px, 96vw)" fullscreenable :confirm-loading="adjustBusy" @confirm="submitAdjustment">
<OperationErrorToast :details="adjustError" />
<el-form class="density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="adjustBusy">
<section v-if="adjustStatement" class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前对账单"><h3 class="density-dialog-section__title">当前对账单</h3><table class="density-table statement-dialog-facts"><tbody><tr><th scope="row">对账单号</th><td>{{ adjustStatement.billNo || '—' }}</td><th scope="row">客户</th><td>{{ customerDisplayName(adjustStatement.customerId, adjustStatement.customerName) }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="对账单调整登记"><h3 class="density-dialog-section__title">调整登记</h3><div class="statement-adjust-editor">
<div class="workbench-evidence"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></td></tr></tbody></table></div>
<div class="workbench-table"><el-table :data="adjustLines" size="small" border stripe row-key="statementLineId"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" :aria-label="`选择${row.feeItemName || '费用明细'}`" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" show-overflow-tooltip /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table></div>
</div></section>
</el-form>
</AppDialog>
</AdminPage> </AdminPage>
</template> </template>
@@ -761,10 +738,8 @@ watch(detailRouteId, (statementId) => {
.finance-statements-page { min-width: 0; } .finance-statements-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; } :deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.selected-source-summary { width: 100%; margin-top: var(--admin-space-2); text-align: right; } .selected-source-summary { width: 100%; margin-top: var(--admin-space-2); text-align: right; }
.statement-create-table, .statement-dialog-facts { table-layout: fixed; } .statement-create-table { table-layout: fixed; }
.statement-create-table td:not([colspan]) { width: 50%; } .statement-create-table td:not([colspan]) { width: 50%; }
.statement-dialog-facts th { width: var(--admin-label-width); }
.statement-adjust-editor { display: grid; gap: var(--admin-space-5); }
.statement-detail-page { min-width: 0; width: 100%; margin: 0 auto; overflow: hidden; } .statement-detail-page { min-width: 0; width: 100%; margin: 0 auto; overflow: hidden; }
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); } .statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statement-outstanding { color: var(--el-color-warning-dark-2); } .statement-outstanding { color: var(--el-color-warning-dark-2); }
@@ -90,6 +90,7 @@ const receiptValidationError = ref<string | null>(null)
const receiptError = ref<ApiErrorDetails | null>(null) const receiptError = ref<ApiErrorDetails | null>(null)
const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' }) const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' })
const actionBusy = ref(false) const actionBusy = ref(false)
const actionError = ref<ApiErrorDetails | null>(null)
const exportSubmitting = ref(false) const exportSubmitting = ref(false)
const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create') const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create')
@@ -459,12 +460,19 @@ async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
}).then((result) => result.value.trim()).catch(() => null) }).then((result) => result.value.trim()).catch(() => null)
if (reason === null) return if (reason === null) return
actionBusy.value = true actionBusy.value = true
actionError.value = null
try { try {
await actionFinanceSettlement(row.id, { action, reason, expectedVersion: row.version }) await actionFinanceSettlement(row.id, { action, reason, expectedVersion: row.version })
ElMessage.success(action === 'VOID' ? '结算单已作废' : '结算单已重开') ElMessage.success(action === 'VOID' ? '结算单已作废' : '结算单已重开')
if (detailMode.value) await loadDetail(row.id) if (detailMode.value) await loadDetail(row.id)
else await load() else await load()
} catch (cause) { error.value = getApiErrorDetails(cause, `${action === 'VOID' ? '作废' : '重开'}结算单失败`) } } catch (cause) {
const details = getApiErrorDetails(cause, `${action === 'VOID' ? '作废' : '重开'}结算单失败`)
if (props.kind === 'receivable' && action === 'REOPEN' && details.message === '原对账单已生成其他有效应收结算单') {
details.message = '已有新的应收单,无法重开'
}
actionError.value = details
}
finally { actionBusy.value = false } finally { actionBusy.value = false }
} }
@@ -524,6 +532,7 @@ onMounted(() => {
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" /> <OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
<OperationErrorToast :message="receiptValidationError" /> <OperationErrorToast :message="receiptValidationError" />
<OperationErrorToast :details="receiptError" /> <OperationErrorToast :details="receiptError" />
<OperationErrorToast :details="actionError" />
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" /> <el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface" :aria-label="`${title}详情`"> <DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface" :aria-label="`${title}详情`">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" aria-label="返回列表" @click="backToList"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.settlementNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div><div class="finance-detail-actions"> <template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" aria-label="返回列表" @click="backToList"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.settlementNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div><div class="finance-detail-actions">
@@ -674,7 +683,7 @@ onMounted(() => {
</template> </template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate"> <AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3> <section class="density-dialog-section workbench-evidence" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
<el-form class="density-form" label-position="left" label-width="120px" :disabled="createBusy"><table class="density-table density-table--editable"><tbody> <el-form class="density-form" label-position="left" label-width="120px" :disabled="createBusy"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr> <tr><td><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="币种"><span>CNY</span></el-form-item></td></tr> <tr><td><el-form-item label="币种"><span>CNY</span></el-form-item></td></tr>
@@ -95,7 +95,7 @@ describe('finance G3/G4 page operations', () => {
vi.mocked(createFinanceReceipt).mockReset().mockResolvedValue({ id: '801', customerId: '10001', customerName: '客户A', receivedDate: '2026-08-11', amount: 300, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: ['9001'], allocationCount: 1 }) vi.mocked(createFinanceReceipt).mockReset().mockResolvedValue({ id: '801', customerId: '10001', customerName: '客户A', receivedDate: '2026-08-11', amount: 300, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: ['9001'], allocationCount: 1 })
}) })
it('loads frozen G3 detail and submits multiple selected adjustment lines', async () => { it('keeps issued statement history readable and ignores stale adjustment or void commands', async () => {
const pinia = piniaWithPermissions(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust']) const pinia = piniaWithPermissions(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust'])
const router = createRouter({ history: createMemoryHistory(), routes: [ const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView }, { path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
@@ -105,26 +105,17 @@ describe('finance G3/G4 page operations', () => {
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } }) const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises() await flushPromises()
const vm = wrapper.vm as unknown as { const vm = wrapper.vm as unknown as {
openAdjustment(row: FinanceStatementReadModel): Promise<void> openDetail(row: FinanceStatementReadModel): Promise<void>
submitAdjustment(): Promise<void> handleStatementAction(command: string, row: FinanceStatementReadModel): void
adjustReason: string
adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }>
} }
vm.handleStatementAction('adjust', statement)
await vm.openAdjustment(statement) vm.handleStatementAction('void', statement)
vm.adjustReason = '账期复核调整' await flushPromises()
vm.adjustLines[0]!.selected = true; vm.adjustLines[0]!.adjustedAmount = 90; vm.adjustLines[0]!.reason = '运费更正' expect(actionReconciliationStatement).not.toHaveBeenCalled()
vm.adjustLines[1]!.selected = true; vm.adjustLines[1]!.adjustedAmount = 180; vm.adjustLines[1]!.reason = '操作费更正' await vm.openDetail(statement)
await vm.submitAdjustment() await flushPromises()
expect(getReconciliationStatement).toHaveBeenCalledWith('501') expect(getReconciliationStatement).toHaveBeenCalledWith('501')
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', { expect(wrapper.text()).toContain(statement.billNo)
action: 'ADJUST', reason: '账期复核调整', expectedVersion: 3,
adjustmentLines: [
{ statementLineId: '601', adjustedAmount: 90, reason: '运费更正' },
{ statementLineId: '602', adjustedAmount: 180, reason: '操作费更正' },
],
})
}, 15_000) }, 15_000)
it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => { it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => {
@@ -101,16 +101,14 @@ describe('Finance G3/G4 views', () => {
expect(actionReconciliationStatement).not.toHaveBeenCalled() expect(actionReconciliationStatement).not.toHaveBeenCalled()
}) })
it('submits every selected adjustment line with the detail version', async () => { it('keeps an issued statement read-only with legacy adjustment permission', async () => {
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust']) const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void'])
const vm = wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise<void>; submitAdjustment(): Promise<void>; adjustReason: string; adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }> } const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
await vm.openAdjustment(statement) expect(labels).toContain('详情')
vm.adjustReason = '月末核对更正' expect(labels).not.toContain('调整')
vm.adjustLines.forEach((line, index) => { line.selected = true; line.adjustedAmount = index === 0 ? 90 : 180; line.reason = `明细${index + 1}更正` }) expect(labels).not.toContain('作废')
await vm.submitAdjustment() expect(actionReconciliationStatement).not.toHaveBeenCalled()
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', { action: 'ADJUST', reason: '月末核对更正', expectedVersion: 3, adjustmentLines: [{ statementLineId: '601', adjustedAmount: 90, reason: '明细1更正' }, { statementLineId: '602', adjustedAmount: 180, reason: '明细2更正' }] })
}) })
it('creates a payable settlement and registers a voucher-backed payment', async () => { it('creates a payable settlement and registers a voucher-backed payment', async () => {
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:create', 'platform:finance:payment:create'], { kind: 'payable' }) const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:create', 'platform:finance:payment:create'], { kind: 'payable' })
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void>; openCreate(): Promise<void>; submitCreate(): Promise<void>; openPayment(row: FinancePayableSettlement): void; submitPayment(): Promise<void>; createForm: { customerId: string; supplierId: string; dueDate: string; feeSourceIds: string[] }; paymentFile: File | null; paymentForm: { amount: number; transactionDate: string; method: 'BANK_TRANSFER'; referenceNo: string; remark: string } } const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void>; openCreate(): Promise<void>; submitCreate(): Promise<void>; openPayment(row: FinancePayableSettlement): void; submitPayment(): Promise<void>; createForm: { customerId: string; supplierId: string; dueDate: string; feeSourceIds: string[] }; paymentFile: File | null; paymentForm: { amount: number; transactionDate: string; method: 'BANK_TRANSFER'; referenceNo: string; remark: string } }
@@ -1,8 +1,51 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest' import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformTenants } from '@/modules/iam'
import { getReceivableStatistics } from '../api/finance'
import type { ReceivableStatisticsResult } from '../types'
import FinanceReceivableStatisticsView from './FinanceReceivableStatisticsView.vue'
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('../api/finance', () => ({ getReceivableStatistics: vi.fn() }))
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceReceivableStatisticsView.vue'), 'utf8') const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceReceivableStatisticsView.vue'), 'utf8')
const statistics: ReceivableStatisticsResult = {
statisticsMonth: '2026-10', currency: 'CNY', totalReceivable: '2600', totalReceived: '2550', totalOutstanding: '50',
totalCustomers: 2, page: 1, pageSize: 20,
records: [
{ customerId: '10001', customerName: '客户A', currency: 'CNY', totalReceivable: '2400', totalReceived: '2400', totalOutstanding: '0' },
{ customerId: '10002', customerName: '客户 10002', currency: 'CNY', totalReceivable: '200', totalReceived: '150', totalOutstanding: '50' },
],
}
async function mountPage() {
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/receivable-statistics', name: 'finance-receivable-statistics', component: FinanceReceivableStatisticsView },
{ path: '/other', name: 'other-page', component: { template: '<div>其他页面</div>' } },
] })
await router.push('/finance/receivable-statistics')
await router.isReady()
const wrapper = mount(FinanceReceivableStatisticsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
return { wrapper, router }
}
enableAutoUnmount(afterEach)
beforeEach(() => {
can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10002', tenantName: '客户B', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getReceivableStatistics).mockResolvedValue(statistics)
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('receivable statistics page', () => { describe('receivable statistics page', () => {
it('renders only the approved CNY derived metrics and current-filter actions', () => { it('renders only the approved CNY derived metrics and current-filter actions', () => {
@@ -25,4 +68,47 @@ describe('receivable statistics page', () => {
expect(page).toContain('当前月份暂无应收数据') expect(page).toContain('当前月份暂无应收数据')
expect(page).toContain(':loading="loading"') expect(page).toContain(':loading="loading"')
}) })
it('prints an isolated report with all five columns, the loaded month and server totals', async () => {
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
const { wrapper } = await mountPage()
const report = document.body.querySelector(':scope > .receivable-statistics-print')!
expect(report).not.toBeNull()
expect(report.querySelector('h1')?.textContent).toBe('应收款统计表')
expect(report.textContent).toContain('统计月份:2026-10')
expect([...report.querySelectorAll('thead th')].map(cell => cell.textContent)).toEqual(['应收单位', '币种', '应收总额(CNY)', '已收合计(CNY)', '余额未收(CNY)'])
expect([...report.querySelectorAll('tbody tr')].map(row => [...row.querySelectorAll('td')].map(cell => cell.textContent))).toEqual([
['客户A', 'CNY', '2,400.00', '2,400.00', '0.00'],
['客户B', 'CNY', '200.00', '150.00', '50.00'],
])
expect([...report.querySelectorAll('tfoot td')].map(cell => cell.textContent)).toEqual(['2,600.00', '2,550.00', '50.00'])
expect(report.querySelector('button, input, .finance-metric-grid, .data-table__pagination')).toBeNull()
expect(page).toContain(':global(body:has(> .receivable-statistics-print) > :not(.receivable-statistics-print)) { display: none !important; }')
expect(page).toContain('width: 100%; border-collapse: collapse; table-layout: fixed;')
await wrapper.findAll('button').find(button => button.text() === '打印')!.trigger('click')
expect(print).toHaveBeenCalledOnce()
})
it('removes the print report when a cached statistics page is no longer the active route', async () => {
const { router } = await mountPage()
expect(document.querySelector('.receivable-statistics-print')).not.toBeNull()
await router.push('/other')
await flushPromises()
expect(document.querySelector('.receivable-statistics-print')).toBeNull()
await router.push('/finance/receivable-statistics')
await flushPromises()
expect(document.querySelector('.receivable-statistics-print')).not.toBeNull()
})
it.each(['loading', 'error', 'no-permission'])('does not print unavailable statistics: %s', async state => {
if (state === 'loading') vi.mocked(getReceivableStatistics).mockReturnValueOnce(new Promise(() => {}))
if (state === 'error') vi.mocked(getReceivableStatistics).mockRejectedValueOnce(new Error('不可用'))
if (state === 'no-permission') can.mockReturnValue(false)
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { printCurrent(): void }
expect(document.querySelector('.receivable-statistics-print')).toBeNull()
vm.printCurrent()
expect(print).not.toHaveBeenCalled()
})
}) })
@@ -5,7 +5,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue' import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads' import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -21,6 +21,7 @@ import type { ReceivableStatisticsResult, ReceivableStatisticsRow } from '../typ
const now = new Date() const now = new Date()
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter() const router = useRouter()
const month = ref(defaultMonth) const month = ref(defaultMonth)
const customerId = ref('') const customerId = ref('')
@@ -33,6 +34,7 @@ const error = ref<ApiErrorDetails | null>(null)
const result = ref<ReceivableStatisticsResult | null>(null) const result = ref<ReceivableStatisticsResult | null>(null)
const canView = computed(() => access.can('platform:finance:receivable-statistics:list')) const canView = computed(() => access.can('platform:finance:receivable-statistics:list'))
const canPrint = computed(() => route.name === 'finance-receivable-statistics' && canView.value && !loading.value && !error.value && Boolean(result.value))
const canExport = computed(() => access.can('platform:finance:receivable-statistics:export') && access.can('platform:download:list')) const canExport = computed(() => access.can('platform:finance:receivable-statistics:export') && access.can('platform:download:list'))
const rows = computed<ReceivableStatisticsRow[]>(() => result.value?.records ?? []) const rows = computed<ReceivableStatisticsRow[]>(() => result.value?.records ?? [])
const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName]))) const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName])))
@@ -65,7 +67,7 @@ async function loadCustomers() {
function search() { page.value = 1; void load() } function search() { page.value = 1; void load() }
function reset() { month.value = defaultMonth; customerId.value = ''; page.value = 1; void load() } function reset() { month.value = defaultMonth; customerId.value = ''; page.value = 1; void load() }
function printCurrent() { window.print() } function printCurrent() { if (canPrint.value) window.print() }
function statisticsRowKey(row: ReceivableStatisticsRow) { return `${row.customerId}:${row.customerName}` } function statisticsRowKey(row: ReceivableStatisticsRow) { return `${row.customerId}:${row.customerName}` }
function displayCustomerName(value: unknown) { function displayCustomerName(value: unknown) {
const row = value as ReceivableStatisticsRow const row = value as ReceivableStatisticsRow
@@ -116,12 +118,30 @@ onMounted(() => { void loadCustomers(); void load() })
</section></template> </section></template>
<template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template> <template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template> <template #toolbar-actions><div class="report-actions"><el-button :disabled="!canPrint" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template> <template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
<template #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template> <template #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template>
</DataTable></div> </DataTable></div>
</template> </template>
</AdminPage> </AdminPage>
<Teleport to="body">
<section v-if="canPrint && result" class="receivable-statistics-print" aria-label="应收款统计打印报表">
<header>
<h1>应收款统计表</h1>
<p>统计月份:{{ result.statisticsMonth }} · 币种:CNY</p>
</header>
<table>
<colgroup><col class="print-customer-column" /><col class="print-currency-column" /><col /><col /><col /></colgroup>
<thead><tr><th scope="col">应收单位</th><th scope="col">币种</th><th scope="col">应收总额(CNY)</th><th scope="col">已收合计(CNY)</th><th scope="col">余额未收(CNY)</th></tr></thead>
<tbody>
<tr v-for="row in rows" :key="statisticsRowKey(row)"><td>{{ displayCustomerName(row) }}</td><td>{{ row.currency }}</td><td>{{ money(row.totalReceivable) }}</td><td>{{ money(row.totalReceived) }}</td><td>{{ money(row.totalOutstanding) }}</td></tr>
<tr v-if="!rows.length"><td colspan="5">当前月份暂无应收数据</td></tr>
</tbody>
<tfoot><tr><th scope="row" colspan="2">统计汇总</th><td>{{ money(result.totalReceivable) }}</td><td>{{ money(result.totalReceived) }}</td><td>{{ money(result.totalOutstanding) }}</td></tr></tfoot>
</table>
<p>数据第 {{ result.page }} 页 · 共 {{ result.totalCustomers }} 家应收单位</p>
</section>
</Teleport>
</template> </template>
<style scoped> <style scoped>
@@ -133,10 +153,21 @@ onMounted(() => { void loadCustomers(); void load() })
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; } .query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; } .outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.statistics-table { overflow: hidden; } .statistics-table { overflow: hidden; }
.receivable-statistics-print { display: none; }
@media print { @media print {
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; } :global(body:has(> .receivable-statistics-print) > :not(.receivable-statistics-print)) { display: none !important; }
.receivable-statistics-page { gap: 10px; padding: 0; color: #000; } :global(body:has(> .receivable-statistics-print)) { min-width: 0; height: auto; margin: 0; overflow: visible; background: #fff; }
.workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; } .receivable-statistics-print { display: block; width: 100%; color: #000; font: 10pt/1.5 Arial, 'Microsoft YaHei', sans-serif; }
.finance-metric-grid > article, .statistics-table { border-color: #999; box-shadow: none; break-inside: avoid; } .receivable-statistics-print h1 { margin: 0 0 8pt; text-align: center; font-size: 18pt; }
.receivable-statistics-print p { margin: 8pt 0; }
.receivable-statistics-print table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.receivable-statistics-print col { width: 18%; }
.receivable-statistics-print .print-customer-column { width: 38%; }
.receivable-statistics-print .print-currency-column { width: 8%; }
.receivable-statistics-print th, .receivable-statistics-print td { padding: 5pt; border: 1px solid #999; text-align: left; overflow-wrap: anywhere; }
.receivable-statistics-print tbody td:nth-child(n+3), .receivable-statistics-print thead th:nth-child(n+3), .receivable-statistics-print tfoot td { text-align: right; font-variant-numeric: tabular-nums; }
.receivable-statistics-print thead { display: table-header-group; }
.receivable-statistics-print tfoot { display: table-row-group; font-weight: 700; }
.receivable-statistics-print tr { break-inside: avoid; }
} }
</style> </style>
@@ -2,7 +2,7 @@ import { createRequire } from 'node:module'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router' import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 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 { ApiResponseError } from '@/shared/api/http'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { preferenceStorageKey } from '@/shared/utils/uiLayoutPreference' import { preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
@@ -295,4 +295,46 @@ describe('formal settlement native dialogs', () => {
expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(current.settlementNo), expect.objectContaining({ customClass: 'admin-density-confirm admin-workbench', inputPattern: /\S+/ })) expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(current.settlementNo), expect.objectContaining({ customClass: 'admin-density-confirm admin-workbench', inputPattern: /\S+/ }))
expect(api.actionFinanceSettlement).toHaveBeenCalledWith(current.id, { action, reason: '测试原因', expectedVersion: current.version }) expect(api.actionFinanceSettlement).toHaveBeenCalledWith(current.id, { action, reason: '测试原因', expectedVersion: current.version })
}) })
it('shows a concise reopen conflict message and retains both the voided and replacement receivables', async () => {
const voided = { ...ar, status: 'VOIDED' }
const replacement = { ...ar, id: '9223372036854775012', settlementNo: 'AR202610200002' }
vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([voided, replacement])
vi.mocked(api.actionFinanceSettlement).mockRejectedValue(new ApiResponseError(
'原对账单已生成其他有效应收结算单', 'FIN_409', 409, null, 'reopen-test-request', 'reopen-test-trace',
))
vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: '测试重开', action: 'confirm' as const }))
const message = vi.spyOn(ElMessage, 'error')
const wrapper = await mountPage('receivable')
for (let attempt = 1; attempt <= 2; attempt += 1) {
await click(wrapper, '重开', '.el-table__body-wrapper button')
expect(message).toHaveBeenCalledTimes(attempt)
expect(message).toHaveBeenLastCalledWith({ message: '已有新的应收单,无法重开', showClose: true, duration: 4500 })
}
expect(api.actionFinanceSettlement).toHaveBeenLastCalledWith(ar.id, { action: 'REOPEN', reason: '测试重开', expectedVersion: ar.version })
expect(errorDetails(wrapper)).toMatchObject({ message: '已有新的应收单,无法重开', code: 'FIN_409', requestId: 'reopen-test-request', traceId: 'reopen-test-trace' })
const list = wrapper.get('.el-table__body-wrapper')
expect(list.text()).toContain(voided.settlementNo)
expect(list.text()).toContain(replacement.settlementNo)
expect(wrapper.text()).not.toContain('加载失败')
expect(wrapper.text()).not.toContain('reopen-test-request')
expect(wrapper.text()).not.toContain('reopen-test-trace')
})
it.each([
['receivable', 'VOID', 'ISSUED'], ['receivable', 'REOPEN', 'VOIDED'],
['payable', 'VOID', 'ISSUED'], ['payable', 'REOPEN', 'VOIDED'],
] as const)('retains the %s list and original diagnostics when %s fails', async (kind, action, status) => {
const current = kind === 'receivable' ? ar : ap
if (kind === 'receivable') vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([{ ...ar, status }])
else vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([{ ...ap, status }])
vi.mocked(api.actionFinanceSettlement).mockRejectedValue(failure())
vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: '测试原因', action: 'confirm' as const }))
const wrapper = await mountPage(kind)
await click(wrapper, action === 'VOID' ? '作废' : '重开', '.el-table__body-wrapper button')
expect(errorDetails(wrapper)).toMatchObject({ message: '测试操作失败', code: 'TEST_REJECTED', requestId: 'settlement-test-request' })
expect(wrapper.get('.el-table__body-wrapper').text()).toContain(current.settlementNo)
expect(wrapper.text()).not.toContain('加载失败')
})
}) })
+15
View File
@@ -52,6 +52,21 @@ describe('finance status presentation', () => {
expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作') expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作')
}) })
it.each([
['OVERSEAS_INBOUND_FEE', '海外入仓费用'],
['OVERSEAS_PROCUREMENT_FEE', '海外采购费用'],
['OVERSEAS_TRUCK_FEE', '本土卡车费用'],
['OVERSEAS_LOOSEBOARD_FEE', '本土散板费用'],
['OVERSEAS_WAREHOUSE_FEE', '海外仓服务费'],
['OVERSEAS_CONTAINER_FEE', '海外柜子费用'],
['OVERSEAS_ORDER_FEE', '尾程订单费用'],
['OVERSEAS_DELIVERY_FEE', '尾程派送费用'],
['OVERSEAS_RELABEL_FEE', '换标费用'],
])('labels the current overseas fee source %s consistently', (code, label) => {
expect(financeSourceTypeLabel(code)).toBe(label)
expect(financeSourceTypeLabel(` ${code.toLowerCase()} `)).toBe(label)
})
it('uses review-status values for the customer AR/AP review filter', () => { it('uses review-status values for the customer AR/AP review filter', () => {
expect(financeArapStatusOptions).toEqual([ expect(financeArapStatusOptions).toEqual([
{ label: '草稿', value: 'DRAFT' }, { label: '草稿', value: 'DRAFT' },
+5
View File
@@ -60,6 +60,11 @@ export function financeStatusTone(status: unknown): FinanceStatusTone {
const financeSourceTypeLabels: Record<string, string> = { const financeSourceTypeLabels: Record<string, string> = {
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_FEE: '装柜费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用', INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_FEE: '装柜费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
OVERSEAS_INBOUND_FEE: '海外入仓费用', OVERSEAS_PROCUREMENT_FEE: '海外采购费用',
OVERSEAS_TRUCK_FEE: '本土卡车费用', OVERSEAS_LOOSEBOARD_FEE: '本土散板费用',
OVERSEAS_WAREHOUSE_FEE: '海外仓服务费', OVERSEAS_CONTAINER_FEE: '海外柜子费用',
OVERSEAS_ORDER_FEE: '尾程订单费用', OVERSEAS_DELIVERY_FEE: '尾程派送费用',
OVERSEAS_RELABEL_FEE: '换标费用',
} }
const financeBusinessTypeLabels: Record<string, string> = { const financeBusinessTypeLabels: Record<string, string> = {
RECONCILIATION_CREATED: '生成对账单', RECONCILIATION_CREATED: '生成对账单',
@@ -19,6 +19,7 @@ vi.mock('../weight/components/WeightOcrPanel.vue', () => ({
name: 'WeightOcrPanel', name: 'WeightOcrPanel',
props: { customerId: String, inboundId: String, reviewMode: Boolean, correctionMode: Boolean, tableMode: Boolean }, props: { customerId: String, inboundId: String, reviewMode: Boolean, correctionMode: Boolean, tableMode: Boolean },
emits: ['changed'], emits: ['changed'],
methods: { reload: async () => {} },
template: '<button class="mock-weight-change" @click="$emit(\'changed\')">重量证据</button>', template: '<button class="mock-weight-change" @click="$emit(\'changed\')">重量证据</button>',
}, },
})) }))
@@ -42,6 +43,14 @@ const detail: MeasurementReviewDetail = {
weightResults: [], weightResults: [],
} }
const measuredItem: MeasurementReviewDetail['items'][number] = {
id: 'item-raw', boxCode: 'BOX-RAW-001', itemNo: '1', boxSpecification: null, packageCount: 22,
lengthCm: 62, widthCm: 52, heightCm: 52, girthCm: 270, unitWeightKg: 25.9318,
unitVolumeCbm: 0.3275, totalWeightKg: 570.5, totalVolumeCbm: 7.205, volumeWeightKg: 1203.235,
intelligentImageFileId: null, machineNo: null, deviceMeasuredAt: null, overLimitWarning: null,
outerLengthCm: 75, outerWidthCm: 66, outerHeightCm: 66, remark: null,
}
enableAutoUnmount(afterEach) enableAutoUnmount(afterEach)
describe('MeasurementReviewPanel', () => { describe('MeasurementReviewPanel', () => {
@@ -98,18 +107,23 @@ describe('MeasurementReviewPanel', () => {
expect(source).not.toContain('listMeasurementReviewHistory') expect(source).not.toContain('listMeasurementReviewHistory')
}) })
it.each([true, false])('describes dropship palletizing and uses the server result when pricing succeeds=%s', async (initialPricingSucceeded) => { it.each([
vi.mocked(approveMeasurement).mockResolvedValue({ inboundId: '90001', measurementId: '70001', processStatus: 'WAIT_PALLETIZE', feeStatus: initialPricingSucceeded ? 'CALCULATED' : 'UNPRICED', initialPricingSucceeded, initialPricingMessage: null, initialFee: null }) { fulfillmentType: 'ONE_CLICK_DROPSHIP', processStatus: 'WAIT_PALLETIZE', targetLabel: '待一件代发组托', initialPricingSucceeded: true },
{ fulfillmentType: 'ONE_CLICK_DROPSHIP', processStatus: 'WAIT_PALLETIZE', targetLabel: '待一件代发组托', initialPricingSucceeded: false },
{ fulfillmentType: 'FBA', processStatus: 'WAIT_SPLIT', targetLabel: '待分柜', initialPricingSucceeded: true },
{ fulfillmentType: 'FBA', processStatus: 'WAIT_SPLIT', targetLabel: '待分柜', initialPricingSucceeded: false },
])('describes $fulfillmentType approval target and uses the server result when pricing succeeds=$initialPricingSucceeded', async ({ fulfillmentType, processStatus, targetLabel, initialPricingSucceeded }) => {
vi.mocked(approveMeasurement).mockResolvedValue({ inboundId: '90001', measurementId: '70001', processStatus, feeStatus: initialPricingSucceeded ? 'CALCULATED' : 'UNPRICED', initialPricingSucceeded, initialPricingMessage: null, initialFee: null })
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const success = vi.spyOn(ElMessage, 'success') const success = vi.spyOn(ElMessage, 'success')
const warning = vi.spyOn(ElMessage, 'warning') const warning = vi.spyOn(ElMessage, 'warning')
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001', fulfillmentType: 'ONE_CLICK_DROPSHIP' }, global: { plugins: [ElementPlus] } }) const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001', fulfillmentType }, global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
expect(wrapper.text()).not.toContain('通过并进入') expect(wrapper.text()).not.toContain('通过并进入')
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click') await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
await flushPromises() await flushPromises()
expect(confirmation).toHaveBeenCalledWith('审核通过后,入库单将进入待分柜', '确认测量', expect.objectContaining({ confirmButtonText: '通过' })) expect(confirmation).toHaveBeenCalledWith(`审核通过后,入库单将进入${targetLabel}`, '确认测量', expect.objectContaining({ confirmButtonText: '通过' }))
expect(initialPricingSucceeded ? success : warning).toHaveBeenCalledWith(expect.stringContaining('入库单已进入待一件代发组托')) expect(initialPricingSucceeded ? success : warning).toHaveBeenCalledWith(expect.stringContaining(`入库单已进入${targetLabel}`))
expect(approveMeasurement).toHaveBeenCalledTimes(1) expect(approveMeasurement).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('changed')).toHaveLength(1) expect(wrapper.emitted('changed')).toHaveLength(1)
}) })
@@ -125,7 +139,7 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.get('.review-toolbar__context').text()).toBe('客户:欧阳的公司 · 渠道:海运') expect(wrapper.get('.review-toolbar__context').text()).toBe('客户:欧阳的公司 · 渠道:海运')
}) })
it('shows declared facts, authoritative measurement totals, and box-detail evidence for manual review', async () => { it('shows declared facts, raw measured volume, weight totals and box-detail evidence for manual review', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ vi.mocked(getMeasurementReviewDetail).mockResolvedValue({
...detail, ...detail,
originalPackageCount: 9, originalPackageCount: 9,
@@ -153,19 +167,19 @@ describe('MeasurementReviewPanel', () => {
expect(comparisonRows.map(row => row.findAll('th, td').map(cell => cell.text()))).toEqual([ expect(comparisonRows.map(row => row.findAll('th, td').map(cell => cell.text()))).toEqual([
['箱数', '9', '10', '差异 +1'], ['箱数', '9', '10', '差异 +1'],
['毛重(kg)', '118', '120', '差异 +2 kg'], ['毛重(kg)', '118', '120', '差异 +2 kg'],
['体积(m³)', '2', '2.2', '差异 +0.2 m³'], ['体积(m³)', '2', '0.72', '差异 -1.28 m³'],
]) ])
expect(wrapper.get('.review-comparison').text()).not.toContain('实际品名') expect(wrapper.get('.review-comparison').text()).not.toContain('实际品名')
expect(wrapper.get('.review-comparison').text()).not.toContain('原单据号码') expect(wrapper.get('.review-comparison').text()).not.toContain('原单据号码')
const extraFacts = wrapper.get('.review-extra-facts').text().replace(/\s/g, '') const extraFacts = wrapper.get('.review-extra-facts').text().replace(/\s/g, '')
expect(extraFacts).toContain('货物净重110kg') expect(extraFacts).toContain('货物净重110kg')
expect(extraFacts).toContain('体积重367.4kg') expect(extraFacts).toContain('计费体积重367.4kg')
const items = wrapper.get('.review-items').text() const items = wrapper.get('.review-items').text()
expect(items).toContain('箱规测量明细') expect(items).toContain('箱规测量明细')
expect(items).toContain('BOX-001') expect(items).toContain('BOX-001')
expect(items).toContain('60 × 40 × 30') expect(items).toContain('60 × 40 × 30')
expect(items).toContain('120.2') expect(items).not.toContain('120.2')
expect(items).toContain('2026-08-18 10:30:00') expect(items).toContain('2026-08-18 10:30:00')
expect(items).not.toContain('每箱毛重(kg)') expect(items).not.toContain('每箱毛重(kg)')
expect(items).not.toContain('总重量(kg)') expect(items).not.toContain('总重量(kg)')
@@ -182,6 +196,103 @@ describe('MeasurementReviewPanel', () => {
expect(source).toContain('.review-table :deep(.review-time-cell .cell) { white-space: nowrap; }') expect(source).toContain('.review-table :deep(.review-time-cell .cell) { white-space: nowrap; }')
}) })
it('uses original measured dimensions for unit volume, row volume and summary without replacing billing snapshots', async () => {
const value = { ...detail, originalVolumeCbm: 3.3, actualVolumeCbm: 7.205, items: [{ ...measuredItem }] }
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(value)
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const volumeRow = wrapper.get('.review-comparison tbody tr:last-child')
expect(volumeRow.findAll('td').map(cell => cell.text())).toEqual(['3.3', '3.688', '差异 +0.388 m³'])
const cells = wrapper.get('.review-items .el-table__body tbody tr').findAll('td')
expect(cells.slice(5, 7).map(cell => cell.text())).toEqual(['0.168', '3.688'])
expect(wrapper.get('.review-items').text()).not.toContain('1,203.235')
expect(value.actualVolumeCbm).toBe(7.205)
expect(value.items[0]).toMatchObject({ unitVolumeCbm: 0.3275, totalVolumeCbm: 7.205, outerLengthCm: 75 })
})
it('sums all unrounded measured row volumes before formatting the review comparison', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, originalVolumeCbm: 3.3, actualVolumeCbm: 9.999,
items: [{ ...measuredItem }, { ...measuredItem, id: 'item-2', boxCode: 'BOX-RAW-002', lengthCm: 50, widthCm: 40, heightCm: 30, packageCount: 2 }] })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('.review-comparison tbody tr:last-child').findAll('td').map(cell => cell.text())).toEqual(['3.3', '3.808', '差异 +0.508 m³'])
})
it('separates warehouse overrides and their saved volume from original measured data', async () => {
const corrected = { ...measuredItem, outerLengthCm: 63, outerWidthCm: 53, outerHeightCm: 53,
unitVolumeCbm: 0.177, totalVolumeCbm: 3.894, volumeWeightKg: 650.298 }
const unmodified = { ...measuredItem, id: 'item-unmodified', boxCode: 'BOX-RAW-002',
lengthCm: 10, widthCm: 20, heightCm: 30, packageCount: 2,
outerLengthCm: null, outerWidthCm: null, outerHeightCm: null,
unitVolumeCbm: 0.006, totalVolumeCbm: 0.012, volumeWeightKg: 2.004 }
const value = { ...detail, originalVolumeCbm: 3.3, actualPackageCount: 24,
actualVolumeCbm: 3.906, items: [corrected, unmodified] }
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(value)
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('.review-comparison tbody tr:last-child').findAll('td').map(cell => cell.text())).toEqual(['3.3', '3.7', '差异 +0.4 m³'])
const measuredCells = wrapper.get('.review-items .el-table__body tbody tr').findAll('td')
expect(measuredCells[3]!.text()).toBe('62 × 52 × 52')
expect(measuredCells.slice(5, 7).map(cell => cell.text())).toEqual(['0.168', '3.688'])
const outer = wrapper.get('.review-outer-items')
expect(outer.get('h3').text()).toBe('仓库修改外箱')
expect(outer.findAll('.el-table__body tbody tr')).toHaveLength(1)
expect(outer.get('.el-table__body tbody tr').findAll('td').map(cell => cell.text())).toEqual([
'1', 'BOX-RAW-001', '63', '53', '53', '22', '0.177', '3.894', '650.298',
])
expect(outer.get('.el-table__footer').findAll('td').map(cell => cell.text())).toEqual([
'合计', '', '', '', '', '22', '—', '3.894', '650.298',
])
expect(value.items[0]).toBe(corrected)
expect(corrected).toMatchObject({ lengthCm: 62, unitVolumeCbm: 0.177, totalVolumeCbm: 3.894 })
})
it('hides the warehouse outer section when no manual dimension was entered', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, items: [{ ...measuredItem,
outerLengthCm: null, outerWidthCm: null, outerHeightCm: null }] })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.find('.review-outer-items').exists()).toBe(false)
expect(wrapper.get('.review-items').text()).toContain('3.688')
})
it('keeps partial outer edits and missing saved volumes explicit, then removes the section after clearing', async () => {
const item = { ...measuredItem, outerLengthCm: 63, outerWidthCm: null, outerHeightCm: null,
unitVolumeCbm: null, totalVolumeCbm: null, volumeWeightKg: null }
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, items: [item] })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const outer = wrapper.get('.review-outer-items')
expect(outer.get('.el-table__body tbody tr').findAll('td').slice(2).map(cell => cell.text())).toEqual([
'63', '—', '—', '22', '—', '—', '—',
])
expect(outer.get('.el-table__footer').findAll('td').slice(7).map(cell => cell.text())).toEqual(['—', '—'])
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, version: 5, items: [{ ...item, outerLengthCm: null }] })
await wrapper.get('.review-refresh').trigger('click')
await flushPromises()
expect(wrapper.find('.review-outer-items').exists()).toBe(false)
expect(wrapper.get('.review-items').text()).toContain('3.688')
})
it('totals only the saved correction rows before formatting', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, items: [
{ ...measuredItem, totalVolumeCbm: 1.2344 },
{ ...measuredItem, id: 'item-2', boxCode: 'BOX-RAW-002', totalVolumeCbm: 1.2344 },
] })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('.review-outer-items .el-table__footer').findAll('td')[7]!.text()).toBe('2.469')
})
it.each([{ lengthCm: null }, { widthCm: 0 }, { heightCm: Number.NaN }, { packageCount: null }, { packageCount: 1.5 }])('keeps incomplete or invalid measured volume unknown instead of using billing totals: %o', async (invalid) => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, items: [{ ...measuredItem }, { ...measuredItem, ...invalid, id: 'item-invalid' }] })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('.review-comparison tbody tr:last-child').findAll('td').map(cell => cell.text())).toEqual(['2', '—', '—'])
expect(wrapper.findAll('.review-items .el-table__body tbody tr')[1]!.findAll('td')[6]!.text()).toBe('—')
})
it('keeps difference labels absent when declared values are unavailable', async () => { it('keeps difference labels absent when declared values are unavailable', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, originalGrossWeightKg: null, originalVolumeCbm: null }) vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, originalGrossWeightKg: null, originalVolumeCbm: null })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
@@ -190,7 +301,7 @@ describe('MeasurementReviewPanel', () => {
const rows = wrapper.findAll('.review-comparison tbody tr') const rows = wrapper.findAll('.review-comparison tbody tr')
expect(rows[0].findAll('td').at(-1)?.text()).toBe('一致') expect(rows[0].findAll('td').at(-1)?.text()).toBe('一致')
expect(rows[1].findAll('td').map(cell => cell.text())).toEqual(['—', '120', '—']) expect(rows[1].findAll('td').map(cell => cell.text())).toEqual(['—', '120', '—'])
expect(rows[2].findAll('td').map(cell => cell.text())).toEqual(['—', '2.2', '—']) expect(rows[2].findAll('td').map(cell => cell.text())).toEqual(['—', '—', '—'])
}) })
it('reuses WeightOcrPanel and enables only its review correction mode', async () => { it('reuses WeightOcrPanel and enables only its review correction mode', async () => {
@@ -15,7 +15,7 @@ import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLab
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue' import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
import type { WeightResultResource } from '../weight/types' import type { WeightResultResource } from '../weight/types'
import { approveMeasurement, getMeasurementReviewDetail } from './api' import { approveMeasurement, getMeasurementReviewDetail } from './api'
import type { MeasurementReviewDetail } from './types' import type { MeasurementReviewDetail, MeasurementReviewItem } from './types'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
customerId?: string customerId?: string
@@ -60,6 +60,52 @@ const displayChannelName = computed(() => detail.value?.channelName?.trim() || d
const measurementCount = computed(() => detail.value?.items.length || 0) const measurementCount = computed(() => detail.value?.items.length || 0)
const weightCount = computed(() => detail.value?.weightResults.length || 0) const weightCount = computed(() => detail.value?.weightResults.length || 0)
// 实测展示只采用原始尺寸与确认箱数,规则计算后的体积仍留在服务端计费快照中。
function measuredUnitVolumeCbm(item: MeasurementReviewItem) {
const { lengthCm, widthCm, heightCm } = item
if (lengthCm == null || widthCm == null || heightCm == null
|| ![lengthCm, widthCm, heightCm].every(value => Number.isFinite(value) && value > 0)) return null
const volume = lengthCm * widthCm * heightCm / 1_000_000
return Number.isFinite(volume) ? volume : null
}
function measuredVolumeCbm(item: MeasurementReviewItem) {
const unitVolume = measuredUnitVolumeCbm(item)
if (unitVolume == null || item.packageCount == null || !Number.isInteger(item.packageCount) || item.packageCount < 1) return null
const volume = unitVolume * item.packageCount
return Number.isFinite(volume) ? volume : null
}
const measuredTotalVolumeCbm = computed(() => {
if (!detail.value?.items.length) return null
let total = 0
for (const item of detail.value.items) {
const volume = measuredVolumeCbm(item)
if (volume == null) return null
total += volume
}
return Number.isFinite(total) ? total : null
})
const warehouseOuterItems = computed(() => (detail.value?.items || []).filter(item =>
item.outerLengthCm != null || item.outerWidthCm != null || item.outerHeightCm != null))
function warehouseOuterTotal(key: 'packageCount' | 'totalVolumeCbm' | 'volumeWeightKg') {
if (!warehouseOuterItems.value.length) return null
let total = 0
for (const item of warehouseOuterItems.value) {
const value = item[key]
if (value == null || !Number.isFinite(value) || value < 0) return null
total += value
}
return Number.isFinite(total) ? total : null
}
function warehouseOuterSummary() {
return ['合计', '', '', '', '', formatNumber(warehouseOuterTotal('packageCount'), 0), '—',
formatNumber(warehouseOuterTotal('totalVolumeCbm')), formatNumber(warehouseOuterTotal('volumeWeightKg'))]
}
function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) { function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) {
if (value === null || value === undefined || !Number.isFinite(value)) return '—' if (value === null || value === undefined || !Number.isFinite(value)) return '—'
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits, minimumFractionDigits: 0 }).format(value) return new Intl.NumberFormat('zh-CN', { maximumFractionDigits, minimumFractionDigits: 0 }).format(value)
@@ -142,7 +188,7 @@ async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value || actionLoading.value || props.postApproveLoading || props.approvalCompleted) return if (!detail.value || !isPending.value || !canReview.value || actionLoading.value || props.postApproveLoading || props.approvalCompleted) return
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
'审核通过后,入库单将进入待分柜', `审核通过后,入库单将进入${approvalTargetLabel.value}`,
'确认测量', '确认测量',
{ customClass: 'admin-density-confirm', confirmButtonText: approvalButtonLabel.value, cancelButtonText: '返回', type: 'warning' }, { customClass: 'admin-density-confirm', confirmButtonText: approvalButtonLabel.value, cancelButtonText: '返回', type: 'warning' },
) )
@@ -213,10 +259,10 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
<tbody> <tbody>
<tr><th scope="row">箱数</th><td>{{ formatNumber(detail.originalPackageCount, 0) }}</td><td>{{ formatNumber(detail.actualPackageCount, 0) }}</td><td>{{ formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0) }}</td></tr> <tr><th scope="row">箱数</th><td>{{ formatNumber(detail.originalPackageCount, 0) }}</td><td>{{ formatNumber(detail.actualPackageCount, 0) }}</td><td>{{ formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0) }}</td></tr>
<tr><th scope="row">毛重(kg)</th><td>{{ formatNumber(detail.originalGrossWeightKg) }}</td><td>{{ formatNumber(detail.actualGrossWeightKg) }}</td><td>{{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }}</td></tr> <tr><th scope="row">毛重(kg)</th><td>{{ formatNumber(detail.originalGrossWeightKg) }}</td><td>{{ formatNumber(detail.actualGrossWeightKg) }}</td><td>{{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }}</td></tr>
<tr><th scope="row">体积(m³)</th><td>{{ formatNumber(detail.originalVolumeCbm, 3) }}</td><td>{{ formatNumber(detail.actualVolumeCbm, 3) }}</td><td>{{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 3, ' m³') }}</td></tr> <tr><th scope="row">体积(m³)</th><td>{{ formatNumber(detail.originalVolumeCbm, 3) }}</td><td>{{ formatNumber(measuredTotalVolumeCbm, 3) }}</td><td>{{ formatDifference(measuredTotalVolumeCbm, detail.originalVolumeCbm, 3, ' m³') }}</td></tr>
</tbody> </tbody>
</table></div> </table></div>
<div class="review-extra-facts"><span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span><span>体积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span></div> <div class="review-extra-facts"><span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span><span>计费体积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span></div>
</section> </section>
<section class="review-items density-dialog-section" data-tone="source" aria-labelledby="review-items-title"> <section class="review-items density-dialog-section" data-tone="source" aria-labelledby="review-items-title">
@@ -249,13 +295,10 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
<template #default="{ row }">{{ formatNumber(row.packageCount, 0) }}</template> <template #default="{ row }">{{ formatNumber(row.packageCount, 0) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="单箱体积(m³)" width="106" align="right"> <el-table-column label="单箱体积(m³)" width="106" align="right">
<template #default="{ row }">{{ formatNumber(row.unitVolumeCbm, 3) }}</template> <template #default="{ row }">{{ formatNumber(measuredUnitVolumeCbm(row as MeasurementReviewItem), 3) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="总体积(m³)" width="96" align="right"> <el-table-column label="总体积(m³)" width="96" align="right">
<template #default="{ row }">{{ formatNumber(row.totalVolumeCbm, 3) }}</template> <template #default="{ row }">{{ formatNumber(measuredVolumeCbm(row as MeasurementReviewItem), 3) }}</template>
</el-table-column>
<el-table-column label="体积重(kg)" width="96" align="right">
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="测量时间" width="220" class-name="review-time-cell"> <el-table-column label="测量时间" width="220" class-name="review-time-cell">
<template #default="{ row }">{{ formatDate(row.deviceMeasuredAt) }}</template> <template #default="{ row }">{{ formatDate(row.deviceMeasuredAt) }}</template>
@@ -265,6 +308,42 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
<el-empty v-else description="暂无箱规测量明细" :image-size="48" /> <el-empty v-else description="暂无箱规测量明细" :image-size="48" />
</section> </section>
<section v-if="warehouseOuterItems.length" class="review-items review-outer-items density-dialog-section" aria-labelledby="review-outer-title">
<header class="review-section-heading density-dialog-section__title">
<h3 id="review-outer-title" class="review-detail-heading"><Ruler :size="18" aria-hidden="true" />仓库修改外箱</h3>
<span>{{ warehouseOuterItems.length }} 条</span>
</header>
<div class="review-table-scroll">
<el-table :data="warehouseOuterItems" border stripe table-layout="fixed" class="review-table" show-summary :summary-method="warehouseOuterSummary">
<el-table-column type="index" label="#" width="54" align="center" />
<el-table-column label="箱号" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
</el-table-column>
<el-table-column label="仓库修改外箱(长)cm" min-width="150" align="right">
<template #default="{ row }">{{ formatNumber(row.outerLengthCm) }}</template>
</el-table-column>
<el-table-column label="仓库修改外箱(宽)cm" min-width="150" align="right">
<template #default="{ row }">{{ formatNumber(row.outerWidthCm) }}</template>
</el-table-column>
<el-table-column label="仓库修改外箱(高)cm" min-width="150" align="right">
<template #default="{ row }">{{ formatNumber(row.outerHeightCm) }}</template>
</el-table-column>
<el-table-column label="箱数" width="66" align="right">
<template #default="{ row }">{{ formatNumber(row.packageCount, 0) }}</template>
</el-table-column>
<el-table-column label="计算后单箱体积(m³)" min-width="138" align="right">
<template #default="{ row }">{{ formatNumber(row.unitVolumeCbm, 3) }}</template>
</el-table-column>
<el-table-column label="计算后总体积(m³)" min-width="138" align="right">
<template #default="{ row }">{{ formatNumber(row.totalVolumeCbm, 3) }}</template>
</el-table-column>
<el-table-column label="计费体积重(kg)" min-width="120" align="right">
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
</el-table-column>
</el-table>
</div>
</section>
<section class="review-items review-weight-items density-dialog-section" data-tone="source" aria-labelledby="review-weight-title"> <section class="review-items review-weight-items density-dialog-section" data-tone="source" aria-labelledby="review-weight-title">
<header class="review-section-heading density-dialog-section__title"> <header class="review-section-heading density-dialog-section__title">
<h3 id="review-weight-title" class="review-detail-heading"><Scale :size="18" aria-hidden="true" />称重明细</h3> <h3 id="review-weight-title" class="review-detail-heading"><Scale :size="18" aria-hidden="true" />称重明细</h3>
@@ -2,8 +2,11 @@ import { flushPromises, mount, shallowMount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue' import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
import { printLoadingList } from '../printLoadingList'
import type { LoadingDetail } from '../api/platformLoadingLists' import type { LoadingDetail } from '../api/platformLoadingLists'
vi.mock('../printLoadingList', () => ({ printLoadingList: vi.fn(async () => true) }))
afterEach(() => vi.restoreAllMocks()) afterEach(() => vi.restoreAllMocks())
describe('shared manifest presentation', () => { describe('shared manifest presentation', () => {
@@ -33,7 +36,7 @@ describe('shared manifest presentation', () => {
}) })
it.each([undefined, '返回货柜概览'])('keeps print in the common header and emits return for %s', async (backLabel) => { it.each([undefined, '返回货柜概览'])('keeps print in the common header and emits return for %s', async (backLabel) => {
const print = vi.spyOn(window, 'print').mockImplementation(() => {}) const print = vi.mocked(printLoadingList)
const wrapper = shallowMount(LoadingListDetailDrawer, { const wrapper = shallowMount(LoadingListDetailDrawer, {
props: { mode: 'confirmed', dense: true, backLabel, canEdit: false, loading: false, error: null, props: { mode: 'confirmed', dense: true, backLabel, canEdit: false, loading: false, error: null,
detail: { summary: { planId: '1', warehouseId: '11', loadingListNo: 'LP-TEST', status: 'COMPLETED' }, items: [], histories: [] } as unknown as LoadingDetail }, detail: { summary: { planId: '1', warehouseId: '11', loadingListNo: 'LP-TEST', status: 'COMPLETED' }, items: [], histories: [] } as unknown as LoadingDetail },
@@ -44,7 +47,7 @@ describe('shared manifest presentation', () => {
const button = wrapper.get('.detail-page-header__actions button') const button = wrapper.get('.detail-page-header__actions button')
expect(button.text()).toBe('打印装柜清单') expect(button.text()).toBe('打印装柜清单')
await button.trigger('click'); await flushPromises() await button.trigger('click'); await flushPromises()
expect(print).toHaveBeenCalledTimes(1) expect(print).toHaveBeenCalledExactlyOnceWith(wrapper.element)
expect(wrapper.get('.detail-page-header__back').text()).toBe(backLabel || '返回列表') expect(wrapper.get('.detail-page-header__back').text()).toBe(backLabel || '返回列表')
await wrapper.get('.detail-page-header__back').trigger('click') await wrapper.get('.detail-page-header__back').trigger('click')
expect(wrapper.emitted('back')).toHaveLength(1) expect(wrapper.emitted('back')).toHaveLength(1)
@@ -2,6 +2,7 @@
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Check, ChevronRight, Pencil, Printer } from '@lucide/vue' import { ArrowLeft, Check, ChevronRight, Pencil, Printer } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -30,6 +31,15 @@ const props = defineProps<{
}>() }>()
const emit = defineEmits<{ back: []; retry: []; edit: []; editRemarks: [item: LoadingItem]; confirmPreload: [] }>() const emit = defineEmits<{ back: []; retry: []; edit: []; editRemarks: [item: LoadingItem]; confirmPreload: [] }>()
const printRoot = ref<HTMLElement>()
async function printDetail() {
if (props.mode !== 'confirmed' || props.loading || props.error || !props.detail) return false
try { return await printLoadingList(printRoot.value) }
catch (cause) { ElMessage.error(cause instanceof Error ? cause.message : '装柜清单打印失败,请重试'); return false }
}
defineExpose({ print: printDetail })
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 132, slotName: 'actions', hideable: false, movable: false } const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 132, slotName: 'actions', hideable: false, movable: false }
const baseItemColumns: DataTableColumn[] = [ const baseItemColumns: DataTableColumn[] = [
@@ -169,7 +179,7 @@ function formatNumber(value?: number | null, digits = 3) {
</script> </script>
<template> <template>
<section v-loading="loading" class="loading-detail-content admin-workbench" :class="{ 'admin-density loading-density': mode === 'preload' || dense, 'workbench-panel is-compact is-page-surface': !embedded }" :aria-label="title" :aria-busy="loading"> <section ref="printRoot" v-loading="loading" class="loading-detail-content admin-workbench" :class="{ 'admin-density loading-density': mode === 'preload' || dense, 'workbench-panel is-compact is-page-surface': !embedded }" :aria-label="title" :aria-busy="loading">
<section class="detail-overview" aria-label="清单概览"> <section class="detail-overview" aria-label="清单概览">
<header class="detail-page-header" :class="{ 'detail-page-header--embedded': embedded }"> <header class="detail-page-header" :class="{ 'detail-page-header--embedded': embedded }">
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />{{ backLabel || '返回列表' }}</el-button> <el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />{{ backLabel || '返回列表' }}</el-button>
@@ -177,7 +187,7 @@ function formatNumber(value?: number | null, digits = 3) {
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
</div> </div>
<div v-if="detail && summary" class="detail-page-header__actions"> <div v-if="detail && summary" class="detail-page-header__actions">
<el-button v-if="mode === 'confirmed'" :disabled="loading" @click="printLoadingList"><Printer :size="16" />打印装柜清单</el-button> <el-button v-if="mode === 'confirmed'" :disabled="loading || Boolean(error)" @click="printDetail"><Printer :size="16" />打印装柜清单</el-button>
<PalletLabelPrintDialog :dense="mode === 'preload' || dense" v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" /> <PalletLabelPrintDialog :dense="mode === 'preload' || dense" v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
<template v-if="canEditItemRemark"> <template v-if="canEditItemRemark">
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button> <el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
@@ -185,7 +195,7 @@ function formatNumber(value?: number | null, digits = 3) {
</template> </template>
</div> </div>
</header> </header>
<section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息"> <section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息" data-loading-list-print-summary>
<div><span>柜次号</span><strong>{{ summary.cabinetNo || '—' }}</strong></div> <div><span>柜次号</span><strong>{{ summary.cabinetNo || '—' }}</strong></div>
<div><span>集装箱号</span><strong>{{ summary.containerNo || '待补录' }}</strong></div> <div><span>集装箱号</span><strong>{{ summary.containerNo || '待补录' }}</strong></div>
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div> <div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
@@ -213,7 +223,7 @@ function formatNumber(value?: number | null, digits = 3) {
</div> </div>
<section class="detail-section"> <section class="detail-section">
<div class="workbench-table"><DataTable <div class="workbench-table" data-loading-list-print-items><DataTable
:data="detail.items" :data="detail.items"
:columns="itemColumns" :columns="itemColumns"
stripe stripe
@@ -40,6 +40,9 @@ describe('PalletLabelPrintDialog', () => {
await flushPromises() await flushPromises()
const dialog = wrapper.getComponent(AppDialog) const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe(dense ? '散货托盘码 · HLCU1723867' : '散货托盘码') expect(dialog.props('title')).toBe(dense ? '散货托盘码 · HLCU1723867' : '散货托盘码')
expect(dialog.text()).not.toContain('编号规则')
expect(dialog.text()).not.toContain('一件代发托盘不在此清单')
expect(dialog.find('section[data-tone]').exists()).toBe(false)
wrapper.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'WAREHOUSE') wrapper.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'WAREHOUSE')
await flushPromises() await flushPromises()
wrapper.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1) wrapper.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1)
@@ -58,7 +61,7 @@ describe('PalletLabelPrintDialog', () => {
click.mockRestore() click.mockRestore()
}) })
it('shows the server-generated internal code rule and both warehouse codes', async () => { it('keeps server-generated label numbers and warehouse codes without the extra numbering explanation', async () => {
const wrapper = mount(PalletLabelPrintDialog, { const wrapper = mount(PalletLabelPrintDialog, {
props: { planId: '10', warehouseId: '20' }, props: { planId: '10', warehouseId: '20' },
global: { global: {
@@ -67,7 +70,6 @@ describe('PalletLabelPrintDialog', () => {
LoadErrorState: true, LoadErrorState: true,
Printer: true, Printer: true,
'el-button': { template: '<button @click="$emit(\'click\')"><slot /></button>' }, 'el-button': { template: '<button @click="$emit(\'click\')"><slot /></button>' },
'el-alert': { props: ['title'], template: '<p>{{ title }}</p>' },
'el-radio-group': { template: '<div><slot /></div>' }, 'el-radio-group': { template: '<div><slot /></div>' },
'el-radio': { template: '<span><slot /></span>' }, 'el-radio': { template: '<span><slot /></span>' },
'el-select': { template: '<div><slot /></div>' }, 'el-select': { template: '<div><slot /></div>' },
@@ -83,7 +85,7 @@ describe('PalletLabelPrintDialog', () => {
await nextTick() await nextTick()
expect(preparePalletLabels).toHaveBeenCalledWith('10', '20') expect(preparePalletLabels).toHaveBeenCalledWith('10', '20')
expect(wrapper.text()).toContain('HLCU1723867-FBA地址内部编号-三位序号') expect(wrapper.text()).not.toContain('编号规则')
expect(wrapper.text()).toContain('ACY2') expect(wrapper.text()).toContain('ACY2')
expect(wrapper.text()).toContain('101') expect(wrapper.text()).toContain('101')
expect(wrapper.text()).toContain('HLCU1723867-101-001') expect(wrapper.text()).toContain('HLCU1723867-101-001')
@@ -56,12 +56,11 @@ async function print() {
<AppDialog class="admin-workbench is-dialog" :class="{ 'admin-density-dialog': dense }" v-model="open" :title="dialogTitle" :width="dense ? 'min(920px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :fullscreenable="dense" confirm-text="生成 PDF" :loading="loading" :confirm-loading="printing" :confirm-disabled="disabledConfirm" @confirm="print"> <AppDialog class="admin-workbench is-dialog" :class="{ 'admin-density-dialog': dense }" v-model="open" :title="dialogTitle" :width="dense ? 'min(920px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :fullscreenable="dense" confirm-text="生成 PDF" :loading="loading" :confirm-loading="printing" :confirm-disabled="disabledConfirm" @confirm="print">
<LoadErrorState v-if="error" compact title="托盘码操作失败" :message="error.message" :show-retry="false" /> <LoadErrorState v-if="error" compact title="托盘码操作失败" :message="error.message" :show-retry="false" />
<div class="admin-workbench" v-if="manifest" :class="{ 'density-dialog-sections': dense }"> <div class="admin-workbench" v-if="manifest" :class="{ 'density-dialog-sections': dense }">
<section :class="{ 'density-dialog-section': dense }" :data-tone="dense ? 'work' : undefined"> <section :class="{ 'density-dialog-section': dense }">
<el-alert type="info" :closable="false" :title="`编号规则:${manifest.containerNo}-FBA地址内部编号-三位序号;一件代发托盘不在此清单。`" />
<el-radio-group v-model="scope" class="print-scope" :class="{ 'print-scope--dense': dense }"><el-radio value="ALL">全部打印({{ manifest.totalCount }} 张)</el-radio><el-radio value="WAREHOUSE">按 FBA 仓补打</el-radio></el-radio-group> <el-radio-group v-model="scope" class="print-scope" :class="{ 'print-scope--dense': dense }"><el-radio value="ALL">全部打印({{ manifest.totalCount }} 张)</el-radio><el-radio value="WAREHOUSE">按 FBA 仓补打</el-radio></el-radio-group>
<div v-if="scope === 'WAREHOUSE'" class="print-options" :class="{ 'density-form': dense }"><el-select v-model="warehouseCode" placeholder="选择 FBA 仓" @change="onWarehouseChange"><el-option v-for="group in manifest.groups" :key="group.destinationWarehouseCode" :label="`${group.destinationWarehouseCode} · ${group.totalCount} 张`" :value="group.destinationWarehouseCode" /></el-select><el-input-number v-model="count" placeholder="打印张数" :min="1" :max="selectedGroup?.totalCount || 1" /><span>张(从该仓第 1 张起)</span></div> <div v-if="scope === 'WAREHOUSE'" class="print-options" :class="{ 'density-form': dense }"><el-select v-model="warehouseCode" placeholder="选择 FBA 仓" @change="onWarehouseChange"><el-option v-for="group in manifest.groups" :key="group.destinationWarehouseCode" :label="`${group.destinationWarehouseCode} · ${group.totalCount} 张`" :value="group.destinationWarehouseCode" /></el-select><el-input-number v-model="count" placeholder="打印张数" :min="1" :max="selectedGroup?.totalCount || 1" /><span>张(从该仓第 1 张起)</span></div>
</section> </section>
<section :class="{ 'density-dialog-section': dense }" :data-tone="dense ? 'source' : undefined"> <section :class="{ 'density-dialog-section': dense }">
<el-table :data="manifest.groups" border :max-height="dense ? undefined : 300"><el-table-column label="FBA 仓编码" prop="destinationWarehouseCode" width="140" /><el-table-column label="内部编号" prop="destinationWarehouseInternalCode" width="120" /><el-table-column label="FBA 仓名称" prop="destinationWarehouseName" min-width="160" :show-overflow-tooltip="dense"><template #default="{ row }">{{ row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column label="托盘数" prop="totalCount" width="90" align="right" /><el-table-column label="编号范围" min-width="230"><template #default="{ row }">{{ row.labels[0]?.palletCode || '—' }}<template v-if="row.labels.length > 1"> ~ {{ row.labels[row.labels.length - 1]?.palletCode }}</template></template></el-table-column></el-table> <el-table :data="manifest.groups" border :max-height="dense ? undefined : 300"><el-table-column label="FBA 仓编码" prop="destinationWarehouseCode" width="140" /><el-table-column label="内部编号" prop="destinationWarehouseInternalCode" width="120" /><el-table-column label="FBA 仓名称" prop="destinationWarehouseName" min-width="160" :show-overflow-tooltip="dense"><template #default="{ row }">{{ row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column label="托盘数" prop="totalCount" width="90" align="right" /><el-table-column label="编号范围" min-width="230"><template #default="{ row }">{{ row.labels[0]?.palletCode || '—' }}<template v-if="row.labels.length > 1"> ~ {{ row.labels[row.labels.length - 1]?.palletCode }}</template></template></el-table-column></el-table>
</section> </section>
</div> </div>
@@ -3,7 +3,7 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue' import { reactive } from 'vue'
import ConfirmedLoadingListDetailView from './ConfirmedLoadingListDetailView.vue' import ConfirmedLoadingListDetailView from './ConfirmedLoadingListDetailView.vue'
const mocks = vi.hoisted(() => ({ get: vi.fn(), push: vi.fn(), replace: vi.fn(), back: vi.fn(), route: { params: { planId: '1' }, query: { warehouseId: '11' } as Record<string, string> } })) const mocks = vi.hoisted(() => ({ get: vi.fn(), print: vi.fn(), push: vi.fn(), replace: vi.fn(), back: vi.fn(), route: { params: { planId: '1' }, query: { warehouseId: '11' } as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => mocks })) vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => mocks }))
vi.mock('../api/platformLoadingLists', () => ({ getConfirmedLoadingList: mocks.get, getPreloadPlan: vi.fn() })) vi.mock('../api/platformLoadingLists', () => ({ getConfirmedLoadingList: mocks.get, getPreloadPlan: vi.fn() }))
vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) })) vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) }))
@@ -11,7 +11,7 @@ vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ m
function render() { function render() {
return shallowMount(ConfirmedLoadingListDetailView, { global: { stubs: { return shallowMount(ConfirmedLoadingListDetailView, { global: { stubs: {
AdminPage: { template: '<main><slot /></main>' }, AdminPage: { template: '<main><slot /></main>' },
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '<section />' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], methods: { print: () => mocks.print() }, template: '<section />' },
} } }) } } })
} }
const completed = (planId = '1') => ({ summary: { status: 'COMPLETED', planId, loadingListNo: `LIST-${planId}` }, items: [], histories: [] }) const completed = (planId = '1') => ({ summary: { status: 'COMPLETED', planId, loadingListNo: `LIST-${planId}` }, items: [], histories: [] })
@@ -21,7 +21,7 @@ describe('confirmed loading list detail page', () => {
vi.clearAllMocks() vi.clearAllMocks()
mocks.route = reactive({ params: { planId: '1' }, query: { warehouseId: '11' } }) mocks.route = reactive({ params: { planId: '1' }, query: { warehouseId: '11' } })
mocks.get.mockResolvedValue(completed()) mocks.get.mockResolvedValue(completed())
vi.spyOn(window, 'print').mockImplementation(() => {}) mocks.print.mockResolvedValue(true)
}) })
afterEach(() => vi.restoreAllMocks()) afterEach(() => vi.restoreAllMocks())
it('loads read-only detail on the dedicated page and returns to the list', async () => { it('loads read-only detail on the dedicated page and returns to the list', async () => {
@@ -29,7 +29,7 @@ describe('confirmed loading list detail page', () => {
expect(mocks.get).toHaveBeenCalledWith('1', '11') expect(mocks.get).toHaveBeenCalledWith('1', '11')
const detail = wrapper.findComponent({ name: 'LoadingListDetailDrawer' }) const detail = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
expect(detail.props('detail')).toEqual(completed()) expect(detail.props('detail')).toEqual(completed())
expect(window.print).not.toHaveBeenCalled() expect(mocks.print).not.toHaveBeenCalled()
vi.spyOn(window.history, 'length', 'get').mockReturnValue(1) vi.spyOn(window.history, 'length', 'get').mockReturnValue(1)
detail.vm.$emit('back') detail.vm.$emit('back')
expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-lists' }) expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-lists' })
@@ -38,7 +38,7 @@ describe('confirmed loading list detail page', () => {
it('prints only after detail has loaded and removes the print request', async () => { it('prints only after detail has loaded and removes the print request', async () => {
mocks.route.query.print = '1' mocks.route.query.print = '1'
const wrapper = render(); await flushPromises() const wrapper = render(); await flushPromises()
expect(window.print).toHaveBeenCalledTimes(1) expect(mocks.print).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('loading')).toBe(false) expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('loading')).toBe(false)
expect(mocks.replace).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } }) expect(mocks.replace).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } })
wrapper.unmount() wrapper.unmount()
@@ -48,7 +48,7 @@ describe('confirmed loading list detail page', () => {
mocks.get.mockRejectedValueOnce(new Error('清单不可用')) mocks.get.mockRejectedValueOnce(new Error('清单不可用'))
const wrapper = render(); await flushPromises() const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('error').message).toBe('清单不可用') expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('error').message).toBe('清单不可用')
expect(window.print).not.toHaveBeenCalled() expect(mocks.print).not.toHaveBeenCalled()
wrapper.unmount() wrapper.unmount()
}) })
it('ignores a stale response after switching manifests', async () => { it('ignores a stale response after switching manifests', async () => {
@@ -1,23 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import { computed, onMounted, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue' import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import { useLoadingListDetail } from '../useLoadingListDetail' import { useLoadingListDetail } from '../useLoadingListDetail'
import { printLoadingList } from '../printLoadingList'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { detail, loading, error, load } = useLoadingListDetail() const { detail, loading, error, load } = useLoadingListDetail()
const detailView = ref<InstanceType<typeof LoadingListDetailDrawer>>()
const planId = computed(() => String(route.params.planId || '')) const planId = computed(() => String(route.params.planId || ''))
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '') const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
async function loadDetail() { async function loadDetail() {
const result = await load('confirmed', planId.value, warehouseId.value) const result = await load('confirmed', planId.value, warehouseId.value)
if (!result || detail.value !== result || route.query.print !== '1') return if (!result || detail.value !== result || route.query.print !== '1') return
await printLoadingList() await nextTick()
if (!await detailView.value?.print() || detail.value !== result) return
const { print: _print, ...query } = route.query const { print: _print, ...query } = route.query
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query }) await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query })
} }
@@ -34,7 +35,7 @@ watch([planId, warehouseId], () => void loadDetail())
<template> <template>
<AdminPage class="admin-workbench admin-density"> <AdminPage class="admin-workbench admin-density">
<div class="confirmed-loading-detail-page"> <div class="confirmed-loading-detail-page">
<LoadingListDetailDrawer dense <LoadingListDetailDrawer ref="detailView" dense
mode="confirmed" mode="confirmed"
:loading="loading" :loading="loading"
:detail="detail" :detail="detail"
@@ -0,0 +1,101 @@
import { nextTick } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { printLoadingList } from './printLoadingList'
afterEach(() => { vi.restoreAllMocks(); document.body.replaceChildren() })
function manifest() {
document.body.innerHTML = `<aside>平台导航</aside><nav>后台页签</nav><section id="manifest" aria-label="装柜清单 · LIST-TEST">
<header><button>返回货柜概览</button><button>打印装柜清单</button></header>
<section class="detail-summary" data-loading-list-print-summary><div><span>柜次号</span><strong>CB-TEST</strong></div><div><span>集装箱号</span><strong>CONTAINER-TEST</strong></div></section>
<div data-loading-list-print-items><button>字段设置</button><div style="width:3000px;overflow:hidden"><table>
<thead><tr><th>入仓码</th><th>SKU</th><th>备注</th></tr></thead>
<tbody><tr><td style="width:200px;white-space:nowrap">IN-FIRST</td><td>SKU &amp; &lt;盒&gt;</td><td>第一行备注</td></tr>
<tr><td>IN-LAST</td><td>SKU-LAST</td><td>最后一列完整备注</td></tr></tbody>
</table></div></div><section>操作历史:创建计划、调整货物</section></section>`
return document.getElementById('manifest')!
}
function interceptPrint(onPrint: (frame: HTMLIFrameElement) => void) {
let frame: HTMLIFrameElement | undefined
const append = document.body.append.bind(document.body)
vi.spyOn(document.body, 'append').mockImplementation((...nodes) => {
append(...nodes)
for (const node of nodes) {
if (!(node instanceof HTMLIFrameElement)) continue
frame = node
vi.spyOn(node.contentWindow!, 'focus').mockImplementation(() => {})
vi.spyOn(node.contentWindow!, 'print').mockImplementation(() => onPrint(node))
}
})
return async () => {
await nextTick()
if (!frame) throw new Error('Print frame was not created')
frame.dispatchEvent(new Event('load'))
return frame
}
}
describe('loading list printing', () => {
it('prints the manifest summary and every cargo row and column in an isolated document', async () => {
const root = manifest(), original = root.outerHTML
const hostPrint = vi.spyOn(window, 'print').mockImplementation(() => {})
let snapshot: Document | undefined
const load = interceptPrint(frame => {
snapshot = frame.contentDocument!
expect(snapshot.title).toBe('装柜清单 · LIST-TEST')
expect(snapshot.querySelector('h1')?.textContent).toBe('装柜清单 · LIST-TEST')
expect(snapshot.body.textContent).toContain('CB-TEST')
expect(snapshot.body.textContent).toContain('CONTAINER-TEST')
expect(snapshot.querySelectorAll('tbody tr')).toHaveLength(2)
expect(snapshot.querySelectorAll('thead th')).toHaveLength(3)
expect(snapshot.body.textContent).toContain('最后一列完整备注')
expect(snapshot.body.textContent).toContain('SKU & <盒>')
expect(snapshot.querySelector('td')?.getAttribute('style')).toBeNull()
expect(snapshot.body.textContent).not.toMatch(/平台导航|后台页签|返回货柜概览|字段设置|操作历史/)
expect(snapshot.querySelectorAll('button')).toHaveLength(0)
frame.contentWindow!.dispatchEvent(new Event('afterprint'))
})
const pending = printLoadingList(root)
const frame = await load()
await expect(pending).resolves.toBe(true)
expect(snapshot).toBeDefined()
expect(hostPrint).not.toHaveBeenCalled()
expect(frame.isConnected).toBe(false)
expect(root.outerHTML).toBe(original)
})
it('keeps the print document until the dialog closes and prevents duplicate print jobs', async () => {
const root = manifest(), print = vi.fn()
const load = interceptPrint(print)
const pending = printLoadingList(root)
const frame = await load()
expect(frame.isConnected).toBe(true)
expect(print).toHaveBeenCalledOnce()
await expect(printLoadingList(root)).resolves.toBe(false)
expect(document.querySelectorAll('iframe')).toHaveLength(1)
frame.contentWindow!.dispatchEvent(new Event('afterprint'))
await expect(pending).resolves.toBe(true)
expect(frame.isConnected).toBe(false)
expect(document.querySelector('aside')?.textContent).toBe('平台导航')
})
it('never falls back to printing the entire page when the manifest is missing or incomplete', async () => {
const hostPrint = vi.spyOn(window, 'print').mockImplementation(() => {})
await expect(printLoadingList(null)).rejects.toThrow('清单尚未加载完成')
await expect(printLoadingList(document.createElement('section'))).rejects.toThrow('清单尚未加载完成')
expect(hostPrint).not.toHaveBeenCalled()
expect(document.querySelector('iframe')).toBeNull()
})
it('removes the isolated document after a print failure', async () => {
const root = manifest(), original = root.outerHTML
const load = interceptPrint(() => { throw new Error('printer unavailable') })
const pending = printLoadingList(root)
const rejected = expect(pending).rejects.toThrow('printer unavailable')
const frame = await load()
await rejected
expect(frame.isConnected).toBe(false)
expect(root.outerHTML).toBe(original)
})
})
+79 -3
View File
@@ -1,6 +1,82 @@
import { nextTick } from 'vue' import { nextTick } from 'vue'
export async function printLoadingList() { const printStyles = `
await nextTick() @page { size: A4 landscape; margin: 10mm; }
window.print() body { margin: 0; color: #111; font: 10pt/1.4 Arial, "Microsoft YaHei", sans-serif; }
h1 { margin: 0 0 12px; font-size: 16pt; }
.detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 16px; border-top: 1px solid #aaa; border-left: 1px solid #aaa; }
.detail-summary > div { display: flex; gap: 8px; padding: 6px; border-right: 1px solid #aaa; border-bottom: 1px solid #aaa; }
.detail-summary > div > span:first-child { flex-shrink: 0; color: #555; }
.detail-summary strong { min-width: 0; overflow-wrap: anywhere; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 8pt; }
caption { margin-bottom: 6px; font-size: 11pt; font-weight: bold; text-align: left; }
th, td { padding: 4px; border: 1px solid #aaa; vertical-align: top; text-align: left; overflow-wrap: anywhere; }
th { background: #eee; }
.cell { white-space: normal; overflow-wrap: anywhere; }
small { display: block; }
thead { display: table-header-group; }
tr { break-inside: avoid; }
`
let printing = false
function printDocumentHtml(root: HTMLElement) {
const summary = root.querySelector('[data-loading-list-print-summary]')
const items = root.querySelector('[data-loading-list-print-items]')
const head = items?.querySelector('thead')
const body = items?.querySelector('tbody')
if (!summary || !head || !body) throw new Error('清单尚未加载完成,请稍后重试')
const title = root.getAttribute('aria-label') || '装柜清单'
const page = document.implementation.createHTMLDocument(title)
page.documentElement.lang = 'zh-CN'
const style = page.createElement('style')
style.textContent = printStyles
page.head.append(style)
const heading = page.createElement('h1')
heading.textContent = title
const table = page.createElement('table')
const caption = page.createElement('caption')
caption.textContent = '装柜货物明细'
table.append(caption, head.cloneNode(true), body.cloneNode(true))
page.body.append(heading, summary.cloneNode(true), table)
page.body.querySelectorAll('button, input, svg, [hidden], [aria-hidden="true"]').forEach(node => node.remove())
page.body.querySelectorAll<HTMLElement>('[style]').forEach(node => {
if (node.style.display === 'none') node.remove()
else node.removeAttribute('style')
})
return `<!doctype html>${page.documentElement.outerHTML}`
}
export async function printLoadingList(root: HTMLElement | null | undefined): Promise<boolean> {
if (printing) return false
if (!root) throw new Error('清单尚未加载完成,请稍后重试')
printing = true
let frame: HTMLIFrameElement | undefined
try {
await nextTick()
const html = printDocumentHtml(root)
frame = document.createElement('iframe')
frame.title = '装柜清单打印'
frame.setAttribute('aria-hidden', 'true')
frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:297mm;height:210mm;border:0'
document.body.append(frame)
const printDocument = frame.contentDocument
if (!printDocument) throw new Error('无法创建打印页面,请重试')
const printFrame = frame
return await new Promise<boolean>((resolve, reject) => {
printFrame.addEventListener('load', () => {
const target = printFrame.contentWindow
if (!target) { reject(new Error('无法创建打印页面,请重试')); return }
target.addEventListener('afterprint', () => resolve(true), { once: true })
try { target.focus(); target.print() } catch (cause) { reject(cause) }
}, { once: true })
printDocument.open()
printDocument.write(html)
printDocument.close()
})
} finally {
frame?.remove()
printing = false
}
} }
+7 -6
View File
@@ -3,28 +3,29 @@
## 路径基准与读取顺序 ## 路径基准与读取顺序
- 本文件适用于 `src/modules/overseas-business/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,采购与本土业务细则见 `../../../docs/business-index.md`。 - 本文件适用于 `src/modules/overseas-business/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,采购与本土业务细则见 `../../../docs/business-index.md`。
- 三条业务线共用同一模块:`purchases`(海外采购)、`trucks`(本土卡车)、`looseboards`(本土散板);`trucks` 与 `looseboards` 复用同一组页面,按路由区分模式。 - 本模块承载海外采购(`purchases`)、本土卡车(`trucks`)、本土散板(`looseboards`)和海外费用(`fees`);卡车/散板复用页面,仓租库存位于费用页页签,无独立路由。
- 后端只查 `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/`、`financefee/`;纯页面与文案不读后端。 - 后端只查 `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/`、`financefee/`、`storagerent/`;纯页面与文案不读后端。
## 页面与入口 ## 页面与入口
| 目标 | 页面/路由 | API 与后端 | | 目标 | 页面/路由 | API 与后端 |
|---|---|---| |---|---|---|
| 海外采购订单列表与库存变化 | `pages/PurchaseOrdersView.vue`;`overseas-business/purchases` | `api/platformOverseasBusiness.ts`;`lastleg-service/procurement/` | | 海外采购订单列表与库存变化 | `pages/PurchaseOrdersView.vue`;`overseas-business/purchases` | `api/platformOverseasBusiness.ts`;`lastleg-service/procurement/` |
| 采购订单详情(商品、库存、订单、快递、进销存) | `pages/PurchaseOrderDetailView.vue`;`overseas-business/purchases/:purchaseId` | `api/platformOverseasBusiness.ts` | | 采购详情、分批收货/出库与签收 | `pages/PurchaseOrderDetailView.vue`、`components/ProcurementWorkflowPanel.vue`;`overseas-business/purchases/:purchaseId` | `api/platformOverseasBusiness.ts`、`api/procurementWorkflow.ts`、`api/procurementCommands.ts` |
| 采购订单编辑(采购商品、库存、快递) | `pages/PurchaseOrderEditView.vue`;`overseas-business/purchases/:purchaseId/edit` | `api/platformOverseasBusiness.ts`(`getOverseasPurchaseEditor`、`updateOverseasPurchase`) | | 采购订单编辑(采购商品、库存、快递) | `pages/PurchaseOrderEditView.vue`;`overseas-business/purchases/:purchaseId/edit` | `api/platformOverseasBusiness.ts`(`getOverseasPurchaseEditor`、`updateOverseasPurchase`) |
| 采购订单分区字段:管控、财务、节点、实物、运输 | `components/ProcurementControlFields.vue`、`ProcurementFinanceFields.vue`、`ProcurementNodeActions.vue`、`ProcurementPhysicalFields.vue`、`ProcurementTransportFields.vue` | `api/procurementCommands.ts`;节点动作与费用入池归 `lastleg-service/financefee/` |
| 采购订单分区字段:费用(纯展示,无请求) | `components/ProcurementChargeFields.vue` | 只接收 props,数据由详情页 `PurchaseOrderDetailView.vue` 提供 |
| 本土卡车订单列表 | `pages/TransportOrdersView.vue`;`overseas-business/trucks` | `api/platformOverseasBusiness.ts`;`lastleg-service/trucking/` | | 本土卡车订单列表 | `pages/TransportOrdersView.vue`;`overseas-business/trucks` | `api/platformOverseasBusiness.ts`;`lastleg-service/trucking/` |
| 本土卡车新增/编辑 | `pages/TransportOrderFormView.vue`;`overseas-business/trucks/new`、`overseas-business/trucks/:orderId/edit` | `api/platformOverseasBusiness.ts`;`components/TransportDemandForm.vue` | | 本土卡车新增/编辑 | `pages/TransportOrderFormView.vue`;`overseas-business/trucks/new`、`overseas-business/trucks/:orderId/edit` | `api/platformOverseasBusiness.ts`;`components/TransportDemandForm.vue` |
| 本土散板订单列表与新增/编辑 | `pages/TransportOrdersView.vue`、`pages/TransportOrderFormView.vue`;`overseas-business/looseboards`、`.../new`、`.../:orderId/edit` | `api/platformOverseasBusiness.ts`;`lastleg-service/looseboard/` | | 本土散板订单列表与新增/编辑 | `pages/TransportOrdersView.vue`、`pages/TransportOrderFormView.vue`;`overseas-business/looseboards`、`.../new`、`.../:orderId/edit` | `api/platformOverseasBusiness.ts`;`lastleg-service/looseboard/` |
| 车次安排摘要 | `components/FleetArrangementSummary.vue` | 无直接请求;数据由 `fleetArrangement.ts` 与父页面传入 | | 车次安排摘要 | `components/FleetArrangementSummary.vue` | 无直接请求;数据由 `fleetArrangement.ts` 与父页面传入 |
| 海外费用明细、审核、调价与资金/投递重试 | `pages/OverseasFeesView.vue`;`overseas-business/fees` | `api/overseasFees.ts`、`components/OverseasFeeRetryHistory.vue`;Backend `financefee/` |
| 业务单费用窗口与新增费用 | `components/BusinessDocumentFees.vue` → `BusinessOrderFees.vue`、`OverseasFeeEditor.vue` | `api/overseasFees.ts`;采购及其他业务单共用,不重建费用链路 |
| 仓租库存、计费明细与审核调价 | `components/StorageRentPanel.vue`、`StorageRentFeeDetails.vue`、`StorageRentFeeActions.vue` | `api/storageRent.ts`;Backend `storagerent/`,审核沿海外费用接口 |
## 关键边界 ## 关键边界
- 采购按 `baiyue-firstleg/lastleg-service/docs/procurement-workflow.md` 的七状态流程执行:审核、分批收货、客户确认、分批出库及签收扣采购库存;费用复用海外费用窗口,默认余额支付,不恢复旧两段样品节点。 - 采购按 `baiyue-firstleg/lastleg-service/docs/procurement-workflow.md` 的七状态流程执行:审核、分批收货、客户确认、分批出库及签收扣采购库存;费用复用海外费用窗口,默认余额支付,不恢复旧两段样品节点。
- 本土卡车与散板是海外本地运输业务,区别于国内订舱拖车与客户排车;车型、司机与车队主数据归 `overseas-master-data`,本模块只消费。 - 本土卡车与散板是海外本地运输业务,区别于国内订舱拖车与客户排车;车型、司机与车队主数据归 `overseas-master-data`,本模块只消费。
- 采购费用入海外费用池后由 `finance/` 记账,本模块不判定收付状态,不重复写入费用行。 - 费用、仓租及钱包/账单边界按 Lastleg 详细索引“海外业务费用来源”“自动仓租及出库资金门禁”核对;页面只消费返回状态和操作资格,不推定收付状态或重复记账。
- 列表与表单的客户、仓库、供应商候选一律取服务端结果,不从本地或名称推算;长整型 ID 按字符串透传。 - 列表与表单的客户、仓库、供应商候选一律取服务端结果,不从本地或名称推算;长整型 ID 按字符串透传。
- 同一路由组件的两种模式(卡车/散板)变更时,两侧都要回归,避免只改一侧。 - 同一路由组件的两种模式(卡车/散板)变更时,两侧都要回归,避免只改一侧。
@@ -24,7 +24,7 @@
| 海外采购订单 | 列表/筛选 | `page-overseas-purchase` 2–12 行;海外面板 9–11 行;`renderOverseasPurchaseList()` 13531–13534 行 | 独立列表页,订单编号/采购品类/商品名称筛选 | 仅实现海外面板;国内页与 `domestic-*` 全部排除;操作列第一列;真实联调补充后端客户候选筛选 | | 海外采购订单 | 列表/筛选 | `page-overseas-purchase` 2–12 行;海外面板 9–11 行;`renderOverseasPurchaseList()` 13531–13534 行 | 独立列表页,订单编号/采购品类/商品名称筛选 | 仅实现海外面板;国内页与 `domestic-*` 全部排除;操作列第一列;真实联调补充后端客户候选筛选 |
| 海外采购订单 | 详情 | `page-overseas-purchase-detail` 204–206 行;`showOverseasPurchaseDetail()` 13538–13540 行 | 独立详情页,商品/库存/订单/快递/进销存五分区 | 建立正式详情路由,详情复用 `purchase:list` | | 海外采购订单 | 详情 | `page-overseas-purchase-detail` 204–206 行;`showOverseasPurchaseDetail()` 13538–13540 行 | 独立详情页,商品/库存/订单/快递/进销存五分区 | 建立正式详情路由,详情复用 `purchase:list` |
| 海外采购订单 | 编辑 | `page-overseas-purchase-edit` 143–150 行;`editOverseasPurchase()`、`saveOverseasPurchaseEdit()` 13536–13537 行 | 独立编辑页 | 建立正式编辑路由,使用独立 `purchase:edit` 权限 | | 海外采购订单 | 编辑 | `page-overseas-purchase-edit` 143–150 行;`editOverseasPurchase()`、`saveOverseasPurchaseEdit()` 13536–13537 行 | 独立编辑页 | 建立正式编辑路由,使用独立 `purchase:edit` 权限 |
| 海外采购订单 | 审核 | 原型未定义;当前对话明确要求按后端既有审核契约补齐前端 | 详情页顶部操作 + 居中确认弹框 | 仅待审核订单且具备 `purchase:review` 权限展示;提交版本、通过/拒绝决定、稳定幂等键及可选备注 | | 海外采购订单 | 审核 | 原型未定义;当前对话明确要求按后端既有审核契约补齐前端 | 列表待审核行操作 + 审核弹框 | 仅待审核订单且具备 `purchase:review` 权限展示;通过需选择采购仓,驳回原因按 `remark` 提交;保留幂等键 |
| 海外采购订单 | 新增 | 14–15 行明确“历史新增表单仅保留为不可达模板” | 不可达 | 不建立新增入口或路由 | | 海外采购订单 | 新增 | 14–15 行明确“历史新增表单仅保留为不可达模板” | 不可达 | 不建立新增入口或路由 |
| 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 | | 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
| 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;使用平台创建接口真实提交并显式选择客户 | | 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;使用平台创建接口真实提交并显式选择客户 |
@@ -130,3 +130,6 @@
| 本土散板 | 新增、编辑 | 既有独立路由,与卡车复用运输表单 | 同步布局修复,保留运输双列、货物四列及散板专属字段 | 同上 | | 本土散板 | 新增、编辑 | 既有独立路由,与卡车复用运输表单 | 同步布局修复,保留运输双列、货物四列及散板专属字段 | 同上 |
本次仅调整 Admin 运输表单展示,不改采购编辑的宽度或 Client 表单。在 `apps/admin-web` 经 `local-check.ps1` 执行:`pnpm exec vitest run src/modules/overseas-business/pages/OverseasBusinessViews.test.ts src/modules/overseas-business/contract.test.ts src/styles/adminDensity.test.ts`,3 文件 115 项通过;`pnpm typecheck`、`pnpm build` 通过。共享完整 `pnpm test`(任务名 `Admin restored dispatch full regression`,13:55:33–14:05:50)退出码 0,覆盖本次未再变化的运输表单源码;其他在途排车变更由对应任务验证,不据此推定其最新快照通过。视觉仍待人工截图确认。 本次仅调整 Admin 运输表单展示,不改采购编辑的宽度或 Client 表单。在 `apps/admin-web` 经 `local-check.ps1` 执行:`pnpm exec vitest run src/modules/overseas-business/pages/OverseasBusinessViews.test.ts src/modules/overseas-business/contract.test.ts src/styles/adminDensity.test.ts`,3 文件 115 项通过;`pnpm typecheck`、`pnpm build` 通过。共享完整 `pnpm test`(任务名 `Admin restored dispatch full regression`,13:55:33–14:05:50)退出码 0,覆盖本次未再变化的运输表单源码;其他在途排车变更由对应任务验证,不据此推定其最新快照通过。视觉仍待人工截图确认。
2026-10-29 用户截图调整:采购详情显示客户驳回原因,收货批次提供重新上传照片;各批旧图全部替换前不可再次采购完成。本土卡车新增/编辑数量改托数,托数和预算均须大于0。自动检查与人工视觉验收分别记录;待人工1920×1080、100%截图确认。
@@ -155,3 +155,8 @@ export async function updateLocalLooseboardOrder(orderNo: string, customerId: st
const response = await http.put<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } }) const response = await http.put<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
return looseboardDetail(unwrapApiResponse(response)) return looseboardDetail(unwrapApiResponse(response))
} }
export async function reviewLocalLooseboardOrder(orderNo: string, customerId: string, payload: {version: number; decision: 'APPROVE' | 'REJECT'; idempotencyKey: string; remark?: string}) {
const response = await http.post<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}/review`, payload, { params: { customerId } })
return looseboardDetail(unwrapApiResponse(response))
}
@@ -4,9 +4,10 @@ export interface ProcurementWorkflowReceipt { id: string; quantity: number; file
export interface ProcurementOutbound { id: string; outboundNo: string; quantity: number; trackingNo: string; status: string; fileIds: string[]; createdAt: string; completedAt: string | null } export interface ProcurementOutbound { id: string; outboundNo: string; quantity: number; trackingNo: string; status: string; fileIds: string[]; createdAt: string; completedAt: string | null }
export interface ProcurementWorkflow { export interface ProcurementWorkflow {
orderNo: string; version: number; status: string; receivedQuantity: number; stockQuantity: number; reservedQuantity: number; availableQuantity: number orderNo: string; version: number; status: string; receivedQuantity: number; stockQuantity: number; reservedQuantity: number; availableQuantity: number
rejectionReason: string | null; photosNeedUpdate: boolean
receipts: ProcurementWorkflowReceipt[]; outbounds: ProcurementOutbound[] receipts: ProcurementWorkflowReceipt[]; outbounds: ProcurementOutbound[]
} }
export interface WorkflowCommand { version: number; idempotencyKey: string; quantity?: number; trackingNo?: string; fileIds?: string[] } export interface WorkflowCommand { version: number; idempotencyKey: string; receiptId?: string; quantity?: number; trackingNo?: string; fileIds?: string[] }
const base = '/platform/v1/last-leg/overseas-procurements' const base = '/platform/v1/last-leg/overseas-procurements'
export async function getProcurementWorkflow(orderNo: string, customerId: string) { export async function getProcurementWorkflow(orderNo: string, customerId: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/workflow`, { params: { customerId } })) return unwrapApiResponse(await http.get<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/workflow`, { params: { customerId } }))
@@ -14,3 +15,6 @@ export async function getProcurementWorkflow(orderNo: string, customerId: string
export async function submitProcurementWorkflow(orderNo: string, customerId: string, action: string, payload: WorkflowCommand) { export async function submitProcurementWorkflow(orderNo: string, customerId: string, action: string, payload: WorkflowCommand) {
return unwrapApiResponse(await http.post<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/${action}`, payload, { params: { customerId } })) return unwrapApiResponse(await http.post<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/${action}`, payload, { params: { customerId } }))
} }
export async function editProcurementOutbound(orderNo: string, customerId: string, batchId: string, payload: WorkflowCommand) {
return unwrapApiResponse(await http.put<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/outbounds/${encodeURIComponent(batchId)}`, payload, { params: { customerId } }))
}
@@ -68,6 +68,19 @@ beforeEach(() => {
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('单据费用操作', () => { describe('单据费用操作', () => {
it('resets a previous bill selection to wallet when switching to procurement', async () => {
const wrapper = mount(OverseasFeeEditor, { props: { modelValue: false, fee: null, items: [] }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await wrapper.setProps({ modelValue: true }); await flushPromises()
const dialog = activeDialog(wrapper)
const form = dialog.getComponent({ name: 'ElForm' }).props('model')
form.settlementMode = 'BILL'
const typeSelect = dialog.findAllComponents({ name: 'ElSelect' })[0]!
typeSelect.vm.$emit('update:modelValue', 'OVERSEAS_PROCUREMENT')
typeSelect.vm.$emit('change', 'OVERSEAS_PROCUREMENT')
await flushPromises()
expect(form.settlementMode).toBe('WALLET')
expect(api.createOverseasFee).not.toHaveBeenCalled()
})
it('uses the same fee dialog for procurement with balance deduction selected by default', async () => { it('uses the same fee dialog for procurement with balance deduction selected by default', async () => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
const procurement = { ...business, businessType: 'OVERSEAS_PROCUREMENT', businessNo: 'OP-1' } const procurement = { ...business, businessType: 'OVERSEAS_PROCUREMENT', businessNo: 'OP-1' }
@@ -257,6 +270,20 @@ describe('单据费用操作', () => {
expect(dialog.text()).toContain('审核状态') expect(dialog.text()).toContain('审核状态')
expect(dialog.findAll('button').some(button => button.text().trim() === '审核')).toBe(false) expect(dialog.findAll('button').some(button => button.text().trim() === '审核')).toBe(false)
}) })
it.each([['ADJUST', '调整'], ['VOID', '作废']] as const)('does not execute %s after the fee was approved elsewhere', async (action, label) => {
const pending = { ...fee, allowedActions: [action] }
const approved = { ...fee, reviewStatus: 'APPROVED', allowedActions: ['ADJUST', 'VOID'] }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValueOnce({ records: [pending], total: 1, page: 1, size: 20, pages: 1 })
.mockResolvedValue({ records: [approved], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(approved)
const wrapper = await mountPage()
await click(wrapper, label, '.el-table__body-wrapper button')
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
expect(wrapper.findAllComponents(AppDialog).some(component => component.props('modelValue'))).toBe(false)
const buttons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(buttons).not.toContain('调整')
expect(buttons).not.toContain('作废')
})
it('voids with the freshly loaded version and a required reason', async () => { it('voids with the freshly loaded version and a required reason', async () => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['VOID'] }], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['VOID'] }], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, sourceVersion: 5, allowedActions: ['VOID'] }) vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, sourceVersion: 5, allowedActions: ['VOID'] })
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getLocalLooseboardOrder, reviewLocalLooseboardOrder } from '../api/platformOverseasBusiness'
import type { LocalLooseboardDetail, LocalLooseboardOrder } from '../types'
const open = defineModel<boolean>({default: false})
const props = defineProps<{order: LocalLooseboardOrder | null}>()
const emit = defineEmits<{saved: []}>()
const detail = ref<LocalLooseboardDetail | null>(null), busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const decision = ref<'APPROVE' | 'REJECT'>('APPROVE'), remark = ref('')
let generation = 0
async function load() {
if (!props.order) return
const current=++generation; loading.value=true; detail.value=null; error.value=null
try { const value=await getLocalLooseboardOrder(props.order.orderNo,props.order.customerId); if(current===generation)detail.value=value }
catch(cause) { if(current===generation)error.value=getApiErrorDetails(cause,'散板订单读取失败') }
finally { if(current===generation)loading.value=false }
}
async function submit() {
if (!detail.value || busy.value) return
if (decision.value==='REJECT' && !remark.value.trim()) { ElMessage.warning('请填写驳回原因'); return }
busy.value=true; error.value=null
try {
await reviewLocalLooseboardOrder(detail.value.orderNo,detail.value.customerId,{version:detail.value.version,decision:decision.value,remark:remark.value.trim() || undefined,idempotencyKey:crypto.randomUUID()})
ElMessage.success(decision.value==='APPROVE'?'审核通过,已生成关联收货任务':'订单已驳回'); open.value=false; emit('saved')
} catch(cause) { error.value=getApiErrorDetails(cause,'散板审核失败'); await load().catch(()=>undefined); error.value=getApiErrorDetails(cause,'散板审核失败') }
finally { busy.value=false }
}
watch(open,value=>{if(value){decision.value='APPROVE';remark.value='';void load()}else generation++})
</script>
<template><AppDialog v-model="open" title="审核本土散板订单" width="680px" class="admin-density-dialog admin-workbench" :confirm-loading="busy" :confirm-disabled="loading || !detail || detail.status !== 'PENDING_ACCEPTANCE'" lock-while-submitting @confirm="submit">
<LoadErrorState v-if="error" title="审核暂未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-form v-if="detail" label-position="top" :disabled="busy" class="density-form">
<el-form-item label="订单号">{{detail.orderNo}}</el-form-item>
<el-form-item label="入仓方式">{{detail.deliveryMode==='WAREHOUSE_PICKUP'?'仓库上门取货':detail.deliveryMode==='DELIVERY_TO_WAREHOUSE'?'送货到仓':'未填写,需先编辑补齐'}}</el-form-item>
<el-form-item label="审核结果"><el-radio-group v-model="decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item>
<el-form-item label="审核意见" :required="decision==='REJECT'"><el-input v-model="remark" type="textarea" maxlength="1000" :rows="3" /></el-form-item>
</el-form>
</AppDialog></template>
@@ -48,7 +48,12 @@ async function loadOptions(keyword = '') {
} catch (failure) { if (sequence === optionSequence) error.value = getApiErrorDetails(failure, '业务订单加载失败') } } catch (failure) { if (sequence === optionSequence) error.value = getApiErrorDetails(failure, '业务订单加载失败') }
finally { if (sequence === optionSequence) optionsLoading.value = false } finally { if (sequence === optionSequence) optionsLoading.value = false }
} }
function changeBusinessType() { form.businessId = ''; options.value = []; void loadOptions() } function changeBusinessType() {
form.businessId = ''; options.value = []
// 切换业务时重置结算选择,采购不能沿用上一业务的账期选择。
form.settlementMode = form.businessType === 'OVERSEAS_PROCUREMENT' ? 'WALLET' : ''
void loadOptions()
}
const selected = computed(() => props.business || options.value.find(row => row.businessId === form.businessId)) const selected = computed(() => props.business || options.value.find(row => row.businessId === form.businessId))
const selectedItem = computed(() => manualItems.value.find(item => item.feeCode === form.feeItemCode)) const selectedItem = computed(() => manualItems.value.find(item => item.feeCode === form.feeItemCode))
let existingSequence = 0 let existingSequence = 0
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import ElementPlus from 'element-plus' import ElementPlus, { ElInputNumber } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import { useAuthStore } from '@/core/auth/auth' import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -9,14 +9,12 @@ import ProcurementWorkflowPanel from './ProcurementWorkflowPanel.vue'
import type { PlatformProcurement } from '../procurementTypes' import type { PlatformProcurement } from '../procurementTypes'
import type { ProcurementWorkflow } from '../api/procurementWorkflow' import type { ProcurementWorkflow } from '../api/procurementWorkflow'
import { submitProcurementWorkflow } from '../api/procurementWorkflow' import { submitProcurementWorkflow } from '../api/procurementWorkflow'
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands' import { uploadProcurementFile } from '../api/procurementCommands'
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
vi.mock('../api/procurementWorkflow', () => ({ submitProcurementWorkflow: vi.fn() })) vi.mock('../api/procurementWorkflow', () => ({ submitProcurementWorkflow: vi.fn() }))
vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() })) vi.mock('../api/procurementCommands', () => ({ uploadProcurementFile: vi.fn() }))
vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() }))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) })) vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
enableAutoUnmount(afterEach) enableAutoUnmount(afterEach)
const workflow = (status = 'RECEIVING'): ProcurementWorkflow => ({ orderNo: 'OP-1', version: 4, status, receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] }) const workflow = (status = 'RECEIVING'): ProcurementWorkflow => ({ orderNo: 'OP-1', version: 4, status, receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, rejectionReason: null, photosNeedUpdate: false, receipts: [], outbounds: [] })
const detail = { customerView: { order: { purchaseQuantity: 20 } }, files: [] } as unknown as PlatformProcurement const detail = { customerView: { order: { purchaseQuantity: 20 } }, files: [] } as unknown as PlatformProcurement
const beforeOutbound = vi.fn<() => Promise<boolean | null>>() const beforeOutbound = vi.fn<() => Promise<boolean | null>>()
function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-customer']) { function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-customer']) {
@@ -24,8 +22,94 @@ function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-c
const auth = useAuthStore(); auth.token = 'test'; auth.permissions = permissions.map(value => `platform:overseas-business:purchase:${value}`); useAccessPreviewStore().initializeAfterLogin() const auth = useAuthStore(); auth.token = 'test'; auth.permissions = permissions.map(value => `platform:overseas-business:purchase:${value}`); useAccessPreviewStore().initializeAfterLogin()
return mount(ProcurementWorkflowPanel, { props: { detail, workflow: workflow(status), customerId: '1001', beforeOutbound }, global: { plugins: [pinia, ElementPlus] } }) return mount(ProcurementWorkflowPanel, { props: { detail, workflow: workflow(status), customerId: '1001', beforeOutbound }, global: { plugins: [pinia, ElementPlus] } })
} }
beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(null); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()); vi.mocked(listOverseasPurchaseWarehouseOptions).mockResolvedValue([]) }) beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(null); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()) })
describe('simplified procurement operations', () => { describe('simplified procurement operations', () => {
it('allows a rejected purchase to be resubmitted without uploading photos and hides the old reason after success', async () => {
const wrapper = render()
const rejected = { ...workflow(), rejectionReason: '请核对', photosNeedUpdate: true, receipts: [
{ id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' },
{ id: 'r-2', quantity: 3, fileIds: ['old-2'], receivedAt: '' },
] }
await wrapper.setProps({ workflow: rejected }); await flushPromises()
const button = wrapper.findAll('button').find(button => button.text() === '采购完成')!
expect(button.attributes('disabled')).toBeUndefined()
expect(wrapper.text()).toContain('请核对')
await button.trigger('click'); await flushPromises()
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
expect(uploadProcurementFile).not.toHaveBeenCalled()
expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'request-confirmation', { version: 4, idempotencyKey: expect.any(String) })
expect(wrapper.emitted('changed')).toHaveLength(1)
await wrapper.setProps({ workflow: { ...rejected, status: 'WAIT_CUSTOMER_CONFIRM', version: 5 } })
expect(wrapper.text()).not.toContain('客户驳回原因')
expect(wrapper.text()).not.toContain('请核对')
await wrapper.setProps({ workflow: { ...rejected, status: 'CONFIRMED', version: 6 } })
expect(wrapper.text()).not.toContain('客户驳回原因')
expect(wrapper.text()).not.toContain('请核对')
})
it('hides the previous rejection after receipt photos are updated and shows a new rejection again', async () => {
const wrapper = render()
const rejected = { ...workflow(), rejectionReason: '照片不清晰', photosNeedUpdate: true, receipts: [
{ id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' },
{ id: 'r-2', quantity: 3, fileIds: ['old-2'], receivedAt: '' },
] }
await wrapper.setProps({ workflow: rejected })
expect(wrapper.text()).toContain('照片不清晰')
await wrapper.setProps({ workflow: { ...rejected, version: 5, rejectionReason: null, photosNeedUpdate: true } })
expect(wrapper.text()).not.toContain('客户驳回原因')
expect(wrapper.text()).not.toContain('照片不清晰')
expect(wrapper.findAll('button').some(button => button.text() === '重新上传照片')).toBe(true)
await wrapper.setProps({ workflow: { ...rejected, version: 6, rejectionReason: null, photosNeedUpdate: false } })
expect(wrapper.findAll('button').some(button => button.text() === '重新上传照片')).toBe(false)
await wrapper.setProps({ workflow: { ...rejected, version: 7, rejectionReason: '请补拍完整商品' } })
expect(wrapper.text()).toContain('请补拍完整商品')
})
it('accepts the first and subsequent receipt batches using the remaining purchase quantity', async () => {
const wrapper = render()
const receive = () => wrapper.findAll('button').find(button => button.text() === '登记收货')!
await wrapper.setProps({ workflow: { ...workflow(), receivedQuantity: 0, stockQuantity: 0, availableQuantity: 0 } })
await receive().trigger('click'); await flushPromises()
const quantity = wrapper.findComponent(ElInputNumber)
const increment = quantity.find('.el-input-number__increase')
expect(increment.attributes('aria-disabled')).not.toBe('true')
await increment.trigger('mousedown', { button: 0 })
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
expect(quantity.props('modelValue')).toBe(2)
await quantity.find('input').setValue('10')
await quantity.find('input').trigger('blur')
expect(quantity.props('modelValue')).toBe(10)
const form = wrapper.findComponent({ name: 'ElForm' }).props('model')
form.fileIds = ['photo-1']
const dialog = wrapper.findComponent(AppDialog)
dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementWorkflow).toHaveBeenLastCalledWith('OP-1', '1001', 'receipts', expect.objectContaining({ version: 4, quantity: 10, fileIds: ['photo-1'] }))
expect(wrapper.emitted('changed')).toHaveLength(1)
await wrapper.setProps({ workflow: { ...workflow(), version: 5, receivedQuantity: 10, stockQuantity: 10, availableQuantity: 10 } })
await receive().trigger('click'); await flushPromises()
await wrapper.findComponent(ElInputNumber).find('input').setValue('10')
await wrapper.findComponent(ElInputNumber).find('input').trigger('blur')
form.fileIds = ['photo-2']
dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementWorkflow).toHaveBeenLastCalledWith('OP-1', '1001', 'receipts', expect.objectContaining({ version: 5, quantity: 10, fileIds: ['photo-2'] }))
expect(wrapper.emitted('changed')).toHaveLength(2)
const calls = vi.mocked(submitProcurementWorkflow).mock.calls
expect(calls[0]![3].idempotencyKey).not.toBe(calls[1]![3].idempotencyKey)
})
it.each([
{ received: 1, input: '10', expected: 10 },
{ received: 18, input: '3', expected: 2 },
])('limits receipts by remaining purchase quantity after $received units were received', async ({ received, input, expected }) => {
const wrapper = render()
await wrapper.setProps({ workflow: { ...workflow(), receivedQuantity: received, stockQuantity: received, availableQuantity: received } })
await wrapper.findAll('button').find(button => button.text() === '登记收货')!.trigger('click'); await flushPromises()
const quantity = wrapper.findComponent(ElInputNumber)
await quantity.find('input').setValue(input)
await quantity.find('input').trigger('blur')
expect(quantity.props('modelValue')).toBe(expected)
})
it('allows outbound when the fee check reports missing or unreviewed details', async () => { it('allows outbound when the fee check reports missing or unreviewed details', async () => {
const wrapper = render('CONFIRMED') const wrapper = render('CONFIRMED')
const button = wrapper.findAll('button').find(button => button.text() === '创建出库单')! const button = wrapper.findAll('button').find(button => button.text() === '创建出库单')!
@@ -68,14 +152,6 @@ describe('simplified procurement operations', () => {
expect(form.trackingNo).toBe('TRACK-1'); expect(form.quantity).toBe(3) expect(form.trackingNo).toBe('TRACK-1'); expect(form.quantity).toBe(3)
form.quantity = 6; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2) form.quantity = 6; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2)
}) })
it('requires a rejection reason and preserves the current version and customer scope', async () => {
const wrapper = render('PENDING_REVIEW', ['review'])
await wrapper.findAll('button').find(button => button.text() === '审核')!.trigger('click'); await flushPromises()
const form = wrapper.findComponent({ name: 'ElForm' }).props('model'), dialog = wrapper.findComponent(AppDialog)
form.decision = 'REJECT'; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementCommand).not.toHaveBeenCalled()
form.reason = '商品无法采购'; dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-1', '1001', 'review', expect.objectContaining({ version: 4, decision: 'REJECT', reason: '商品无法采购', purchaseWarehouseId: undefined }))
})
it('only completes the selected delivering batch after signature evidence is supplied', async () => { it('only completes the selected delivering batch after signature evidence is supplied', async () => {
const wrapper = render('DELIVERING') const wrapper = render('DELIVERING')
await wrapper.setProps({ workflow: { ...workflow('DELIVERING'), outbounds: [{ id: '9007199254740993', outboundNo: 'OUT-1', status: 'DELIVERING', quantity: 3, trackingNo: 'TRACK-1', fileIds: [], createdAt: '', completedAt: null }] } }) await wrapper.setProps({ workflow: { ...workflow('DELIVERING'), outbounds: [{ id: '9007199254740993', outboundNo: 'OUT-1', status: 'DELIVERING', quantity: 3, trackingNo: 'TRACK-1', fileIds: [], createdAt: '', completedAt: null }] } })
@@ -7,22 +7,24 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness' import { uploadProcurementFile } from '../api/procurementCommands'
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands' import { editProcurementOutbound, submitProcurementWorkflow, type ProcurementWorkflow, type ProcurementOutbound, type ProcurementWorkflowReceipt } from '../api/procurementWorkflow'
import { submitProcurementWorkflow, type ProcurementWorkflow, type ProcurementOutbound, type ProcurementWorkflowReceipt } from '../api/procurementWorkflow'
import { dateTime, statusLabel } from '../procurementPresentation' import { dateTime, statusLabel } from '../procurementPresentation'
import type { PlatformProcurement } from '../procurementTypes' import type { PlatformProcurement } from '../procurementTypes'
import type { OverseasPurchaseWarehouseOption } from '../types'
const props = defineProps<{ detail: PlatformProcurement; workflow: ProcurementWorkflow; customerId: string; downloading?: string; beforeOutbound: () => Promise<boolean | null> }>() const props = defineProps<{ detail: PlatformProcurement; workflow: ProcurementWorkflow; customerId: string; downloading?: string; beforeOutbound: () => Promise<boolean | null> }>()
const emit = defineEmits<{ changed: []; download: [id: string] }>() const emit = defineEmits<{ changed: []; download: [id: string] }>()
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const open = ref(false), saving = ref(false), uploading = ref(false), checkingFees = ref(false), error = ref<ApiErrorDetails | null>(null) const open = ref(false), saving = ref(false), uploading = ref(false), checkingFees = ref(false), error = ref<ApiErrorDetails | null>(null)
const action = ref(''), batchId = ref(''), warehouses = ref<OverseasPurchaseWarehouseOption[]>([]), warehouseLoading = ref(false) const action = ref(''), batchId = ref('')
const form = reactive({ quantity: 1, trackingNo: '', fileIds: [] as string[], warehouseId: '', decision: 'APPROVE' as 'APPROVE' | 'REJECT', reason: '' }) const remainingReceiptQuantity = computed(() => props.detail.customerView.order.purchaseQuantity - props.workflow.receivedQuantity)
const quantityLimit = computed(() => Math.max(1, action.value === 'receipts'
? remainingReceiptQuantity.value
: props.workflow.availableQuantity + (action.value === 'edit-outbound' ? (props.workflow.outbounds.find(row => row.id === batchId.value)?.quantity || 0) : 0)))
const form = reactive({ quantity: 1, trackingNo: '', fileIds: [] as string[] })
const uploadedNames = ref<string[]>([]) const uploadedNames = ref<string[]>([])
const title = computed(() => ({ review: '审核采购单', receipts: '登记收货', 'request-confirmation': '采购完成', outbounds: '创建出库单', dispatch: '开始派送', complete: '上传签收凭证' })[action.value] || '采购操作') const title = computed(() => ({ receipts: '登记收货', 'receipt-photos': '重新上传收货照片', 'request-confirmation': '采购完成', outbounds: '创建出库单', 'edit-outbound': '编辑出库单', dispatch: '开始派送', cancel: '取消出库单', complete: '上传签收凭证' })[action.value] || '采购操作')
const permission = computed(() => `platform:overseas-business:purchase:${action.value === 'review' ? 'review' : ['receipts', 'request-confirmation'].includes(action.value) ? 'receive-overseas' : 'ship-customer'}`) const permission = computed(() => `platform:overseas-business:purchase:${['receipts', 'receipt-photos', 'request-confirmation'].includes(action.value) ? 'receive-overseas' : 'ship-customer'}`)
const can = (name: string) => access.can(`platform:overseas-business:purchase:${name}`) const can = (name: string) => access.can(`platform:overseas-business:purchase:${name}`)
const permissionHint = computed(() => props.workflow.status === 'RECEIVING' && !can('receive-overseas') const permissionHint = computed(() => props.workflow.status === 'RECEIVING' && !can('receive-overseas')
? '当前账号未开通采购收货权限,请联系管理员开通。' ? '当前账号未开通采购收货权限,请联系管理员开通。'
@@ -33,9 +35,9 @@ let fingerprint = '', requestKey = '', openedScope = '', dialogGeneration = 0
const scope = () => `${props.customerId}:${props.workflow.orderNo}` const scope = () => `${props.customerId}:${props.workflow.orderNo}`
watch(scope, () => { dialogGeneration++; open.value = false; error.value = null }) watch(scope, () => { dialogGeneration++; open.value = false; error.value = null })
watch(open, value => { if (!value) dialogGeneration++ }) watch(open, value => { if (!value) dialogGeneration++ })
async function begin(name: string, batch?: ProcurementOutbound) { async function begin(name: string, batch?: ProcurementOutbound | ProcurementWorkflowReceipt) {
if (saving.value || uploading.value || checkingFees.value) return if (saving.value || uploading.value || checkingFees.value) return
const required = name === 'review' ? 'review' : ['receipts', 'request-confirmation'].includes(name) ? 'receive-overseas' : 'ship-customer' const required = ['receipts', 'receipt-photos', 'request-confirmation'].includes(name) ? 'receive-overseas' : 'ship-customer'
if (!can(required)) return if (!can(required)) return
const current = ++dialogGeneration const current = ++dialogGeneration
if (name === 'outbounds') { if (name === 'outbounds') {
@@ -47,15 +49,10 @@ async function begin(name: string, batch?: ProcurementOutbound) {
if (current !== dialogGeneration) return if (current !== dialogGeneration) return
if (noFeeDetails) ElMessage.info('没有新增费用明细,仍可继续创建出库单') if (noFeeDetails) ElMessage.info('没有新增费用明细,仍可继续创建出库单')
} }
action.value = name; batchId.value = batch?.id || ''; error.value = null; warehouseLoading.value = false action.value = name; batchId.value = batch?.id || ''; error.value = null
Object.assign(form, { quantity: 1, trackingNo: '', fileIds: [], warehouseId: '', decision: 'APPROVE', reason: '' }); uploadedNames.value = [] Object.assign(form, { quantity: 1, trackingNo: '', fileIds: [] }); uploadedNames.value = []
if (name === 'edit-outbound' && batch && 'trackingNo' in batch) Object.assign(form, { quantity: batch.quantity, trackingNo: batch.trackingNo })
fingerprint = ''; requestKey = ''; openedScope = scope(); open.value = true; captureBaseline() fingerprint = ''; requestKey = ''; openedScope = scope(); open.value = true; captureBaseline()
if (name === 'review') {
warehouseLoading.value = true
try { const result = await listOverseasPurchaseWarehouseOptions(props.detail.customerView.order.procurementDirection); if (current === dialogGeneration) warehouses.value = result }
catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '仓库加载失败') }
finally { if (current === dialogGeneration) warehouseLoading.value = false }
}
} }
async function upload(event: Event) { async function upload(event: Event) {
const input = event.target as HTMLInputElement; const files = Array.from(input.files || []); input.value = '' const input = event.target as HTMLInputElement; const files = Array.from(input.files || []); input.value = ''
@@ -63,7 +60,7 @@ async function upload(event: Event) {
const current = dialogGeneration; const orderNo = props.workflow.orderNo; const customer = props.customerId; uploading.value = true; error.value = null const current = dialogGeneration; const orderNo = props.workflow.orderNo; const customer = props.customerId; uploading.value = true; error.value = null
try { try {
for (const file of files) { for (const file of files) {
if (action.value === 'receipts' && !file.type.startsWith('image/')) throw new Error('收货凭证请选择图片') if (['receipts', 'receipt-photos'].includes(action.value) && !file.type.startsWith('image/')) throw new Error('收货凭证请选择图片')
const result = await uploadProcurementFile(orderNo, customer, 'RECEIPT', file) const result = await uploadProcurementFile(orderNo, customer, 'RECEIPT', file)
if (current !== dialogGeneration) return if (current !== dialogGeneration) return
form.fileIds.push(result.fileId); uploadedNames.value.push(file.name) form.fileIds.push(result.fileId); uploadedNames.value.push(file.name)
@@ -73,20 +70,18 @@ async function upload(event: Event) {
} }
async function save() { async function save() {
if (saving.value || uploading.value || openedScope !== scope() || !access.can(permission.value)) return if (saving.value || uploading.value || openedScope !== scope() || !access.can(permission.value)) return
if (action.value === 'review' && form.decision === 'APPROVE' && !form.warehouseId) { ElMessage.warning('请选择收货仓'); return } if (['receipts', 'outbounds', 'edit-outbound'].includes(action.value) && (!Number.isSafeInteger(form.quantity) || form.quantity < 1)) { ElMessage.warning('请输入有效数量'); return }
if (action.value === 'review' && form.decision === 'REJECT' && !form.reason.trim()) { ElMessage.warning('请填写拒绝原因'); return } if (action.value === 'receipts' && form.quantity > remainingReceiptQuantity.value) { ElMessage.warning('收货数量不能超过剩余采购数量'); return }
if (['receipts', 'outbounds'].includes(action.value) && (!Number.isSafeInteger(form.quantity) || form.quantity < 1)) { ElMessage.warning('请输入有效数量'); return } if (['outbounds', 'edit-outbound'].includes(action.value) && (!form.trackingNo.trim() || form.quantity > props.workflow.availableQuantity + (action.value === 'edit-outbound' ? (props.workflow.outbounds.find(row => row.id === batchId.value)?.quantity || 0) : 0))) { ElMessage.warning('请填写物流跟踪号,数量不能超过可出库库存'); return }
if (action.value === 'receipts' && form.quantity > props.detail.customerView.order.purchaseQuantity - props.workflow.receivedQuantity) { ElMessage.warning('收货数量不能超过剩余采购数量'); return } if (['receipts', 'receipt-photos', 'complete'].includes(action.value) && !form.fileIds.length) { ElMessage.warning(action.value === 'receipts' ? '请上传收货照片' : '请上传签收文件或图片'); return }
if (action.value === 'outbounds' && (!form.trackingNo.trim() || form.quantity > props.workflow.availableQuantity)) { ElMessage.warning('请填写物流跟踪号,数量不能超过可出库库存'); return } const data = { version: props.workflow.version, ...(action.value === 'receipts' ? { quantity: form.quantity, fileIds: [...form.fileIds] } : ['outbounds', 'edit-outbound'].includes(action.value) ? { quantity: form.quantity, trackingNo: form.trackingNo.trim() } : action.value === 'receipt-photos' ? { receiptId: batchId.value, fileIds: [...form.fileIds] } : action.value === 'complete' ? { fileIds: [...form.fileIds] } : {}) }
if (['receipts', 'complete'].includes(action.value) && !form.fileIds.length) { ElMessage.warning(action.value === 'receipts' ? '请上传收货照片' : '请上传签收文件或图片'); return } const next = JSON.stringify([action.value, batchId.value, data])
const data = { version: props.workflow.version, ...(action.value === 'receipts' ? { quantity: form.quantity, fileIds: [...form.fileIds] } : action.value === 'outbounds' ? { quantity: form.quantity, trackingNo: form.trackingNo.trim() } : action.value === 'complete' ? { fileIds: [...form.fileIds] } : {}) }
const next = JSON.stringify([action.value, batchId.value, form.warehouseId, form.decision, form.reason, data])
if (fingerprint !== next || !requestKey) { fingerprint = next; requestKey = crypto.randomUUID() } if (fingerprint !== next || !requestKey) { fingerprint = next; requestKey = crypto.randomUUID() }
const current = dialogGeneration const current = dialogGeneration
saving.value = true; error.value = null saving.value = true; error.value = null
try { try {
if (action.value === 'review') await submitProcurementCommand(props.workflow.orderNo, props.customerId, 'review', { version: props.workflow.version, idempotencyKey: requestKey, decision: form.decision, purchaseWarehouseId: form.decision === 'APPROVE' ? form.warehouseId : undefined, reason: form.reason.trim() || undefined }) if (action.value === 'edit-outbound') await editProcurementOutbound(props.workflow.orderNo, props.customerId, batchId.value, { ...data, idempotencyKey: requestKey })
else await submitProcurementWorkflow(props.workflow.orderNo, props.customerId, batchId.value ? `outbounds/${encodeURIComponent(batchId.value)}/${action.value}` : action.value, { ...data, idempotencyKey: requestKey }) else await submitProcurementWorkflow(props.workflow.orderNo, props.customerId, batchId.value && action.value !== 'receipt-photos' ? `outbounds/${encodeURIComponent(batchId.value)}/${action.value}` : action.value, { ...data, idempotencyKey: requestKey })
if (current !== dialogGeneration) return if (current !== dialogGeneration) return
captureBaseline(); open.value = false; ElMessage.success('操作成功'); emit('changed') captureBaseline(); open.value = false; ElMessage.success('操作成功'); emit('changed')
} catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '操作失败') } } catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '操作失败') }
@@ -94,7 +89,7 @@ async function save() {
} }
const receiptColumns: DataTableColumn[] = [{ prop: 'quantity', label: '收货数量', width: 140 }, { key: 'receivedAt', label: '收货时间', minWidth: 170, formatter: (row: ProcurementWorkflowReceipt) => dateTime(row.receivedAt) }, { key: 'files', label: '照片', minWidth: 180, slotName: 'files' }] const receiptColumns: DataTableColumn[] = [{ prop: 'quantity', label: '收货数量', width: 140 }, { key: 'receivedAt', label: '收货时间', minWidth: 170, formatter: (row: ProcurementWorkflowReceipt) => dateTime(row.receivedAt) }, { key: 'files', label: '照片', minWidth: 180, slotName: 'files' }]
const outboundColumns: DataTableColumn[] = [ const outboundColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, slotName: 'actions', fixed: 'left' }, { prop: 'outboundNo', label: '出库单号', minWidth: 190 }, { key: 'actions', label: '操作', width: 250, slotName: 'actions', fixed: 'left' }, { prop: 'outboundNo', label: '出库单号', minWidth: 190 },
{ key: 'status', label: '状态', width: 120, formatter: (row: ProcurementOutbound) => statusLabel(row.status), afterColumn: 'outboundNo' }, { key: 'status', label: '状态', width: 120, formatter: (row: ProcurementOutbound) => statusLabel(row.status), afterColumn: 'outboundNo' },
{ prop: 'quantity', label: '出库数量', width: 110 }, { prop: 'trackingNo', label: '物流跟踪号', minWidth: 190 }, { prop: 'quantity', label: '出库数量', width: 110 }, { prop: 'trackingNo', label: '物流跟踪号', minWidth: 190 },
{ key: 'createdAt', label: '创建时间', minWidth: 170, formatter: (row: ProcurementOutbound) => dateTime(row.createdAt) }, { key: 'createdAt', label: '创建时间', minWidth: 170, formatter: (row: ProcurementOutbound) => dateTime(row.createdAt) },
@@ -106,22 +101,21 @@ const fileName = (id: string) => props.detail.files.find(file => file.fileId ===
</script> </script>
<template> <template>
<section class="procurement-workflow"> <section class="procurement-workflow">
<el-alert v-if="workflow.status === 'RECEIVING' && workflow.rejectionReason" title="客户驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" show-icon />
<div class="density-actions"> <div class="density-actions">
<el-button v-if="workflow.status === 'PENDING_REVIEW' && can('review')" type="primary" @click="begin('review')">审核</el-button>
<template v-if="workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity" @click="begin('request-confirmation')">采购完成</el-button></template> <template v-if="workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity" @click="begin('request-confirmation')">采购完成</el-button></template>
<el-button v-if="['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(workflow.status)" type="primary" :loading="checkingFees" :disabled="!can('ship-customer') || workflow.availableQuantity < 1" @click="begin('outbounds')">创建出库单</el-button> <el-button v-if="['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(workflow.status)" type="primary" :loading="checkingFees" :disabled="!can('ship-customer') || workflow.availableQuantity < 1" @click="begin('outbounds')">创建出库单</el-button>
</div> </div>
<el-text v-if="permissionHint" type="warning" role="status">{{ permissionHint }}</el-text> <el-text v-if="permissionHint" type="warning" role="status">{{ permissionHint }}</el-text>
<dl class="density-fields inventory"><div><dt>累计收货</dt><dd>{{ workflow.receivedQuantity }}</dd></div><div><dt>采购库存</dt><dd>{{ workflow.stockQuantity }}</dd></div><div><dt>出库处理中</dt><dd>{{ workflow.reservedQuantity }}</dd></div><div><dt>可出库</dt><dd>{{ workflow.availableQuantity }}</dd></div></dl> <dl class="density-fields inventory"><div><dt>累计收货</dt><dd>{{ workflow.receivedQuantity }}</dd></div><div><dt>采购库存</dt><dd>{{ workflow.stockQuantity }}</dd></div><div><dt>出库处理中</dt><dd>{{ workflow.reservedQuantity }}</dd></div><div><dt>可出库</dt><dd>{{ workflow.availableQuantity }}</dd></div></dl>
<h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable> <h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-if="row.id && workflow.status === 'RECEIVING' && (workflow.rejectionReason || workflow.photosNeedUpdate) && can('receive-overseas')" link type="primary" @click="begin('receipt-photos', row as ProcurementWorkflowReceipt)">重新上传照片</el-button><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<h3>出库单</h3><DataTable :data="workflow.outbounds" :columns="outboundColumns" row-key="id" empty-text="暂无出库单"><template #actions="{ row }"><el-button v-if="row.status === 'IN_TRANSIT'" :disabled="!can('ship-customer')" title="开始本批次派送" link type="primary" @click="begin('dispatch', row as ProcurementOutbound)">开始派送</el-button><el-button v-if="row.status === 'DELIVERING'" :disabled="!can('ship-customer')" title="上传图片或文件并确认本批次签收" link type="primary" @click="begin('complete', row as ProcurementOutbound)">上传签收凭证</el-button></template><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable> <h3>出库单</h3><DataTable :data="workflow.outbounds" :columns="outboundColumns" row-key="id" empty-text="暂无出库单"><template #actions="{ row }"><template v-if="row.status === 'IN_TRANSIT'"><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('edit-outbound', row as ProcurementOutbound)">编辑</el-button><el-button :disabled="!can('ship-customer')" link type="danger" @click="begin('cancel', row as ProcurementOutbound)">取消</el-button><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('dispatch', row as ProcurementOutbound)">开始派送</el-button></template><el-button v-if="row.status === 'DELIVERING'" :disabled="!can('ship-customer')" link type="primary" @click="begin('complete', row as ProcurementOutbound)">上传签收凭证</el-button></template><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="title" width="580px" confirm-text="确认" :confirm-loading="saving" :confirm-disabled="uploading || warehouseLoading" @confirm="save"> <AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="title" width="580px" confirm-text="确认" :confirm-loading="saving" :confirm-disabled="uploading" @confirm="save">
<el-form :model="form" class="density-form density-form--outlined" label-position="top" :disabled="saving"> <el-form :model="form" class="density-form density-form--outlined" label-position="top" :disabled="saving">
<template v-if="action === 'review'"><el-form-item label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item><el-form-item v-if="form.decision === 'APPROVE'" label="收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehouseLoading"><el-option v-for="warehouse in warehouses" :key="warehouse.warehouseId" :value="warehouse.warehouseId" :label="warehouse.warehouseName" /></el-select></el-form-item><el-form-item v-else label="拒绝原因" required><el-input v-model="form.reason" type="textarea" :rows="2" maxlength="1000" /></el-form-item></template> <el-form-item v-if="['receipts', 'outbounds', 'edit-outbound'].includes(action)" :label="action === 'receipts' ? '本批收货数量' : '本批出库数量'" required><el-input-number v-model="form.quantity" :min="1" :max="quantityLimit" :precision="0" /></el-form-item>
<el-form-item v-if="['receipts', 'outbounds'].includes(action)" :label="action === 'receipts' ? '本批收货数量' : '本批出库数量'" required><el-input-number v-model="form.quantity" :min="1" :max="action === 'outbounds' ? workflow.availableQuantity : detail.customerView.order.purchaseQuantity - workflow.receivedQuantity" :precision="0" /></el-form-item> <el-form-item v-if="['outbounds', 'edit-outbound'].includes(action)" label="物流跟踪号" required><el-input v-model="form.trackingNo" maxlength="100" /></el-form-item>
<el-form-item v-if="action === 'outbounds'" label="物流跟踪号" required><el-input v-model="form.trackingNo" maxlength="100" /></el-form-item> <el-form-item v-if="['receipts', 'receipt-photos', 'complete'].includes(action)" :label="action !== 'complete' ? '收货照片' : '签收文件或图片'" required><input type="file" multiple :accept="action !== 'complete' ? 'image/*' : 'image/*,application/pdf'" :disabled="saving || uploading" @change="upload" /><div v-for="name in uploadedNames" :key="name">{{ name }}</div></el-form-item>
<el-form-item v-if="['receipts', 'complete'].includes(action)" :label="action === 'receipts' ? '收货照片' : '签收文件或图片'" required><input type="file" multiple :accept="action === 'receipts' ? 'image/*' : 'image/*,application/pdf'" :disabled="saving || uploading" @change="upload" /><div v-for="name in uploadedNames" :key="name">{{ name }}</div></el-form-item> <p v-if="action === 'request-confirmation'">确认采购完成并提交客户确认?</p><p v-if="action === 'dispatch'">确认本批次开始派送?</p><p v-if="action === 'cancel'">确认取消本批出库单?取消后库存占用将释放。</p>
<p v-if="action === 'request-confirmation'">确认采购完成并提交客户确认?</p><p v-if="action === 'dispatch'">确认本批次开始派送?</p>
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert> </el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
</AppDialog> </AppDialog>
</section> </section>
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, ref } from 'vue' import { computed, ref, onMounted, watch } from 'vue'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import type { FormInstance } from 'element-plus' import type { FormInstance } from 'element-plus'
import FormSection from '@/shared/components/FormSection.vue' import FormSection from '@/shared/components/FormSection.vue'
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm' import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
@@ -16,7 +17,20 @@ const props = withDefaults(defineProps<{
}>(), { disabled: false, submitting: false, canSubmit: true, editing: false }) }>(), { disabled: false, submitting: false, canSubmit: true, editing: false })
const emit = defineEmits<{ submit: []; cancel: [] }>() const emit = defineEmits<{ submit: []; cancel: [] }>()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const warehouses = ref<PlatformWarehouseResource[]>([]), warehouseError = ref('')
async function loadWarehouses() {
if (props.kind !== 'looseboard') return
try { warehouses.value = (await listPlatformWarehouses({page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE'})).records; warehouseError.value = '' }
catch (cause) { warehouseError.value = cause instanceof Error ? cause.message : '仓库读取失败' }
}
function chooseWarehouse() {
const warehouse = warehouses.value.find(row => String(row.id) === String(props.model.operatingWarehouseId))
props.model.deliveryContactName = warehouse?.contactName || ''; props.model.deliveryContactPhone = warehouse?.contactPhone || ''
}
onMounted(loadWarehouses)
watch([() => props.model.operatingWarehouseId, warehouses], chooseWarehouse, { immediate: true })
const rules = computed(() => transportDemandRules(props.kind, props.model)) const rules = computed(() => transportDemandRules(props.kind, props.model))
const isTruck = computed(() => props.kind === 'truck')
defineExpose({ defineExpose({
validate: () => formRef.value?.validate() ?? Promise.resolve(false), validate: () => formRef.value?.validate() ?? Promise.resolve(false),
clearValidate: (fields?: string | string[]) => formRef.value?.clearValidate(fields), clearValidate: (fields?: string | string[]) => formRef.value?.clearValidate(fields),
@@ -26,26 +40,40 @@ defineExpose({
<template> <template>
<el-form ref="formRef" class="transport-demand-form admin-density admin-workbench density-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting"> <el-form ref="formRef" class="transport-demand-form admin-density admin-workbench density-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
<slot name="customer" /> <slot name="customer" />
<FormSection v-if="kind === 'looseboard'" class="density-form-section" title="入仓安排">
<div class="transport-demand-grid">
<el-form-item v-if="kind === 'looseboard' && model.destinationType === 'PRIVATE'" label="目的地邮编" prop="destinationPostalCode"><el-input v-model.trim="model.destinationPostalCode" maxlength="32" /></el-form-item>
<el-form-item class="transport-demand-wide" label="送货方式" prop="deliveryMode"><el-radio-group v-model="model.deliveryMode"><el-radio-button value="DELIVERY_TO_WAREHOUSE">送货到仓</el-radio-button><el-radio-button value="WAREHOUSE_PICKUP">仓库上门取货</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="收货仓库" prop="operatingWarehouseId"><el-select v-model="model.operatingWarehouseId" filterable placeholder="请选择收货仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select><span v-if="warehouseError" role="alert">{{ warehouseError }} <el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<template v-if="model.deliveryMode === 'DELIVERY_TO_WAREHOUSE'">
<el-form-item label="到仓联系人" prop="deliveryContactName"><el-input v-model="model.deliveryContactName" readonly placeholder="随收货仓库带出,未维护请联系仓库" /></el-form-item>
<el-form-item label="到仓联系电话" prop="deliveryContactPhone"><el-input v-model="model.deliveryContactPhone" readonly placeholder="随收货仓库带出,未维护请联系仓库" /></el-form-item>
<el-form-item label="预计到货时间" prop="estimatedArrivalAt"><el-date-picker v-model="model.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item>
<el-form-item label="物流跟踪号" prop="trackingNo"><el-input v-model.trim="model.trackingNo" maxlength="255" /></el-form-item>
</template>
</div>
</FormSection>
<FormSection class="density-form-section" title="运输信息"> <FormSection class="density-form-section" title="运输信息">
<div class="transport-demand-grid"> <div class="transport-demand-grid">
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType"> <el-form-item v-if="isTruck" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select> <el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
</el-form-item> </el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item> <el-form-item v-if="isTruck || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="isTruck ? 'B点取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item> <el-form-item :label="isTruck ? 'C点送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item> <el-form-item v-if="isTruck || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="isTruck ? 'B点联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item> <el-form-item :label="isTruck ? 'C点联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item> <el-form-item v-if="isTruck || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="isTruck ? 'B点联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点联系电话' : '目的地联系电话'" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item> <el-form-item :label="isTruck ? 'C点联系电话' : '目的地联系电话'" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'B点取货地址' : '起运详细地址'"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item> <el-form-item v-if="isTruck || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="isTruck ? 'B点取货地址' : '起运详细地址'" prop="originAddress"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点送达地址' : '目的详细地址'"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item> <el-form-item :label="isTruck ? 'C点送达地址' : '目的详细地址'" prop="destinationAddress"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
</div> </div>
</FormSection> </FormSection>
<FormSection class="density-form-section" title="货物信息"> <FormSection class="density-form-section" title="货物信息">
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'"> <div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" /></el-form-item> <el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" /></el-form-item> <el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
<el-form-item v-if="kind === 'truck'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" /></el-form-item> <el-form-item v-if="isTruck" label="托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="1" :precision="0" controls-position="right" /></el-form-item>
<el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" /></el-form-item> <el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" /></el-form-item> <el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" /></el-form-item> <el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
@@ -58,8 +86,8 @@ defineExpose({
<el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select> <el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select>
</div></el-form-item> </div></el-form-item>
<div /> <div />
<el-form-item :label="kind === 'truck' ? 'B点取货时间' : '取货时间'" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item> <el-form-item :label="isTruck ? 'B点取货时间' : '取货时间'" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
<el-form-item :label="kind === 'truck' ? 'C点送达时间' : '送货时间'" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item> <el-form-item :label="isTruck ? 'C点送达时间' : '送货时间'" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item> <el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</div> </div>
</FormSection> </FormSection>
@@ -105,12 +105,12 @@ async function submit() {
</div> </div>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="'确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit"> <AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="'确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
<template v-if="active"> <template v-if="active">
<p>{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p> <p v-if="active !== 'review' && active !== 'arrange'">{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p>
<el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" /> <el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" />
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div> <div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit"> <el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item> <el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="active === 'arrange' || (active === 'review' && form.decision === 'APPROVE')" label="调度仓库" required><el-select v-model="form.warehouseId" placeholder="选择 A 海外仓" filterable><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item> <el-form-item v-if="active === 'arrange' || (active === 'review' && form.decision === 'APPROVE')" label="调度仓库" required><el-select v-model="form.warehouseId" placeholder="" filterable><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<template v-if="active === 'receive-pallets'"> <template v-if="active === 'receive-pallets'">
<el-form-item label="累计实收托数" required><el-input-number v-model="form.actualPalletCount" :min="Math.max(1, detail.receivedPalletCount)" :precision="0" /></el-form-item> <el-form-item label="累计实收托数" required><el-input-number v-model="form.actualPalletCount" :min="Math.max(1, detail.receivedPalletCount)" :precision="0" /></el-form-item>
<el-form-item label="实收件数"><el-input-number v-model="form.packageCount" :min="0" :precision="0" /></el-form-item> <el-form-item label="实收件数"><el-input-number v-model="form.packageCount" :min="0" :precision="0" /></el-form-item>
@@ -14,12 +14,16 @@ describe('overseas business real contract pages', () => {
expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/) expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/)
}) })
it('keeps procurement general editing limited to internal remarks', () => { it('keeps procurement editing limited to remarks and reviews pending rows from the list', () => {
const list = read('pages/PurchaseOrdersView.vue'); const detail = read('pages/PurchaseOrderDetailView.vue'); const edit = read('pages/PurchaseOrderEditView.vue') const list = read('pages/PurchaseOrdersView.vue'); const detail = read('pages/PurchaseOrderDetailView.vue'); const edit = read('pages/PurchaseOrderEditView.vue')
expect(list).toContain('listOverseasPurchaseCustomerOptions') expect(list).toContain('listOverseasPurchaseCustomerOptions')
expect(edit).toContain('getOverseasPurchaseEditor') expect(edit).toContain('getOverseasPurchaseEditor')
expect(detail).toContain('detail.reviewHistory') expect(detail).toContain('<ProcurementWorkflowPanel')
expect(detail).not.toContain('outbounds') expect(detail).not.toContain('outbounds')
expect(list).toContain("status === 'PENDING_REVIEW'")
expect(list).toContain('remark: reviewForm.remark.trim()')
expect(list).not.toContain('>编辑</el-button>')
expect(detail).not.toContain('编辑备注')
expect(edit).toContain('remark: form.remark.trim() || null') expect(edit).toContain('remark: form.remark.trim() || null')
expect(edit).not.toMatch(/v-model="form\.(inboundQuantity|purchaseWarehouseId|outboundDetails|productName)/) expect(edit).not.toMatch(/v-model="form\.(inboundQuantity|purchaseWarehouseId|outboundDetails|productName)/)
expect(edit).not.toContain('入库量不能小于 0') expect(edit).not.toContain('入库量不能小于 0')
@@ -44,7 +48,8 @@ describe('overseas business real contract pages', () => {
const purchaseEdit = read('pages/PurchaseOrderEditView.vue'); const transportList = read('pages/TransportOrdersView.vue'); const transportForm = read('pages/TransportOrderFormView.vue') const purchaseEdit = read('pages/PurchaseOrderEditView.vue'); const transportList = read('pages/TransportOrdersView.vue'); const transportForm = read('pages/TransportOrderFormView.vue')
expect(purchaseEdit).toContain('useUnsavedWorkRegistration') expect(purchaseEdit).toContain('useUnsavedWorkRegistration')
expect(transportForm).toContain('useUnsavedWorkRegistration') expect(transportForm).toContain('useUnsavedWorkRegistration')
expect(transportList).toContain("row.status === 'PENDING_ACCEPTANCE'") expect(transportList).not.toContain("isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE'")
expect(transportList).toContain("!isLooseboard.value && !row.fleetArrangement && row.status === 'PENDING_REVIEW'")
expect(transportList).toContain("row.status === 'PENDING_REVIEW'") expect(transportList).toContain("row.status === 'PENDING_REVIEW'")
expect(transportForm).toContain("form.status === 'PENDING_ACCEPTANCE'") expect(transportForm).toContain("form.status === 'PENDING_ACCEPTANCE'")
expect(transportForm).toContain("form.status === 'PENDING_REVIEW'") expect(transportForm).toContain("form.status === 'PENDING_REVIEW'")
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { statusLabel, statusTone } from './procurementPresentation'
import { availableTruckCommands, truckStatusLabels } from './truckWorkflow'
describe('异步下单资金门禁', () => {
it.each(['FUNDING_PENDING', 'FUNDING_FAILED'] as const)('资金状态 %s 不开放卡车审核或排车', status => {
expect(availableTruckCommands({ status, fleetArrangement: null })).toEqual([])
expect(truckStatusLabels[status]).toBe(status === 'FUNDING_PENDING' ? '订单受理中' : '订单处理失败')
})
it('采购清晰区分订单受理中、失败和待审核', () => {
expect(statusLabel('FUNDING_PENDING')).toBe('订单受理中')
expect(statusLabel('FUNDING_FAILED')).toBe('订单处理失败')
expect(statusTone('FUNDING_FAILED')).toBe('danger')
expect(statusLabel('PENDING_REVIEW')).toBe('待审核')
expect(availableTruckCommands({ status: 'PENDING_REVIEW', fleetArrangement: null })).toContain('review')
})
})
@@ -13,6 +13,17 @@ const fee = (overrides: Partial<OverseasFee> = {}): OverseasFee => ({
}) })
describe('海外费用动作资格', () => { describe('海外费用动作资格', () => {
it.each(['DRAFT', 'PENDING'])('审核前按服务端资格和独立权限展示调整、作废:%s', reviewStatus => {
const pending = fee({ reviewStatus, allowedActions: ['ADJUST', 'VOID', 'APPROVE', 'REJECT'] })
expect(feeActions(pending, () => true).map(row => row.key)).toEqual(['DETAIL', 'ADJUST', 'VOID', 'REVIEW'])
expect(feeActions(pending, permission => permission === feePermissions.edit).map(row => row.key)).toEqual(['DETAIL', 'ADJUST'])
expect(feeActions(pending, permission => permission === feePermissions.review).map(row => row.key)).toEqual(['DETAIL', 'VOID', 'REVIEW'])
expect(feeActions(fee({ reviewStatus }), () => true).map(row => row.key)).toEqual(['DETAIL'])
})
it.each(['BILL', 'WALLET'])('审核通过后即使旧白名单仍包含调整、作废也不展示:%s', collectionMode => {
const approved = fee({ reviewStatus: 'APPROVED', collectionMode, walletStatus: collectionMode === 'WALLET' ? 'SETTLED' : 'NOT_REQUIRED', allowedActions: ['ADJUST', 'VOID'] })
expect(feeActions(approved, () => true).map(row => row.key)).toEqual(['DETAIL'])
})
it('明确时区的同一时刻显示一致,旧无时区时间不猜测转换', () => { it('明确时区的同一时刻显示一致,旧无时区时间不猜测转换', () => {
expect(feeTime('2026-10-23T10:43:25Z')).toBe(feeTime('2026-10-23T18:43:25+08:00')) expect(feeTime('2026-10-23T10:43:25Z')).toBe(feeTime('2026-10-23T18:43:25+08:00'))
const date = new Date('2026-10-23T10:43:25Z') const date = new Date('2026-10-23T10:43:25Z')
@@ -79,6 +79,7 @@ export function feeActions(fee: OverseasFee, can: (permission: string) => boolea
const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus) const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus)
const canReview = can(feePermissions.review) const canReview = can(feePermissions.review)
const visibleActions = fee.allowedActions.filter(action => action in actionLabels && !['APPROVE', 'REJECT', 'CANCEL'].includes(action)) const visibleActions = fee.allowedActions.filter(action => action in actionLabels && !['APPROVE', 'REJECT', 'CANCEL'].includes(action))
.filter(action => !['ADJUST', 'VOID'].includes(action) || ['DRAFT', 'PENDING'].includes(fee.reviewStatus))
.filter(action => !closed || ['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action)) .filter(action => !closed || ['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action))
.filter(action => can(['VOID'].includes(action) ? feePermissions.review : feePermissions.edit)) .filter(action => can(['VOID'].includes(action) ? feePermissions.review : feePermissions.edit))
.map(action => ({ key: action, label: actionLabels[action]!, priority: 20, .map(action => ({ key: action, label: actionLabels[action]!, priority: 20,
@@ -1,7 +1,7 @@
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue' import { reactive, ref } from 'vue'
import { ApiResponseError } from '@/shared/api/http' import { ApiResponseError } from '@/shared/api/http'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue' import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness' import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
@@ -36,6 +36,22 @@ beforeEach(() => {
}) })
describe('looseboard document fees', () => { describe('looseboard document fees', () => {
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' }))
.mockResolvedValueOnce(order({ status: 'PENDING_DISPATCH' }))
const wrapper = mount({ components: { LooseboardOrderDetailView }, setup: () => ({ visible }),
template: '<KeepAlive><LooseboardOrderDetailView v-if="visible" /></KeepAlive>',
}, { global: { plugins: [ElementPlus], stubs: { BusinessDocumentFees: true, FleetArrangementSummary: true, DataTable: true } } })
await flushPromises()
expect(getLocalLooseboardOrder).toHaveBeenCalledTimes(1)
expect(wrapper.find('.status-badge').text()).toBe('待入仓')
visible.value = false; await flushPromises()
visible.value = true; await flushPromises()
expect(getLocalLooseboardOrder).toHaveBeenCalledTimes(2)
expect(wrapper.find('.status-badge').text()).toBe('待派车')
})
it('loads the customer scoped document and passes its exact identity to fees', async () => { it('loads the customer scoped document and passes its exact identity to fees', async () => {
const wrapper = mountDetail(); await flushPromises() const wrapper = mountDetail(); await flushPromises()
expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-TEST', '9007199254741999') expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-TEST', '9007199254741999')
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue' import { computed, onActivated, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue' import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
@@ -14,6 +14,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness' import { getLocalLooseboardOrder } from '../api/platformOverseasBusiness'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue' import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue' import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import TransferPickupProgressPanel from '@/modules/overseas-warehouse/components/TransferPickupProgressPanel.vue'
import { poolOrderStatusLabels } from '../fleetArrangement' import { poolOrderStatusLabels } from '../fleetArrangement'
import type { LocalLooseboardDetail, OverseasBusinessHistory } from '../types' import type { LocalLooseboardDetail, OverseasBusinessHistory } from '../types'
@@ -58,6 +59,12 @@ async function load() {
finally { if (sequence === requestSequence) loading.value = false } finally { if (sequence === requestSequence) loading.value = false }
} }
watch([orderNo, customerId], load, { immediate: true }) watch([orderNo, customerId], load, { immediate: true })
// 首次由路由加载;缓存标签重新打开时读取PDA和排车已推进的最新状态。
let activatedOnce = false
onActivated(() => {
if (activatedOnce) void load()
activatedOnce = true
})
</script> </script>
<template> <template>
@@ -73,6 +80,7 @@ watch([orderNo, customerId], load, { immediate: true })
<el-skeleton v-else-if="loading" :rows="8" animated /> <el-skeleton v-else-if="loading" :rows="8" animated />
<template v-else-if="detail"> <template v-else-if="detail">
<section v-if="detail.fleetArrangement" class="looseboard-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section> <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 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"><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> <section class="looseboard-detail-section"><h2>操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
@@ -1,5 +1,5 @@
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import ElementPlus from 'element-plus' import ElementPlus, { ElMessage } from 'element-plus'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants } from '@/modules/iam' import { listPlatformTenants } from '@/modules/iam'
import type { PlatformProcurement } from '../procurementTypes' import type { PlatformProcurement } from '../procurementTypes'
import { downloadProcurementFile } from '../api/procurementCommands' import { downloadProcurementFile } from '../api/procurementCommands'
import { submitProcurementCommand } from '../api/procurementCommands'
import { getProcurementWorkflow } from '../api/procurementWorkflow' import { getProcurementWorkflow } from '../api/procurementWorkflow'
import type { FleetArrangement } from '../fleetArrangement' import type { FleetArrangement } from '../fleetArrangement'
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder } from '../types' import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder } from '../types'
@@ -34,7 +35,7 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/procurementWorkflow', () => ({ getProcurementWorkflow: vi.fn(), submitProcurementWorkflow: vi.fn() })) vi.mock('../api/procurementWorkflow', () => ({ getProcurementWorkflow: vi.fn(), submitProcurementWorkflow: vi.fn() }))
vi.mock('../api/procurementCommands', async importOriginal => ({ vi.mock('../api/procurementCommands', async importOriginal => ({
...await importOriginal<typeof import('../api/procurementCommands')>(), ...await importOriginal<typeof import('../api/procurementCommands')>(),
downloadProcurementFile: vi.fn(), downloadProcurementFile: vi.fn(), submitProcurementCommand: vi.fn(),
})) }))
vi.mock('../api/platformOverseasBusiness', () => ({ vi.mock('../api/platformOverseasBusiness', () => ({
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), getOverseasPurchaseEditor: vi.fn(), updateOverseasPurchase: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(), listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), getOverseasPurchaseEditor: vi.fn(), updateOverseasPurchase: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
@@ -65,6 +66,32 @@ const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', busi
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 } 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 }
function mountPage(component: Parameters<typeof mount>[0], props?: Record<string, unknown>) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) } function mountPage(component: Parameters<typeof mount>[0], props?: Record<string, unknown>) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) }
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: Error) => void
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail })
return { promise, resolve, reject }
}
async function mountPurchaseReviewRequests() {
const first = deferred<Awaited<ReturnType<typeof listOverseasPurchaseWarehouseOptions>>>()
const second = deferred<Awaited<ReturnType<typeof listOverseasPurchaseWarehouseOptions>>>()
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [
{ ...purchase, id: '101', orderNo: 'OP-A', status: 'PENDING_REVIEW', procurementDirection: 'OVERSEAS_TO_DOMESTIC' },
{ ...purchase, id: '102', orderNo: 'OP-B', status: 'PENDING_REVIEW', procurementDirection: 'DOMESTIC_TO_OVERSEAS' },
], page: 1, size: 20, total: 2, pages: 1 })
vi.mocked(listOverseasPurchaseWarehouseOptions).mockImplementation(direction => direction === 'OVERSEAS_TO_DOMESTIC' ? first.promise : second.promise)
const wrapper = mountPage(PurchaseOrdersView)
await flushPromises()
const buttons = wrapper.findAll('button').filter(button => button.text() === '审核')
await buttons[0]!.trigger('click')
const dialog = wrapper.findComponent(AppDialog)
const openSecond = async () => {
dialog.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
await buttons[1]!.trigger('click')
}
return { wrapper, dialog, first, second, openSecond }
}
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals()) enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
describe('overseas business mounted pages', () => { describe('overseas business mounted pages', () => {
@@ -72,7 +99,7 @@ describe('overseas business mounted pages', () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = '' vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = ''
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {} routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(downloadProcurementFile).mockReset() vi.mocked(downloadProcurementFile).mockReset()
vi.mocked(getProcurementWorkflow).mockReset().mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] }) vi.mocked(getProcurementWorkflow).mockReset().mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, rejectionReason: null, photosNeedUpdate: false, receipts: [], outbounds: [] })
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(procurementDetail()); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 }) vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(procurementDetail()); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 })
vi.mocked(getOverseasPurchaseEditor).mockReset().mockResolvedValue({ customerView: { order: { orderNo: purchase.orderNo, customerId: purchase.customerId, productName: purchase.productName, purchaseQuantity: purchase.purchaseQuantity, version: purchase.version } }, internalRemark: purchase.remark }) vi.mocked(getOverseasPurchaseEditor).mockReset().mockResolvedValue({ customerView: { order: { orderNo: purchase.orderNo, customerId: purchase.customerId, productName: purchase.productName, purchaseQuantity: purchase.purchaseQuantity, version: purchase.version } }, internalRemark: purchase.remark })
vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }]) vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }])
@@ -172,6 +199,8 @@ describe('overseas business mounted pages', () => {
expect(wrapper.text()).toContain('收货记录') expect(wrapper.text()).toContain('收货记录')
expect(wrapper.text()).toContain('出库单') expect(wrapper.text()).toContain('出库单')
expect(wrapper.text()).toContain('采购库存') expect(wrapper.text()).toContain('采购库存')
expect(wrapper.text()).toContain('采购要求')
expect(wrapper.text()).toContain('采购备注')
expect(wrapper.text()).not.toContain('两段实际交寄') expect(wrapper.text()).not.toContain('两段实际交寄')
expect(wrapper.text()).not.toContain('internal-purchase-route') expect(wrapper.text()).not.toContain('internal-purchase-route')
await wrapper.get('.density-back').trigger('click') await wrapper.get('.density-back').trigger('click')
@@ -183,7 +212,7 @@ describe('overseas business mounted pages', () => {
const result = procurementDetail() const result = procurementDetail()
result.files = [{ id: '9007199254741000', fileId: '9007199254741001', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 123, createdAt: '2026-10-10T10:00:00' }] result.files = [{ id: '9007199254741000', fileId: '9007199254741001', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 123, createdAt: '2026-10-10T10:00:00' }]
vi.mocked(getOverseasPurchase).mockResolvedValue(result) vi.mocked(getOverseasPurchase).mockResolvedValue(result)
vi.mocked(getProcurementWorkflow).mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [{ id: '1', quantity: 5, fileIds: ['9007199254741001'], receivedAt: '2026-10-10T10:00:00' }], outbounds: [] }) vi.mocked(getProcurementWorkflow).mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, rejectionReason: null, photosNeedUpdate: false, receipts: [{ id: '1', quantity: 5, fileIds: ['9007199254741001'], receivedAt: '2026-10-10T10:00:00' }], outbounds: [] })
let rejectDownload!: (error: Error) => void let rejectDownload!: (error: Error) => void
vi.mocked(downloadProcurementFile).mockImplementation(() => new Promise((_resolve, reject) => { rejectDownload = reject })) vi.mocked(downloadProcurementFile).mockImplementation(() => new Promise((_resolve, reject) => { rejectDownload = reject }))
const wrapper = mountPage(PurchaseOrderDetailView) const wrapper = mountPage(PurchaseOrderDetailView)
@@ -209,6 +238,97 @@ describe('overseas business mounted pages', () => {
}) })
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('收货中'); expect(wrapper.text()).toContain('2026-08-01 10:00') }) it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('收货中'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it.each([
{ status: 'REJECTED' as const, label: '已拒绝' },
{ status: 'CANCELLED' as const, label: '已取消' },
])('filters procurement orders by $status and resets to all statuses', async ({ status, label }) => {
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [{ ...purchase, status }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage(PurchaseOrdersView)
await flushPromises()
const statusSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部状态')!
expect(statusSelect.findAllComponents({ name: 'ElOption' }).map(option => ({ value: option.props('value'), label: option.props('label') }))).toEqual(expect.arrayContaining([
{ value: 'REJECTED', label: '已拒绝' }, { value: 'CANCELLED', label: '已取消' },
]))
statusSelect.vm.$emit('update:modelValue', status)
await wrapper.vm.$nextTick()
await wrapper.findAll('button').find(button => button.text() === '查询')!.trigger('click')
await flushPromises()
expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ status }))
expect(wrapper.find('.el-table__body .el-tag').text()).toBe(label)
await wrapper.findAll('button').find(button => button.text() === '重置')!.trigger('click')
await flushPromises()
expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ status: '' }))
})
it('reviews pending procurement from the list, submits rejection as remark, and omits edit', async () => {
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [{ ...purchase, status: 'PENDING_REVIEW' }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage(PurchaseOrdersView); await flushPromises()
expect(wrapper.text()).not.toContain('编辑')
await wrapper.findAll('button').find(button => button.text() === '审核')!.trigger('click'); await flushPromises()
const dialog = wrapper.findComponent(AppDialog), form = wrapper.findComponent({ name: 'ElForm' }).props('model') as { decision: string; remark: string }
form.decision = 'REJECT'; form.remark = '商品型号与要求不符'; dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ version: 3, decision: 'REJECT', remark: '商品型号与要求不符' }))
expect(submitProcurementCommand).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), expect.objectContaining({ reason: expect.anything() }))
})
it('keeps the current procurement warehouses when an earlier order resolves after the new order', async () => {
const { dialog, first, second, openSecond } = await mountPurchaseReviewRequests()
await openSecond()
second.resolve([{ warehouseId: 'W-B', warehouseCode: 'B', warehouseName: 'B 仓', countryCode: 'CN' }])
await flushPromises()
first.resolve([{ warehouseId: 'W-A', warehouseCode: 'A', warehouseName: 'A 仓', countryCode: 'US' }])
await flushPromises()
expect(dialog.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['W-B'])
const form = dialog.findComponent({ name: 'ElForm' }).props('model') as { purchaseWarehouseId: string }
form.purchaseWarehouseId = 'W-B'
dialog.vm.$emit('confirm'); await flushPromises()
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-B', purchase.customerId, 'review', expect.objectContaining({ purchaseWarehouseId: 'W-B', version: purchase.version }))
})
it.each(['success', 'failure'])('ignores an old procurement warehouse %s while the current request is still loading', async outcome => {
const notification = vi.spyOn(ElMessage, 'error')
try {
const { dialog, first, second, openSecond } = await mountPurchaseReviewRequests()
await openSecond()
if (outcome === 'success') first.resolve([{ warehouseId: 'W-A', warehouseCode: 'A', warehouseName: 'A 仓', countryCode: 'US' }])
else first.reject(new Error('A 仓库查询失败'))
await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(dialog.findComponent({ name: 'ElSelect' }).props('loading')).toBe(true)
expect(dialog.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
expect(notification).not.toHaveBeenCalled()
second.resolve([{ warehouseId: 'W-B', warehouseCode: 'B', warehouseName: 'B 仓', countryCode: 'CN' }])
await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
expect(dialog.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['W-B'])
} finally { notification.mockRestore() }
})
it.each(['close', 'unmount', 'account'])('drops late procurement warehouse failures after %s', async action => {
const notification = vi.spyOn(ElMessage, 'error')
try {
const { wrapper, dialog, first } = await mountPurchaseReviewRequests()
if (action === 'close') dialog.vm.$emit('update:modelValue', false)
else if (action === 'unmount') wrapper.unmount()
else { const auth = useAuthStore(); auth.user = { ...auth.user!, id: 'other-operator' } }
await flushPromises()
first.reject(new Error('已离开的订单查询失败'))
await flushPromises()
expect(notification).not.toHaveBeenCalled()
if (action !== 'unmount') {
expect(dialog.props('modelValue')).toBe(false)
expect(dialog.props('confirmDisabled')).toBe(false)
}
} finally { notification.mockRestore() }
})
it('still reports a current procurement warehouse failure and releases its loading state', async () => {
const notification = vi.spyOn(ElMessage, 'error')
try {
const { dialog, first } = await mountPurchaseReviewRequests()
first.reject(new Error('当前仓库查询失败'))
await flushPromises()
expect(notification).toHaveBeenCalledOnce()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.props('confirmDisabled')).toBe(false)
expect(dialog.findComponent({ name: 'ElSelect' }).props('loading')).toBe(false)
} finally { notification.mockRestore() }
})
it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => { it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => {
const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' } const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' }
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 })
@@ -287,7 +407,7 @@ describe('overseas business mounted pages', () => {
? ['B点取货地', 'C点送达地', 'B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话', 'B点取货地址', 'C点送达地址'] ? ['B点取货地', 'C点送达地', 'B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话', 'B点取货地址', 'C点送达地址']
: ['起运地', '目的地', '起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址'] : ['起运地', '目的地', '起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址']
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual(['客户', ...(domain === 'truck' ? ['车辆类型'] : []), ...routeLabels]) expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual(['客户', ...(domain === 'truck' ? ['车辆类型'] : []), ...routeLabels])
Object.assign(model, { vehicleType: 'PALLET_15', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: 0, currency: 'CNY' }) Object.assign(model, { vehicleType: 'PALLET_15', estimatedPalletCount: domain === 'truck' ? 2 : 0, origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: domain === 'truck' ? 100 : 0, currency: 'CNY' })
const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')! const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')!
await submit.trigger('click'); await flushPromises() await submit.trigger('click'); await flushPromises()
expect(createUsTruckOrder).not.toHaveBeenCalled(); expect(createLocalLooseboardOrder).not.toHaveBeenCalled() expect(createUsTruckOrder).not.toHaveBeenCalled(); expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
@@ -297,24 +417,28 @@ describe('overseas business mounted pages', () => {
expect(command).toHaveBeenCalledOnce() expect(command).toHaveBeenCalledOnce()
const [customer, payload] = command.mock.calls[0]! const [customer, payload] = command.mock.calls[0]!
expect(customer).toBe('10001') expect(customer).toBe('10001')
expect(payload).toEqual(expect.objectContaining({ origin: 'LA', destination: 'ONT', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', totalWeightKg: 0, totalVolumeCbm: 0, budgetAmount: 0, currency: 'CNY', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00' })) expect(payload).toEqual(expect.objectContaining({ origin: 'LA', destination: 'ONT', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', totalWeightKg: 0, totalVolumeCbm: 0, budgetAmount: domain === 'truck' ? 100 : 0, currency: 'CNY', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00' }))
if (domain === 'truck') { expect(payload).toMatchObject({ palletCount: 2 }); expect(payload).not.toHaveProperty('packageCount') }
for (const field of ['customerId', 'tenantId', 'orderNo', 'status', 'transportMode', 'containerType']) expect(payload).not.toHaveProperty(field) for (const field of ['customerId', 'tenantId', 'orderNo', 'status', 'transportMode', 'containerType']) expect(payload).not.toHaveProperty(field)
}) })
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('15拖'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() }) it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('15拖'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
it('opens the requested truck review from the list using its customer scope', async () => { it('shows a truck detail entry instead of review and edit and keeps its customer scope', async () => {
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises() const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
const auth = useAuthStore(); auth.permissions.push('platform:overseas-business:truck:status') const auth = useAuthStore(); auth.permissions.push('platform:overseas-business:truck:status')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const review = wrapper.findAll('button').find(button => button.text() === '审核') const actions = wrapper.findAll('.el-table__body-wrapper button')
expect(review).toBeDefined(); await review!.trigger('click'); await flushPromises() expect(actions.map(button => button.text())).toContain('详情')
expect(actions.map(button => button.text())).not.toContain('审核')
expect(actions.map(button => button.text())).not.toContain('编辑')
await actions.find(button => button.text() === '详情')!.trigger('click'); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-truck-detail', params: { orderId: 'TK-200' }, query: { customerId: '10002' } }) expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-truck-detail', params: { orderId: 'TK-200' }, query: { customerId: '10002' } })
}) })
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') }) it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) }) it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) })
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() }) it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() }) it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() }) it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const model = wrapper.findComponent({ name: 'ElForm' }).props('model'); Object.assign(model, { estimatedPalletCount: 2, budgetAmount: 100 }); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['B点联系人', 'C点联系人', 'B点联系电话', 'C点联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() }) it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, estimatedPalletCount: 2 })) }) it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, estimatedPalletCount: 2 })) })
it('requires split contacts and both local times, rejecting reversed times', async () => { it('requires split contacts and both local times, rejecting reversed times', async () => {
@@ -353,3 +477,4 @@ describe('overseas business mounted pages', () => {
}) })
}) })
@@ -5,7 +5,6 @@ import { computed, onActivated, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue' import { ArrowLeft } from '@lucide/vue'
import { useAuthStore } from '@/core/auth/auth' import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue' import StatusBadge from '@/shared/components/StatusBadge.vue'
@@ -18,12 +17,11 @@ import { downloadProcurementFile } from '../api/procurementCommands'
import { getProcurementWorkflow, type ProcurementWorkflow } from '../api/procurementWorkflow' import { getProcurementWorkflow, type ProcurementWorkflow } from '../api/procurementWorkflow'
import ProcurementWorkflowPanel from '../components/ProcurementWorkflowPanel.vue' import ProcurementWorkflowPanel from '../components/ProcurementWorkflowPanel.vue'
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue' import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
import { overseasBusinessPermissions } from '../permissions'
import { statusLabel, money, dateTime, text } from '../procurementPresentation' import { statusLabel, money, dateTime, text } from '../procurementPresentation'
import type { PlatformProcurement } from '../procurementTypes' import type { PlatformProcurement } from '../procurementTypes'
import { directionLabel } from '../procurementDirection' import { directionLabel } from '../procurementDirection'
import { hasProcurementFeeDetails } from '../procurementOutboundFees' import { hasProcurementFeeDetails } from '../procurementOutboundFees'
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const access = useAccessPreviewStore() const route = useRoute(); const router = useRouter(); const auth = useAuthStore()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '') const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '') const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const detail = ref<PlatformProcurement | null>(null), workflow = ref<ProcurementWorkflow | null>(null), loading = ref(false) const detail = ref<PlatformProcurement | null>(null), workflow = ref<ProcurementWorkflow | null>(null), loading = ref(false)
@@ -40,7 +38,8 @@ const fields = computed(() => [
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['规格', order.value?.productSpecification], ['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['规格', order.value?.productSpecification],
['采购数量', order.value?.purchaseQuantity], ['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['预计到仓', dateTime(order.value?.estimatedArrivalAt)], ['采购数量', order.value?.purchaseQuantity], ['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['预计到仓', dateTime(order.value?.estimatedArrivalAt)],
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['收货地址', order.value?.receivingAddress], ['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['收货地址', order.value?.receivingAddress],
...(detail.value?.internalRemark ? [['内部备注', detail.value.internalRemark]] : []), ...(order.value?.remark ? [['采购要求', order.value.remark]] : []),
...(detail.value?.internalRemark || order.value?.remark ? [['采购备注', detail.value?.internalRemark || order.value?.remark]] : []),
]) ])
let generation = 0, inFlight: Promise<void> | null = null, inFlightScope = '' let generation = 0, inFlight: Promise<void> | null = null, inFlightScope = ''
const scope = computed(() => `${auth.user?.id || ''}:${customerId.value}:${purchaseId.value}`) const scope = computed(() => `${auth.user?.id || ''}:${customerId.value}:${purchaseId.value}`)
@@ -78,7 +77,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<template> <template>
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="operationError" /> <AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="operationError" />
<DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单详情"> <DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="workflow" :status="statusLabel(workflow.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑备注</el-button></div></div></template> <template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="workflow" :status="statusLabel(workflow.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button></div></div></template>
<LoadErrorState v-if="loadError" title="采购详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState> <LoadErrorState v-if="loadError" title="采购详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="detail-grid"> <div v-else v-loading="loading" class="detail-grid">
<template v-if="detail && order && workflow"> <template v-if="detail && order && workflow">
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, onActivated, onMounted, onBeforeUnmount, ref, watch } from 'vue' import { computed, onActivated, onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/core/auth/auth' import { useAuthStore } from '@/core/auth/auth'
import { procurementStatuses, statusLabel, statusTone, money } from '../procurementPresentation' import { procurementStatuses, statusLabel, statusTone, money } from '../procurementPresentation'
@@ -11,9 +12,11 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOverseasPurchaseCustomerOptions, listOverseasPurchases } from '../api/platformOverseasBusiness' import { listOverseasPurchaseCustomerOptions, listOverseasPurchaseWarehouseOptions, listOverseasPurchases } from '../api/platformOverseasBusiness'
import { submitProcurementCommand } from '../api/procurementCommands'
import { overseasBusinessPermissions } from '../permissions' import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types' import type { OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
@@ -23,6 +26,12 @@ const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); con
const page = ref(1); const pageSize = ref(20); const total = ref(0) const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = ref<QueryModel>({ orderNo: '', customerId: '', purchaseCategory: '', productName: '', status: '' }); const applied = ref({ ...filters.value }) const filters = ref<QueryModel>({ orderNo: '', customerId: '', purchaseCategory: '', productName: '', status: '' }); const applied = ref({ ...filters.value })
const customerOptions = ref<OverseasPurchaseCustomerOption[]>([]); const customersLoading = ref(false) const customerOptions = ref<OverseasPurchaseCustomerOption[]>([]); const customersLoading = ref(false)
const reviewOpen = ref(false), reviewSaving = ref(false), reviewLoading = ref(false)
const reviewRow = ref<OverseasPurchaseSummary | null>(null), reviewWarehouses = ref<Awaited<ReturnType<typeof listOverseasPurchaseWarehouseOptions>>>([])
const reviewRequestKey = ref('')
const reviewForm = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', purchaseWarehouseId: '', remark: '' })
let reviewGeneration = 0
watch(reviewOpen, value => { if (!value) invalidateReviewRequest() }, { flush: 'sync' })
const fields = computed<QueryField[]>(() => [ const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 }, { key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } }, { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
@@ -31,7 +40,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) }, { key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
]) ])
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 120, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false },
{ key: 'status', label: '状态', minWidth: 145, slotName: 'status', afterColumn: 'orderNo' }, { key: 'status', label: '状态', minWidth: 145, slotName: 'status', afterColumn: 'orderNo' },
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' }, { key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
@@ -46,7 +55,7 @@ const columns: DataTableColumn[] = [
] ]
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
let generation = 0; let inFlight: Promise<void> | null = null; let requestKey = '' let generation = 0; let inFlight: Promise<void> | null = null; let requestKey = ''
onBeforeUnmount(() => { generation++ }) onBeforeUnmount(() => { generation++; invalidateReviewRequest() })
function load() { function load() {
const query: OverseasPurchaseQuery = { page: page.value, size: pageSize.value, customerId: String(applied.value.customerId || ''), orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || ''), status: String(applied.value.status || '') as OverseasPurchaseStatus | '' } const query: OverseasPurchaseQuery = { page: page.value, size: pageSize.value, customerId: String(applied.value.customerId || ''), orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || ''), status: String(applied.value.status || '') as OverseasPurchaseStatus | '' }
const key = JSON.stringify([auth.user?.id, query]) const key = JSON.stringify([auth.user?.id, query])
@@ -62,9 +71,38 @@ function load() {
}) })
inFlight = promise; return promise inFlight = promise; return promise
} }
watch(() => auth.user?.id, () => { rows.value = []; total.value = 0; void load() }) watch(() => auth.user?.id, () => { reviewOpen.value = false; rows.value = []; total.value = 0; void load() })
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 } function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function open(row: OverseasPurchaseSummary, edit = false) { void router.push({ name: edit ? 'overseas-business-purchase-edit' : 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) } function open(row: OverseasPurchaseSummary) { void router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
function invalidateReviewRequest() {
reviewGeneration++
reviewLoading.value = false; reviewWarehouses.value = []; reviewRow.value = null
}
async function beginReview(row: OverseasPurchaseSummary) {
if (reviewSaving.value) return
const current = ++reviewGeneration; const owner = auth.user?.id
reviewRow.value = row; reviewWarehouses.value = []; reviewRequestKey.value = crypto.randomUUID(); Object.assign(reviewForm, { decision: 'APPROVE', purchaseWarehouseId: '', remark: '' }); reviewOpen.value = true
reviewLoading.value = true
// 关闭、切换订单或操作员后,旧请求的结果、提示和结束回调均不得影响当前审核。
const isCurrent = () => current === reviewGeneration && reviewOpen.value && owner === auth.user?.id
try {
const warehouses = await listOverseasPurchaseWarehouseOptions(row.procurementDirection)
if (isCurrent()) reviewWarehouses.value = warehouses
} catch (error) { if (isCurrent()) ElMessage.error(getApiErrorDetails(error, '仓库加载失败').message) }
finally { if (isCurrent()) reviewLoading.value = false }
}
async function submitReview() {
const row = reviewRow.value
if (!row || reviewSaving.value || reviewLoading.value || !reviewOpen.value) return
if (reviewForm.decision === 'APPROVE' && !reviewForm.purchaseWarehouseId) { ElMessage.warning('请选择采购仓库'); return }
if (reviewForm.decision === 'REJECT' && !reviewForm.remark.trim()) { ElMessage.warning('请填写驳回原因'); return }
reviewSaving.value = true
try {
await submitProcurementCommand(row.orderNo, row.customerId, 'review', { version: row.version, idempotencyKey: reviewRequestKey.value, decision: reviewForm.decision, ...(reviewForm.decision === 'APPROVE' ? { purchaseWarehouseId: reviewForm.purchaseWarehouseId } : { remark: reviewForm.remark.trim() }) })
reviewOpen.value = false; ElMessage.success('审核完成'); await load()
} catch (error) { ElMessage.error(getApiErrorDetails(error, '审核失败').message) }
finally { reviewSaving.value = false }
}
async function loadCustomerOptions() { customersLoading.value = true; try { customerOptions.value = await listOverseasPurchaseCustomerOptions() } catch { customerOptions.value = [] } finally { customersLoading.value = false } } async function loadCustomerOptions() { customersLoading.value = true; try { customerOptions.value = await listOverseasPurchaseCustomerOptions() } catch { customerOptions.value = [] } finally { customersLoading.value = false } }
let activatedOnce = false let activatedOnce = false
watch([page, pageSize], () => { void load() }); onMounted(() => { void load(); void loadCustomerOptions() }) watch([page, pageSize], () => { void load() }); onMounted(() => { void load(); void loadCustomerOptions() })
@@ -81,10 +119,17 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<template #notices><template v-if="loadError"><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template> <template #notices><template v-if="loadError"><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as OverseasPurchaseSummary)">详情</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(overseasBusinessPermissions.purchaseEdit)" size="small" @click="open(row as OverseasPurchaseSummary, true)">编辑</el-button></div></template> <template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as OverseasPurchaseSummary)">详情</el-button><el-button v-if="(row as OverseasPurchaseSummary).status === 'PENDING_REVIEW' && access.can(overseasBusinessPermissions.purchaseReview)" class="density-action" type="primary" size="small" @click="beginReview(row as OverseasPurchaseSummary)">审核</el-button></div></template>
<template #status="{ row }"><el-tag :type="statusTone(row.status)" size="small" effect="light">{{ statusLabel(row.status, row.procurementDirection) }}</el-tag></template> <template #status="{ row }"><el-tag :type="statusTone(row.status)" size="small" effect="light">{{ statusLabel(row.status, row.procurementDirection) }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条海外采购订单</template> <template #pagination-left>共 {{ total }} 条海外采购订单</template>
</DataTable></div></section> </DataTable></div></section>
<AppDialog v-model="reviewOpen" class="admin-density-dialog admin-workbench is-dialog" title="采购审核" width="560px" confirm-text="提交审核" :confirm-loading="reviewSaving" :confirm-disabled="reviewLoading" @confirm="submitReview">
<el-form :model="reviewForm" label-position="top" class="density-form density-form--outlined">
<el-form-item label="审核结果"><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="reviewForm.decision === 'APPROVE'" label="采购仓库" required><el-select v-model="reviewForm.purchaseWarehouseId" filterable :loading="reviewLoading" placeholder="请选择采购仓库"><el-option v-for="warehouse in reviewWarehouses" :key="warehouse.warehouseId" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseId" /></el-select></el-form-item>
<el-form-item v-else label="驳回原因" required><el-input v-model="reviewForm.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item>
</el-form>
</AppDialog>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
</template> </template>
@@ -60,11 +60,11 @@ async function load() {
if (isLooseboard.value) { if (isLooseboard.value) {
const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value) const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value)
fleetArrangement.value = detail.fleetArrangement fleetArrangement.value = detail.fleetArrangement
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, productName: detail.productName || '', estimatedPalletCount: detail.estimatedPalletCount ?? 0, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', remark: detail.remark || '', status: detail.status, version: detail.version }) Object.assign(form, { deliveryMode: detail.deliveryMode || 'DELIVERY_TO_WAREHOUSE', operatingWarehouseId: detail.operatingWarehouseId || '', destinationType: detail.destinationType || 'PRIVATE', destinationPostalCode: detail.destinationPostalCode || '', deliveryContactName: detail.deliveryContactName || '', deliveryContactPhone: detail.deliveryContactPhone || '', estimatedArrivalAt: detail.estimatedArrivalAt || '', trackingNo: detail.trackingNo || '', customerId: detail.customerId, orderNo: detail.orderNo, productName: detail.productName || '', estimatedPalletCount: detail.estimatedPalletCount ?? 0, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', remark: detail.remark || '', status: detail.status, version: detail.version })
} else { } else {
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value) const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
fleetArrangement.value = detail.fleetArrangement fleetArrangement.value = detail.fleetArrangement
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', vehicleType: detail.vehicleType || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, remark: detail.remark || '', status: detail.status, version: detail.version }) Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', vehicleType: detail.vehicleType || '', estimatedPalletCount: detail.palletCount, originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, remark: detail.remark || '', status: detail.status, version: detail.version })
} }
captureBaseline() captureBaseline()
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单详情加载失败`) } finally { loading.value = false } } catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单详情加载失败`) } finally { loading.value = false }
@@ -5,9 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth' import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import RowActions from '@/shared/components/RowActions.vue' import RowActions from '@/shared/components/RowActions.vue'
import type { DataTableColumn } from '@/shared/components/DataTable.vue'
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness' import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions as permissions } from '../permissions' import { overseasBusinessPermissions as permissions } from '../permissions'
import type { LocalLooseboardOrder } from '../types' import type { LocalLooseboardOrder, LocalTransportOrder } from '../types'
import TransportOrdersView from './TransportOrdersView.vue' import TransportOrdersView from './TransportOrdersView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { query: {} } })) const routing = vi.hoisted(() => ({ push: vi.fn(), route: { query: {} } }))
@@ -30,7 +31,7 @@ function mountList(domain: 'truck' | 'looseboard', edit = false) {
auth.permissions = [permissions.truckList, permissions.looseboardList, ...(edit ? [permissions.looseboardEdit] : [])] auth.permissions = [permissions.truckList, permissions.looseboardList, ...(edit ? [permissions.looseboardEdit] : [])]
useAccessPreviewStore().initializeAfterLogin() useAccessPreviewStore().initializeAfterLogin()
return mount(TransportOrdersView, { props: { domain }, global: { plugins: [pinia, ElementPlus], stubs: { return mount(TransportOrdersView, { props: { domain }, global: { plugins: [pinia, ElementPlus], stubs: {
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.id"><slot name="actions" :row="row" /></div></div>' }, DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.id"><span v-for="column in columns" :key="column.key" :data-column="column.key">{{ column.prop ? row[column.prop] : \'\' }}</span><slot name="actions" :row="row" /></div></div>' },
AppDialog: true, AppDialog: true,
} } }) } } })
} }
@@ -52,7 +53,7 @@ describe('transport document entry', () => {
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-looseboard-detail', params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } }) expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-looseboard-detail', params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } })
}) })
it('preserves the permitted looseboard edit entry alongside its detail', async () => { it('lets authorized users complete missing receiving information before review', async () => {
const wrapper = mountList('looseboard', true); await flushPromises() const wrapper = mountList('looseboard', true); await flushPromises()
const actions = wrapper.findComponent(RowActions) const actions = wrapper.findComponent(RowActions)
expect(actions.text()).toContain('编辑') expect(actions.text()).toContain('编辑')
@@ -65,4 +66,34 @@ describe('transport document entry', () => {
expect(listUsTruckOrders).toHaveBeenCalledOnce() expect(listUsTruckOrders).toHaveBeenCalledOnce()
expect(listLocalLooseboardOrders).not.toHaveBeenCalled() expect(listLocalLooseboardOrders).not.toHaveBeenCalled()
}) })
it('displays truck pallet counts instead of the unused package count', async () => {
const truck: LocalTransportOrder = {
id: '9007199254741001', customerId: '9007199254741999', orderNo: 'TR-TEST', fleetArrangement: null,
vehicleType: 'PALLET_15', origin: 'LA', destination: 'ONT', palletCount: 3, packageCount: 0,
receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0,
totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
originContactName: null, originContactPhone: null, destinationContactName: null, destinationContactPhone: null,
pickupAt: null, deliveryAt: null, status: 'PENDING_REVIEW', version: 1, createdAt: '', updatedAt: '',
}
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [truck], total: 1, page: 1, size: 20, pages: 1 })
const wrapper = mountList('truck'); await flushPromises()
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as DataTableColumn[]
expect(columns.find(column => column.key === 'palletCount')).toMatchObject({ label: '托数', prop: 'palletCount' })
expect(columns.some(column => column.key === 'packageCount')).toBe(false)
expect(wrapper.get('[data-column="palletCount"]').text()).toBe('3')
const actions = wrapper.findComponent(RowActions)
expect(actions.text()).toContain('详情')
expect(actions.text()).not.toContain('审核')
expect(actions.text()).not.toContain('编辑')
})
it('preserves looseboard package counts and forecast pallet counts', async () => {
const wrapper = mountList('looseboard'); await flushPromises()
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as DataTableColumn[]
expect(columns.find(column => column.key === 'packageCount')).toMatchObject({ label: '件数', prop: 'packageCount' })
expect(columns.find(column => column.key === 'estimatedPalletCount')).toMatchObject({ label: '预计托数', prop: 'estimatedPalletCount' })
expect(wrapper.get('[data-column="packageCount"]').text()).toBe('20')
expect(wrapper.get('[data-column="estimatedPalletCount"]').text()).toBe('2')
})
}) })
@@ -15,6 +15,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { truckStatusLabels } from '../truckWorkflow' import { truckStatusLabels } from '../truckWorkflow'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue' import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import LooseboardReviewDialog from '../components/LooseboardReviewDialog.vue'
import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement' import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -23,6 +24,7 @@ import { overseasBusinessPermissions } from '../permissions'
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType } from '../types' import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>() const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const reviewOpen = ref(false), reviewOrder = ref<LocalLooseboardOrder | null>(null)
const routeOpen = ref(false) const routeOpen = ref(false)
const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([]) const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
function openRoute(row: LocalTransportOrder | LocalLooseboardOrder) { function openRoute(row: LocalTransportOrder | LocalLooseboardOrder) {
@@ -34,7 +36,7 @@ const route = useRoute(); const router = useRouter(); const access = useAccessPr
const isLooseboard = computed(() => props.domain === 'looseboard'); const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车') const isLooseboard = computed(() => props.domain === 'looseboard'); const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
const queryPreferenceKey = computed(() => isLooseboard.value ? 'overseas-business-looseboard-query' : 'overseas-business-truck-query') const queryPreferenceKey = computed(() => isLooseboard.value ? 'overseas-business-looseboard-query' : 'overseas-business-truck-query')
const createPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardCreate : overseasBusinessPermissions.truckCreate) const createPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardCreate : overseasBusinessPermissions.truckCreate)
const editPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.truckEdit) const editPermission = computed(() => overseasBusinessPermissions.truckEdit)
const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null) const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1); const pageSize = ref(20); const total = ref(0) const page = ref(1); const pageSize = ref(20); const total = ref(0)
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null) const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
@@ -44,7 +46,7 @@ const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
const statusLabels = computed<Record<string, string>>(() => isLooseboard.value ? looseboardStatusLabels : truckStatusLabels) const statusLabels = computed<Record<string, string>>(() => isLooseboard.value ? looseboardStatusLabels : truckStatusLabels)
const statusTones: Record<LocalTransportStatus | LocalLooseboardStatus, 'warning' | 'primary' | 'success' | 'danger' | 'info'> = { const statusTones: Record<LocalTransportStatus | LocalLooseboardStatus, 'warning' | 'primary' | 'success' | 'danger' | 'info'> = {
PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', WAITING_INBOUND: 'warning', FUNDING_PENDING: 'warning', FUNDING_FAILED: 'danger', PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', WAITING_INBOUND: 'warning',
RECEIVING: 'primary', WAITING_CONSOLIDATION: 'warning', PALLET_OVERAGE_REVIEW: 'warning', RECEIVING: 'primary', WAITING_CONSOLIDATION: 'warning', PALLET_OVERAGE_REVIEW: 'warning',
PARTIALLY_ALLOCATED: 'primary', ALLOCATED: 'primary', PENDING_DISPATCH: 'warning', PARTIALLY_ALLOCATED: 'primary', ALLOCATED: 'primary', PENDING_DISPATCH: 'warning',
DISPATCHED: 'primary', IN_TRANSIT: 'primary', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info', DISPATCHED: 'primary', IN_TRANSIT: 'primary', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info',
@@ -60,7 +62,7 @@ const truckColumns: DataTableColumn[] = [
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
{ key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 }, { key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
{ key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 }, { key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 },
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` }, { key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
{ key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.pickupAt) }, { key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.pickupAt) },
{ key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.deliveryAt) }, { key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.deliveryAt) },
@@ -104,7 +106,7 @@ async function load() {
} }
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 } function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) } function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') } function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' && !(row as LocalLooseboardOrder).deliveryMode : row.status === 'PENDING_REVIEW') }
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) } function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
/** 单据详情保留订单与客户范围,费用在原业务单中处理。 */ /** 单据详情保留订单与客户范围,费用在原业务单中处理。 */
function openDetail(row: LocalTransportOrder | LocalLooseboardOrder) { function openDetail(row: LocalTransportOrder | LocalLooseboardOrder) {
@@ -114,19 +116,20 @@ function openDetail(row: LocalTransportOrder | LocalLooseboardOrder) {
function looseboardActions(row: LocalLooseboardOrder): RowActionItem[] { function looseboardActions(row: LocalLooseboardOrder): RowActionItem[] {
return [ return [
{ key: 'detail', label: '详情', priority: 10 }, { key: 'detail', label: '详情', priority: 10 },
{ key: 'edit', label: '编辑', visible: !row.fleetArrangement && access.can(editPermission.value), disabled: !canEdit(row) }, { key: 'review', label: '审核', visible: row.status === 'PENDING_ACCEPTANCE' && access.can(overseasBusinessPermissions.looseboardStatus), priority: 20 },
{ key: 'edit', label: '编辑', visible: row.status === 'PENDING_ACCEPTANCE' && !row.deliveryMode && access.can(overseasBusinessPermissions.looseboardEdit) },
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement }, { key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
] ]
} }
function truckActions(row: LocalTransportOrder): RowActionItem[] { function truckActions(row: LocalTransportOrder): RowActionItem[] {
return [ return [
{ key: 'detail', label: row.status === 'PENDING_REVIEW' && !row.fleetArrangement && access.can(overseasBusinessPermissions.truckStatus) ? '审核' : '详情', priority: 10 }, { key: 'detail', label: '详情', priority: 10 },
{ key: 'edit', label: '编辑', visible: !row.fleetArrangement && access.can(editPermission.value), disabled: !canEdit(row) },
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement }, { key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
] ]
} }
function handleTruckAction(key: string, row: LocalTransportOrder | LocalLooseboardOrder) { function handleTruckAction(key: string, row: LocalTransportOrder | LocalLooseboardOrder) {
if (key === 'edit') openEdit(row) if (key === 'review' && isLooseboard.value) { reviewOrder.value=row as LocalLooseboardOrder; reviewOpen.value=true }
else if (key === 'edit') openEdit(row)
else if (key === 'arrangement') showArrangement(row) else if (key === 'arrangement') showArrangement(row)
else if (key === 'detail') openDetail(row) else if (key === 'detail') openDetail(row)
} }
@@ -136,6 +139,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
</script> </script>
<template> <template>
<LooseboardReviewDialog v-model="reviewOpen" :order="reviewOrder" @saved="load" />
<AdminPage class="admin-density admin-workbench"><ListPageLayout :aria-label="`${domainLabel}订单列表`"> <AdminPage class="admin-density admin-workbench"><ListPageLayout :aria-label="`${domainLabel}订单列表`">
@@ -68,6 +68,14 @@ describe('truck review and fulfillment', () => {
}) })
}) })
it('displays saved pallet counts instead of the unused package count', async () => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ palletCount: 3, packageCount: 0 }))
const wrapper = mountDetail(); await flushPromises()
const facts = new Map(wrapper.findAll('.truck-detail-fact').map(fact => [fact.get('dt').text(), fact.get('dd').text()]))
expect(facts.get('托数')).toBe('3')
expect(facts.has('件数')).toBe(false)
})
it('groups the business number, single status and actions in the shared detail header', async () => { it('groups the business number, single status and actions in the shared detail header', async () => {
const wrapper = mountDetail(); await flushPromises() const wrapper = mountDetail(); await flushPromises()
const header = wrapper.find('.detail-page-layout__header') const header = wrapper.find('.detail-page-layout__header')
@@ -42,7 +42,7 @@ const fields = computed(() => detail.value ? [
['取货联系人', detail.value.originContactName], ['取货联系电话', detail.value.originContactPhone], ['取货联系人', detail.value.originContactName], ['取货联系电话', detail.value.originContactPhone],
['送达地', detail.value.destination], ['送达地址', detail.value.destinationAddress], ['送达地', detail.value.destination], ['送达地址', detail.value.destinationAddress],
['送达联系人', detail.value.destinationContactName], ['送达联系电话', detail.value.destinationContactPhone], ['送达联系人', detail.value.destinationContactName], ['送达联系电话', detail.value.destinationContactPhone],
['件数', detail.value.packageCount], ['重量(kg)', detail.value.totalWeightKg], ['体积(m³)', detail.value.totalVolumeCbm], ['托数', detail.value.palletCount], ['重量(kg)', detail.value.totalWeightKg], ['体积(m³)', detail.value.totalVolumeCbm],
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`], ['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)], ['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark], ['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
@@ -97,7 +97,7 @@ watch([orderNo, customerId], load, { immediate: true })
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /><p class="truck-detail-note">取货和送达照片请在排车任务中上传。</p></section> <section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /><p class="truck-detail-note">取货和送达照片请在排车任务中上传。</p></section>
<section class="truck-detail-section truck-flow-section" aria-label="运输流程"> <section class="truck-detail-section truck-flow-section" aria-label="运输流程">
<div class="truck-section-heading"><h2>运输流程</h2><span class="truck-flow-caption">审核通过后由排车单推进取货、派送和送达</span></div> <div class="truck-section-heading"><h2>运输流程</h2></div>
<ol class="truck-flow"> <ol class="truck-flow">
<li v-for="(step, index) in flowSteps" :key="step" :class="{ active: index === flowIndex, done: index < flowIndex || detail.status === 'COMPLETED' }"> <li v-for="(step, index) in flowSteps" :key="step" :class="{ active: index === flowIndex, done: index < flowIndex || detail.status === 'COMPLETED' }">
<span class="truck-flow-marker"><Check v-if="index < flowIndex || detail.status === 'COMPLETED'" :size="14" /><MapPin v-else-if="index === 2 || index === 4" :size="14" /><span v-else>{{ index + 1 }}</span></span> <span class="truck-flow-marker"><Check v-if="index < flowIndex || detail.status === 'COMPLETED'" :size="14" /><MapPin v-else-if="index === 2 || index === 4" :size="14" /><span v-else>{{ index + 1 }}</span></span>
@@ -106,9 +106,9 @@ watch([orderNo, customerId], load, { immediate: true })
</ol> </ol>
</section> </section>
<section class="truck-detail-section"><div class="truck-section-heading"><h2>订单资料</h2><span class="truck-flow-caption">基础信息与货量</span></div><dl class="truck-detail-grid"><div v-for="[label, value] in fields" :key="String(label)" class="truck-detail-fact"><dt>{{ label }}</dt><dd>{{ value ?? '—' }}</dd></div></dl></section> <section class="truck-detail-section"><div class="truck-section-heading"><h2>订单资料</h2></div><dl class="truck-detail-grid"><div v-for="[label, value] in fields" :key="String(label)" class="truck-detail-fact"><dt>{{ label }}</dt><dd>{{ value ?? '—' }}</dd></div></dl></section>
<section class="truck-detail-section"><BusinessDocumentFees business-type="US_TRUCK" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section> <section class="truck-detail-section"><BusinessDocumentFees business-type="US_TRUCK" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
<section class="truck-detail-section"><div class="truck-section-heading"><h2>审核与操作记录</h2><span class="truck-flow-caption">保留关键节点</span></div><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section> <section class="truck-detail-section"><div class="truck-section-heading"><h2>审核与操作记录</h2></div><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template> </template>
</DetailPageLayout></AdminPage> </DetailPageLayout></AdminPage>
</template> </template>
@@ -121,7 +121,7 @@ watch([orderNo, customerId], load, { immediate: true })
.truck-detail-section { margin-bottom: var(--admin-space-6); } .truck-detail-section { margin-bottom: var(--admin-space-6); }
.truck-section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; } .truck-section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.truck-section-heading h2 { margin: 0; font-size: var(--admin-font-section); color: var(--cc-text); } .truck-section-heading h2 { margin: 0; font-size: var(--admin-font-section); color: var(--cc-text); }
.truck-flow-caption, .truck-detail-note { color: var(--cc-muted); font-size: var(--admin-font-caption); } .truck-detail-note { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.truck-detail-note { margin: 10px 0 0; } .truck-detail-note { margin: 10px 0 0; }
.truck-flow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; margin: 0; padding: 0; list-style: none; } .truck-flow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; margin: 0; padding: 0; list-style: none; }
.truck-flow li { position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 0; color: var(--cc-muted); text-align: center; } .truck-flow li { position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 0; color: var(--cc-muted); text-align: center; }
@@ -2,17 +2,18 @@ import { regionText } from './procurementDirection'
import type { Amount, ProcurementFee } from './procurementTypes' import type { Amount, ProcurementFee } from './procurementTypes'
export const procurementStatuses: Record<string, string> = { export const procurementStatuses: Record<string, string> = {
PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认', FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认',
IN_TRANSIT: '运输中', DELIVERING: '派送中', COMPLETED: '签收完成', IN_TRANSIT: '运输中', DELIVERING: '派送中', COMPLETED: '签收完成',
REJECTED: '已拒绝', CANCELLED: '已取消',
} }
export const feePurposes: Record<string, string> = { export const feePurposes: Record<string, string> = {
SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费', SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费',
DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款', DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款',
} }
export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || ({ REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭' } as Record<string, string>)[status] || '待核对进度' : '—', direction) } export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || ({ EXCEPTION_CLOSED: '异常关闭' } as Record<string, string>)[status] || '待核对进度' : '—', direction) }
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' { export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
if (status === 'COMPLETED') return 'success' if (status === 'COMPLETED') return 'success'
if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger' if ((status === 'REJECTED' || status === 'FUNDING_FAILED') || status === 'EXCEPTION_CLOSED') return 'danger'
if (status === 'CANCELLED') return 'info' if (status === 'CANCELLED') return 'info'
if (status === 'IN_TRANSIT' || status === 'DELIVERING') return 'primary' if (status === 'IN_TRANSIT' || status === 'DELIVERING') return 'primary'
return 'warning' return 'warning'
@@ -21,6 +21,7 @@ export interface ProcurementOrder extends ProcurementSummary {
receivingAddress: string receivingAddress: string
contactName: string contactName: string
contactPhone: string contactPhone: string
remark?: string | null
estimatedArrivalAt: string | null estimatedArrivalAt: string | null
plannedWarehouseName: string | null plannedWarehouseName: string | null
actualWarehouseCode: string | null actualWarehouseCode: string | null
@@ -2,6 +2,14 @@ import type { FormRules } from 'element-plus'
export type TransportDemandKind = 'truck' | 'looseboard' export type TransportDemandKind = 'truck' | 'looseboard'
export interface TransportDemandForm { export interface TransportDemandForm {
deliveryMode: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
operatingWarehouseId: string
deliveryContactName: string
deliveryContactPhone: string
estimatedArrivalAt: string
trackingNo: string
destinationType: 'FBA' | 'PRIVATE'
destinationPostalCode: string
vehicleType: '' | 'PALLET_15' | 'PALLET_30' vehicleType: '' | 'PALLET_15' | 'PALLET_30'
origin: string origin: string
destination: string destination: string
@@ -25,6 +33,7 @@ export interface TransportDemandForm {
export function createTransportDemandForm(): TransportDemandForm { export function createTransportDemandForm(): TransportDemandForm {
return { return {
deliveryMode: 'DELIVERY_TO_WAREHOUSE', operatingWarehouseId: '', deliveryContactName: '', deliveryContactPhone: '', estimatedArrivalAt: '', trackingNo: '', destinationType: 'PRIVATE', destinationPostalCode: '',
vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '', vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '',
originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '', originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '',
productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0, productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0,
@@ -32,13 +41,11 @@ export function createTransportDemandForm(): TransportDemandForm {
} }
} }
const requiredText = (kind: TransportDemandKind) => ({ const requiredText = (kind: TransportDemandKind, form: TransportDemandForm) => ({
origin: kind === 'truck' ? 'B点取货地' : '起运地', ...(kind === 'truck' || form.deliveryMode === 'WAREHOUSE_PICKUP' ? { origin: kind === 'truck' ? '取货地' : '起运地', originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人', originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话' } : {}),
destination: kind === 'truck' ? 'C点送达地' : '目的地', destination: kind === 'truck' ? '送达地' : '目的地',
originContactName: kind === 'truck' ? 'B点联系人' : '起运地联系人', destinationContactName: kind === 'truck' ? '送达联系人' : '目的地联系人',
originContactPhone: kind === 'truck' ? 'B点联系电话' : '起运地联系电话', destinationContactPhone: kind === 'truck' ? '送达联系电话' : '目的地联系电话',
destinationContactName: kind === 'truck' ? 'C点联系人' : '目的地联系人',
destinationContactPhone: kind === 'truck' ? 'C点联系电话' : '目的地联系电话',
} as const) } as const)
export function localMinuteInput(value: unknown) { export function localMinuteInput(value: unknown) {
@@ -47,17 +54,28 @@ export function localMinuteInput(value: unknown) {
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> { export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
const rules: FormRules<TransportDemandForm> = {} const rules: FormRules<TransportDemandForm> = {}
const labels = requiredText(kind) const labels = requiredText(kind, form)
for (const [field, label] of Object.entries(labels)) { for (const [field, label] of Object.entries(labels)) {
rules[field as keyof typeof labels] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }] rules[field as keyof typeof labels] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
} }
if (kind === 'looseboard') {
if (form.deliveryMode === 'WAREHOUSE_PICKUP') rules.originAddress = [{ required: true, whitespace: true, message: '请输入起运详细地址', trigger: 'blur' }]
rules.operatingWarehouseId = [{ required: true, message: '请选择收货仓库', trigger: 'change' }]
if (form.destinationType === 'PRIVATE') {
rules.destinationAddress = [{ required: true, whitespace: true, message: '请输入目的详细地址', trigger: 'blur' }]
rules.destinationPostalCode = [{ required: true, whitespace: true, message: '请输入目的地邮编', trigger: 'blur' }]
}
if (form.deliveryMode === 'DELIVERY_TO_WAREHOUSE') {
for (const [field, label] of [['deliveryContactName', '到仓联系人'], ['deliveryContactPhone', '到仓联系电话'], ['estimatedArrivalAt', '预计到货时间']] as const) rules[field] = [{ required: true, message: '请填写' + label, trigger: 'change' }]
}
}
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }] if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) { for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
if (field === 'estimatedPalletCount' && kind !== 'looseboard') continue if (field === 'packageCount' && kind === 'truck') continue
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }] rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
} }
rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }] rules.budgetAmount = [{ required: kind === 'truck', type: 'number', min: kind === 'truck' ? 0.01 : 0, message: '请输入大于 0 的预算总价', trigger: 'change' }]
const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间' const pickupLabel = kind === 'truck' ? '取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? '送达时间' : '送货时间'
rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, trigger: 'change' }] rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, trigger: 'change' }]
rules.deliveryAt = [{ required: true, message: `请选择${deliveryLabel}`, trigger: 'change' }, { rules.deliveryAt = [{ required: true, message: `请选择${deliveryLabel}`, trigger: 'change' }, {
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error(`${deliveryLabel}不能早于${pickupLabel}`) : undefined), trigger: 'change', validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error(`${deliveryLabel}不能早于${pickupLabel}`) : undefined), trigger: 'change',
@@ -66,12 +84,20 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
} }
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined { export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
if (kind === 'looseboard') {
if (!form.operatingWarehouseId) return '请选择收货仓库'
if (!['DELIVERY_TO_WAREHOUSE', 'WAREHOUSE_PICKUP'].includes(form.deliveryMode)) return '请选择送货方式'
if (form.destinationType === 'PRIVATE' && (!form.destinationAddress.trim() || !form.destinationPostalCode.trim())) return '请填写目的详细地址和邮编'
if (form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' && (!form.deliveryContactName.trim() || !form.deliveryContactPhone.trim() || !form.estimatedArrivalAt)) return '送货到仓须填写仓库联系人和预计到货时间'
if (form.deliveryMode === 'WAREHOUSE_PICKUP' && !form.originAddress.trim()) return '请输入起运详细地址'
if (form.packageCount < 1) return '散板收货件数须大于 0'
}
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型' if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
const labels = requiredText(kind) const labels = requiredText(kind, form)
for (const [field, label] of Object.entries(labels)) { for (const [field, label] of Object.entries(labels)) {
if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}` if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}`
} }
const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间' const pickupLabel = kind === 'truck' ? '取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? '送达时间' : '送货时间'
for (const [field, label] of [['pickupAt', pickupLabel], ['deliveryAt', deliveryLabel]] as const) { for (const [field, label] of [['pickupAt', pickupLabel], ['deliveryAt', deliveryLabel]] as const) {
if (!form[field]) return `请选择${label}` if (!form[field]) return `请选择${label}`
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}` if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}`
@@ -81,6 +107,8 @@ export function transportDemandError(kind: TransportDemandKind, form: TransportD
if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字` if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字`
} }
if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数' if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数'
if (kind === 'truck' && (!Number.isSafeInteger(form.estimatedPalletCount) || form.estimatedPalletCount <= 0)) return '请输入大于 0 的托数'
if (kind === 'truck' && (form.budgetAmount == null || !Number.isFinite(form.budgetAmount) || form.budgetAmount <= 0)) return '请输入大于 0 的预算总价'
if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0' if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0'
if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种' if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种'
return undefined return undefined
@@ -99,16 +127,16 @@ function commonPayload(form: TransportDemandForm) {
} }
type CommonPayload = ReturnType<typeof commonPayload> type CommonPayload = ReturnType<typeof commonPayload>
type TruckPayload = CommonPayload & { vehicleType: 'PALLET_15' | 'PALLET_30' } type TruckPayload = Omit<CommonPayload, 'packageCount'> & { palletCount: number; vehicleType: 'PALLET_15' | 'PALLET_30' }
type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number } type LooseboardPayload = CommonPayload & { deliveryMode: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'; operatingWarehouseId: string; destinationType: 'FBA' | 'PRIVATE'; destinationPostalCode?: string; deliveryContactName?: string; deliveryContactPhone?: string; estimatedArrivalAt?: string; trackingNo?: string; productName: string | undefined; estimatedPalletCount: number }
export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload
export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload
export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm) { export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm): TruckPayload | LooseboardPayload {
const failure = transportDemandError(kind, form) const failure = transportDemandError(kind, form)
if (failure) throw new Error(failure) if (failure) throw new Error(failure)
const common = commonPayload(form) const common = commonPayload(form)
return kind === 'truck' return kind === 'truck'
? { ...common, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' } ? { ...(({ packageCount: _packages, ...rest }) => rest)(common), palletCount: form.estimatedPalletCount, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
: { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount } : { ...common, ...(form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? { origin: '', originAddress: undefined, originContactName: '', originContactPhone: '' } : {}), deliveryMode: form.deliveryMode, operatingWarehouseId: form.operatingWarehouseId, deliveryContactName: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.deliveryContactName.trim() : undefined, deliveryContactPhone: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.deliveryContactPhone.trim() : undefined, estimatedArrivalAt: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? localMinuteInput(form.estimatedArrivalAt) : undefined, trackingNo: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.trackingNo.trim() || undefined : undefined, destinationType: form.destinationType, destinationPostalCode: form.destinationType === 'PRIVATE' ? form.destinationPostalCode.trim() : undefined, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
} }
@@ -1,7 +1,7 @@
import type { LocalTransportDetail, LocalTransportStatus } from './types' import type { LocalTransportDetail, LocalTransportStatus } from './types'
export const truckStatusLabels: Record<LocalTransportStatus, string> = { export const truckStatusLabels: Record<LocalTransportStatus, string> = {
PENDING_REVIEW: '待审核', WAITING_INBOUND: '待取货', RECEIVING: '待取货', WAITING_CONSOLIDATION: '待取货', FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', WAITING_INBOUND: '待取货', RECEIVING: '待取货', WAITING_CONSOLIDATION: '待取货',
PALLET_OVERAGE_REVIEW: '待取货', PARTIALLY_ALLOCATED: '待取货', ALLOCATED: '待取货', PALLET_OVERAGE_REVIEW: '待取货', PARTIALLY_ALLOCATED: '待取货', ALLOCATED: '待取货',
PENDING_DISPATCH: '待取货', DISPATCHED: '待取货', IN_TRANSIT: '派送中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消', PENDING_DISPATCH: '待取货', DISPATCHED: '待取货', IN_TRANSIT: '派送中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消',
} }
+24 -3
View File
@@ -5,7 +5,7 @@ export interface OverseasBusinessHistory {
operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string
} }
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'RECEIVING' | 'WAIT_CUSTOMER_CONFIRM' | 'CONFIRMED' | 'IN_TRANSIT' | 'DELIVERING' | 'COMPLETED' export type OverseasPurchaseStatus = 'FUNDING_PENDING' | 'FUNDING_FAILED' | 'PENDING_REVIEW' | 'RECEIVING' | 'WAIT_CUSTOMER_CONFIRM' | 'CONFIRMED' | 'IN_TRANSIT' | 'DELIVERING' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
export interface OverseasPurchaseQuery { export interface OverseasPurchaseQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | '' purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | ''
@@ -18,6 +18,7 @@ export interface OverseasPurchaseSummary {
purchaseQuantity: number; budgetUnitPrice: string | null; currency: string purchaseQuantity: number; budgetUnitPrice: string | null; currency: string
purchaseWarehouseId: string | null; purchaseWarehouseName: string | null purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
status: OverseasPurchaseStatus; version: number; createdAt: string; updatedAt: string status: OverseasPurchaseStatus; version: number; createdAt: string; updatedAt: string
remark?: string | null
} }
export interface OverseasPurchaseCustomerOption { customerId: string; customerName: string } export interface OverseasPurchaseCustomerOption { customerId: string; customerName: string }
export interface OverseasPurchaseWarehouseOption { warehouseId: string; warehouseCode: string; warehouseName: string; countryCode: string } export interface OverseasPurchaseWarehouseOption { warehouseId: string; warehouseCode: string; warehouseName: string; countryCode: string }
@@ -28,7 +29,7 @@ export interface OverseasPurchaseEditorDetail {
internalRemark: string | null internalRemark: string | null
} }
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR' export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
export type LocalTransportStatus = 'PENDING_REVIEW' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PALLET_OVERAGE_REVIEW' | 'PARTIALLY_ALLOCATED' | 'ALLOCATED' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'REJECTED' | 'CANCELLED' export type LocalTransportStatus = 'FUNDING_PENDING' | 'FUNDING_FAILED' | 'PENDING_REVIEW' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PALLET_OVERAGE_REVIEW' | 'PARTIALLY_ALLOCATED' | 'ALLOCATED' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
export type TruckVehicleType = 'PALLET_15' | 'PALLET_30' export type TruckVehicleType = 'PALLET_15' | 'PALLET_30'
export interface LocalTransportQuery { export interface LocalTransportQuery {
page?: number; size?: number; orderNo?: string; routeKeyword?: string; status?: LocalTransportStatus | '' page?: number; size?: number; orderNo?: string; routeKeyword?: string; status?: LocalTransportStatus | ''
@@ -55,7 +56,7 @@ export interface LocalTransportDetail extends LocalTransportOrder {
} }
export interface CreateLocalTransportOrderRequest { export interface CreateLocalTransportOrderRequest {
vehicleType: TruckVehicleType; origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null vehicleType: TruckVehicleType; origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
packageCount?: number; totalWeightKg: number; totalVolumeCbm: number palletCount: number; totalWeightKg: number; totalVolumeCbm: number
budgetAmount?: number | null; currency?: string budgetAmount?: number | null; currency?: string
originContactName: string; originContactPhone: string; destinationContactName: string; destinationContactPhone: string originContactName: string; originContactPhone: string; destinationContactName: string; destinationContactPhone: string
pickupAt: string; deliveryAt: string; remark?: string | null; idempotencyKey: string pickupAt: string; deliveryAt: string; remark?: string | null; idempotencyKey: string
@@ -71,6 +72,16 @@ export interface LocalLooseboardQuery {
status?: LocalLooseboardStatus | ''; requestedFrom?: string; requestedTo?: string status?: LocalLooseboardStatus | ''; requestedFrom?: string; requestedTo?: string
} }
export interface LocalLooseboardOrder { export interface LocalLooseboardOrder {
deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
operatingWarehouseId?: string
destinationType?: 'FBA' | 'PRIVATE'
destinationPostalCode?: string
deliveryContactName?: string
deliveryContactPhone?: string
estimatedArrivalAt?: string
trackingNo?: string
transferNo?: string
fleetArrangement: import('./fleetArrangement').FleetArrangement | null fleetArrangement: import('./fleetArrangement').FleetArrangement | null
id: string; customerId: string; orderNo: string; palletRequired: PalletRequired | null id: string; customerId: string; orderNo: string; palletRequired: PalletRequired | null
transportType: LocalTransportType | null; origin: string; destination: string; productName: string | null; estimatedPalletCount: number | null; packageCount: number transportType: LocalTransportType | null; origin: string; destination: string; productName: string | null; estimatedPalletCount: number | null; packageCount: number
@@ -83,6 +94,16 @@ export interface LocalLooseboardDetail extends LocalLooseboardOrder {
histories: OverseasBusinessHistory[] histories: OverseasBusinessHistory[]
} }
export interface CreateLocalLooseboardOrderRequest { export interface CreateLocalLooseboardOrderRequest {
deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
operatingWarehouseId?: string
destinationType?: 'FBA' | 'PRIVATE'
destinationPostalCode?: string
deliveryContactName?: string
deliveryContactPhone?: string
estimatedArrivalAt?: string
trackingNo?: string
transferNo?: string
productName?: string | null; estimatedPalletCount: number productName?: string | null; estimatedPalletCount: number
origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
packageCount: number; totalWeightKg: number; totalVolumeCbm: number packageCount: number; totalWeightKg: number; totalVolumeCbm: number
@@ -19,6 +19,7 @@ export interface DriverQuery {
region?: DriverDraft['region'] region?: DriverDraft['region']
licenseState?: DriverLicenseState licenseState?: DriverLicenseState
expectedEnd?: string expectedEnd?: string
dispatchNo?: string
} }
function numeric(value: Numeric): number { function numeric(value: Numeric): number {
@@ -93,3 +93,12 @@ it('accepts unknown transport ownership without guessing current master data', a
expect(result.row.vehicle.ownership).toBeNull() expect(result.row.vehicle.ownership).toBeNull()
expect(result.row.actualPalletCount).toBeNull() expect(result.row.actualPalletCount).toBeNull()
}) })
it('accepts transport rows when nullable ownership is omitted by JSON serialization', async () => {
const detail = ownedBusinessFixture()
const { ownership: _omitted, ...vehicle } = detail.row.vehicle
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, taskType: 'PICKUP', actualPalletCount: null, vehicle } } })
const result = await getOwnedBusiness(detail.row.dispatchNo)
expect(result.row.taskType).toBe('PICKUP')
expect(result.row.vehicle.ownership).toBeUndefined()
})
@@ -16,7 +16,11 @@ function operations(value: OwnedBusinessOperations): OwnedBusinessOperations {
return { ...value, money: value.money ? { ...value.money, amount: decimal(value.money.amount, 2, 18)! } : null, actualDistanceMiles: decimal(value.actualDistanceMiles,3,12), fuelConsumptionLitresPer100Km: decimal(value.fuelConsumptionLitresPer100Km,3,12) } return { ...value, money: value.money ? { ...value.money, amount: decimal(value.money.amount, 2, 18)! } : null, actualDistanceMiles: decimal(value.actualDistanceMiles,3,12), fuelConsumptionLitresPer100Km: decimal(value.fuelConsumptionLitresPer100Km,3,12) }
} }
function row(value: OwnedBusinessRow): OwnedBusinessRow { function row(value: OwnedBusinessRow): OwnedBusinessRow {
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || !(['OWNED','DISPATCHED'].includes(value.vehicle?.ownership || '') || (value.vehicle?.ownership === null && ['LOCAL_TRUCK','PICKUP'].includes(value.taskType || '')))) throw new Error('自有排车来源或阶段不完整,请重新读取') // Jackson omits nullable ownership fields from transport rows. Treat both
// null and omitted values as the same transport snapshot state.
const ownership = value.vehicle?.ownership
const transportWithoutVehicleOwnership = ownership == null && ['LOCAL_TRUCK','PICKUP'].includes(value.taskType || '')
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || !(['OWNED','DISPATCHED'].includes(ownership || '') || transportWithoutVehicleOwnership)) throw new Error('自有排车来源或阶段不完整,请重新读取')
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: ownedBusinessVersion(value.recordVersion), dispatchRecordVersion: ownedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount), fuelCount: count(value.photoSummary.fuelCount) } } return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: ownedBusinessVersion(value.recordVersion), dispatchRecordVersion: ownedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount), fuelCount: count(value.photoSummary.fuelCount) } }
} }
function detail(value: OwnedBusinessDetail): OwnedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } } function detail(value: OwnedBusinessDetail): OwnedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getDispatch, listLooseboardCargo, addLooseboardCargo, expandSelectionGroups, type Candidate, type DispatchDetail } from '@/modules/overseas-warehouse'
const open=defineModel<boolean>({default:false})
const props=defineProps<{dispatchNo:string}>()
const emit=defineEmits<{saved:[]}>()
const rows=ref<Candidate[]>([]), selected=ref<Candidate[]>([]), detail=ref<DispatchDetail|null>(null)
const page=ref(1), size=ref(20), total=ref(0), loading=ref(false), saving=ref(false), error=ref<ApiErrorDetails|null>(null)
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:'availablePackages',label:'计划 / 可用箱数',width:100},
{key:'blockReasons',label:'资格',minWidth:200,formatter:(row:Candidate)=>row.blockReasons.length?row.blockReasons.join(';'):'可添加'},
]
let generation=0
async function load() {
const current=++generation;loading.value=true;error.value=null;selected.value=[];rows.value=[];detail.value=null
try {
const [dispatch,value]=await Promise.all([getDispatch(props.dispatchNo),listLooseboardCargo(props.dispatchNo,page.value,size.value)])
if(current!==generation)return
if(dispatch.dispatchNo!==props.dispatchNo)throw new Error('排车身份不一致,请重新打开')
detail.value=dispatch;rows.value=value.candidates;total.value=value.total
}catch(cause){if(current===generation)error.value=getApiErrorDetails(cause,'散板货物读取失败')}
finally{if(current===generation)loading.value=false}
}
async function submit(){
if(!detail.value || saving.value || !selected.value.length)return
saving.value=true;error.value=null
try{
if(selected.value.some(row=>row.blockReasons.length))throw new Error('所选货物存在阻断,请重新选择')
const cargo=expandSelectionGroups(selected.value)
// 完整组来源版本由后端复核;预约批次继续通过原排车预约工作台绑定。
await addLooseboardCargo(props.dispatchNo,{recordVersion:detail.value.recordVersion,idempotencyKey:crypto.randomUUID(),cargo,batchAllocations:[]})
ElMessage.success('散板货物已加入排车,请按当前货物重新审核');open.value=false;emit('saved')
}catch(cause){await load();error.value=getApiErrorDetails(cause,'添加货物失败')}
finally{saving.value=false}
}
watch(open,value=>{if(value){page.value=1;void load()}else generation++})
watch([page,size],()=>{if(open.value)void load()})
</script>
<template><AppDialog v-model="open" title="添加散板货物" width="1180px" class="admin-density-dialog admin-workbench" :confirm-loading="saving" :confirm-disabled="loading || !selected.length || !detail?.actions.ADD_CARGO?.allowed" lock-while-submitting @confirm="submit">
<LoadErrorState v-if="error" title="添加货物暂未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="detail && !detail.actions.ADD_CARGO?.allowed" :title="detail.actions.ADD_CARGO?.blockReason || '当前排车不可加货,请先退回审核或重新排车'" type="warning" :closable="false" />
<el-alert title="申报货物可先加入排车;实际到仓后核对货量,完成实收后才能出库拣货。上门取货的凭证在对应取货排车节点上传。" type="info" :closable="false" />
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="sourceKey" :page-sizes="[10,20,50]" empty-text="暂无可添加的已审核散板货物" @selection-change="selected=$event" />
</AppDialog></template>
@@ -2,6 +2,10 @@
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import LooseboardCargoDialog from '../components/LooseboardCargoDialog.vue'
const access=useAccessPreviewStore(), cargoOpen=ref(false), cargoDispatchNo=ref('')
function addCargo(row:DispatchCapacityDemand){cargoDispatchNo.value=row.dispatchNo;cargoOpen.value=true}
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -16,6 +20,7 @@ const filters = ref<QueryModel>({ origin: '', destination: '' }), applied = ref(
const fields: QueryField[] = [{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 190 }, { key: 'destination', label: '目的地', type: 'input', placeholder: '目的地名称', width: 190 }] const fields: QueryField[] = [{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 190 }, { key: 'destination', label: '目的地', type: 'input', placeholder: '目的地名称', width: 190 }]
const amount = (value: number | null | undefined) => value == null ? '—' : String(value) const amount = (value: number | null | undefined) => value == null ? '—' : String(value)
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{key:'actions',label:'操作',width:120,fixed:'left',slotName:'actions'},
{ key: 'dispatchNo', label: '排车单号', minWidth: 200 }, { key: 'dispatchNo', label: '排车单号', minWidth: 200 },
{ key: 'capacityReason', label: '余位状态', minWidth: 220, formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? '余位已确认' : row.capacityReason || '余位待确认', afterColumn: 'dispatchNo' }, { key: 'warehouseName', label: '起运仓', minWidth: 160 }, { key: 'capacityReason', label: '余位状态', minWidth: 220, formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? '余位已确认' : row.capacityReason || '余位待确认', afterColumn: 'dispatchNo' }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
{ key: 'route', label: '路线', minWidth: 240, formatter: (row: DispatchCapacityDemand) => row.route || '—' }, { key: 'route', label: '路线', minWidth: 240, formatter: (row: DispatchCapacityDemand) => row.route || '—' },
@@ -39,8 +44,10 @@ function search() { applied.value = { ...filters.value }; if (page.value === 1)
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }) watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script> </script>
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求"><div class="workbench-table is-page-surface"> <template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求"><div class="workbench-table is-page-surface">
<LooseboardCargoDialog v-model="cargoOpen" :dispatch-no="cargoDispatchNo" @saved="load" />
<DataTable appearance="integrated" preference-key="dispatch-capacity-demands" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="dispatchNo" empty-text="暂无已发布的散板需求"> <DataTable appearance="integrated" preference-key="dispatch-capacity-demands" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="dispatchNo" empty-text="暂无已发布的散板需求">
<template #toolbar-start><h1>散板需求</h1></template> <template #toolbar-start><h1>散板需求</h1></template>
<template #actions="{row}"><el-button v-if="(access.can('platform:fleet:capacity-offer:cargo-add') || access.can('platform:fleet:capacity-offer:manage')) && access.can('platform:overseas-warehouse:dispatch:edit') && access.can('platform:overseas-warehouse:dispatch:list')" link type="primary" @click="addCargo(row as DispatchCapacityDemand)">添加货物</el-button><span v-else>—</span></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="散板需求查询" :loading="loading" @search="search" /></template> <template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="散板需求查询" :loading="loading" @search="search" /></template>
<template #notices><LoadErrorState v-if="error" title="散板需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template> <template #notices><LoadErrorState v-if="error" title="散板需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #pagination-left>共 {{ total }} 条散板需求</template> <template #pagination-left>共 {{ total }} 条散板需求</template>
@@ -25,11 +25,11 @@ import CapacityOfferSettingsDialog from '../components/CapacityOfferSettingsDial
import TripCostPanel from '../components/TripCostPanel.vue' import TripCostPanel from '../components/TripCostPanel.vue'
import TripHandoverPanel from '../components/TripHandoverPanel.vue' import TripHandoverPanel from '../components/TripHandoverPanel.vue'
import { getTrip, bindTripDispatch, unbindTripDispatch, cancelTrip, getTripLoading, updateTripPlan, import { getTrip, bindTripDispatch, unbindTripDispatch, cancelTrip, getTripLoading, updateTripPlan,
getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading } from '../api/trips' getTripCompletion, completeTrip, listTripHistory, reviewTripDispatch, reviewTripLoading } from '../api/trips'
import { overseasMasterDataPermissions as permission } from '../permissions' import { overseasMasterDataPermissions as permission } from '../permissions'
import { driverQualificationLabels } from '../driverTypes' import { driverQualificationLabels } from '../driverTypes'
import { tripStatusLabels, tripReviewLabels, type TripDetail, type TripLoading, type TripHistory, type TripCompletionInspection, import { tripStatusLabels, tripReviewLabels, type TripDetail, type TripLoading, type TripHistory, type TripCompletionInspection,
type TripCapacityPlanning, type TripComplete, type TripResourceRelease, type ReleasedVehicleStatus } from '../tripTypes' type TripCapacityPlanning, type TripComplete } from '../tripTypes'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore() const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const detail = ref<TripDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null) const detail = ref<TripDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null)
@@ -40,9 +40,7 @@ const appointmentsOpen = ref(false)
const appointmentsPanel = ref<InstanceType<typeof TripAppointmentPanel> | null>(null) const appointmentsPanel = ref<InstanceType<typeof TripAppointmentPanel> | null>(null)
const inspection = ref<TripCompletionInspection | null>(null), inspectionLoading = ref(false) const inspection = ref<TripCompletionInspection | null>(null), inspectionLoading = ref(false)
const history = ref<TripHistory[]>([]), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false) const history = ref<TripHistory[]>([]), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false)
const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new Date().toISOString()), endLocation = ref('') const completionRequest = ref<TripComplete | null>(null)
const vehicleStatus = ref<ReleasedVehicleStatus>('IN_SERVICE')
const completionRequest = ref<TripComplete | null>(null), releaseRequest = ref<TripResourceRelease | null>(null)
let generation = 0, inspectionSequence = 0, historySequence = 0 let generation = 0, inspectionSequence = 0, historySequence = 0
const isPickup = computed(() => detail.value?.trip.tripPurpose === 'PICKUP') const isPickup = computed(() => detail.value?.trip.tripPurpose === 'PICKUP')
const planned = computed(() => detail.value?.trip.status === 'PLANNED') const planned = computed(() => detail.value?.trip.status === 'PLANNED')
@@ -67,9 +65,7 @@ const actionLabels: Record<string, string> = { PICKUP_PLAN: '安排上门取货'
BOOKING_CREATE: '登记外接询价', BOOKING_EDIT: '更新外接询价', BOOKING_CONFIRM: '确认外接承运', BOOKING_CANCEL: '取消外接预订', BOOKING_INCOMING: '登记外接来货', BOOKING_RECEIPT: '确认外接实收', PLAN_CAPACITY: '核对计划容量', REMARK: '更新备注', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货审核', BOOKING_CREATE: '登记外接询价', BOOKING_EDIT: '更新外接询价', BOOKING_CONFIRM: '确认外接承运', BOOKING_CANCEL: '取消外接预订', BOOKING_INCOMING: '登记外接来货', BOOKING_RECEIPT: '确认外接实收', PLAN_CAPACITY: '核对计划容量', REMARK: '更新备注', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货审核',
CONFIRM_LOAD: '确认装载', DEPART: '整车发车', HANDOVER: '在途交接', COMPLETE: '货物结案', CANCEL: '取消计划', RELEASE_VEHICLE: '结束车辆任务', RELEASE_DRIVER: '结束司机任务' } CONFIRM_LOAD: '确认装载', DEPART: '整车发车', HANDOVER: '在途交接', COMPLETE: '货物结案', CANCEL: '取消计划', RELEASE_VEHICLE: '结束车辆任务', RELEASE_DRIVER: '结束司机任务' }
function accept(value: TripDetail) { function accept(value: TripDetail) {
if (value.trip.status === 'COMPLETED' && detail.value?.trip.status !== 'COMPLETED') actualEndAt.value = new Date().toISOString() detail.value = value; operationError.value = null
detail.value = value; operationError.value = null; endLocation.value ||= value.trip.stops.at(-1)?.name || ''
if (value.trip.vehicleRelease && resourceKind.value === 'VEHICLE') resourceKind.value = 'DRIVER'
} }
async function load() { async function load() {
if (pickupLocked.value) { ElMessage.warning('请先重试并确认原取货操作'); return } if (pickupLocked.value) { ElMessage.warning('请先重试并确认原取货操作'); return }
@@ -78,7 +74,7 @@ async function load() {
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = route.query.tab === 'loading' ? 'loading' : 'overview' loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = route.query.tab === 'loading' ? 'loading' : 'overview'
editorOpen.value = false; busy.value = ''; dispatchNo.value = ''; inspection.value = null; history.value = []; historyPage.value = 1 editorOpen.value = false; busy.value = ''; dispatchNo.value = ''; inspection.value = null; history.value = []; historyPage.value = 1
appointmentsOpen.value = false appointmentsOpen.value = false
completionRequest.value = null; releaseRequest.value = null; endLocation.value = '' completionRequest.value = null
try { const result = await getTrip(String(route.params.tripNo)); if (current === generation) accept(result) } try { const result = await getTrip(String(route.params.tripNo)); if (current === generation) accept(result) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '车次详情加载失败') } catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '车次详情加载失败') }
finally { if (current === generation) loading.value = false } finally { if (current === generation) loading.value = false }
@@ -90,7 +86,7 @@ async function run(action: string, command: () => Promise<TripDetail>): Promise<
catch (cause) { catch (cause) {
if (current === generation) { if (current === generation) {
operationError.value = getApiErrorDetails(cause, '操作未完成') operationError.value = getApiErrorDetails(cause, '操作未完成')
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { completionRequest.value = null; releaseRequest.value = null } if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { completionRequest.value = null }
} }
return false return false
} finally { if (current === generation) busy.value = '' } } finally { if (current === generation) busy.value = '' }
@@ -165,23 +161,13 @@ async function inspect() {
async function complete() { async function complete() {
const trip = detail.value?.trip, current = generation const trip = detail.value?.trip, current = generation
if (!trip || !inspection.value?.canComplete) return if (!trip || !inspection.value?.canComplete) return
try { await ElMessageBox.confirm('确认本车货物均已签收或完成异常处置?货物结案后,还需分别结束车辆和司机任务。', '货物结案', { customClass: 'admin-density-confirm admin-workbench is-dialog' }) } try { await ElMessageBox.confirm('确认本车货物均已签收或完成异常处置?结案后将自动释放车辆和司机。', '货物结案', { customClass: 'admin-density-confirm admin-workbench is-dialog' }) }
catch { return } catch { return }
if (current !== generation) return if (current !== generation) return
completionRequest.value ||= { recordVersion: inspection.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: '核对全部货物终态后结案' } completionRequest.value ||= { recordVersion: inspection.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: '核对全部货物终态后结案' }
if (await run('complete', () => completeTrip(trip.tripNo, completionRequest.value!))) { completionRequest.value = null; inspection.value = null } if (await run('complete', () => completeTrip(trip.tripNo, completionRequest.value!))) { completionRequest.value = null; inspection.value = null }
} }
async function release() {
const trip = detail.value?.trip, current = generation
if (!trip) return
if (!actualEndAt.value || !endLocation.value.trim()) { ElMessage.warning('请填写实际任务结束时间和地点'); return }
try { await ElMessageBox.confirm(`确认${resourceKind.value === 'VEHICLE' ? '车辆' : '司机'}已结束本次任务,可以安排下一车次?`, '结束资源任务', { customClass: 'admin-density-confirm admin-workbench is-dialog' }) }
catch { return }
if (current !== generation) return
releaseRequest.value ||= { recordVersion: trip.recordVersion, resourceKind: resourceKind.value, actualEndAt: new Date(actualEndAt.value).toISOString(),
endLocation: endLocation.value.trim(), vehicleStatus: resourceKind.value === 'VEHICLE' ? vehicleStatus.value : null, idempotencyKey: crypto.randomUUID(), reason: '确认资源实际任务结束' }
if (await run('release', () => releaseTripResource(trip.tripNo, releaseRequest.value!))) { releaseRequest.value = null; resourceKind.value = detail.value?.trip.vehicleRelease ? 'DRIVER' : 'VEHICLE' }
}
async function loadHistory() { async function loadHistory() {
if (!detail.value) return if (!detail.value) return
const current = ++historySequence; historyLoading.value = true; operationError.value = null const current = ++historySequence; historyLoading.value = true; operationError.value = null
@@ -225,12 +211,8 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
<FormSection v-if="!isPickup && detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p> <FormSection v-if="!isPickup && detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
<div class="workbench-table"><DataTable :data="inspection?.outcomes || []" :columns="outcomeColumns" row-key="deliveryNo"><template #status="{ row }">{{ row.status === 'DELIVERED' && row.returnedPackages > 0 ? '余货已签收' : outcomeLabels[row.status] || '待处理' }}</template><template #terminalAt="{ row }">{{ time(row.terminalAt) }}</template></DataTable></div> <div class="workbench-table"><DataTable :data="inspection?.outcomes || []" :columns="outcomeColumns" row-key="deliveryNo"><template #status="{ row }">{{ row.status === 'DELIVERED' && row.returnedPackages > 0 ? '余货已签收' : outcomeLabels[row.status] || '待处理' }}</template><template #terminalAt="{ row }">{{ time(row.terminalAt) }}</template></DataTable></div>
</FormSection> </FormSection>
<FormSection class="density-form-section" title="车辆和司机分别结束任务"><p>货物结案与资源释放分别记录;仍有返程或转场任务时,等实际任务结束再释放。</p><dl class="density-fields"><div><dt>车辆任务</dt><dd>{{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}</dd></div><div><dt>司机任务</dt><dd>{{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}</dd></div></dl> <FormSection class="density-form-section" title="车辆和司机释放记录"><p>派送完成后自动释放车辆和司机。</p><dl class="density-fields"><div><dt>车辆任务</dt><dd>{{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}</dd></div><div><dt>司机任务</dt><dd>{{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}</dd></div></dl>
<el-form v-if="detail.trip.status === 'COMPLETED' && access.can(permission.tripRelease) && (!detail.trip.vehicleRelease || !detail.trip.driverRelease)" label-position="top" class="density-form release-form" :disabled="!!busy || !!releaseRequest" @submit.prevent="release">
<el-form-item label="结束任务的资源"><el-select v-model="resourceKind"><el-option label="车辆" value="VEHICLE" :disabled="!!detail.trip.vehicleRelease" /><el-option label="司机" value="DRIVER" :disabled="!!detail.trip.driverRelease" /></el-select></el-form-item>
<el-form-item label="实际任务结束时间"><el-date-picker v-model="actualEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="实际结束地点"><el-input v-model="endLocation" placeholder="请输入实际任务结束地点" /></el-form-item>
<el-form-item v-if="resourceKind === 'VEHICLE'" label="车辆结束状态"><el-select v-model="vehicleStatus"><el-option label="在役可调度" value="IN_SERVICE" /><el-option label="维修" value="MAINTENANCE" /><el-option label="停用" value="INACTIVE" /><el-option label="已归还" value="RETURNED" /></el-select></el-form-item>
</el-form><el-button v-if="detail.trip.status === 'COMPLETED' && access.can(permission.tripRelease) && (!detail.trip.vehicleRelease || !detail.trip.driverRelease)" type="primary" :loading="busy === 'release'" :disabled="!!busy" @click="release">{{ releaseRequest ? '重试原结束请求' : '确认结束资源任务' }}</el-button>
</FormSection> </FormSection>
</template> </template>
<FormSection v-if="activeTab === 'history'" class="density-form-section" title="操作历史"><div class="workbench-table"><DataTable v-model:page="historyPage" :page-size="20" :data="history" :total="historyTotal" :loading="historyLoading" :columns="historyColumns" row-key="recordVersion"><template #time="{ row }">{{ time(row.occurredAt) }}</template><template #action="{ row }">{{ actionLabels[row.action] || '更新记录' }}</template></DataTable></div></FormSection> <FormSection v-if="activeTab === 'history'" class="density-form-section" title="操作历史"><div class="workbench-table"><DataTable v-model:page="historyPage" :page-size="20" :data="history" :total="historyTotal" :loading="historyLoading" :columns="historyColumns" row-key="recordVersion"><template #time="{ row }">{{ time(row.occurredAt) }}</template><template #action="{ row }">{{ actionLabels[row.action] || '更新记录' }}</template></DataTable></div></FormSection>
@@ -1,6 +1,7 @@
export const overseasMasterDataPermissions = { export const overseasMasterDataPermissions = {
capacityOfferList: 'platform:fleet:capacity-offer:list', capacityOfferList: 'platform:fleet:capacity-offer:list',
capacityOfferManage: 'platform:fleet:capacity-offer:manage', capacityOfferManage: 'platform:fleet:capacity-offer:manage',
capacityCargoAdd: 'platform:fleet:capacity-offer:cargo-add',
capacityInquiryCreate: 'platform:fleet:capacity-offer:inquiry-create', capacityInquiryCreate: 'platform:fleet:capacity-offer:inquiry-create',
capacityInquiryEdit: 'platform:fleet:capacity-offer:inquiry-edit', capacityInquiryEdit: 'platform:fleet:capacity-offer:inquiry-edit',
capacityInquiryCancel: 'platform:fleet:capacity-offer:inquiry-cancel', capacityInquiryCancel: 'platform:fleet:capacity-offer:inquiry-cancel',
@@ -62,4 +63,3 @@ export const overseasMasterDataPermissions = {
vehicleRecordUpload: 'platform:overseas-master-data:vehicle:record-upload', vehicleRecordUpload: 'platform:overseas-master-data:vehicle:record-upload',
vehicleRecordFinanceSubmit: 'platform:overseas-master-data:vehicle:record-finance-submit', vehicleRecordFinanceSubmit: 'platform:overseas-master-data:vehicle:record-finance-submit',
} as const } as const
@@ -446,7 +446,7 @@
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 | | 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|---|---|---|---|---| |---|---|---|---|---|
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;2026-10-22 用户截图确认移除资料核对、油耗和装车照片改表格;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;嵌入编辑器隐藏资料核对;三个页签仅显示各自资料,装车资料只保留整车装车照片,不再附带BOL及车辆/司机附件,保存资料按钮仅在签收页签显示,切换保留签收草稿;油耗两行、装车照片五行表格,操作列在首列,保留预览、上传/替换和原保存行为;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 4文件27项定向测试及受影响组件与测试源码类型检查通过;页签隔离追加修改后5项定向测试及组件/测试类型检查通过;1920×1080、100%待人工截图确认 | | 排车列表:装车中、装车后、派送中 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;2026-10-22 用户截图确认移除资料核对、油耗和装车照片改表格;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;嵌入编辑器隐藏资料核对;三个页签仅显示各自资料,装车资料只保留整车装车照片,不再附带BOL及车辆/司机附件,保存资料按钮仅在签收页签显示,切换保留签收草稿;油耗两行、装车照片五行表格,操作列在首列,保留预览、上传/替换和原保存行为;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 4文件27项定向测试及受影响组件与测试源码类型检查通过;页签隔离追加修改后5项定向测试及组件/测试类型检查通过;1920×1080、100%待人工截图确认 |
| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 | | 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 |
@@ -473,3 +473,6 @@
依据用户PDA截图标注,五类统一为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字。PDA每类最多5张,可多选追加、逐张更换;Admin排车资料仍沿五行表格展示,每类逐图预览/更换,自有和外配照片弹框展示全部当前图片,油耗保持单张。五类各至少1张与装车确认共同控制自动派送。PDA相关75项、后端47项、Admin照片编辑17项及照片弹框4项通过,定向类型检查通过;扩大到运营布局时另有2项既存关闭行为断言失败,未改动该运营代码。未部署,PDA真机及Admin人工截图待确认。 依据用户PDA截图标注,五类统一为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字。PDA每类最多5张,可多选追加、逐张更换;Admin排车资料仍沿五行表格展示,每类逐图预览/更换,自有和外配照片弹框展示全部当前图片,油耗保持单张。五类各至少1张与装车确认共同控制自动派送。PDA相关75项、后端47项、Admin照片编辑17项及照片弹框4项通过,定向类型检查通过;扩大到运营布局时另有2项既存关闭行为断言失败,未改动该运营代码。未部署,PDA真机及Admin人工截图待确认。
- 同日双端复核:Admin装车资料补齐多选上传;PDA/Admin保存响应异常时重读服务端当前照片,核对失败保留刷新入口,停止本批后续上传。旧图替换不影响同类其他图,批次已成功部分保留。Admin 6文件32项、PDA 3文件38项及定向类型检查通过;此前2项运营弹框过期断言已按仅busy期间锁定的现行规则修正并通过。后端本轮未改,沿用47项验证结果;未部署、未做真机/人工截图验收。 - 同日双端复核:Admin装车资料补齐多选上传;PDA/Admin保存响应异常时重读服务端当前照片,核对失败保留刷新入口,停止本批后续上传。旧图替换不影响同类其他图,批次已成功部分保留。Admin 6文件32项、PDA 3文件38项及定向类型检查通过;此前2项运营弹框过期断言已按仅busy期间锁定的现行规则修正并通过。后端本轮未改,沿用47项验证结果;未部署、未做真机/人工截图验收。
2026-10-29 用户截图调整:排车列表查看路线更名查看轨迹。已完成本土卡车详情补充货物数量卡片、起讫地址与联系人、司机/车辆及证照照片、实际发车和完成时间。维护车辆/司机候选排除其他车次实际占用,当前车次已选资源允许保留。尾程查询占位提示字体12px。自动检查与人工视觉验收分别记录;待人工1920×1080、100%截图确认。
@@ -3,7 +3,7 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string } export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
export interface CargoDiscardSummary { skuCodes?: string[]; skuCount?: number; requestedPackages?: number; actualPackages?: number | null; id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null } export interface CargoDiscardSummary { skuCodes?: string[]; skuCount?: number; requestedPackages?: number; actualPackages?: number | null; id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
export interface CargoDiscardItem { quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null } export interface CargoDiscardItem { actualCartonMarks?: string[]; palletNo?: string | null; quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
export interface CargoDiscardStatistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null } export interface CargoDiscardStatistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null }
export async function getCargoDiscardStatistics(query: CargoDiscardQuery) { export async function getCargoDiscardStatistics(query: CargoDiscardQuery) {
const response = await http.get<ApiEnvelope<CargoDiscardStatistics>, ApiEnvelope<CargoDiscardStatistics>>('/platform/v1/last-leg/cargo-discards/statistics', { params: query }) const response = await http.get<ApiEnvelope<CargoDiscardStatistics>, ApiEnvelope<CargoDiscardStatistics>>('/platform/v1/last-leg/cargo-discards/statistics', { params: query })
@@ -0,0 +1,23 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type LooseboardPhotoPhase = 'PICKUP' | 'ARRIVAL'
export interface LooseboardPhoto { fileId: string; fileName: string; contentType: string; sizeBytes: string | number }
export interface LooseboardExecutionItem { itemId: string; customerId: string; orderNo: string; destination: string | null; packages: number; pickupPhotos: LooseboardPhoto[]; arrivalPhotos: LooseboardPhoto[]; canPickup: boolean; canArrive: boolean }
export interface LooseboardExecution { dispatchNo: string; recordVersion: string | number; stage: string; direct: boolean; items: LooseboardExecutionItem[] }
const path = (number: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}/looseboard-execution`
const itemPath = (number: string, item: string, phase: LooseboardPhotoPhase) => `${path(number)}/${encodeURIComponent(item)}/${phase}`
export async function getLooseboardExecution(number: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<LooseboardExecution>, ApiEnvelope<LooseboardExecution>>(path(number)))
}
export async function uploadLooseboardPhoto(number: string, item: string, phase: LooseboardPhotoPhase, file: File) {
const body = new FormData(); body.append('file', file)
return unwrapApiResponse(await http.post<ApiEnvelope<LooseboardPhoto>, ApiEnvelope<LooseboardPhoto>>(`${itemPath(number, item, phase)}/uploads`, body))
}
export async function saveLooseboardPhotos(number: string, item: string, phase: LooseboardPhotoPhase, body: { recordVersion: string | number; idempotencyKey: string; fileIds: string[] }) {
return unwrapApiResponse(await http.put<ApiEnvelope<LooseboardExecution>, ApiEnvelope<LooseboardExecution>>(itemPath(number, item, phase), body))
}
export async function readLooseboardPhoto(number: string, item: string, phase: LooseboardPhotoPhase, photo: LooseboardPhoto) {
const blob = await http.get<Blob, Blob>(`${itemPath(number, item, phase)}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || blob.type !== photo.contentType || !blob.size) throw new Error('散板照片读取失败')
return blob
}
@@ -17,6 +17,11 @@ export async function listDispatches(query: DispatchQuery) {
return normalizeDispatchPage(unwrapApiResponse(await http.get<ApiEnvelope<DispatchPageWire>, ApiEnvelope<DispatchPageWire>>(base, { params: query }))) return normalizeDispatchPage(unwrapApiResponse(await http.get<ApiEnvelope<DispatchPageWire>, ApiEnvelope<DispatchPageWire>>(base, { params: query })))
} }
export const getDispatch = (dispatchNo: string) => get<DispatchDetailWire>(path(dispatchNo)).then(normalizeDispatchDetail) export const getDispatch = (dispatchNo: string) => get<DispatchDetailWire>(path(dispatchNo)).then(normalizeDispatchDetail)
/** 散板余位页只选择已实收打托且与本车同仓的散板来源。 */
export async function listLooseboardCargo(no: string, page = 1, size = 20) {
return normalizeCandidateList(unwrapApiResponse(await http.get<ApiEnvelope<CandidateListWire>, ApiEnvelope<CandidateListWire>>(`${base}/capacity-demands/${encodeURIComponent(no)}/cargo`, { params: { page, size } })))
}
export const addLooseboardCargo = (no: string, body: AddCargo) => { versioned(body); requireBatchVersions(body.batchAllocations); return postCommand(`${base}/capacity-demands/${encodeURIComponent(no)}/cargo`, body) }
export const getDispatchItems = (no: string) => get<DispatchItemsWire>(`${path(no)}/items`).then(normalizeDispatchItems) export const getDispatchItems = (no: string) => get<DispatchItemsWire>(`${path(no)}/items`).then(normalizeDispatchItems)
export async function getDispatchAppointmentAllocations(no: string) { export async function getDispatchAppointmentAllocations(no: string) {
const value = await get<AppointmentAllocations>(`${path(no)}/appointment-allocations`) const value = await get<AppointmentAllocations>(`${path(no)}/appointment-allocations`)
@@ -74,3 +79,6 @@ export const convertDispatchDraft = (body: ConvertDraft) => { versioned(body); r
export interface BatchDispatchCandidate { planPalletId: string; palletNo: string; planId: string; planNo: string; customerId: string; sourceStage: string; packageCount: number | null; allocations: DispatchBatchAllocation[]; blockReasons: string[] } export interface BatchDispatchCandidate { planPalletId: string; palletNo: string; planId: string; planNo: string; customerId: string; sourceStage: string; packageCount: number | null; allocations: DispatchBatchAllocation[]; blockReasons: string[] }
export interface BatchDispatchCandidates { batchKey: string; version: number; candidates: BatchDispatchCandidate[]; blockReasons: string[] } export interface BatchDispatchCandidates { batchKey: string; version: number; candidates: BatchDispatchCandidate[]; blockReasons: string[] }
export const getBatchDispatchCandidates = (key: string) => get<BatchDispatchCandidates>(`/platform/v1/last-leg/appointment-planning/batches/${encodeURIComponent(key)}/dispatch-candidates`).then(value => { requireBatchVersions(value.candidates.flatMap(row => row.allocations)); return value }) export const getBatchDispatchCandidates = (key: string) => get<BatchDispatchCandidates>(`/platform/v1/last-leg/appointment-planning/batches/${encodeURIComponent(key)}/dispatch-candidates`).then(value => { requireBatchVersions(value.candidates.flatMap(row => row.allocations)); return value })
/** 退回货物仅使用当前排车服务端给出的原出库候选。 */
export const getDispatchReturnOptions = (no: string) => get<import('../dispatchContracts').DispatchReturnCandidate[]>( `${path(no)}/exception-return-options`)
@@ -1,10 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http' import { http } from '@/shared/api/http'
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, getExceptionDetail, commandException, uploadExceptionEvidence, normalizeExceptionDetail, type ExceptionDetail } from './exceptionOrders' import { generateExceptionReturn, createExceptionOrder, listExceptionOrders, resolveExceptionRelation, getExceptionDetail, commandException, uploadExceptionEvidence, normalizeExceptionDetail, type ExceptionDetail } from './exceptionOrders'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } })) vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
describe('exception order API', () => { describe('exception order API', () => {
it('generates the return order through the dedicated endpoint without evidence or receipt data', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { orderNo: 'WC-RETURN-1' } })
expect(await generateExceptionReturn('100', 'EX/1', 7)).toEqual({ orderNo: 'WC-RETURN-1' })
expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/exception-orders/EX%2F1/generate-return-inbound',
{ expectedVersion: 7, idempotencyKey: expect.any(String) }, { params: { customerId: '100' } })
})
it('normalizes native carton sources and rejects missing, ambiguous or unsafe source identities', () => { it('normalizes native carton sources and rejects missing, ambiguous or unsafe source identities', () => {
const raw = { order: { version: '1' }, returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: '1' }], returnReceipts: [] } as unknown as ExceptionDetail const raw = { order: { version: '1' }, returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: '1' }], returnReceipts: [] } as unknown as ExceptionDetail
expect(normalizeExceptionDetail(raw).returnSources[0]).toMatchObject({ inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: 1 }) expect(normalizeExceptionDetail(raw).returnSources[0]).toMatchObject({ inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: 1 })
@@ -33,7 +39,7 @@ describe('exception order API', () => {
await getExceptionDetail('100', 'EX/1') await getExceptionDetail('100', 'EX/1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders/EX%2F1', { params: { customerId: '100' } }) expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders/EX%2F1', { params: { customerId: '100' } })
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { exceptionNo: 'EX/1', version: '8', restoredPackages: '0' } }) vi.mocked(http.post).mockResolvedValue({ code: '200', data: { exceptionNo: 'EX/1', version: '8', restoredPackages: '0' } })
for (const action of ['start-processing', 'cancel', 'complete'] as const) { for (const action of ['start-processing', 'cancel', 'complete', 'force-close'] as const) {
const body = { expectedVersion: 7, idempotencyKey: `original-${action}` } const body = { expectedVersion: 7, idempotencyKey: `original-${action}` }
await commandException('100', 'EX/1', action, body) await commandException('100', 'EX/1', action, body)
expect(http.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/exception-orders/EX%2F1/${action}`, body, { params: { customerId: '100' } }) expect(http.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/exception-orders/EX%2F1/${action}`, body, { params: { customerId: '100' } })
@@ -18,10 +18,10 @@ export interface ExceptionReturnSource extends ReturnSourceIdentity { outboundPa
export interface ExceptionRelatedDelivery { deliveryNo: string; status: string } export interface ExceptionRelatedDelivery { deliveryNo: string; status: string }
export interface ExceptionRedispatch { dispatchNo: string; status: string; selectedPackages: number; pickedPackages: number; deliveries: ExceptionRelatedDelivery[] } export interface ExceptionRedispatch { dispatchNo: string; status: string; selectedPackages: number; pickedPackages: number; deliveries: ExceptionRelatedDelivery[] }
export interface ExceptionReturnReceipt { receiptNo: string; receivedPackages: number; receivedAt: string; redispatches: ExceptionRedispatch[] } export interface ExceptionReturnReceipt { receiptNo: string; receivedPackages: number; receivedAt: string; redispatches: ExceptionRedispatch[] }
export interface ExceptionDetail { order: ExceptionOrderRow; returnSources: ExceptionReturnSource[]; returnReceipts: ExceptionReturnReceipt[] } export interface ExceptionDetail { order: ExceptionOrderRow; returnSources: ExceptionReturnSource[]; returnReceipts: ExceptionReturnReceipt[]; returnOrderNo?: string | null }
export interface ExceptionReceipt extends ReturnSourceIdentity { outboundPalletItemId: string; receivedPackages: number; targetPalletId: string; targetPalletNo: string; targetLocationId: string; targetLocationCode: string } export interface ExceptionReceipt extends ReturnSourceIdentity { outboundPalletItemId: string; receivedPackages: number; targetPalletId: string; targetPalletNo: string; targetLocationId: string; targetLocationCode: string }
export interface ExceptionCommand { expectedVersion: number; idempotencyKey: string; handlingAction?: string; reason?: string; handlingResult?: string; evidenceFileIds?: string[]; receipts?: ExceptionReceipt[] } export interface ExceptionCommand { expectedVersion: number; idempotencyKey: string; handlingAction?: string; reason?: string; handlingResult?: string; evidenceFileIds?: string[]; receipts?: ExceptionReceipt[] }
export type ExceptionAction = 'start-processing' | 'complete' | 'cancel' export type ExceptionAction = 'start-processing' | 'complete' | 'cancel' | 'force-close'
export interface ExceptionCommandResult { exceptionNo: string; status: string; version: number; restoredPackages: number; replayed: boolean } export interface ExceptionCommandResult { exceptionNo: string; status: string; version: number; restoredPackages: number; replayed: boolean }
const exceptionPath = (number: string) => `/platform/v1/last-leg/exception-orders/${encodeURIComponent(number)}` const exceptionPath = (number: string) => `/platform/v1/last-leg/exception-orders/${encodeURIComponent(number)}`
function safeInteger(value: unknown, label: string) { function safeInteger(value: unknown, label: string) {
@@ -68,3 +68,9 @@ export async function createExceptionOrder(payload: CreateExceptionOrderPayload)
const response = await http.post<ApiEnvelope<{ id: string; exceptionNo: string; status: string }>, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>('/platform/v1/last-leg/exception-orders', payload) const response = await http.post<ApiEnvelope<{ id: string; exceptionNo: string; status: string }>, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>('/platform/v1/last-leg/exception-orders', payload)
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
/** 异常单生成关联原单的退回尾程单,现场从PDA列表点单实收。 */
export async function generateExceptionReturn(customerId: string, number: string, expectedVersion: number) {
return unwrapApiResponse(await http.post<ApiEnvelope<{ orderNo: string }>, ApiEnvelope<{ orderNo: string }>>(`${exceptionPath(number)}/generate-return-inbound`,
{ expectedVersion, idempotencyKey: crypto.randomUUID() }, { params: { customerId } }))
}
@@ -472,6 +472,15 @@ export async function createManualInboundPlan(payload: ManualInboundPlanPayload,
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
/** 异常退回以本单预计到仓时间创建计划,后端核对原异常及真实客户归属。 */
export async function createExceptionReturnInboundPlan(plan: ManualInboundPlanPayload, expectedArrivalAt: string, idempotencyKey: string) {
const response = await http.post<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(
'/platform/v1/last-leg/exception-orders/return-inbound-plans', { plan, expectedArrivalAt },
{ headers: { 'Idempotency-Key': idempotencyKey } },
)
return unwrapApiResponse(response)
}
export async function updateLastMileOrderHold(orderId: string, payload: HoldLastMileOrderPayload, idempotencyKey: string) { export async function updateLastMileOrderHold(orderId: string, payload: HoldLastMileOrderPayload, idempotencyKey: string) {
const response = await http.put<ApiEnvelope<HoldLastMileOrderResponse>, ApiEnvelope<HoldLastMileOrderResponse>>( const response = await http.put<ApiEnvelope<HoldLastMileOrderResponse>, ApiEnvelope<HoldLastMileOrderResponse>>(
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold`, payload, `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold`, payload,
@@ -4,7 +4,7 @@ import type { Resources, ReviewAttachments, Version } from '../dispatchContracts
export type TransportType = 'PICKUP' | 'LOCAL_TRUCK' export type TransportType = 'PICKUP' | 'LOCAL_TRUCK'
export const transportLabels = { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' } export const transportLabels = { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; departureEvidence?: PickupEvidence[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null } export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; departureEvidence?: PickupEvidence[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null; cargo: { palletCount: number; packageCount: number; totalWeightKg: number | string | null; totalVolumeCbm: number | string | null; origin: string; originAddress: string | null; originContactName: string; originContactPhone: string; destination: string; destinationAddress: string | null; destinationContactName: string; destinationContactPhone: string } | null }
export type TransportAssignment = TripAssignment & { dispatchRecordVersion?: Version; reviewPhotos?: ReviewAttachments; masterLicenseFileId?: string | null } export type TransportAssignment = TripAssignment & { dispatchRecordVersion?: Version; reviewPhotos?: ReviewAttachments; masterLicenseFileId?: string | null }
export interface TruckCandidate { customerId: string; orderNo: string; origin: string; originAddress: string; destination: string; destinationAddress: string; palletCount: number; packageCount: number } export interface TruckCandidate { customerId: string; orderNo: string; origin: string; originAddress: string; destination: string; destinationAddress: string; palletCount: number; packageCount: number }
export interface TruckPlan { customerId: string; orderNo: string; warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; idempotencyKey: string } export interface TruckPlan { customerId: string; orderNo: string; warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; idempotencyKey: string }
@@ -13,6 +13,7 @@ import { getDispatch } from '../api/dispatches'
import type { DispatchDetail } from '../dispatchContracts' import type { DispatchDetail } from '../dispatchContracts'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue' import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue' import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
import DispatchLooseboardExecution from './DispatchLooseboardExecution.vue'
const open = defineModel<boolean>({ default: false }) const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string }>() const props = defineProps<{ dispatchNo: string }>()
@@ -22,10 +23,14 @@ const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = r
const tab = ref('RECEIPT'), selected = ref('') const tab = ref('RECEIPT'), selected = ref('')
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>() const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>()
const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>() const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>()
const looseboardEditor = ref<InstanceType<typeof DispatchLooseboardExecution>>()
const directLooseboard = ref(false)
const hasLooseboard = computed(() => detail.value?.orderDetails.some(row => row.sourceBusinessType === 'LOCAL_LOOSEBOARD') === true)
const tabs = computed(() => [...(hasLooseboard.value ? [{ key: 'LOOSEBOARD', label: '散板取货与送达' }] : []), ...(!directLooseboard.value ? [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }] : [])])
const deliveries = computed(() => detail.value?.documents.deliveries || []) const deliveries = computed(() => detail.value?.documents.deliveries || [])
const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || '')) const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || ''))
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value)) const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy)) const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy || looseboardEditor.value?.busy))
let sequence = 0 let sequence = 0
onBeforeUnmount(() => { sequence++ }) onBeforeUnmount(() => { sequence++ })
async function load() { async function load() {
@@ -36,6 +41,7 @@ async function load() {
if (value.dispatchNo !== props.dispatchNo) throw new Error('资料与当前排车单不一致') if (value.dispatchNo !== props.dispatchNo) throw new Error('资料与当前排车单不一致')
if (value.status !== 'ACTIVE' || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('进入装车中后可管理资料') if (value.status !== 'ACTIVE' || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('进入装车中后可管理资料')
if (!detail.value && ['LOADING', 'AFTER_LOADING'].includes(value.stage)) tab.value = 'LOADING' if (!detail.value && ['LOADING', 'AFTER_LOADING'].includes(value.stage)) tab.value = 'LOADING'
if (!detail.value && value.orderDetails.some(row => row.sourceBusinessType === 'LOCAL_LOOSEBOARD')) tab.value = 'LOOSEBOARD'
detail.value = value detail.value = value
if (!value.documents.deliveries.some(item => item.deliveryNo === selected.value)) selected.value = value.documents.deliveries[0]?.deliveryNo || '' if (!value.documents.deliveries.some(item => item.deliveryNo === selected.value)) selected.value = value.documents.deliveries[0]?.deliveryNo || ''
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '排车资料读取失败') } } catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '排车资料读取失败') }
@@ -43,7 +49,7 @@ async function load() {
} }
async function canLeave() { async function canLeave() {
if (busy.value) return false if (busy.value) return false
if (!editor.value?.dirty) return true if (!editor.value?.dirty && !looseboardEditor.value?.dirty) return true
try { await ElMessageBox.confirm('有尚未保存的派送资料,是否放弃修改?', '未保存资料', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }); return true } catch { return false } try { await ElMessageBox.confirm('有尚未保存的派送资料,是否放弃修改?', '未保存资料', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }); return true } catch { return false }
} }
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value } async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
@@ -52,7 +58,7 @@ function selectTab(value: string) { if (!busy.value) { tab.value = value } }
function saved() { emit('saved') } function saved() { emit('saved') }
async function loadingSaved() { emit('saved'); await load() } async function loadingSaved() { emit('saved'); await load() }
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => { watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null; directLooseboard.value = false
if (visible && props.dispatchNo) void load() if (visible && props.dispatchNo) void load()
}, { immediate: true }) }, { immediate: true })
</script> </script>
@@ -60,14 +66,15 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
<AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab === 'RECEIPT' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()"> <AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab === 'RECEIPT' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="detail"> <template v-if="detail">
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div> <div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in tabs" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
<DispatchLooseboardExecution v-if="hasLooseboard" v-show="tab === 'LOOSEBOARD'" ref="looseboardEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @mode="directLooseboard = $event" @saved="loadingSaved" />
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" /> <DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
<div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture"> <div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture">
<el-form v-if="deliveries.length > 1" class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form> <el-form v-if="deliveries.length > 1" class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="['RECEIPT']" @saved="saved" /> <DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="['RECEIPT']" @saved="saved" />
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p> <p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
</div> </div>
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p> <p v-if="beforeDeparture && tab === 'RECEIPT'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
</template> </template>
</AppDialog> </AppDialog>
</template> </template>
@@ -0,0 +1,92 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchLooseboardExecution from './DispatchLooseboardExecution.vue'
import { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos } from '../api/dispatchLooseboardExecution'
import type { LooseboardExecution } from '../api/dispatchLooseboardExecution'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/dispatchLooseboardExecution', () => ({ getLooseboardExecution: vi.fn(), uploadLooseboardPhoto: vi.fn(), saveLooseboardPhotos: vi.fn(), readLooseboardPhoto: vi.fn() }))
const value = (direct = true): LooseboardExecution => ({ dispatchNo: 'PC1', recordVersion: '3', stage: direct ? 'LOADING' : 'DELIVERING', direct, items: [{ itemId: '101', customerId: '1', orderNo: 'LB1', destination: 'TEST', packages: 2, pickupPhotos: [], arrivalPhotos: [], canPickup: true, canArrive: false }] })
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getLooseboardExecution).mockResolvedValue(value()) })
it('shows pure looseboard photo flow and uploads to its authoritative item and node', async () => {
vi.mocked(uploadLooseboardPhoto).mockResolvedValue({ fileId: '77', fileName: 'pickup.png', contentType: 'image/png', sizeBytes: 10 })
vi.mocked(saveLooseboardPhotos).mockResolvedValue({ ...value(), stage: 'DELIVERING' })
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.text()).not.toContain('全车为散板')
expect(wrapper.findAll('input[type=file]')).toHaveLength(2)
expect(wrapper.get('input[aria-label="LB1上传送达照片"]').attributes('disabled')).toBeDefined()
const input = wrapper.get('input[type=file]')
Object.defineProperty(input.element, 'files', { value: [new File(['test'], 'pickup.png', { type: 'image/png' })], configurable: true })
await input.trigger('change'); await flushPromises()
expect(uploadLooseboardPhoto).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.any(File))
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', { recordVersion: '3', idempotencyKey: expect.any(String), fileIds: ['77'] })
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('keeps pickup upload visible but disabled until mixed warehouse cargo departs', async () => {
const snapshot = value(false)
snapshot.stage = 'LOADING'
snapshot.items[0]!.canPickup = false
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.text()).not.toContain('混排货物按原流程')
expect(wrapper.text()).toContain('上传取货照片')
expect(wrapper.text()).toContain('完成仓内拣货并确认装车后可上传')
const input = wrapper.get('input[aria-label="LB1上传取货照片"]')
expect(input.attributes('disabled')).toBeDefined()
Object.defineProperty(input.element, 'files', { value: [new File(['test'], 'pickup.png', { type: 'image/png' })], configurable: true })
input.element.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
expect(uploadLooseboardPhoto).not.toHaveBeenCalled()
wrapper.unmount()
})
it('enables pickup upload for mixed cargo in delivery', async () => {
vi.mocked(getLooseboardExecution).mockResolvedValue(value(false))
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.get('input[aria-label="LB1上传取货照片"]').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('enables mixed pickup from after loading using server eligibility', async () => {
const snapshot = value(false)
snapshot.stage = 'AFTER_LOADING'
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.get('input[aria-label="LB1上传取货照片"]').attributes('disabled')).toBeUndefined()
expect(wrapper.get('input[aria-label="LB1上传送达照片"]').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('keeps upload available after saving and appends multiple files to the current list', async () => {
const snapshot = value(false)
const old = { fileId: '77', fileName: 'old.png', contentType: 'image/png', sizeBytes: 10 }
snapshot.items[0]!.pickupPhotos = [old]
snapshot.items[0]!.canArrive = true
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
vi.mocked(uploadLooseboardPhoto)
.mockResolvedValueOnce({ ...old, fileId: '78', fileName: 'new1.png' })
.mockResolvedValueOnce({ ...old, fileId: '79', fileName: 'new2.png' })
vi.mocked(saveLooseboardPhotos).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
const input = wrapper.get('input[aria-label="LB1上传取货照片"]')
expect(input.attributes('disabled')).toBeUndefined()
Object.defineProperty(input.element, 'files', { value: ['new1.png', 'new2.png'].map(name => new File(['test'], name, { type: 'image/png' })), configurable: true })
await input.trigger('change'); await flushPromises()
expect(uploadLooseboardPhoto).toHaveBeenCalledTimes(2)
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.objectContaining({ recordVersion: '3', fileIds: ['77', '78', '79'] }))
wrapper.unmount()
})
it('deletes one photo while preserving the others in the versioned save', async () => {
const snapshot = value(false)
const old = { fileId: '77', fileName: 'old.png', contentType: 'image/png', sizeBytes: 10 }
snapshot.items[0]!.pickupPhotos = [old, { ...old, fileId: '78', fileName: 'keep.png' }]
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const updated = value(false)
updated.items[0]!.pickupPhotos = [snapshot.items[0]!.pickupPhotos[1]!]
vi.mocked(saveLooseboardPhotos).mockResolvedValue(updated)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.get('button[aria-label="删除取货照片old.png"]').trigger('click'); await flushPromises()
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.objectContaining({ recordVersion: '3', fileIds: ['78'] }))
expect(wrapper.text()).not.toContain('old.png')
expect(wrapper.text()).not.toContain('keep.png')
expect(wrapper.find('button[aria-label="删除取货照片keep.png"]').exists()).toBe(true)
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos, readLooseboardPhoto, type LooseboardExecution, type LooseboardExecutionItem, type LooseboardPhoto, type LooseboardPhotoPhase } from '../api/dispatchLooseboardExecution'
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: []; mode: [direct: boolean] }>()
const access = useAccessPreviewStore()
const snapshot = ref<LooseboardExecution | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const previews = ref<Record<string, string>>({})
const pending = ref<{ itemId: string; phase: LooseboardPhotoPhase; photos: LooseboardPhoto[] } | null>(null)
const dirty = computed(() => Boolean(pending.value?.photos.length))
const phases: { key: LooseboardPhotoPhase; label: string }[] = [{ key: 'PICKUP', label: '取货照片' }, { key: 'ARRIVAL', label: '送达照片' }]
function uploadReason(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase): string {
if (busy.value) return '正在处理,请稍候'
if (pending.value) return '请先保存已上传照片'
if ((phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length >= 20) return '每个节点最多20张照片'
if (phase === 'PICKUP' ? item.canPickup : item.canArrive) return ''
if (phase === 'ARRIVAL' && !item.pickupPhotos.length) return '请先上传取货照片'
if (phase === 'PICKUP' && !snapshot.value?.direct && snapshot.value?.stage === 'LOADING') return '完成仓内拣货并确认装车后可上传'
return '当前状态不可上传'
}
const number = props.dispatchNo
let active = true
function accept(value: LooseboardExecution) {
if (!active || number !== props.dispatchNo || value.dispatchNo !== number) throw new Error('排车身份已变化,请重新打开')
snapshot.value = value; emit('mode', value.direct)
}
async function images() {
for (const item of snapshot.value?.items || []) for (const phase of phases) {
const photos = phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
await Promise.allSettled(photos.map(async photo => {
if (previews.value[photo.fileId]) return
const blob = await readLooseboardPhoto(number, item.itemId, phase.key, photo)
if (active) previews.value[photo.fileId] = URL.createObjectURL(blob)
}))
}
}
async function load() {
if (busy.value) return
busy.value = true; error.value = null
try { accept(await getLooseboardExecution(number)); await images() }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '散板履约资料读取失败') }
finally { busy.value = false }
}
async function save() {
if (!pending.value?.photos.length || !snapshot.value || busy.value) return
busy.value = true; error.value = null
const value = pending.value
try {
const item = snapshot.value.items.find(row => row.itemId === value.itemId)
if (!item) throw new Error('散板明细已变化,请重新打开')
const existing = value.phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
accept(await saveLooseboardPhotos(number, value.itemId, value.phase, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileIds: [...existing, ...value.photos].map(photo => photo.fileId) }))
pending.value = null; await images(); ElMessage.success('散板照片已保存'); emit('saved')
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '保存失败,已上传照片保留,请核对当前状态后重试')
try {
accept(await getLooseboardExecution(number)); await images()
const item = snapshot.value?.items.find(row => row.itemId === value.itemId)
const bound = (value.phase === 'PICKUP' ? item?.pickupPhotos : item?.arrivalPhotos) || []
if (value.photos.every(photo => bound.some(file => file.fileId === photo.fileId))) { pending.value = null; emit('saved') }
} catch { /* 保留照片和当前错误,用户可重新读取。 */ }
}
} finally { busy.value = false }
}
async function upload(itemId: string, phase: LooseboardPhotoPhase, event: Event) {
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = ''
const item = snapshot.value?.items.find(row => row.itemId === itemId)
if (!files.length || !item || uploadReason(item, phase) || !access.can(permission.dispatchEdit)) return
const existing = phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
if (files.length + existing.length > 20 || files.some(file => !['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('每个节点最多20张JPG或PNG,每张不超过10MiB'); return }
busy.value = true; error.value = null; pending.value = { itemId, phase, photos: [] }; let failed = false
try { for (const file of files) { const photo = await uploadLooseboardPhoto(number, itemId, phase, file); if (!active) return; pending.value.photos.push(photo) } }
catch (cause) { failed = true; error.value = getApiErrorDetails(cause, '照片上传失败,已成功上传的照片可继续保存'); if (!pending.value?.photos.length) pending.value = null }
finally { busy.value = false }
if (!failed && pending.value?.photos.length) await save()
}
async function remove(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase, fileId: string) {
if (!snapshot.value || busy.value || pending.value || !access.can(permission.dispatchEdit) || !(phase === 'PICKUP' ? item.canPickup : item.canArrive)) return
busy.value = true; error.value = null
try {
const photos = phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
accept(await saveLooseboardPhotos(number, item.itemId, phase, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileIds: photos.filter(photo => photo.fileId !== fileId).map(photo => photo.fileId) }))
await images(); ElMessage.success('照片已删除'); emit('saved')
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '照片删除失败,请刷新后重试')
try { accept(await getLooseboardExecution(number)); await images() } catch { /* 保留原错误。 */ }
}
} finally { busy.value = false }
}
onMounted(() => { void load() })
onBeforeUnmount(() => { active = false; Object.values(previews.value).forEach(URL.revokeObjectURL) })
defineExpose({ busy, dirty })
</script>
<template>
<div v-loading="busy" class="looseboard-execution">
<el-button :disabled="busy" @click="load">刷新资料</el-button>
<LoadErrorState v-if="error" compact title="散板履约资料操作失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending"><span>已上传 {{ pending.photos.length }} 张,尚未保存。</span><el-button :disabled="busy" @click="save">保存已上传照片</el-button></div>
<section v-for="item in snapshot?.items || []" :key="item.itemId" class="looseboard-cargo">
<h3>{{ item.orderNo }} · {{ item.destination || '—' }} · {{ item.packages }} 箱</h3>
<div v-for="phase in phases" :key="phase.key" class="looseboard-node">
<h4>{{ phase.label }}</h4>
<label v-if="access.can(permission.dispatchEdit)" class="node-upload" :class="{ 'is-disabled': Boolean(uploadReason(item, phase.key)) }" :title="uploadReason(item, phase.key)">
上传{{ phase.label }}
<input type="file" multiple accept="image/jpeg,image/png" :aria-label="`${item.orderNo}上传${phase.label}`" :disabled="Boolean(uploadReason(item, phase.key))" @change="upload(item.itemId, phase.key, $event)" />
</label>
<span v-if="access.can(permission.dispatchEdit) && uploadReason(item, phase.key)" class="upload-reason">{{ uploadReason(item, phase.key) }}</span>
<p v-if="!(phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length">尚未上传{{ phase.label }}</p>
<div v-else class="node-photos">
<div v-for="photo in phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos" :key="photo.fileId" class="node-photo">
<el-image v-if="previews[photo.fileId]" :src="previews[photo.fileId]" :preview-src-list="[previews[photo.fileId]!]" preview-teleported fit="contain" class="node-image" :alt="phase.label" />
<button v-if="access.can(permission.dispatchEdit) && (phase.key === 'PICKUP' ? item.canPickup : item.canArrive)" type="button" class="node-photo-remove" :aria-label="`删除${phase.label}${photo.fileName}`" title="删除照片" :disabled="busy || Boolean(pending)" @click.stop="remove(item, phase.key, photo.fileId)"><span aria-hidden="true">×</span></button>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.looseboard-execution { display: flex; flex-direction: column; gap: 12px; }
.looseboard-cargo { border: 1px solid var(--cc-border, var(--el-border-color)); }
.looseboard-cargo > h3 { margin: 0; padding: 8px 12px; font-size: 14px; background: var(--el-fill-color-light); }
.looseboard-node { padding: 8px 12px; border-top: 1px solid var(--el-border-color-lighter); }
.looseboard-node h4 { display: inline-block; margin: 0 16px 8px 0; font-size: 14px; }
.looseboard-node p { color: var(--el-text-color-secondary); }
.node-photos { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0 4px; }
.node-photo { position: relative; flex: 0 0 160px; width: 160px; height: 100px; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); background: var(--el-fill-color-light); }
.node-image { display: block; width: 100%; height: 100%; border-radius: inherit; }
.node-photo-remove { position: absolute; top: 4px; right: 4px; z-index: 1; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; background: var(--el-color-danger); color: var(--el-color-white); font-size: 20px; line-height: 1; cursor: pointer; }
.node-photo-remove:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.node-photo-remove:disabled { opacity: .5; cursor: not-allowed; }
.node-upload { position: relative; display: inline-flex; align-items: center; padding: 6px 12px; border: 1px solid var(--el-color-primary); border-radius: var(--el-border-radius-base); color: var(--el-color-primary); cursor: pointer; }
.node-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.node-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.upload-reason { margin-left: 8px; color: var(--el-text-color-secondary); font-size: 12px; }
.node-upload.is-disabled input { cursor: not-allowed; }
.node-upload.is-disabled { border-color: var(--el-border-color); color: var(--el-text-color-disabled); cursor: not-allowed; }
</style>
@@ -12,9 +12,9 @@ it('carries the selected master license for preview without carrying another dri
const candidate: DriverDetail = { id: '22', driverNo: 'DR22', details: { name: '司机乙', phone: '555-0022', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LICENSE22', licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2028-01-01', licenseZone: 'UTC', remark: null, profile: null }, active: true, reviewStatus: 'APPROVED', licenseFileId: '33', recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-23T00:00:00Z', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2028-01-01' }, licenseState: 'VALID', warehouseCode: null, warehouseName: null } const candidate: DriverDetail = { id: '22', driverNo: 'DR22', details: { name: '司机乙', phone: '555-0022', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LICENSE22', licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2028-01-01', licenseZone: 'UTC', remark: null, profile: null }, active: true, reviewStatus: 'APPROVED', licenseFileId: '33', recordVersion: 1, updatedBy: 'operator', updatedAt: '2026-10-23T00:00:00Z', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2028-01-01' }, licenseState: 'VALID', warehouseCode: null, warehouseName: null }
vi.mocked(listDrivers).mockResolvedValue({ records: [candidate], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } }) vi.mocked(listDrivers).mockResolvedValue({ records: [candidate], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
const value = resources() const value = resources()
const wrapper = mount(DispatchResourceFields, { props: { ...value, disabled: false }, global: { plugins: [ElementPlus] } }) const wrapper = mount(DispatchResourceFields, { props: { ...value, disabled: false, dispatchNo: 'PC-OWN' }, global: { plugins: [ElementPlus] } })
const state = wrapper.vm as unknown as { searchDrivers(): Promise<void>; selectDriver(id: string): void } const state = wrapper.vm as unknown as { searchDrivers(): Promise<void>; selectDriver(id: string): void }
await state.searchDrivers(); state.selectDriver('22'); await flushPromises() await state.searchDrivers(); expect(listDrivers).toHaveBeenCalledWith(expect.objectContaining({ dispatchNo: 'PC-OWN' })); state.selectDriver('22'); await flushPromises()
expect(wrapper.emitted('update:driver')!.at(-1)![0]).toMatchObject({ driverId: '22', evidence: { fileIds: [], masterLicenseFileId: '33' } }) expect(wrapper.emitted('update:driver')!.at(-1)![0]).toMatchObject({ driverId: '22', evidence: { fileIds: [], masterLicenseFileId: '33' } })
wrapper.unmount() wrapper.unmount()
}) })
@@ -76,7 +76,7 @@ it('filters real unavailable statuses, sends dispatch context, and lets an inval
expect(listVehicles).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'IN_SERVICE', region: 'OVERSEAS', dispatchNo: 'PC-REAL' })) expect(listVehicles).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'IN_SERVICE', region: 'OVERSEAS', dispatchNo: 'PC-REAL' }))
const options = wrapper.findAllComponents({ name: 'ElOption' }) const options = wrapper.findAllComponents({ name: 'ElOption' })
expect(options.find(item => item.props('value') === 'good')?.props('disabled')).toBe(false) expect(options.find(item => item.props('value') === 'good')?.props('disabled')).toBe(false)
expect(options.find(item => item.props('value') === 'bad')?.props('disabled')).toBe(true) expect(options.find(item => item.props('value') === 'bad')).toBeUndefined()
state.selectVehicle('bad'); expect(wrapper.emitted('update:vehicle')).toBeUndefined() state.selectVehicle('bad'); expect(wrapper.emitted('update:vehicle')).toBeUndefined()
state.selectVehicle(null); expect(wrapper.emitted('update:vehicle')!.at(-1)![0]).toMatchObject({ vehicleId: null }) state.selectVehicle(null); expect(wrapper.emitted('update:vehicle')!.at(-1)![0]).toMatchObject({ vehicleId: null })
wrapper.unmount() wrapper.unmount()
@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
import { listDrivers, listVehicles, overseasMasterDataPermissions as resourcePermission, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data' import { listDrivers, listVehicles, overseasMasterDataPermissions as resourcePermission, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import type { Driver, Vehicle } from '../dispatchContracts' import type { Driver, Vehicle } from '../dispatchContracts'
import DispatchTimeZonePreview from './DispatchTimeZonePreview.vue'
const vehicle = defineModel<Vehicle>('vehicle', { required: true }) const vehicle = defineModel<Vehicle>('vehicle', { required: true })
const driver = defineModel<Driver>('driver', { required: true }) const driver = defineModel<Driver>('driver', { required: true })
const props = defineProps<{ disabled: boolean; continuous?: boolean; dispatchNo?: string }>() const props = defineProps<{ disabled: boolean; continuous?: boolean; dispatchNo?: string }>()
@@ -36,7 +37,7 @@ async function searchVehicles(keyword = '') {
async function searchDrivers(keyword = '') { async function searchDrivers(keyword = '') {
if (!access.can(resourcePermission.driverList)) { error.value = '缺少司机档案读取权限'; return } if (!access.can(resourcePermission.driverList)) { error.value = '缺少司机档案读取权限'; return }
const sequence = ++driverSequence const sequence = ++driverSequence
try { const result = await listDrivers({ page: 1, size: 100, keyword, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: driver.value.evidence?.expectedTaskEndAt || undefined }); if (sequence === driverSequence) drivers.value = result.records } try { const result = await listDrivers({ page: 1, size: 100, keyword, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', dispatchNo: props.dispatchNo, expectedEnd: driver.value.evidence?.expectedTaskEndAt || undefined }); if (sequence === driverSequence) drivers.value = result.records }
catch (cause) { if (sequence === driverSequence) error.value = cause instanceof Error ? cause.message : '司机候选读取失败' } catch (cause) { if (sequence === driverSequence) error.value = cause instanceof Error ? cause.message : '司机候选读取失败' }
} }
function changeVehicle() { vehicle.value = { mode: vehicle.value.mode, vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] } } function changeVehicle() { vehicle.value = { mode: vehicle.value.mode, vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] } }
@@ -57,7 +58,7 @@ function selectDriver(id: string) {
<template v-if="continuous"> <template v-if="continuous">
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon /> <el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<table class="density-table density-table--editable dispatch-resource-table"><tbody> <table class="density-table density-table--editable dispatch-resource-table"><tbody>
<tr><td><el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicleOptions.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" disabled /><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr> <tr><td><el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><template #label>{{ vehicle.plate || '请选择车辆' }}</template><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
<template v-if="vehicle.mode === 'EXTERNAL'"> <template v-if="vehicle.mode === 'EXTERNAL'">
<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><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> <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>
@@ -66,7 +67,7 @@ function selectDriver(id: string) {
<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><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 === '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> <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>
<tr v-if="driver.evidence"><td v-if="driver.mode === 'EXTERNAL'"><el-form-item label="驾驶证到期日"><el-date-picker v-model="driver.evidence.licenseExpiresOn" :disabled="disabled" type="date" value-format="YYYY-MM-DD" placeholder="请选择证照到期日" /></el-form-item></td><td :colspan="driver.mode === 'MASTER' ? 2 : 1"><el-form-item label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item></td></tr> <tr v-if="driver.evidence"><td v-if="driver.mode === 'EXTERNAL'"><el-form-item label="驾驶证到期日"><el-date-picker v-model="driver.evidence.licenseExpiresOn" :disabled="disabled" type="date" value-format="YYYY-MM-DD" placeholder="请选择证照到期日" /></el-form-item></td><td :colspan="driver.mode === 'MASTER' ? 2 : 1"><el-form-item label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /><DispatchTimeZonePreview :value="driver.evidence.expectedTaskEndAt" label="预计任务结束" /></el-form-item></td></tr>
</tbody></table> </tbody></table>
</template> </template>
@@ -74,7 +75,7 @@ function selectDriver(id: string) {
<p v-if="error" role="alert">{{ error }}</p> <p v-if="error" role="alert">{{ error }}</p>
<div class="resource-grid"> <div class="resource-grid">
<el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item> <el-form-item label="车辆来源"><template #label><span>车辆来源<InlineHelp title="临时资料说明"><p>临时车辆和司机仅用于本趟,不创建档案。保存排车单后,在本车维护中上传证据,资料齐备后由调度审核。</p></InlineHelp></span></template><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item>
<el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicleOptions.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" disabled /><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item> <el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" clearable filterable remote no-data-text="暂无可用车辆" :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><template #label>{{ vehicle.plate || '请选择车辆' }}</template><el-option v-for="item in vehicleOptions" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item>
<template v-else> <template v-else>
<el-form-item label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item> <el-form-item label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item>
<template v-if="vehicle.capacity"><el-form-item label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item></template> <template v-if="vehicle.capacity"><el-form-item label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item></template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getDispatchReturnOptions } from '../api/dispatches'
import type { DispatchReturnCandidate, DispatchReturnSelection } from '../dispatchContracts'
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 = [] }
/** 仅提交用户明确填写的原出库来源与退回箱数。 */
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]! }] : []) }] : []
}
/** 每次打开按本车最新派送事实加载候选,失败明确阻断退回提交。 */
async function load() {
loading.value = true; error.value = ''; selections.value = []
try { candidates.value = await getDispatchReturnOptions(props.dispatchNo) }
catch (cause) { error.value = getApiErrorDetails(cause, '来源货物加载失败').message }
finally { loading.value = false }
}
onMounted(load)
</script>
<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-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>
</template>
@@ -0,0 +1,34 @@
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import DispatchTimeZonePreview from './DispatchTimeZonePreview.vue'
it.each([
['2026-07-01T10:00:00Z', '2026-07-01 06:00:00', '2026-07-01 03:00:00'],
['2026-12-01T10:00:00Z', '2026-12-01 05:00:00', '2026-12-01 02:00:00'],
['2026-07-01T08:00:00+08:00', '2026-06-30 20:00:00', '2026-06-30 17:00:00'],
['2026-11-01T05:30:00Z', '2026-11-01 01:30:00', '2026-10-31 22:30:00'],
['2026-11-01T06:30:00Z', '2026-11-01 01:30:00', '2026-10-31 23:30:00'],
])('converts the same instant with regional daylight saving rules: %s', (value, eastern, pacific) => {
const wrapper = mount(DispatchTimeZonePreview, { props: { value, label: '计划发车时间' } })
expect(wrapper.attributes('aria-label')).toBe('计划发车时间时区换算')
expect(wrapper.findAll('dt').map(item => item.text())).toEqual(['美东', '美西'])
expect(wrapper.findAll('dd').map(item => item.text())).toEqual([eastern, pacific])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
it.each([null, '', 'invalid', '2026-07-01T10:00:00', '2026-07-01'])('does not invent an instant for an empty or unzoned value: %s', value => {
const wrapper = mount(DispatchTimeZonePreview, { props: { value, label: '发车时间' } })
expect(wrapper.findAll('dd').map(item => item.text())).toEqual(['—', '—'])
wrapper.unmount()
})
it('updates and clears both previews without emitting a changed input', async () => {
const wrapper = mount(DispatchTimeZonePreview, { props: { value: '2026-07-01T10:00:00Z', label: '预计到达时间' } })
await wrapper.setProps({ value: '2026-12-01T10:00:00Z' })
expect(wrapper.findAll('dd').map(item => item.text())).toEqual(['2026-12-01 05:00:00', '2026-12-01 02:00:00'])
await wrapper.setProps({ value: '' })
expect(wrapper.findAll('dd').map(item => item.text())).toEqual(['—', '—'])
expect(wrapper.emitted()).toEqual({})
wrapper.unmount()
})
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ value?: string | null; label: string }>()
const zones = [
{ label: '美东', timeZone: 'America/New_York' },
{ label: '美西', timeZone: 'America/Los_Angeles' },
]
// 只换算带时差的同一时间点,地区时区负责夏令时;不回写原始录入值。
const times = computed(() => {
const instant = props.value && /(?:Z|[+-]\d{2}:\d{2})$/i.test(props.value) ? Date.parse(props.value) : NaN
return zones.map(zone => {
if (!Number.isFinite(instant)) return { label: zone.label, value: '—' }
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', {
timeZone: zone.timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
}).formatToParts(instant).map(part => [part.type, part.value]))
return { label: zone.label, value: `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}` }
})
})
</script>
<template>
<dl class="dispatch-time-zone-preview" :aria-label="`${label}时区换算`">
<div v-for="time in times" :key="time.label"><dt>{{ time.label }}</dt><dd>{{ time.value }}</dd></div>
</dl>
</template>
<style scoped>
.dispatch-time-zone-preview { display: flex; flex: 0 0 100%; flex-wrap: wrap; gap: 2px 12px; width: 100%; margin: 0; color: var(--cc-muted); font-size: var(--admin-font-body, 13px); line-height: 1.6; }
.dispatch-time-zone-preview > div { display: flex; flex-wrap: wrap; gap: 0 6px; }
.dispatch-time-zone-preview dt, .dispatch-time-zone-preview dd { margin: 0; white-space: nowrap; }
</style>
@@ -4,6 +4,7 @@ import type { DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
import type { DispatchDestinationAppointment, DispatchVehicleMaintenance } from '../dispatchVehicleMaintenance' import type { DispatchDestinationAppointment, DispatchVehicleMaintenance } from '../dispatchVehicleMaintenance'
import { formatOverseasDateTime } from '../lastMilePresentation' import { formatOverseasDateTime } from '../lastMilePresentation'
import DispatchResourceFields from './DispatchResourceFields.vue' import DispatchResourceFields from './DispatchResourceFields.vue'
import DispatchTimeZonePreview from './DispatchTimeZonePreview.vue'
const props = defineProps<{ detail: DispatchDetail; disabled: boolean }>() const props = defineProps<{ detail: DispatchDetail; disabled: boolean }>()
const vehicle = defineModel<Vehicle>('vehicle', { required: true }) const vehicle = defineModel<Vehicle>('vehicle', { required: true })
@@ -18,9 +19,9 @@ const loadTypes = ['托盘', '地板', '混装']
const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车'] const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
const appointmentFacts = computed(() => new Map(props.detail.dispatchInfo.fbaAppointments.map(row => [row.destinationKey, row]))) const appointmentFacts = computed(() => new Map(props.detail.dispatchInfo.fbaAppointments.map(row => [row.destinationKey, row])))
/** 修改编号后不继续显示原编号的确认时间,保存后由服务端重新匹配正式确认。 */ /** 修改编号后不继续显示原编号的确认时间,保存后由服务端重新匹配正式确认。 */
function confirmedTime(appointment: DispatchDestinationAppointment) { function confirmedAt(appointment: DispatchDestinationAppointment) {
const fact = appointmentFacts.value.get(appointment.destinationKey) const fact = appointmentFacts.value.get(appointment.destinationKey)
return formatOverseasDateTime(fact?.appointmentNo === appointment.appointmentNo?.trim() ? fact?.confirmedAt : null) return fact?.appointmentNo === appointment.appointmentNo?.trim() ? fact?.confirmedAt : null
} }
</script> </script>
@@ -30,12 +31,12 @@ function confirmedTime(appointment: DispatchDestinationAppointment) {
<div class="density-dialog-sections"> <div class="density-dialog-sections">
<section class="density-dialog-section" aria-label="本车资源登记"> <section class="density-dialog-section" aria-label="本车资源登记">
<h3 class="density-dialog-section__title">车辆与司机</h3> <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 /></el-form-item></td></tr></tbody></table> <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>
<DispatchResourceFields :dispatch-no="detail.dispatchNo" continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" /> <DispatchResourceFields :dispatch-no="detail.dispatchNo" continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="disabled" />
</section> </section>
<section class="density-dialog-section" aria-label="本车时间登记"> <section class="density-dialog-section" aria-label="本车时间登记">
<h3 class="density-dialog-section__title">排车时间</h3> <h3 class="density-dialog-section__title">排车时间</h3>
<table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="plannedDepartureAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="estimatedArrivalAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table> <table class="density-table density-table--editable maintenance-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="plannedDepartureAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /><DispatchTimeZonePreview :value="plannedDepartureAt" label="计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="estimatedArrivalAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /><DispatchTimeZonePreview :value="estimatedArrivalAt" label="预计到达时间" /></el-form-item></td></tr></tbody></table>
</section> </section>
<section class="density-dialog-section" aria-label="派送补充资料"> <section class="density-dialog-section" aria-label="派送补充资料">
<h3 class="density-dialog-section__title">派送信息</h3> <h3 class="density-dialog-section__title">派送信息</h3>
@@ -49,7 +50,7 @@ function confirmedTime(appointment: DispatchDestinationAppointment) {
<el-form-item label="预约账号"><el-input v-model="maintenance.appointmentAccount" :disabled="disabled" maxlength="128" placeholder="请输入预约账号" /></el-form-item> <el-form-item label="预约账号"><el-input v-model="maintenance.appointmentAccount" :disabled="disabled" maxlength="128" placeholder="请输入预约账号" /></el-form-item>
</div> </div>
<table v-if="maintenance.destinationAppointments.length" class="density-table density-table--editable" aria-label="目的仓预约"><thead><tr><th>目的仓</th><th>预约号</th><th>确认送达时间</th><th>货件号</th></tr></thead><tbody> <table v-if="maintenance.destinationAppointments.length" class="density-table density-table--editable" aria-label="目的仓预约"><thead><tr><th>目的仓</th><th>预约号</th><th>确认送达时间</th><th>货件号</th></tr></thead><tbody>
<tr v-for="appointment in maintenance.destinationAppointments" :key="appointment.destinationKey"><td>{{ appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '—' }}</td><td><el-form-item :label="`${appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '目的仓'} 预约号`"><el-input v-model="appointment.appointmentNo" :disabled="disabled" maxlength="128" placeholder="填写已约好的编号,提交审核前必填" /></el-form-item></td><td>{{ confirmedTime(appointment) }}</td><td>{{ appointmentFacts.get(appointment.destinationKey)?.shipmentNumbers?.join('、') || '—' }}</td></tr> <tr v-for="appointment in maintenance.destinationAppointments" :key="appointment.destinationKey"><td>{{ appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '—' }}</td><td><el-form-item :label="`${appointmentFacts.get(appointment.destinationKey)?.destinationWarehouseCode || '目的仓'} 预约号`"><el-input v-model="appointment.appointmentNo" :disabled="disabled" maxlength="128" placeholder="填写已约好的编号,提交审核前必填" /></el-form-item></td><td>{{ formatOverseasDateTime(confirmedAt(appointment)) }}<DispatchTimeZonePreview v-if="confirmedAt(appointment)" :value="confirmedAt(appointment)" label="确认送达时间" /></td><td>{{ appointmentFacts.get(appointment.destinationKey)?.shipmentNumbers?.join('、') || '—' }}</td></tr>
</tbody></table> </tbody></table>
</section> </section>
<section class="density-dialog-section" aria-label="本车费用信息"><h3 class="density-dialog-section__title">费用信息</h3><slot name="fees"><p class="density-dialog-note">{{ detail.documents.deliveries.length ? '费用以关联派送单的正式费用记录为准。' : '尚未生成派送单费用。' }}</p></slot></section> <section class="density-dialog-section" aria-label="本车费用信息"><h3 class="density-dialog-section__title">费用信息</h3><slot name="fees"><p class="density-dialog-note">{{ detail.documents.deliveries.length ? '费用以关联派送单的正式费用记录为准。' : '尚未生成派送单费用。' }}</p></slot></section>
@@ -88,6 +88,8 @@ describe('dispatch workflow native layout', () => {
expect(table.findAll('input')).toHaveLength(1) expect(table.findAll('input')).toHaveLength(1)
expect((table.get('input').element as HTMLInputElement).value).toBe('ISA-ONE') expect((table.get('input').element as HTMLInputElement).value).toBe('ISA-ONE')
expect(table.findAll('tbody td')[2]!.text()).not.toBe('—') expect(table.findAll('tbody td')[2]!.text()).not.toBe('—')
expect(table.get('[aria-label="确认送达时间时区换算"]').text()).toContain('美东2026-10-25 13:00:00')
expect(table.get('[aria-label="确认送达时间时区换算"]').text()).toContain('美西2026-10-25 10:00:00')
for (const shipment of shipmentNumbers) expect(table.text()).toContain(shipment) for (const shipment of shipmentNumbers) expect(table.text()).toContain(shipment)
await table.get('input').setValue('ISA-CHANGED') await table.get('input').setValue('ISA-CHANGED')
expect(table.findAll('tbody td')[2]!.text()).toBe('—') expect(table.findAll('tbody td')[2]!.text()).toBe('—')
@@ -144,6 +146,26 @@ describe('dispatch workflow native layout', () => {
await dialog.findAll('button').find(button => button.text() === '驳回')!.trigger('click'); await flushPromises() await dialog.findAll('button').find(button => button.text() === '驳回')!.trigger('click'); await flushPromises()
expect(action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ decision: 'REJECTED', reason: '请客服补齐预约时间' })) expect(action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ decision: 'REJECTED', reason: '请客服补齐预约时间' }))
}) })
it('shows eastern and pacific previews for maintenance times and saves the original offset values', async () => {
const source = detail()
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(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')
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-07-02T08:00:00+08:00')
await flushPromises()
expect(field(dialog, '计划发车时间').get('[aria-label="计划发车时间时区换算"]').text()).toBe('美东2026-07-01 20:00:00美西2026-07-01 17:00:00')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({
plannedDepartureAt: '2026-07-02T08:00:00+08:00', estimatedArrivalAt: '2026-07-01T17:00:00Z',
driver: expect.objectContaining({ evidence: expect.objectContaining({ expectedTaskEndAt: '2026-11-01T10:00:00Z' }) }),
}))
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt')
})
it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => { it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => {
const { dialog } = await render('EDIT') const { dialog } = await render('EDIT')
expect(dialog.props('title')).toBe('维护车辆 · PC/1') expect(dialog.props('title')).toBe('维护车辆 · PC/1')
@@ -288,6 +310,16 @@ describe('dispatch workflow native layout', () => {
}) })
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => { it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
const { dialog } = await render(action) const { dialog } = await render(action)
if (action === 'CANCEL') {
expect(dialog.props('confirmText')).toBe('确认取消排车')
expect(dialog.props('confirmType')).toBe('danger')
expect(dialog.props('cancelText')).toBe('暂不取消')
expect(dialog.props('width')).toBe('560px')
expect(field(dialog, '取消原因').props()).toMatchObject({ labelPosition: 'top', required: true })
expect(dialog.get('textarea').attributes('placeholder')).toBe('请填写取消原因')
expect(dialog.find('[aria-label="操作原因与意见"]').exists()).toBe(false)
expect(dialog.text()).toContain('取消本次排车,不会自动回库。')
}
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW' const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() } if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
@@ -296,6 +328,20 @@ describe('dispatch workflow native layout', () => {
dialog.vm.$emit('confirm'); await flushPromises() dialog.vm.$emit('confirm'); await flushPromises()
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: null }) }) expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: null }) })
}) })
it('preserves the cancellation reason after failure and refreshing current facts', async () => {
const { dialog } = await render('CANCEL')
await dialog.get('textarea').setValue(' 客户调整送货安排 ')
vi.mocked(api.cancelDispatch).mockRejectedValueOnce(new ApiResponseError('排车资料已更新,请核对后重试', 'A0409', 409))
dialog.vm.$emit('confirm'); await flushPromises()
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户调整送货安排 ')
expect(dialog.get('textarea').attributes('disabled')).toBeUndefined()
expect(dialog.props('confirmLoading')).toBe(false)
dialog.getComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户调整送货安排 ')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.cancelDispatch).toHaveBeenCalledTimes(2)
expect(vi.mocked(api.cancelDispatch).mock.calls[1]![1]).toMatchObject({ recordVersion: 1, reason: '客户调整送货安排' })
})
it('reorders only real unload stops and preserves the external stop and zero route version', async () => { it('reorders only real unload stops and preserves the external stop and zero route version', async () => {
const { dialog } = await render('EDIT', 'ROUTE') const { dialog } = await render('EDIT', 'ROUTE')
const rows = dialog.get('[aria-label="本车卸货顺序"]').findAll('tbody tr').filter(row => row.findAll('button').length) const rows = dialog.get('[aria-label="本车卸货顺序"]').findAll('tbody tr').filter(row => row.findAll('button').length)
@@ -15,7 +15,7 @@ vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />'
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() })) vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn() })) vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn() }))
import * as transportApi from '../api/transportTasks' import * as transportApi from '../api/transportTasks'
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail } interface State { returnSelections: import('../dispatchContracts').DispatchReturnSelection[]; exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
async function open(action: DispatchAction, allowed = true, overrides: Partial<DispatchDetail> = {}) { async function open(action: DispatchAction, allowed = true, overrides: Partial<DispatchDetail> = {}) {
vi.mocked(api.getDispatch).mockResolvedValue(detail({ ...overrides, actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } })) vi.mocked(api.getDispatch).mockResolvedValue(detail({ ...overrides, actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises() const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
@@ -209,6 +209,8 @@ it('requires an exception type and submits optional direction, description and i
await state.submit(); expect(api.markDispatchException).not.toHaveBeenCalled() await state.submit(); expect(api.markDispatchException).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请选择异常类型') expect(wrapper.text()).toContain('请选择异常类型')
Object.assign(state.exception, { exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: ' 现场部分签收 ', imageFileIds: ['91'] }) Object.assign(state.exception, { exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: ' 现场部分签收 ', imageFileIds: ['91'] })
await state.submit(); expect(api.markDispatchException).not.toHaveBeenCalled()
state.returnSelections = [{ deliveryNo: 'DL1', lastMileOrderId: '9', items: [{ outboundPalletItemId: '1', inventoryPositionId: '2', returnCartonId: null, packageCount: 1 }] }]
vi.mocked(api.markDispatchException).mockImplementation(async (_, body) => ({ ...command('MARK_EXCEPTION', body.idempotencyKey), stage: 'DELIVERING' })) vi.mocked(api.markDispatchException).mockImplementation(async (_, body) => ({ ...command('MARK_EXCEPTION', body.idempotencyKey), stage: 'DELIVERING' }))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'DELIVERING', recordVersion: 1 })) vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'DELIVERING', recordVersion: 1 }))
await state.submit() await state.submit()
@@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import DispatchReturnCargo from './DispatchReturnCargo.vue'
import type { DispatchReturnSelection } from '../dispatchContracts'
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
const returnSelections = ref<DispatchReturnSelection[]>([])
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth' import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -96,6 +99,7 @@ async function load() {
} }
form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance() form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance()
fillDestinationAppointments(value) fillDestinationAppointments(value)
returnSelections.value = []
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] }) Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
const oldVehicle = value.vehicleInfo.vehicle const oldVehicle = value.vehicleInfo.vehicle
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle() vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
@@ -135,7 +139,8 @@ function payload(): object {
switch (currentAction.value) { switch (currentAction.value) {
case 'MARK_EXCEPTION': case 'MARK_EXCEPTION':
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型') if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException if (exception.returnDirection === 'OVERSEAS_WAREHOUSE' && (!returnSelections.value.length || returnSelections.value.some(row => !row.items.length))) throw new DispatchInputError('请选择来源订单及退回货物数量')
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds], returnSelections: exception.returnDirection === 'OVERSEAS_WAREHOUSE' ? returnSelections.value : [] } satisfies MarkDispatchException
case 'EDIT': case 'EDIT':
if (missingVehicle.value && vehicle.value.evidenceFileIds.length) throw new DispatchInputError('请先选择车辆,再保存车牌图片;当前图片尚未保存') if (missingVehicle.value && vehicle.value.evidenceFileIds.length) throw new DispatchInputError('请先选择车辆,再保存车牌图片;当前图片尚未保存')
if (missingDriver.value && (driver.value.evidence?.fileIds.length || driverPhotos.value.length)) throw new DispatchInputError('请先选择司机,再保存司机照片和驾驶证照片;当前图片尚未保存') if (missingDriver.value && (driver.value.evidence?.fileIds.length || driverPhotos.value.length)) throw new DispatchInputError('请先选择司机,再保存司机照片和驾驶证照片;当前图片尚未保存')
@@ -145,7 +150,7 @@ function payload(): object {
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因') if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
case 'CANCEL': case 'RETURN_TO_REVIEW': case 'CANCEL': case 'RETURN_TO_REVIEW':
if (!form.reason.trim()) throw new DispatchInputError('请填写操作原因') if (!form.reason.trim()) throw new DispatchInputError(currentAction.value === 'CANCEL' ? '请填写取消原因' : '请填写操作原因')
return { ...common, reason: form.reason.trim() } satisfies ReasonCommand return { ...common, reason: form.reason.trim() } satisfies ReasonCommand
case 'LOADING_COMPLETE': { case 'LOADING_COMPLETE': {
if (!preview.value || preview.value.pickingReconciled !== true || preview.value.loadingComplete.allowed !== true) throw new DispatchInputError(preview.value?.loadingComplete.blockReason || 'PDA真实拣货尚未核平') if (!preview.value || preview.value.pickingReconciled !== true || preview.value.loadingComplete.allowed !== true) throw new DispatchInputError(preview.value?.loadingComplete.blockReason || 'PDA真实拣货尚未核平')
@@ -245,7 +250,7 @@ function moveStop(index: number, offset: number) { const target = index + offset
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true }) watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
</script> </script>
<template> <template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '当前不可操作' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()"> <AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '当前不可操作' : currentAction === 'CANCEL' ? '确认取消排车' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" :confirm-type="currentAction === 'CANCEL' ? 'danger' : 'primary'" :cancel-text="currentAction === 'CANCEL' ? '暂不取消' : '取消'" @confirm="submit()">
<div class="density-dialog-sections"> <div class="density-dialog-sections">
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon /> <el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon /> <el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
@@ -260,6 +265,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<template v-else-if="currentAction === 'MARK_EXCEPTION'"> <template v-else-if="currentAction === 'MARK_EXCEPTION'">
<el-form-item label="异常类型" required><el-select v-model="exception.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, value) in dispatchExceptionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item> <el-form-item label="异常类型" required><el-select v-model="exception.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, value) in dispatchExceptionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="退货去向"><el-select v-model="exception.returnDirection" clearable placeholder="请选择退货去向"><el-option v-for="(label, value) in dispatchReturnDirectionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item> <el-form-item label="退货去向"><el-select v-model="exception.returnDirection" clearable placeholder="请选择退货去向"><el-option v-for="(label, value) in dispatchReturnDirectionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<DispatchReturnCargo v-if="exception.returnDirection === 'OVERSEAS_WAREHOUSE'" :key="currentNo" v-model="returnSelections" :dispatch-no="currentNo" :disabled="frozen || loading || !allowed" />
<el-form-item label="异常说明"><el-input v-model="exception.description" type="textarea" :rows="3" :maxlength="1000" show-word-limit /></el-form-item> <el-form-item label="异常说明"><el-input v-model="exception.description" type="textarea" :rows="3" :maxlength="1000" show-word-limit /></el-form-item>
<DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" /> <DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" />
</template> </template>
@@ -307,14 +313,17 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<section class="density-dialog-section" data-tone="work" aria-label="确认装车完成"><h3 class="density-dialog-section__title">确认装车完成</h3><p>已完成 {{ preview.pallets.length }} 托拣货,共 {{ preview.pallets.reduce((sum, row) => sum + Number(row.pickedPackages), 0) }} 箱。请确认本车货物已全部装车。</p></section> <section class="density-dialog-section" data-tone="work" aria-label="确认装车完成"><h3 class="density-dialog-section__title">确认装车完成</h3><p>已完成 {{ preview.pallets.length }} 托拣货,共 {{ preview.pallets.reduce((sum, row) => sum + Number(row.pickedPackages), 0) }} 箱。请确认本车货物已全部装车。</p></section>
</template> </template>
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon /> <el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon /> <el-alert v-else-if="currentAction === 'CANCEL'" title="取消本次排车,不会自动回库。" type="warning" :closable="false" show-icon />
<section v-if="isReview || currentAction === 'SUBMIT_REVIEW' && !vehicleEditor" class="density-dialog-section" data-tone="source" aria-label="审核照片"> <section v-if="isReview || currentAction === 'SUBMIT_REVIEW' && !vehicleEditor" class="density-dialog-section" data-tone="source" aria-label="审核照片">
<h3 class="density-dialog-section__title">车辆司机照片</h3> <h3 class="density-dialog-section__title">车辆司机照片</h3>
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片"> <div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" /> <DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
</div> </div>
</section> </section>
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="currentAction === 'DISPATCH_REVIEW' ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section> <el-form-item v-if="currentAction === 'CANCEL' && currentOperation !== 'ROUTE'" label="取消原因" label-position="top" required>
<el-input v-model="form.reason" aria-label="取消原因" type="textarea" :rows="4" placeholder="请填写取消原因" />
</el-form-item>
<section v-else-if="currentOperation === 'ROUTE' || ['RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="currentAction === 'DISPATCH_REVIEW' ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || currentAction === 'RETURN_TO_REVIEW' ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section> <section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
</el-form> </el-form>
</div> </div>
@@ -4,13 +4,13 @@ import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus'
import { beforeEach, expect, it, vi } from 'vitest' import { beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http' import { ApiResponseError } from '@/shared/api/http'
import ExceptionProcessingPanel from './ExceptionProcessingPanel.vue' import ExceptionProcessingPanel from './ExceptionProcessingPanel.vue'
import { getExceptionDetail, commandException, uploadExceptionEvidence, type ExceptionDetail, normalizeExceptionDetail } from '../api/exceptionOrders' import { generateExceptionReturn, getExceptionDetail, commandException, uploadExceptionEvidence, type ExceptionDetail, normalizeExceptionDetail } from '../api/exceptionOrders'
import { getPallet } from '../api/pallets' import { getPallet } from '../api/pallets'
import { getOverseasLocation } from '../api/locations' import { getOverseasLocation } from '../api/locations'
const can = vi.hoisted(() => vi.fn(() => true)) const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/exceptionOrders', async original => ({ ...await original<typeof import('../api/exceptionOrders')>(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() })) vi.mock('../api/exceptionOrders', async original => ({ ...await original<typeof import('../api/exceptionOrders')>(), generateExceptionReturn: vi.fn(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() }))
vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listPallets: vi.fn() })) vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listPallets: vi.fn() }))
vi.mock('../api/locations', () => ({ getOverseasLocation: vi.fn() })) vi.mock('../api/locations', () => ({ getOverseasLocation: vi.fn() }))
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } }) const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
@@ -77,6 +77,53 @@ beforeEach(() => {
vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: '92', locationId: '40', palletCode: 'TARGET' } as Awaited<ReturnType<typeof getPallet>>) vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: '92', locationId: '40', palletCode: 'TARGET' } as Awaited<ReturnType<typeof getPallet>>)
vi.mocked(getOverseasLocation).mockResolvedValue({ id: '40', warehouseId: '92', locationCode: 'LOC' } as Awaited<ReturnType<typeof getOverseasLocation>>) vi.mocked(getOverseasLocation).mockResolvedValue({ id: '40', warehouseId: '92', locationCode: 'LOC' } as Awaited<ReturnType<typeof getOverseasLocation>>)
}) })
it.each(['PENDING', 'PROCESSING'])('generates a return order without evidence or receipt inputs in %s', async status => {
const state = snapshot('EX-1', 4)
state.order.status = status; state.order.evidenceFileIds = []
state.order.allowedActions = ['GENERATE_RETURN_INBOUND', 'COMPLETE_EXCEPTION', 'FORCE_CLOSE']
const generated = { ...state, returnOrderNo: 'WC-RETURN-1', order: { ...state.order, status: 'PROCESSING', allowedActions: ['COMPLETE_EXCEPTION'] } }
vi.mocked(getExceptionDetail).mockResolvedValueOnce(state).mockResolvedValue(generated)
vi.mocked(generateExceptionReturn).mockResolvedValue({ orderNo: 'WC-RETURN-1' })
const wrapper = open()
try {
await flushPromises()
expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false)
expect(wrapper.find('[data-test="quantity-0"]').exists()).toBe(false)
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
await wrapper.get('[data-test="generate-return"]').trigger('click'); await flushPromises()
expect(generateExceptionReturn).toHaveBeenCalledExactlyOnceWith('C', 'EX-1', 4)
expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('WC-RETURN-1')
expect(wrapper.text()).toContain('请在 PDA 退货入库中验收')
expect(wrapper.find('[data-test="generate-return"]').exists()).toBe(false)
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
expect(wrapper.emitted('changed')).toHaveLength(1)
} finally { wrapper.unmount() }
})
it.each(['RETURN', 'DAMAGED', 'REJECT', 'OTHER'])('offers other handling for %s and closes with a required reason only', async exceptionType => {
const state = snapshot('EX-1', 4)
state.order.exceptionType = exceptionType; state.order.allowedActions = ['FORCE_CLOSE', 'COMPLETE_EXCEPTION']; state.order.evidenceFileIds = []
state.returnOrderNo = exceptionType === 'RETURN' ? 'WC-RETURN-1' : null
vi.mocked(getExceptionDetail).mockResolvedValueOnce(state).mockResolvedValue(snapshot('EX-1', 5, 0))
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', status: 'COMPLETED', version: 5, restoredPackages: 0, replayed: false })
const wrapper = open()
try {
await flushPromises(); await wrapper.get('[data-test="processing-method"]').setValue('other'); await flushPromises()
expect(wrapper.get('[data-test="notes"]').attributes('placeholder')).toBe('请填写其他处理方式及完结原因')
expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false)
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
await wrapper.get('[data-test="notes"]').setValue(' ')
await wrapper.get('[data-test="force-close"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('请填写其他处理方式的完结原因'); expect(commandException).not.toHaveBeenCalled()
await wrapper.get('[data-test="notes"]').setValue(' 客户沟通后结束处理 ')
await wrapper.get('[data-test="force-close"]').trigger('click'); await flushPromises()
expect(commandException).toHaveBeenCalledExactlyOnceWith('C', 'EX-1', 'force-close', {
expectedVersion: 4, idempotencyKey: expect.any(String), reason: '客户沟通后结束处理',
})
expect(uploadExceptionEvidence).not.toHaveBeenCalled(); expect(generateExceptionReturn).not.toHaveBeenCalled()
expect(wrapper.find('[data-test="force-close"]').exists()).toBe(false)
} finally { wrapper.unmount() }
})
it('real receipt quantity control becomes accessible after starting processing', async () => { it('real receipt quantity control becomes accessible after starting processing', async () => {
const pending = snapshot('EX-1', 0, 2) const pending = snapshot('EX-1', 0, 2)
pending.order.status = 'PENDING'; pending.order.allowedActions = ['START_PROCESSING']; pending.returnSources = [] pending.order.status = 'PENDING'; pending.order.allowedActions = ['START_PROCESSING']; pending.returnSources = []
@@ -166,7 +213,10 @@ it('keeps a conflict visible and reloads when the operator reopens the detail',
wrapper.unmount() wrapper.unmount()
}) })
it('has no processing or upload controls without independent permission', async () => { it('has no processing or upload controls without independent permission', async () => {
const state = snapshot(); state.order.allowedActions = ['COMPLETE_EXCEPTION', 'FORCE_CLOSE']
vi.mocked(getExceptionDetail).mockResolvedValue(state)
can.mockReturnValue(false); const wrapper = open(); await flushPromises() can.mockReturnValue(false); const wrapper = open(); await flushPromises()
expect(wrapper.find('[data-test="processing-method"]').exists()).toBe(false); expect(wrapper.find('[data-test="force-close"]').exists()).toBe(false)
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false); expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false) expect(wrapper.find('[data-test="complete"]').exists()).toBe(false); expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false)
expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled(); wrapper.unmount() expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled(); wrapper.unmount()
}) })
@@ -3,7 +3,7 @@ import { computed, reactive, ref, watch } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http' import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
import { commandException, getExceptionDetail, uploadExceptionEvidence, type ExceptionAction, type ExceptionCommand, type ExceptionDetail, type ExceptionReturnSource } from '../api/exceptionOrders' import { generateExceptionReturn, commandException, getExceptionDetail, uploadExceptionEvidence, type ExceptionAction, type ExceptionCommand, type ExceptionDetail, type ExceptionReturnSource } from '../api/exceptionOrders'
import { getPallet, listPallets, type PalletDto } from '../api/pallets' import { getPallet, listPallets, type PalletDto } from '../api/pallets'
import { getOverseasLocation } from '../api/locations' import { getOverseasLocation } from '../api/locations'
import { formatOverseasDateTime } from '../lastMilePresentation' import { formatOverseasDateTime } from '../lastMilePresentation'
@@ -30,12 +30,17 @@ const pending = computed(() => pendingByScope.get(identity.value))
const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value) const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value)
const warehouseReturn = computed(() => detail.value?.order.exceptionType === 'RETURN' && detail.value.order.returnDirection === 'OVERSEAS_WAREHOUSE') const warehouseReturn = computed(() => detail.value?.order.exceptionType === 'RETURN' && detail.value.order.returnDirection === 'OVERSEAS_WAREHOUSE')
const allowed = (action: string) => canProcess.value && detail.value?.order.allowedActions?.includes(action) const allowed = (action: string) => canProcess.value && detail.value?.order.allowedActions?.includes(action)
const processingMethod = ref('standard')
const otherMethod = computed(() => processingMethod.value === 'other' && allowed('FORCE_CLOSE'))
// 已生成的退回单由 PDA 验收;生成入口与后台完成入口不能同时开放。
const canComplete = computed(() => allowed('COMPLETE_EXCEPTION') && !detail.value?.returnOrderNo && !allowed('GENERATE_RETURN_INBOUND'))
let sequence = 0 let sequence = 0
const targetSequences = new Map<number, number>() const targetSequences = new Map<number, number>()
const current = (ticket: number) => props.active && ticket === sequence const current = (ticket: number) => props.active && ticket === sequence
function definite(cause: unknown) { return cause instanceof ApiResponseError && /^A0[34]\d{2}$/.test(cause.code) && (!cause.status || cause.status < 500) } function definite(cause: unknown) { return cause instanceof ApiResponseError && /^A0[34]\d{2}$/.test(cause.code) && (!cause.status || cause.status < 500) }
async function load() { async function load() {
const ticket = ++sequence; loading.value = true; error.value = ''; detail.value = null; receipts.value = []; targets.value = []; evidence.value = []; selectedHistory.value = []; notes.value = ''; targetSequences.clear() const ticket = ++sequence; loading.value = true; error.value = ''; detail.value = null; receipts.value = []; targets.value = []; evidence.value = []; selectedHistory.value = []; notes.value = ''; targetSequences.clear()
processingMethod.value = 'standard'
try { try {
const result = await getExceptionDetail(props.customerId, props.exceptionNo) const result = await getExceptionDetail(props.customerId, props.exceptionNo)
if (!current(ticket)) return if (!current(ticket)) return
@@ -94,10 +99,12 @@ async function upload(event: Event) {
} finally { uploadingScopes.delete(scope); if (current(ticket)) busy.value = false; input.value = '' } } finally { uploadingScopes.delete(scope); if (current(ticket)) busy.value = false; input.value = '' }
} }
async function execute(action: ExceptionAction) { async function execute(action: ExceptionAction) {
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[action] if (action === 'complete' && !canComplete.value) return
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION', 'force-close': 'FORCE_CLOSE' }[action]
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName))) return if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName))) return
if (pending.value && pending.value.action !== action) return if (pending.value && pending.value.action !== action) return
if (!pending.value) { if (!pending.value) {
if (action === 'force-close' && !notes.value.trim()) { error.value = '请填写其他处理方式的完结原因'; return }
const selectedFiles = [...new Set([...evidence.value.map(row => row.fileId), ...selectedHistory.value])] const selectedFiles = [...new Set([...evidence.value.map(row => row.fileId), ...selectedHistory.value])]
if (action === 'complete' && (selectedFiles.length < 1 || selectedFiles.length > 20)) { error.value = '本次请选择或上传1–20份处理证据'; return } if (action === 'complete' && (selectedFiles.length < 1 || selectedFiles.length > 20)) { error.value = '本次请选择或上传1–20份处理证据'; return }
const selected = receipts.value.filter(row => Number(row.quantity) > 0) const selected = receipts.value.filter(row => Number(row.quantity) > 0)
@@ -110,6 +117,7 @@ async function execute(action: ExceptionAction) {
const payload: ExceptionCommand = { expectedVersion: detail.value!.order.version!, idempotencyKey: crypto.randomUUID() } const payload: ExceptionCommand = { expectedVersion: detail.value!.order.version!, idempotencyKey: crypto.randomUUID() }
if (action === 'start-processing') payload.handlingAction = notes.value.trim() || undefined if (action === 'start-processing') payload.handlingAction = notes.value.trim() || undefined
if (action === 'cancel') payload.reason = notes.value.trim() || '取消异常处理' if (action === 'cancel') payload.reason = notes.value.trim() || '取消异常处理'
if (action === 'force-close') payload.reason = notes.value.trim()
if (action === 'complete') { if (action === 'complete') {
payload.handlingResult = notes.value.trim() || (warehouseReturn.value ? '确认本次退回入库' : '确认异常处理完成'); payload.evidenceFileIds = selectedFiles payload.handlingResult = notes.value.trim() || (warehouseReturn.value ? '确认本次退回入库' : '确认异常处理完成'); payload.evidenceFileIds = selectedFiles
payload.receipts = warehouseReturn.value ? selected.map(row => ({ outboundPalletItemId: row.outboundPalletItemId, ...returnSourceReference(row), receivedPackages: row.quantity!, targetPalletId: row.target, targetPalletNo: row.palletNo, targetLocationId: row.locationId, targetLocationCode: row.locationCode })) : [] payload.receipts = warehouseReturn.value ? selected.map(row => ({ outboundPalletItemId: row.outboundPalletItemId, ...returnSourceReference(row), receivedPackages: row.quantity!, targetPalletId: row.target, targetPalletNo: row.palletNo, targetLocationId: row.locationId, targetLocationCode: row.locationCode })) : []
@@ -124,7 +132,7 @@ async function execute(action: ExceptionAction) {
busy.value = true; error.value = ''; message.value = '' busy.value = true; error.value = ''; message.value = ''
try { try {
const result = await commandException(customer, number, attempt.action, attempt.payload) const result = await commandException(customer, number, attempt.action, attempt.payload)
const statuses = attempt.action === 'complete' ? ['PROCESSING', 'COMPLETED'] : [attempt.action === 'cancel' ? 'CANCELLED' : 'PROCESSING'] const statuses = attempt.action === 'complete' ? ['PROCESSING', 'COMPLETED'] : [attempt.action === 'force-close' ? 'COMPLETED' : attempt.action === 'cancel' ? 'CANCELLED' : 'PROCESSING']
if (!result || result.exceptionNo !== number || Number(result.version) !== attempt.payload.expectedVersion + 1 || !statuses.includes(result.status)) if (!result || result.exceptionNo !== number || Number(result.version) !== attempt.payload.expectedVersion + 1 || !statuses.includes(result.status))
throw new Error('处理结果无法核对,请刷新当前记录') throw new Error('处理结果无法核对,请刷新当前记录')
if (sameAttempt()) { pendingByScope.delete(scope); savedEvidence.delete(scope) } if (sameAttempt()) { pendingByScope.delete(scope); savedEvidence.delete(scope) }
@@ -146,6 +154,22 @@ function selectHistory(fileId: string, event: Event) {
if (locked.value) return if (locked.value) return
selectedHistory.value = (event.target as HTMLInputElement).checked ? [...new Set([...selectedHistory.value, fileId])] : selectedHistory.value.filter(id => id !== fileId) selectedHistory.value = (event.target as HTMLInputElement).checked ? [...new Set([...selectedHistory.value, fileId])] : selectedHistory.value.filter(id => id !== fileId)
} }
/** 生成动作只建单,实物入账由PDA完成。 */
async function generateReturn() {
if (locked.value || !allowed('GENERATE_RETURN_INBOUND') || !detail.value) return
const ticket = sequence, scope = identity.value
busy.value = true; error.value = ''
try {
const result = await generateExceptionReturn(props.customerId, props.exceptionNo, detail.value.order.version!)
if (!result?.orderNo) throw new Error('生成结果无法核对,请重新打开详情')
if (current(ticket)) {
busy.value = false; emit('changed'); await load()
if (props.active && identity.value === scope) message.value = `已生成退回尾程单 ${result.orderNo},请在 PDA 退货入库中验收`
}
}
catch (cause) { if (current(ticket)) error.value = getApiErrorDetails(cause, '生成退回入库单失败').message }
finally { if (current(ticket)) busy.value = false }
}
function removeEvidence(index: number) { function removeEvidence(index: number) {
if (locked.value) return if (locked.value) return
evidence.value.splice(index, 1); savedEvidence.set(identity.value, [...evidence.value]) evidence.value.splice(index, 1); savedEvidence.set(identity.value, [...evidence.value])
@@ -158,6 +182,7 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<p v-if="error" role="alert">{{ error }}</p><p v-if="message" role="status">{{ message }}</p> <p v-if="error" role="alert">{{ error }}</p><p v-if="message" role="status">{{ message }}</p>
<template v-if="detail"> <template v-if="detail">
<el-descriptions class="density-descriptions exception-detail-fields" :column="2" border> <el-descriptions class="density-descriptions exception-detail-fields" :column="2" border>
<el-descriptions-item v-if="detail.returnOrderNo" label="退回尾程单">{{ detail.returnOrderNo }}(PDA退货入库验收)</el-descriptions-item>
<el-descriptions-item label="异常单号">{{ detail.order.exceptionNo }}</el-descriptions-item> <el-descriptions-item label="异常单号">{{ detail.order.exceptionNo }}</el-descriptions-item>
<el-descriptions-item label="处理状态">{{ ({ PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[detail.order.status] || detail.order.status }}</el-descriptions-item> <el-descriptions-item label="处理状态">{{ ({ PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[detail.order.status] || detail.order.status }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ detail.order.customerName || '—' }}</el-descriptions-item> <el-descriptions-item label="客户">{{ detail.order.customerName || '—' }}</el-descriptions-item>
@@ -200,8 +225,14 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p> <p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p>
<template v-if="canProcess"> <template v-if="canProcess">
<el-form class="density-form density-form--outlined exception-processing-form" label-position="left" label-width="140px" :disabled="locked"> <el-form class="density-form density-form--outlined exception-processing-form" label-position="left" label-width="140px" :disabled="locked">
<el-form-item label="处理说明 / 取消原因(选填)" label-width="220px"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" placeholder="请输入处理说明或取消原因(选填)" /></el-form-item> <el-form-item v-if="allowed('FORCE_CLOSE')" label="处理方式">
<template v-if="allowed('COMPLETE_EXCEPTION')"> <el-select v-model="processingMethod" data-test="processing-method" :disabled="locked">
<el-option value="standard" :label="allowed('GENERATE_RETURN_INBOUND') ? '退回海外仓' : '按流程处理'" />
<el-option value="other" label="其他" />
</el-select>
</el-form-item>
<el-form-item :label="otherMethod ? '完结原因(必填)' : '处理说明 / 取消原因(选填)'" :required="!!otherMethod" label-width="220px"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" :placeholder="otherMethod ? '请填写其他处理方式及完结原因' : '请输入处理说明或取消原因(选填)'" /></el-form-item>
<template v-if="canComplete && !otherMethod">
<label>处理证据 <input data-test="evidence" type="file" :disabled="locked" @change="upload" /></label> <label>处理证据 <input data-test="evidence" type="file" :disabled="locked" @change="upload" /></label>
<div v-for="(fileId, index) in detail.order.evidenceFileIds || []" :key="fileId"><label><input :data-test="`history-${index}`" type="checkbox" :checked="selectedHistory.includes(fileId)" :disabled="locked" @change="selectHistory(fileId, $event)" />选用历史证据 {{ index + 1 }}</label></div> <div v-for="(fileId, index) in detail.order.evidenceFileIds || []" :key="fileId"><label><input :data-test="`history-${index}`" type="checkbox" :checked="selectedHistory.includes(fileId)" :disabled="locked" @change="selectHistory(fileId, $event)" />选用历史证据 {{ index + 1 }}</label></div>
<ul><li v-for="(file, index) in evidence" :key="index">{{ file.fileName }} <el-button :disabled="locked" @click="removeEvidence(index)">移出本次提交</el-button></li></ul> <ul><li v-for="(file, index) in evidence" :key="index">{{ file.fileName }} <el-button :disabled="locked" @click="removeEvidence(index)">移出本次提交</el-button></li></ul>
@@ -217,12 +248,17 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
</template> </template>
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p> <p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
</template> </template>
<p v-if="otherMethod">填写原因后直接完结此异常单,无需上传资料。关联退回单及货物实收仍按实际业务处理。</p>
</el-form> </el-form>
<Teleport :to="actionsTarget || 'body'" :disabled="!actionsTarget" defer> <Teleport :to="actionsTarget || 'body'" :disabled="!actionsTarget" defer>
<div class="density-actions density-actions--footer"> <div class="density-actions density-actions--footer">
<el-button v-if="otherMethod" data-test="force-close" type="primary" :disabled="locked" @click="execute('force-close')">完结异常处理</el-button>
<template v-else>
<el-button v-if="allowed('GENERATE_RETURN_INBOUND') && !detail.returnOrderNo" data-test="generate-return" type="primary" :disabled="locked" @click="generateReturn">生成退回入库单</el-button>
<el-button v-if="allowed('START_PROCESSING')" data-test="start" type="primary" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button> <el-button v-if="allowed('START_PROCESSING')" data-test="start" type="primary" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" type="danger" plain :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button> <el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" type="danger" plain :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" type="primary" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button> <el-button v-if="canComplete" data-test="complete" type="primary" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
</template>
</div> </div>
</Teleport> </Teleport>
</template> </template>
@@ -133,7 +133,7 @@ async function candidates() {
try { try {
const [v, d] = await Promise.all([listVehicles({ page: 1, size: 100, region: 'OVERSEAS', status: 'IN_SERVICE' }), listDrivers({ page: 1, size: 100, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: trip.value.expectedDriverEndAt })]) const [v, d] = await Promise.all([listVehicles({ page: 1, size: 100, region: 'OVERSEAS', status: 'IN_SERVICE' }), listDrivers({ page: 1, size: 100, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: trip.value.expectedDriverEndAt })])
const [vo, dr] = await Promise.all([transportOccupancy(props.dispatchNo, 'VEHICLE', v.records.map(row => row.id)), transportOccupancy(props.dispatchNo, 'DRIVER', d.records.map(row => row.id))]) const [vo, dr] = await Promise.all([transportOccupancy(props.dispatchNo, 'VEHICLE', v.records.map(row => row.id)), transportOccupancy(props.dispatchNo, 'DRIVER', d.records.map(row => row.id))])
occupancy.value = Object.fromEntries([...vo.map(row => [`V:${row.resourceId}`, row.available]), ...dr.map(row => [`D:${row.resourceId}`, row.available])]); vehicles.value = v.records; drivers.value = d.records occupancy.value = Object.fromEntries([...vo.map(row => [`V:${row.resourceId}`, row.available]), ...dr.map(row => [`D:${row.resourceId}`, row.available])]); vehicles.value = v.records.filter(row => occupancy.value[`V:${row.id}`] === true); drivers.value = d.records.filter(row => occupancy.value[`D:${row.id}`] === true && row.qualification.canDispatch)
} catch (cause) { error.value = getApiErrorDetails(cause, '资源候选加载失败') } } catch (cause) { error.value = getApiErrorDetails(cause, '资源候选加载失败') }
finally { candidateBusy.value = false } finally { candidateBusy.value = false }
} }
@@ -206,6 +206,10 @@ onBeforeUnmount(clearTruckPreviewUrls)
<el-button link :disabled="locked" aria-label="刷新运输任务" @click="load"><RefreshCw :size="14" />刷新</el-button> <el-button link :disabled="locked" aria-label="刷新运输任务" @click="load"><RefreshCw :size="14" />刷新</el-button>
</div> </div>
</header> </header>
<dl v-if="detail.cargo" class="transport-summary-cards" aria-label="货物数量统计">
<div><dt>托数</dt><dd>{{ detail.cargo.palletCount }} 托</dd></div><div><dt>箱数</dt><dd>{{ detail.cargo.packageCount }} 箱</dd></div>
<div><dt>总重量</dt><dd>{{ detail.cargo.totalWeightKg ?? '—' }} kg</dd></div><div><dt>总体积</dt><dd>{{ detail.cargo.totalVolumeCbm ?? '—' }} m³</dd></div>
</dl>
<section class="workbench-panel is-compact transport-resources" aria-label="车辆信息"> <section class="workbench-panel is-compact transport-resources" aria-label="车辆信息">
<div class="transport-section-heading"><h2 class="workbench-section-title">车辆信息</h2><el-button v-if="canChangeResources && editable && !resourceFormOpen" link type="primary" :disabled="locked" @click="editingResources = true">维护车辆信息</el-button></div> <div class="transport-section-heading"><h2 class="workbench-section-title">车辆信息</h2><el-button v-if="canChangeResources && editable && !resourceFormOpen" link type="primary" :disabled="locked" @click="editingResources = true">维护车辆信息</el-button></div>
<dl class="transport-facts"> <dl class="transport-facts">
@@ -213,12 +217,23 @@ onBeforeUnmount(clearTruckPreviewUrls)
<div><dt>计划发车</dt><dd>{{ time(trip.plannedDepartureAt) }}</dd></div> <div><dt>计划发车</dt><dd>{{ time(trip.plannedDepartureAt) }}</dd></div>
<div><dt>预计结束</dt><dd>{{ time(trip.expectedDriverEndAt) }}</dd></div> <div><dt>预计结束</dt><dd>{{ time(trip.expectedDriverEndAt) }}</dd></div>
<template v-if="!resourceFormOpen"><div><dt>车辆</dt><dd>{{ trip.vehiclePlate || '待分配' }}</dd></div><div><dt>司机</dt><dd>{{ trip.driverName || '待分配' }}<span v-if="trip.driverPhone" class="transport-phone">{{ trip.driverPhone }}</span></dd></div></template> <template v-if="!resourceFormOpen"><div><dt>车辆</dt><dd>{{ trip.vehiclePlate || '待分配' }}</dd></div><div><dt>司机</dt><dd>{{ trip.driverName || '待分配' }}<span v-if="trip.driverPhone" class="transport-phone">{{ trip.driverPhone }}</span></dd></div></template>
<div><dt>实际发车</dt><dd>{{ time(trip.departedAt) }}</dd></div>
<div><dt>实际完成</dt><dd>{{ time(trip.completedAt) }}</dd></div>
<div><dt>最近更新</dt><dd>{{ time(trip.updatedAt) }}</dd></div>
<div><dt>车辆容量</dt><dd>{{ trip.maxPalletSpaces ?? '—' }} 托 / {{ trip.maxLoadKg ?? '—' }} kg / {{ trip.maxVolumeCbm ?? '—' }} m³</dd></div>
<div><dt>驾驶证号</dt><dd>{{ detail.resources?.driver?.evidence?.licenseNo || '—' }}</dd></div>
<div><dt>驾驶证有效期</dt><dd>{{ detail.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</dd></div>
<div v-if="detail.orderNo"><dt>订单号</dt><dd>{{ detail.orderNo }}</dd></div> <div v-if="detail.orderNo"><dt>订单号</dt><dd>{{ detail.orderNo }}</dd></div>
<div v-if="detail.taskType === 'LOCAL_TRUCK'"><dt>路线</dt><dd>{{ trip.stops.map(stop => stop.name).join(' → ') }}</dd></div> <div v-if="detail.taskType === 'LOCAL_TRUCK'"><dt>路线</dt><dd>{{ trip.stops.map(stop => stop.name).join(' → ') }}</dd></div>
</dl> </dl>
<div v-if="readonly || ['COMPLETED', 'CANCELLED'].includes(trip.status)" class="transport-review-photos" aria-label="车辆司机资料">
<DispatchEvidenceFiles photo-card :model-value="detail.resources?.vehicle?.evidenceFileIds || []" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆照片" readonly />
<DispatchEvidenceFiles photo-card :model-value="detail.resources?.driver?.evidence?.photoFileIds || []" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" readonly />
<DispatchEvidenceFiles photo-card :model-value="detail.resources?.driver?.evidence?.fileIds || []" :dispatch-no="dispatchNo" kind="DRIVER" label="驾驶证照片" :master-license="detail.resources?.driver?.driverId && detail.resources.driver.evidence?.masterLicenseFileId ? { driverId: detail.resources.driver.driverId, fileId: detail.resources.driver.evidence.masterLicenseFileId } : null" readonly />
</div>
<el-form v-if="resourceFormOpen" label-position="left" :disabled="locked" class="density-form transport-resource-form"> <el-form v-if="resourceFormOpen" label-position="left" :disabled="locked" class="density-form transport-resource-form">
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item> <el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item> <el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
<section v-if="['PICKUP', 'LOCAL_TRUCK'].includes(detail.taskType)" class="transport-review-photos" aria-label="调度审核照片"> <section v-if="['PICKUP', 'LOCAL_TRUCK'].includes(detail.taskType)" class="transport-review-photos" aria-label="调度审核照片">
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :master-resource="vehicleId ? { kind: 'VEHICLE', id: vehicleId } : null" @available="masterPhotoAvailable.vehicle = $event" :disabled="locked" @busy="photoBusy.vehicle = $event" /> <DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :master-resource="vehicleId ? { kind: 'VEHICLE', id: vehicleId } : null" @available="masterPhotoAvailable.vehicle = $event" :disabled="locked" @busy="photoBusy.vehicle = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driverId ? { kind: 'DRIVER', id: driverId } : null" @available="masterPhotoAvailable.driver = $event" :disabled="locked" @busy="photoBusy.driver = $event" /> <DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driverId ? { kind: 'DRIVER', id: driverId } : null" @available="masterPhotoAvailable.driver = $event" :disabled="locked" @busy="photoBusy.driver = $event" />
@@ -228,6 +243,12 @@ onBeforeUnmount(clearTruckPreviewUrls)
<div class="density-actions transport-resource-save"><el-button type="primary" :loading="busy || candidateBusy" :disabled="locked || !vehicleId || !driverId || !photosComplete" @click="assign">保存车辆司机</el-button><el-button v-if="trip.vehicleId && trip.driverId" :disabled="locked" @click="editingResources = false; vehicleId = trip.vehicleId; driverId = trip.driverId; restoreVehiclePhotos()">收起</el-button></div> <div class="density-actions transport-resource-save"><el-button type="primary" :loading="busy || candidateBusy" :disabled="locked || !vehicleId || !driverId || !photosComplete" @click="assign">保存车辆司机</el-button><el-button v-if="trip.vehicleId && trip.driverId" :disabled="locked" @click="editingResources = false; vehicleId = trip.vehicleId; driverId = trip.driverId; restoreVehiclePhotos()">收起</el-button></div>
</el-form> </el-form>
</section> </section>
<section v-if="detail.cargo" class="workbench-panel is-compact" aria-label="货物与取送信息">
<h2 class="workbench-section-title">货物与取送信息</h2><dl class="transport-facts">
<div><dt>取货地</dt><dd>{{ detail.cargo.origin }}</dd></div><div><dt>取货地址</dt><dd>{{ detail.cargo.originAddress || '—' }}</dd></div><div><dt>取货联系人</dt><dd>{{ detail.cargo.originContactName }} {{ detail.cargo.originContactPhone }}</dd></div>
<div><dt>送达地</dt><dd>{{ detail.cargo.destination }}</dd></div><div><dt>送达地址</dt><dd>{{ detail.cargo.destinationAddress || '—' }}</dd></div><div><dt>送达联系人</dt><dd>{{ detail.cargo.destinationContactName }} {{ detail.cargo.destinationContactPhone }}</dd></div>
</dl>
</section>
<section v-if="detail.taskType === 'PICKUP'" class="transport-pickups" aria-label="取货明细"> <section v-if="detail.taskType === 'PICKUP'" class="transport-pickups" aria-label="取货明细">
<div class="transport-section-heading"><h2 class="workbench-section-title"><Package :size="16" aria-hidden="true" />取货明细</h2><span class="workbench-meta">{{ detail.pickups.length }} 单 · 预报 {{ detail.pickups.reduce((total, row) => total + row.packageCount, 0) }} 箱</span></div> <div class="transport-section-heading"><h2 class="workbench-section-title"><Package :size="16" aria-hidden="true" />取货明细</h2><span class="workbench-meta">{{ detail.pickups.length }} 单 · 预报 {{ detail.pickups.reduce((total, row) => total + row.packageCount, 0) }} 箱</span></div>
<div v-if="!detail.pickups.length" class="workbench-meta">暂无取货明细</div> <div v-if="!detail.pickups.length" class="workbench-meta">暂无取货明细</div>
@@ -276,6 +297,11 @@ onBeforeUnmount(clearTruckPreviewUrls)
</template> </template>
<style scoped> <style scoped>
.transport-summary-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 12px 0; }
.transport-summary-cards > div { padding: 16px; border: 1px solid var(--cc-border, #e3eaf3); border-radius: 8px; background: #f8fbff; }
.transport-summary-cards dt { color: var(--cc-text-muted, #788699); font-size: 12px; }
.transport-summary-cards dd { margin: 8px 0 0; color: #243951; font-size: 18px; font-weight: 600; }
.transport-execution { display: grid; gap: var(--admin-space-5); min-width: 0; padding: var(--admin-space-5); } .transport-execution { display: grid; gap: var(--admin-space-5); min-width: 0; padding: var(--admin-space-5); }
.transport-toolbar, .transport-section-heading, .transport-identity { display: flex; align-items: center; gap: var(--admin-space-4); } .transport-toolbar, .transport-section-heading, .transport-identity { display: flex; align-items: center; gap: var(--admin-space-4); }
.transport-toolbar, .transport-section-heading { justify-content: space-between; flex-wrap: wrap; } .transport-toolbar, .transport-section-heading { justify-content: space-between; flex-wrap: wrap; }
@@ -35,7 +35,10 @@ export interface OrderDetail { customerId: string; customerName: string; orderNo
export type DispatchExceptionType = 'REFUSED' | 'PARTIAL_RECEIPT' | 'VEHICLE_EXCEPTION' export type DispatchExceptionType = 'REFUSED' | 'PARTIAL_RECEIPT' | 'VEHICLE_EXCEPTION'
export type DispatchReturnDirection = 'OVERSEAS_WAREHOUSE' | 'OTHER' export type DispatchReturnDirection = 'OVERSEAS_WAREHOUSE' | 'OTHER'
export interface DispatchExceptionRecord { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[]; occurredAt: string } export interface DispatchExceptionRecord { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[]; occurredAt: string }
export interface MarkDispatchException extends VersionCommand { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[] } export interface DispatchReturnSource { outboundPalletItemId: string; inventoryPositionId: string | null; returnCartonId: string | null; palletNo: string | null; skuCode: string | null; cartonMark: string | null; maxReturnPackages: number }
export interface DispatchReturnCandidate { customerId: string; customerName: string | null; deliveryNo: string; lastMileOrderId: string; lastMileOrderNo: string; sources: DispatchReturnSource[] }
export interface DispatchReturnSelection { deliveryNo: string; lastMileOrderId: string; items: { outboundPalletItemId: string; inventoryPositionId: string | null; returnCartonId: string | null; packageCount: number }[] }
export interface MarkDispatchException extends VersionCommand { returnSelections?: DispatchReturnSelection[]; exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[] }
export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources & { maintenance?: DispatchVehicleMaintenance | null }; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[]; exceptions: DispatchExceptionRecord[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[]; publishLooseboardDemand: boolean } export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources & { maintenance?: DispatchVehicleMaintenance | null }; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[]; exceptions: DispatchExceptionRecord[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[]; publishLooseboardDemand: boolean }
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string } export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[]; publishLooseboardDemand: boolean } export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[]; publishLooseboardDemand: boolean }
+1
View File
@@ -12,6 +12,7 @@ export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, D
export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts' export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts'
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery' export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue' export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
export { listLooseboardCargo, addLooseboardCargo } from './api/dispatches'
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs' export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
export { useDispatchCandidates } from './useDispatchCandidates' export { useDispatchCandidates } from './useDispatchCandidates'
export { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans } from './api/lastMileOrders' export { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans } from './api/lastMileOrders'
@@ -2,6 +2,12 @@ import { describe, expect, it } from 'vitest'
import { formatOverseasDateTime, inboundPlanStatusLabel, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation' import { formatOverseasDateTime, inboundPlanStatusLabel, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => { describe('last-mile order presentation adapter', () => {
it('translates raw return order type, source and pending inbound status', () => {
const row = toLastMileOrderListItem({ id: '1', customerId: '2', orderNo: 'WC1', orderType: 'DELIVERY_RETURN', orderTypeName: 'DELIVERY_RETURN', currentStatus: 'PENDING_INBOUND', businessSource: { label: 'DELIVERY_RETURN' } } as Parameters<typeof toLastMileOrderListItem>[0])
expect(row.orderType).toBe('派送异常退回')
expect(row.status).toBe('待入库')
expect(row.businessSource).toBe('派送异常退回')
})
it('keeps completed and processing relabel applications visible together', () => { it('keeps completed and processing relabel applications visible together', () => {
const row = toLastMileOrderListItem({ const row = toLastMileOrderListItem({
id: '1', customerId: 'customer-1', orderNo: 'RT-001', currentStatus: 'INBOUND_COMPLETED', id: '1', customerId: 'customer-1', orderNo: 'RT-001', currentStatus: 'INBOUND_COMPLETED',
@@ -7,6 +7,7 @@ const orderTypeLabels: Record<string, string> = {
RELABEL_APPLICATION: '换标下单', RELABEL_APPLICATION: '换标下单',
WAREHOUSE_TRANSFER: '仓储转运', WAREHOUSE_TRANSFER: '仓储转运',
RETURN_RELABEL: '退货换标', RETURN_RELABEL: '退货换标',
DELIVERY_RETURN: '派送异常退回',
LOCAL_LOOSEBOARD: '本土散板', LOCAL_LOOSEBOARD: '本土散板',
CARGO_DISCARD: '货物废弃订单', CARGO_DISCARD: '货物废弃订单',
REJECTED: '拒收订单', REJECTED: '拒收订单',
@@ -14,6 +15,7 @@ const orderTypeLabels: Record<string, string> = {
} }
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
PENDING_INBOUND: '待入库',
PENDING_REVIEW: '待审核', PENDING_REVIEW: '待审核',
PENDING_RECEIPT: '待签收入库', PENDING_RECEIPT: '待签收入库',
PARTIALLY_RECEIVED: '部分入库', PARTIALLY_RECEIVED: '部分入库',
@@ -155,13 +157,13 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
orderNo: lastMileOrderReference(dto.orderNo), orderNo: lastMileOrderReference(dto.orderNo),
sourceOrderNo: lastMileOrderReference(dto.sourceOrderNo, ''), sourceOrderNo: lastMileOrderReference(dto.sourceOrderNo, ''),
businessType: dto.businessSource?.businessType || '', businessType: dto.businessSource?.businessType || '',
businessSource: text(dto.businessSource?.label), businessSource: lastMileOrderTypeLabel(dto.businessSource?.label),
businessOrderNo: text(dto.businessSource?.businessOrderNo), businessOrderNo: text(dto.businessSource?.businessOrderNo),
externalBookingNo: text(dto.businessSource?.externalBookingNo), externalBookingNo: text(dto.businessSource?.externalBookingNo),
orderTypeCode: String(dto.orderType ?? '').trim(), orderTypeCode: String(dto.orderType ?? '').trim(),
currentStatusCode: String(dto.currentStatus ?? '').trim(), currentStatusCode: String(dto.currentStatus ?? '').trim(),
warning: dto.abnormal ? '异常' : '—', warning: dto.abnormal ? '异常' : '—',
orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)), orderType: lastMileOrderTypeLabel(dto.orderTypeName || dto.orderType),
rejectedAt: '—', rejectedAt: '—',
inboundCode: text(dto.inboundCode), inboundCode: text(dto.inboundCode),
lxCode: text(dto.lxCode), lxCode: text(dto.lxCode),
@@ -102,6 +102,8 @@ onMounted(() => { void loadCustomers(); void load() })
<el-table-column label="申请数量" width="120"><template #default="{ row }">{{ quantity(row.discardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column> <el-table-column label="申请数量" width="120"><template #default="{ row }">{{ quantity(row.discardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column>
<el-table-column label="实际废弃" width="150"><template #default="{ row }">{{ quantity(row.actualDiscardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column> <el-table-column label="实际废弃" width="150"><template #default="{ row }">{{ quantity(row.actualDiscardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column>
<el-table-column label="所选箱唛" min-width="180"><template #default="{ row }"><LineClampText :content="row.cartonMarks?.join('、') || ''" /></template></el-table-column> <el-table-column label="所选箱唛" min-width="180"><template #default="{ row }"><LineClampText :content="row.cartonMarks?.join('、') || ''" /></template></el-table-column>
<el-table-column prop="palletNo" label="废弃托盘" min-width="160" />
<el-table-column label="实际废弃箱唛" min-width="180"><template #default="{ row }"><LineClampText :content="row.actualCartonMarks?.join('、') || ''" /></template></el-table-column>
</el-table> </el-table>
<h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item"> <h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item">
<el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported /> <el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported />
@@ -45,6 +45,7 @@ const relatedOrders = computed(() => detail.value?.orderDetails || [])
const orderColumns: DataTableColumn[] = [ const orderColumns: DataTableColumn[] = [
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 }, { key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
{ key: 'sourceBusinessType', label: '业务类型', width: 120, formatter: (row: { sourceBusinessType?: string | null }) => row.sourceBusinessType === 'LOCAL_LOOSEBOARD' ? '本土散板' : row.sourceBusinessType === 'WAREHOUSE_TRANSFER' ? '仓储转运' : '—' },
{ key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 }, { key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 },
{ key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' }, { key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' },
@@ -448,6 +448,22 @@ it('clears an earlier input rejection after the corrected workflow succeeds', as
wrapper.unmount() wrapper.unmount()
}) })
it.each([
['DELIVERING', 3, true],
['DELIVERED', 4, false],
] as const)('only opens list document management before delivery completes in %s', async (stage, tabIndex, available) => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY', stage, actions: {} })]))
const wrapper = mount(DispatchesView, { global: { ...options.global, stubs: { ...options.global.stubs, DispatchDocumentsDialog: true } } }); await flushPromises()
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[tabIndex]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage }))
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((row.props('actions') as Array<{ key: string }>).some(item => item.key === 'documents')).toBe(available)
expect(row.findAll('button').some(button => button.text() === '管理资料')).toBe(available)
row.vm.$emit('action', 'documents'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchDocumentsDialog' }).props()).toMatchObject({ modelValue: available, dispatchNo: available ? 'PC/1' : '' })
wrapper.unmount()
})
it('opens the exception form from the dispatching row and hides it without permission', async () => { it('opens the exception form from the dispatching row and hides it without permission', async () => {
const gate = { allowed: true, blockCode: null, blockReason: null } const gate = { allowed: true, blockCode: null, blockReason: null }
const current = reviews() const current = reviews()
@@ -87,7 +87,7 @@ const columns = computed<DataTableColumn[]>(() => [
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'review', label: section.value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核', minWidth: 210, slotName: 'review', afterColumn: 'dispatchNo' }] : []), ...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'review', label: section.value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核', minWidth: 210, slotName: 'review', afterColumn: 'dispatchNo' }] : []),
{ key: 'counts', label: '托数/箱数/已拣', minWidth: 160, slotName: 'counts' }, { key: 'planned', label: '计划发车', minWidth: 165, slotName: 'planned' }, { key: 'counts', label: '托数/箱数/已拣', minWidth: 160, slotName: 'counts' }, { key: 'planned', label: '计划发车', minWidth: 165, slotName: 'planned' },
{ key: 'departure', label: '实际发车', minWidth: 165, slotName: 'departure' }, { key: 'departure', label: '实际发车', minWidth: 165, slotName: 'departure' },
...(stage.value === 'DELIVERING' ? [{ key: 'routeMap', label: '路线', width: 100, slotName: 'routeMap' }] : []), ...(stage.value === 'DELIVERING' ? [{ key: 'routeMap', label: '轨迹', width: 100, slotName: 'routeMap' }] : []),
]) ])
const preferenceKey = computed(() => `overseas-dispatches-${stage.value}-${section.value}`) const preferenceKey = computed(() => `overseas-dispatches-${stage.value}-${section.value}`)
let sequence = 0 let sequence = 0
@@ -122,7 +122,7 @@ function switchStage(value: DispatchStage) {
function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() } function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() }
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) } function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL'] const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(row.stage) } function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(row.stage) }
// 仅当前调度审核通过的仓库派送排车可下载,退回或审核失效后重新收起入口。 // 仅当前调度审核通过的仓库派送排车可下载,退回或审核失效后重新收起入口。
function canDownloadBol(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.reviews?.dispatchReview?.status === 'APPROVED' } function canDownloadBol(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.reviews?.dispatchReview?.status === 'APPROVED' }
function rowActions(row: DispatchListRow): RowActionItem[] { function rowActions(row: DispatchListRow): RowActionItem[] {
@@ -246,7 +246,7 @@ onBeforeUnmount(() => { sequence++ })
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template> <template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
<template #returnReason="{ row }">{{ dispatchRejectionReason(row.reviews) || '—' }}</template> <template #returnReason="{ row }">{{ dispatchRejectionReason(row.reviews) || '—' }}</template>
<template #createdAt="{ row }">{{ time(row.createdAt) }}</template> <template #createdAt="{ row }">{{ time(row.createdAt) }}</template>
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template> <template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看轨迹</el-button></template>
</DataTable></div> </DataTable></div>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
<TransportTaskCreateDialog v-model="transportOpen" :type="transportType" @created="load" /> <TransportTaskCreateDialog v-model="transportOpen" :type="transportType" @created="load" />
@@ -124,7 +124,7 @@ const showReturnForecast = computed(() => isReturnOrder.value && (detail.value?.
const orderFields = computed<DetailField[]>(() => { const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order const order = detail.value?.order
const fields: DetailField[] = [ const fields: DetailField[] = [
{ label: '履约类型', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) }, { label: '履约类型', value: text(lastMileOrderTypeLabel(order?.orderTypeName || order?.orderType)) },
{ label: '入仓码', value: text(order?.inboundCode) }, { label: '入仓码', value: text(order?.inboundCode) },
{ label: '货主', value: text(order?.customerName) }, { label: '货主', value: text(order?.customerName) },
{ label: '海外仓', value: warehouseDisplayName.value }, { label: '海外仓', value: warehouseDisplayName.value },
@@ -149,7 +149,7 @@ function isNonzeroDifference(field: DetailField) { return field.label === '箱
const containerFields = computed<DetailField[]>(() => { const containerFields = computed<DetailField[]>(() => {
const order = detail.value?.order const order = detail.value?.order
return [ return [
{ label: '运输渠道', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) }, { label: '运输渠道', value: text(lastMileOrderTypeLabel(order?.orderTypeName || order?.orderType)) },
{ label: '集装箱号', value: text(order?.containerNo) }, { label: '集装箱号', value: text(order?.containerNo) },
{ label: '托数', value: Number(order?.palletCount || 0) }, { label: '托数', value: Number(order?.palletCount || 0) },
{ label: '箱数', value: Number(order?.packageCount || 0) }, { label: '箱数', value: Number(order?.packageCount || 0) },
@@ -351,7 +351,7 @@ onMounted(load)
<section aria-label="商业来源与关联单据"> <section aria-label="商业来源与关联单据">
<h2 class="source-heading">关联单据</h2> <h2 class="source-heading">关联单据</h2>
<dl class="detail-fields density-fields"> <dl class="detail-fields density-fields">
<div class="wide"><dt>业务来源</dt><dd>{{ text(detail?.order.businessSource?.label) }}</dd></div> <div class="wide"><dt>业务来源</dt><dd>{{ lastMileOrderTypeLabel(detail?.order.businessSource?.label) }}</dd></div>
<div class="wide"><dt>关联商业单号</dt><dd><el-button v-if="commercialLink" link type="primary" @click="router.push(commercialLink)">{{ detail?.order.businessSource?.businessOrderNo }}</el-button><span v-else>{{ text(detail?.order.businessSource?.businessOrderNo) }}</span></dd></div> <div class="wide"><dt>关联商业单号</dt><dd><el-button v-if="commercialLink" link type="primary" @click="router.push(commercialLink)">{{ detail?.order.businessSource?.businessOrderNo }}</el-button><span v-else>{{ text(detail?.order.businessSource?.businessOrderNo) }}</span></dd></div>
<div class="wide"><dt>外接预订号</dt><dd>{{ text(detail?.order.businessSource?.externalBookingNo) }}</dd></div> <div class="wide"><dt>外接预订号</dt><dd>{{ text(detail?.order.businessSource?.externalBookingNo) }}</dd></div>
<div class="wide"><dt>来源单号</dt><dd>{{ text(detail?.order.sourceOrderNo) }}</dd></div> <div class="wide"><dt>来源单号</dt><dd>{{ text(detail?.order.sourceOrderNo) }}</dd></div>
@@ -10,11 +10,14 @@ import { ApiResponseError } from '@/shared/api/http'
import DataTable from '@/shared/components/DataTable.vue' import DataTable from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue' import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import LayoutPreferenceList from '@/shared/components/LayoutPreferenceList.vue'
import { preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
import { listAllPlatformWarehouses } from '@/modules/warehouses' import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listContainerArrivalTimes } from '@/modules/container-loading' import { listContainerArrivalTimes } from '@/modules/container-loading'
import { import {
changeContainerReceivingWarehouse, changeContainerReceivingWarehouse,
createManualInboundPlan, createManualInboundPlan,
createExceptionReturnInboundPlan,
convertLastMileContainerToInboundPlans, convertLastMileContainerToInboundPlans,
getContainerInboundPlanPreview, getContainerInboundPlanPreview,
getContainerPlanProgress, getContainerPlanProgress,
@@ -44,7 +47,7 @@ vi.mock('../api/lastMileOrders', () => ({
getReturnOrderStatistics: vi.fn(), getReturnOrderStatistics: vi.fn(),
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(), listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
changeContainerReceivingWarehouse: vi.fn(), changeContainerReceivingWarehouse: vi.fn(),
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(), createExceptionReturnInboundPlan: vi.fn(), createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(), getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
reviewCargoDiscard: vi.fn(), updateLastMileOrderPalletMerge: vi.fn(), reviewCargoDiscard: vi.fn(), updateLastMileOrderPalletMerge: vi.fn(),
})) }))
@@ -322,10 +325,46 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(true) expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(true)
}) })
it('uses the saved first three filters when collapsed and keeps reordered fields after remounting', async () => {
localStorage.setItem(preferenceStorageKey('query', 'anonymous', 'overseas-last-mile-orders-query'), JSON.stringify({
version: 1, order: ['orderNo', 'containerNo', 'inboundCode'], hidden: [],
}))
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
const placeholders = () => toolbar.findAll('.query-toolbar__field').map(field => field.get('input').attributes('placeholder'))
expect(placeholders()).toEqual(['请输入尾程单号', '请输入集装箱号', '请输入入仓码'])
const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), containerNo: 'CONT-15' })
await wrapper.vm.$nextTick()
await button(wrapper, '高级筛选')!.trigger('click')
expect(placeholders()).toHaveLength(16)
expect(placeholders().slice(0, 3)).toEqual(['请输入尾程单号', '请输入集装箱号', '请输入入仓码'])
await toolbar.get('button[aria-label="调整筛选布局"]').trigger('click')
await flushPromises()
toolbar.findComponent(LayoutPreferenceList).vm.$emit('move', 'externalBookingNo', 0)
await wrapper.vm.$nextTick()
await button(wrapper, '收起筛选')!.trigger('click')
expect(placeholders()).toEqual(['请输入外接预订号', '请输入尾程单号', '请输入集装箱号'])
expect(toolbar.props('modelValue').containerNo).toBe('CONT-15')
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(initialCalls)
wrapper.unmount()
const restored = await mountView()
const restoredToolbar = restored.findComponent(QueryToolbar)
const restoredPlaceholders = () => restoredToolbar.findAll('.query-toolbar__field').map(field => field.get('input').attributes('placeholder'))
expect(restoredPlaceholders()).toEqual(['请输入外接预订号', '请输入尾程单号', '请输入集装箱号'])
await restoredToolbar.get('button[aria-label="调整筛选布局"]').trigger('click')
await flushPromises()
restoredToolbar.findComponent(LayoutPreferenceList).vm.$emit('reset')
await restored.vm.$nextTick()
expect(restoredPlaceholders()).toEqual(['请输入尾程单号', '请输入入仓码', '请输入仓库名称或编码'])
})
it('preserves advanced conditions when collapsed and clears them on reset', async () => { it('preserves advanced conditions when collapsed and clears them on reset', async () => {
const wrapper = await mountView() const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar) const toolbar = wrapper.findComponent(QueryToolbar)
expect(toolbar.props('fields').map((field) => field.key)).toEqual(['orderNo', 'inboundCode', 'warehouse']) expect(toolbar.findAll('.query-toolbar__field input').map(input => input.attributes('placeholder')))
.toEqual(['请输入尾程单号', '请输入入仓码', '请输入仓库名称或编码'])
const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length
await button(wrapper, '高级筛选')!.trigger('click') await button(wrapper, '高级筛选')!.trigger('click')
expect(toolbar.props('fields')).toHaveLength(16) expect(toolbar.props('fields')).toHaveLength(16)
@@ -416,6 +455,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
summary, summary,
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }], orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
}) })
vi.mocked(createExceptionReturnInboundPlan).mockReset().mockResolvedValue({} as never)
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never) vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([arrivalTime]) vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([arrivalTime])
vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never) vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never)
@@ -628,6 +668,30 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(wrapper.text()).not.toContain('尾程订单加载失败') expect(wrapper.text()).not.toContain('尾程订单加载失败')
}) })
it('requires an explicit return ETA and plans the current tail order without a source container', async () => {
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow({ orderNo: 'WC1', sourceOrderNo: 'ZY1', orderTypeCode: 'DELIVERY_RETURN', currentStatusCode: 'PENDING_INBOUND', sourceContainerBookingId: null, inboundCode: null, estimatedWarehouseAt: '', warehouseEta: '—' })])
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.get('[aria-label="入库来源"]').text()).toContain('WC1')
expect(dialog.text()).toContain('ZY1')
expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm')
await flushPromises()
expect(createExceptionReturnInboundPlan).not.toHaveBeenCalled()
dialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-05T14:30:00')
await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm')
await flushPromises()
expect(createExceptionReturnInboundPlan).toHaveBeenCalledWith(expect.objectContaining({ customerId, orderId: '2092873463568596994', expectedOrderVersion: 3 }), '2026-11-05T14:30:00', expect.any(String))
expect(createManualInboundPlan).not.toHaveBeenCalled()
expect(listContainerArrivalTimes).not.toHaveBeenCalled()
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
})
it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => { it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => {
const wrapper = await openAfterDeparture() const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()]) await selectRows(wrapper, [selectedRow()])

Some files were not shown because too many files have changed in this diff Show More