diff --git a/src/modules/domestic-containers/pages/DomesticContainerInventoryDetailView.test.ts b/src/modules/domestic-containers/pages/DomesticContainerInventoryDetailView.test.ts new file mode 100644 index 00000000..1a1442db --- /dev/null +++ b/src/modules/domestic-containers/pages/DomesticContainerInventoryDetailView.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const source = readFileSync(resolve(process.cwd(), 'src/modules/domestic-containers/pages/DomesticContainerInventoryDetailView.vue'), 'utf8') + +describe('DomesticContainerInventoryDetailView', () => { + it('does not invent an action column for immutable transaction history', () => { + expect(source).not.toContain("label: '操作'") + expect(source).toContain('进销存交易记录') + }) + it('surfaces loading and API diagnostic errors and supports retry', () => { + expect(source).toContain('整柜库存详情加载失败') + expect(source).toContain('@retry="load"') + expect(source).toContain('') + expect(source).toContain('v-loading="loading"') + }) + it('uses a four-column desktop field grid and preserves long business text', () => { + expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))') + expect(source).toContain('overflow-wrap: anywhere') + }) +}) diff --git a/src/modules/domestic-containers/pages/DomesticContainersView.test.ts b/src/modules/domestic-containers/pages/DomesticContainersView.test.ts index 1d3af183..c1a22cf6 100644 --- a/src/modules/domestic-containers/pages/DomesticContainersView.test.ts +++ b/src/modules/domestic-containers/pages/DomesticContainersView.test.ts @@ -12,4 +12,7 @@ describe('DomesticContainersView', () => { it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") }) it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') }) it('matches the prototype four-column inventory summary', () => { expect(source).toContain("import './domesticContainersSummary.css'"); expect(summaryStyles).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))'); expect(summaryStyles).toContain('border: 1px solid #e7edf5'); expect(summaryStyles).toContain('font-size: 20px'); expect(summaryStyles).toContain('.inventory-summary + .data-table > .data-table__toolbar') }) + it('does not offer an inventory detail action before an inbound has formed inventory', () => { expect(source).toContain("canViewInventory(row as ContainerOrder | InventoryRecord)"); expect(source).toContain("row.status === 'COMPLETED'") }) + it('matches backend numeric and container-number input boundaries before submit', () => { expect(source).toContain("pattern: /^[A-Za-z0-9-]{4,32}$/"); expect(source).toContain(':max="999999999"'); expect(source).toContain(':max="999999999999.9999"') }) + it('requires a refreshed inventory version for completed outbound submission', () => { expect(source).toContain("outboundForm.inventoryVersion == null"); expect(source).toContain('库存版本未就绪,请重新选择集装箱') }) }) diff --git a/src/modules/domestic-containers/pages/DomesticContainersView.vue b/src/modules/domestic-containers/pages/DomesticContainersView.vue index db22bc55..1b57d5b2 100644 --- a/src/modules/domestic-containers/pages/DomesticContainersView.vue +++ b/src/modules/domestic-containers/pages/DomesticContainersView.vue @@ -45,6 +45,7 @@ const columns = computed(() => activeTab.value === 'inventory ]) function displayTime(value: string) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } function statusLabel(row: ContainerOrder | InventoryRecord) { if ('inventoryStatus' in row) return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as const)[row.inventoryStatus]; return row.status === 'PENDING' ? (activeTab.value === 'inbound' ? '待入库' : '待出库') : (activeTab.value === 'inbound' ? '已入库' : '已出库') } +function canViewInventory(row: ContainerOrder | InventoryRecord) { return 'inventoryStatus' in row || activeTab.value === 'outbound' || row.status === 'COMPLETED' } function queryParams() { const model = appliedFilters.value; return { page: page.value, size: pageSize.value, warehouseId: String(model.warehouseId || '') || undefined, orderNo: activeTab.value === 'inventory' ? undefined : String(model.orderNo || '') || undefined, containerNo: String(model.containerNo || '') || undefined, cargoName: String(model.cargoName || '') || undefined, status: String(model.status || '') || undefined } } async function load() { loading.value = true; loadError.value = null; try { const result = activeTab.value === 'inbound' ? await listDomesticContainerInbounds(queryParams()) : activeTab.value === 'outbound' ? await listDomesticContainerOutbounds(queryParams()) : await listDomesticContainerInventories(queryParams()); rows.value = result.records; total.value = result.total; if ('summary' in result) summary.value = result.summary } catch (cause) { loadError.value = getApiErrorDetails(cause, '整柜业务加载失败') } finally { loading.value = false } } async function loadWarehouses() { warehouseLoading.value = true; warehouseError.value = null; try { warehouses.value = (await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })).filter((item) => item.warehouseType === 'DOMESTIC' && item.status === 'ACTIVE') } catch (cause) { warehouseError.value = getApiErrorDetails(cause, '国内仓候选加载失败') } finally { warehouseLoading.value = false } } @@ -59,20 +60,20 @@ const inboundForm = reactive({ warehouseId: '', cargoName: '', containerNo: '', const outboundForm = reactive({ warehouseId: '', inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined as number | undefined, boxCount: 1, operationTime: '', status: 'COMPLETED' as ContainerOrderStatus, remark: '' }) const inventoryCandidates = ref([]); const candidateLoading = ref(false); const candidateError = ref(null) const currentForm = computed(() => dialogType.value === 'inbound' ? inboundForm : outboundForm) -const rules: FormRules = { warehouseId: [{ required: true, message: '请选择国内仓', trigger: 'change' }], cargoName: [{ required: true, message: '请输入货物名称', trigger: 'blur' }], containerNo: [{ required: true, message: '请选择或输入集装箱号', trigger: 'change' }], boxCount: [{ required: true, type: 'integer', min: 1, message: '箱数必须为正整数', trigger: 'change' }], volumeCbm: [{ required: true, type: 'number', min: 0.0001, message: '体积必须大于等于 0.0001', trigger: 'change' }], operationTime: [{ required: true, message: '请选择操作时间', trigger: 'change' }], status: [{ required: true, message: '请选择状态', trigger: 'change' }] } +const rules: FormRules = { warehouseId: [{ required: true, message: '请选择国内仓', trigger: 'change' }], cargoName: [{ required: true, message: '请输入货物名称', trigger: 'blur' }], containerNo: [{ required: true, message: '请选择或输入集装箱号', trigger: 'change' }, { pattern: /^[A-Za-z0-9-]{4,32}$/, message: '集装箱号须为 4–32 位字母、数字或连字符', trigger: 'blur' }], boxCount: [{ required: true, type: 'integer', min: 1, max: 999999999, message: '箱数须为 1–999999999 的整数', trigger: 'change' }], volumeCbm: [{ required: true, type: 'number', min: 0.0001, max: 999999999999.9999, message: '体积须在 0.0001–999999999999.9999 m³ 之间', trigger: 'change' }], operationTime: [{ required: true, message: '请选择操作时间', trigger: 'change' }], status: [{ required: true, message: '请选择状态', trigger: 'change' }] } function localDateTime() { const offset = new Date().getTimezoneOffset() * 60_000; return `${new Date(Date.now() - offset).toISOString().slice(0, 16)}:00` } function newIdempotencyKey(type: string) { return `${type}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}` } function openInbound() { dialogType.value = 'inbound'; Object.assign(inboundForm, { warehouseId: '', cargoName: '', containerNo: '', boxCount: 1, volumeCbm: 0.01, operationTime: localDateTime(), status: 'COMPLETED', remark: '' }); idempotencyKey.value = newIdempotencyKey('inbound'); submitError.value = null; dialogOpen.value = true } function openOutbound() { dialogType.value = 'outbound'; Object.assign(outboundForm, { warehouseId: '', inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined, boxCount: 1, operationTime: localDateTime(), status: 'COMPLETED', remark: '' }); inventoryCandidates.value = []; candidateError.value = null; idempotencyKey.value = newIdempotencyKey('outbound'); submitError.value = null; dialogOpen.value = true } async function loadCandidates() { inventoryCandidates.value = []; Object.assign(outboundForm, { inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined }); if (!outboundForm.warehouseId) return; candidateLoading.value = true; candidateError.value = null; try { const result = await listDomesticContainerInventories({ page: 1, size: 200, warehouseId: outboundForm.warehouseId }); inventoryCandidates.value = result.records.filter((item) => item.availableBoxes > 0) } catch (cause) { candidateError.value = getApiErrorDetails(cause, '可出库整柜库存加载失败') } finally { candidateLoading.value = false } } async function selectInventory(id: string) { const selected = inventoryCandidates.value.find((item) => String(item.id) === id); if (!selected) return; Object.assign(outboundForm, { inventoryId: id, containerNo: selected.containerNo, cargoName: selected.cargoName, availableBoxes: selected.availableBoxes, inventoryVersion: selected.version }); try { const detail = await getDomesticContainerInventory(id); Object.assign(outboundForm, { availableBoxes: detail.inventory.availableBoxes, inventoryVersion: detail.inventory.version }) } catch (cause) { candidateError.value = getApiErrorDetails(cause, '库存最新版本加载失败') } } -async function submit() { if (!await formRef.value?.validate().catch(() => false)) return; if (dialogType.value === 'outbound' && outboundForm.status === 'COMPLETED' && outboundForm.boxCount > outboundForm.availableBoxes) { submitError.value = { message: `库存不足,当前可用 ${outboundForm.availableBoxes} 箱`, code: 'A0409' }; return } saving.value = true; submitError.value = null; try { const result = dialogType.value === 'inbound' ? await createDomesticContainerInbound({ warehouseId: inboundForm.warehouseId, cargoName: inboundForm.cargoName.trim(), containerNo: inboundForm.containerNo.trim().toUpperCase(), boxCount: inboundForm.boxCount, volumeCbm: inboundForm.volumeCbm, inboundTime: inboundForm.operationTime, status: inboundForm.status, remark: inboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value }) : await createDomesticContainerOutbound({ warehouseId: outboundForm.warehouseId, containerNo: outboundForm.containerNo, boxCount: outboundForm.boxCount, outboundTime: outboundForm.operationTime, status: outboundForm.status, remark: outboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value, inventoryVersion: outboundForm.status === 'COMPLETED' ? outboundForm.inventoryVersion : undefined }); ElMessage.success(result.replayed ? `请求已处理,单号 ${result.orderNo}` : `创建成功,单号 ${result.orderNo}`); dialogOpen.value = false; await load() } catch (cause) { submitError.value = getApiErrorDetails(cause, '整柜单据创建失败'); if (dialogType.value === 'outbound' && submitError.value.code === 'A0409') await loadCandidates() } finally { saving.value = false } } +async function submit() { if (!await formRef.value?.validate().catch(() => false)) return; if (dialogType.value === 'outbound' && outboundForm.status === 'COMPLETED' && outboundForm.inventoryVersion == null) { submitError.value = { message: '库存版本未就绪,请重新选择集装箱', code: 'A0422' }; return } if (dialogType.value === 'outbound' && outboundForm.status === 'COMPLETED' && outboundForm.boxCount > outboundForm.availableBoxes) { submitError.value = { message: `库存不足,当前可用 ${outboundForm.availableBoxes} 箱`, code: 'A0409' }; return } saving.value = true; submitError.value = null; try { const result = dialogType.value === 'inbound' ? await createDomesticContainerInbound({ warehouseId: inboundForm.warehouseId, cargoName: inboundForm.cargoName.trim(), containerNo: inboundForm.containerNo.trim().toUpperCase(), boxCount: inboundForm.boxCount, volumeCbm: inboundForm.volumeCbm, inboundTime: inboundForm.operationTime, status: inboundForm.status, remark: inboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value }) : await createDomesticContainerOutbound({ warehouseId: outboundForm.warehouseId, containerNo: outboundForm.containerNo, boxCount: outboundForm.boxCount, outboundTime: outboundForm.operationTime, status: outboundForm.status, remark: outboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value, inventoryVersion: outboundForm.status === 'COMPLETED' ? outboundForm.inventoryVersion : undefined }); ElMessage.success(result.replayed ? `请求已处理,单号 ${result.orderNo}` : `创建成功,单号 ${result.orderNo}`); dialogOpen.value = false; await load() } catch (cause) { submitError.value = getApiErrorDetails(cause, '整柜单据创建失败'); if (dialogType.value === 'outbound' && submitError.value.code === 'A0409') await loadCandidates() } finally { saving.value = false } } watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) }) +
入库箱数{{ summary.inboundBoxes }}
已出库箱数{{ summary.outboundBoxes }}
当前库存{{ summary.availableBoxes }}
累计入库体积{{ summary.inboundVolumeCbm.toFixed(2) }} m³
+