diff --git a/AGENTS.md b/AGENTS.md index cc66b503..2914efa0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ | 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` | | 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` | | 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/` | `firstleg/loading/`、`firstleg/split/`、`warehouse/`、`file/` | +| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 | | 三期订舱、出仓前船期 | `modules/ocean-bookings/`、`modules/port-schedules/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`;出仓后节点归四期 | | 拖车、报关、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` | @@ -62,7 +63,7 @@ |---|---|---| | 尾程订单 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | `modules/overseas-warehouse/api/lastMileOrders.ts`;Backend `lastleg-service/` | | 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` | -| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;仅查询、候选和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 | +| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 | | 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` | | 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 | | 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` | diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 3632a9a1..16bcb9c4 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -312,13 +312,13 @@ describe('GlobalSmartLoadingView', () => { const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, version: 3 }) - vi.mocked(recommendSmartLoading).mockRejectedValue(new AxiosError( + vi.mocked(recommendSmartLoading).mockRejectedValueOnce(new AxiosError( 'conflict', 'ERR_BAD_REQUEST', undefined, undefined, { data: { code: 'A0409', message: '数据已被修改', requestId: 'request-1', traceId: 'trace-1' }, status: 200, statusText: 'OK', headers: {}, config: {} as never }, - )) + )).mockResolvedValueOnce({ ...plan, version: 4 }) const button = wrapper.findAll('button').find((item) => item.text().includes('智能推荐')) expect(button).toBeDefined() @@ -326,8 +326,9 @@ describe('GlobalSmartLoadingView', () => { await flushPromises() expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2) - expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2) - expect(warningMessage).toHaveBeenCalledWith('计划或 PDA 明细已变化,已刷新最新版本并保留筛选条件,请重新操作') + expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 3) + expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(3) + expect(warningMessage).not.toHaveBeenCalled() expect(wrapper.text()).not.toContain('装柜操作失败') expect(wrapper.text()).not.toContain('request-1') }) diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue index 1817ee1b..d38e9abf 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -296,7 +296,15 @@ async function assign() { async function recommend() { if (!plan.value) return await mutate(async () => { - acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version)) + try { + acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version)) + } catch (error) { + const details = getApiErrorDetails(error, '智能推荐失败') + if (details.code !== 'A0409' || !/(版本|并发|已被修改|已变化)/.test(details.message)) throw error + await loadSelectedData() + if (!plan.value) throw error + acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version)) + } await loadSelectedData() ElMessage.success('智能推荐已生成') }, '智能推荐失败') @@ -377,7 +385,7 @@ async function mutate( const pickingMessage = recovery.pickingConflict && details.message.includes('拣货') ? '该订单在拣货中不许删除' : null - const message = pickingMessage || '计划或 PDA 明细已变化,已刷新最新版本并保留筛选条件,请重新操作' + const message = pickingMessage || details.message || '计划或 PDA 明细已变化,已刷新最新版本并保留筛选条件,请重新操作' if (recovery.preserveAssign) assignOpen.value = true if (pickingMessage) ElMessage.error(pickingMessage) else ElMessage.warning(message) diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts index 1f40f3ac..6464f1b1 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts @@ -75,10 +75,10 @@ describe('SmartLoadingWorkbenchView', () => { vi.mocked(getSmartLoadingPlanByBooking) .mockResolvedValueOnce(planResponse) .mockResolvedValueOnce({ ...planResponse, version: 3 }) - vi.mocked(recommendSmartLoading).mockRejectedValue(new AxiosError( + vi.mocked(recommendSmartLoading).mockRejectedValueOnce(new AxiosError( 'conflict', 'ERR_BAD_REQUEST', undefined, undefined, { data: { code: 'A0409', message: '数据已被修改', requestId: 'request-1', traceId: 'trace-1' }, status: 200, statusText: 'OK', headers: {}, config: {} as never }, - )) + )).mockResolvedValueOnce({ ...planResponse, version: 4 }) const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] }) await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady() const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } }) @@ -87,8 +87,9 @@ describe('SmartLoadingWorkbenchView', () => { await wrapper.findAll('button').find((button) => button.text().includes('智能推荐'))!.trigger('click') await flushPromises() expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2) - expect(wrapper.text()).toContain('已刷新最新版本') - expect(wrapper.text()).toContain('request-1') + expect(recommendSmartLoading).toHaveBeenCalledWith(planResponse.id, 2) + expect(recommendSmartLoading).toHaveBeenCalledWith(planResponse.id, 3) + expect(wrapper.text()).not.toContain('排柜操作失败') }) it('uses bordered row actions and keeps push independent from containerNo', () => { diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue index 599ad0e8..b86791b7 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue @@ -149,7 +149,18 @@ async function recommend() { if (!plan.value) return mutating.value = true mutationError.value = null - try { acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version)); ElMessage.success('智能建议已生成,请人工核对后再推送') } + try { + try { + acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version)) + } catch (error) { + const details = getApiErrorDetails(error, '智能推荐失败') + if (details.code !== 'A0409' || !/(版本|并发|已被修改|已变化)/.test(details.message)) throw error + await load() + if (!plan.value) throw error + acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version)) + } + ElMessage.success('智能建议已生成,请人工核对后再推送') + } catch (error) { await handleMutationError(error, '智能推荐失败') } finally { mutating.value = false } } @@ -196,7 +207,7 @@ async function handleMutationError(error: unknown, fallback: string, pickingConf const pickingMessage = pickingConflict && details.code === 'A0409' && details.message.includes('拣货') ? '该订单在拣货中不许删除' : null - mutationError.value = { ...details, message: pickingMessage || (details.code === 'A0409' ? '计划或 PDA 明细已变化,已刷新最新版本并保留本次输入,请重新操作' : details.message) } + mutationError.value = { ...details, message: pickingMessage || details.message || (details.code === 'A0409' ? '计划或 PDA 明细已变化,已刷新最新版本并保留本次输入,请重新操作' : details.message) } ElMessage.error(mutationError.value.message) } diff --git a/src/modules/trucking/pages/TruckAppointmentDetailView.test.ts b/src/modules/trucking/pages/TruckAppointmentDetailView.test.ts index 077d2de7..6ba8a2b6 100644 --- a/src/modules/trucking/pages/TruckAppointmentDetailView.test.ts +++ b/src/modules/trucking/pages/TruckAppointmentDetailView.test.ts @@ -44,4 +44,12 @@ describe('TruckAppointmentDetailView status contract', () => { expect(source).toContain("status === 'DISPATCHED' && dispatchBlockReason.value") expect(source).toContain('attachmentOpen.value = true') }) + + it('keeps the main detail available when attachments or history fail', () => { + expect(source).toContain('Promise.allSettled([') + expect(source).toContain("attachments.status === 'fulfilled'") + expect(source).toContain("histories.status === 'fulfilled'") + expect(source).toContain('主详情数据不受影响') + expect(source).toContain('v-if="relatedError"') + }) }) diff --git a/src/modules/trucking/pages/TruckAppointmentDetailView.vue b/src/modules/trucking/pages/TruckAppointmentDetailView.vue index 03cf9d33..809d30ab 100644 --- a/src/modules/trucking/pages/TruckAppointmentDetailView.vue +++ b/src/modules/trucking/pages/TruckAppointmentDetailView.vue @@ -15,9 +15,20 @@ import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue' import { truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types' const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref(null); const error = ref(null); const form = reactive({ expectedVersion: 0, customerId: null, carrierId: null, plannedLoadingAt: null }) const history = ref([]) +const relatedError = ref('') const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit')) const relationSnapshot = computed(() => resource.value ? [resource.value.customerName, resource.value.oceanBookingNo, resource.value.cabinetNo, resource.value.containerNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无历史关联' : '') -async function loadRelated() { if (!resource.value) return; const [attachments, histories] = await Promise.all([listTruckAppointmentAttachments(id.value), listTruckAppointmentHistory(id.value)]); resource.value.attachments = attachments; history.value = histories } +async function loadRelated() { + if (!resource.value) return + relatedError.value = '' + const [attachments, histories] = await Promise.allSettled([ + listTruckAppointmentAttachments(id.value), + listTruckAppointmentHistory(id.value), + ]) + if (attachments.status === 'fulfilled') resource.value.attachments = attachments.value + if (histories.status === 'fulfilled') history.value = histories.value + if (attachments.status === 'rejected' || histories.status === 'rejected') relatedError.value = '附件或状态历史暂时未能加载,主详情数据不受影响,可稍后刷新重试。' +} async function load() { loading.value = true; error.value = null; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } } async function submit() { saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } } const actionByTarget: Partial> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' } @@ -56,6 +67,7 @@ onMounted(load) diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index ad9fdd6e..58542937 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -298,9 +298,10 @@ describe('UnifiedOrdersView contract', () => { it('keeps the basic information compact and maps available order fields', () => { expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))') - for (const label of ['业务类型', '买单 / 退税', '提货方式', '配载状态', '目的仓类型', '总净重', '商检验', '提货联系人', '目的地地址']) { + for (const label of ['业务类型', '买单 / 退税', '提货方式', '配载状态', '目的仓类型', '客户申报净重', '客户申报总体积', '商检验', '提货联系人', '目的地地址']) { expect(source).toContain(`
${label}
`) } + expect(source).toContain("['actualVolumeCbm', 'chargeableVolumeCbm'].includes(property)") expect(source).toContain("partyValue(detail.order.parties, 'SENDER', 'phone')") expect(source).toContain("partyValue(detail.order.parties, 'RECEIVER', 'phone')") expect(source).not.toContain("partyValue(detail.order.parties, 'CONSIGNEE'") diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index b7f2d01e..a4563730 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -401,7 +401,10 @@ function displayField(row: UnifiedOrderResource, property: string | undefined) { if (value === null || value === undefined || value === '') return '—' if (typeof value === 'boolean') return value ? '是' : '否' if (Array.isArray(value)) return value.length ? (property === 'skuNos' ? value.join('、') : `${value.length} 个文件`) : '—' - if (typeof value === 'number') return formatNumber(value) + if (typeof value === 'number') { + if (['actualVolumeCbm', 'chargeableVolumeCbm'].includes(property)) return formatNumber(value, 3) + return formatNumber(value) + } if (property.endsWith('At') || property.endsWith('Date')) return formatDate(String(value)) if (property === 'measurementReviewStatus') return measurementStatusLabel(String(value)) if (property === 'internalOrderStatus') return orderStatusLabel(String(value)) @@ -961,9 +964,9 @@ onBeforeUnmount(() => {
是否整柜
{{ firstLegContainerModeLabel(detail.order.order?.containerMode) }}
商检验
{{ displayBoolean(detail.order.order?.customsInspection) }}
总箱数
{{ detail.summary.totalPackageCount ?? '—' }} 箱
-
总毛重
{{ formatNumber(cargoTotal(detail.order.cargoes, 'grossWeightKg') ?? detail.summary.measurement?.actualGrossWeightKg) }} kg
-
总净重
{{ formatNumber(cargoTotal(detail.order.cargoes, 'netWeightKg')) }} kg
-
总体积
{{ formatNumber(cargoTotal(detail.order.cargoes, 'volumeCbm') ?? detail.summary.measurement?.actualVolumeCbm, 3) }} m³
+
客户申报毛重
{{ formatNumber(cargoTotal(detail.order.cargoes, 'grossWeightKg')) }} kg
+
客户申报净重
{{ formatNumber(cargoTotal(detail.order.cargoes, 'netWeightKg')) }} kg
+
客户申报总体积
{{ formatNumber(cargoTotal(detail.order.cargoes, 'volumeCbm'), 3) }} m³
计费毛重
{{ formatNumber(detail.summary.measurement?.chargeableWeightKg) }} kg
计费体积
{{ formatNumber(detail.summary.chargeableVolumeCbm, 3) }} m³
总材积重
{{ formatNumber(detail.summary.volumetricWeightKg) }} kg