From bbb9c40c5014ae1046b1e0ea095d2823abc91773 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 10:24:07 +0800 Subject: [PATCH] feat(admin): edit domestic order declarations --- .../unified-orders/api/unifiedOrders.test.ts | 13 + .../unified-orders/api/unifiedOrders.ts | 78 +++++- .../OrderDeclarationEditorDialog.test.ts | 75 ++++++ .../OrderDeclarationEditorDialog.vue | 227 ++++++++++++++++++ .../pages/UnifiedOrdersView.test.ts | 12 +- .../pages/UnifiedOrdersView.vue | 30 ++- src/modules/unified-orders/status.test.ts | 1 + src/modules/unified-orders/status.ts | 1 + 8 files changed, 432 insertions(+), 5 deletions(-) create mode 100644 src/modules/unified-orders/components/OrderDeclarationEditorDialog.test.ts create mode 100644 src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue diff --git a/src/modules/unified-orders/api/unifiedOrders.test.ts b/src/modules/unified-orders/api/unifiedOrders.test.ts index 6367be29..ea044d1c 100644 --- a/src/modules/unified-orders/api/unifiedOrders.test.ts +++ b/src/modules/unified-orders/api/unifiedOrders.test.ts @@ -8,6 +8,7 @@ import { downloadPlatformProductImage, listPlatformUnifiedOrders, listPlatformUnifiedOrderFeeDetails, + updatePlatformUnifiedOrderDeclaration, } from './unifiedOrders' vi.mock('@/shared/api/http', () => ({ @@ -147,6 +148,18 @@ describe('unified orders API contract', () => { expect(http.post).toHaveBeenCalledWith('/platform/v1/orders/90001/internal-notes', { content: '已确认船期' }, { params: { tenantId: '208' } }) }) + it('updates customer declaration fields through the versioned tenant-scoped contract', async () => { + const request = { + expectedVersion: 7, customerRemark: '易碎', expectedArrivalAt: '2026-09-06T10:00:00', + channelCode: 'US-SEA', channelName: '美西海运', + cargoes: [{ itemNo: 1, cargoName: '台灯', packageCount: 2, totalQuantity: 20, lengthCm: 40, widthCm: 30, heightCm: 20, grossWeightKg: 18, volumeCbm: 0.048 }], + } + vi.mocked(http.put).mockResolvedValue({ data: { orderNo: 'FL-1', inboundCode: 'RK-1', previousVersion: 7, version: 8, channelChangedBefore: false, channelChanged: true, changes: [] } }) + + await expect(updatePlatformUnifiedOrderDeclaration('208', '90001', request)).resolves.toMatchObject({ version: 8, channelChanged: true }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/orders/90001/declaration', request, { params: { tenantId: '208' } }) + }) + it('loads fee detail rows from the inbound fee-detail contract', async () => { vi.mocked(http.get).mockResolvedValue({ data: [{ id: '1', inboundId: '88', reviewStatus: 'APPROVED', amount: 100 }] }) await expect(listPlatformUnifiedOrderFeeDetails('208', '88')).resolves.toHaveLength(1) diff --git a/src/modules/unified-orders/api/unifiedOrders.ts b/src/modules/unified-orders/api/unifiedOrders.ts index cb3e04d7..2dafd398 100644 --- a/src/modules/unified-orders/api/unifiedOrders.ts +++ b/src/modules/unified-orders/api/unifiedOrders.ts @@ -93,6 +93,7 @@ export interface UnifiedOrderResource { customerName: string | null channelCode: string | null channelName: string | null + channelChanged?: boolean receivingWarehouseId: number | string | null receivingWarehouseName: string | null destinationWarehouseId: number | string | null @@ -102,6 +103,7 @@ export interface UnifiedOrderResource { inboundCode: string | null expectedShipDate: string | null totalPackageCount: number | null + expectedVolumeCbm?: number | null locationCodes: string | null actualGrossWeightKg: number | null actualVolumeCbm: number | null @@ -170,6 +172,7 @@ export interface UnifiedOrderTimelineEvent { export interface UnifiedOrderDetailOrderSnapshot { id: number | string | null + version: number | null tenantId: number | string | null orderNo: string | null customerId: number | string | null @@ -329,6 +332,58 @@ function id(value: number | string | null | undefined) { return value === null || value === undefined || value === '' ? null : String(value) } +export interface UnifiedOrderDeclarationCargo { + itemNo: number + cargoName: string + cargoNameEn?: string | null + skuNo?: string | null + model?: string | null + packageCount: number + totalQuantity: number + lengthCm: number + widthCm: number + heightCm: number + grossWeightKg: number + volumeCbm: number + netWeightKg?: number | null + hsCode?: string | null + customsHsCode?: string | null + unitsPerPackage?: number | null + unitDeclaredValue?: number | null + declaredValue?: number | null + currency?: string | null + material?: string | null + usage?: string | null + brand?: string | null + cargoRemark?: string | null +} + +export interface UpdateUnifiedOrderDeclarationRequest { + expectedVersion: number + customerRemark?: string | null + expectedArrivalAt?: string | null + channelCode: string + channelName: string + cargoes: UnifiedOrderDeclarationCargo[] +} + +export interface UnifiedOrderDeclarationChange { + itemNo: number | null + field: string + beforeValue: string | null + afterValue: string | null +} + +export interface UpdateUnifiedOrderDeclarationResult { + orderNo: string | null + inboundCode: string | null + previousVersion: number + version: number + channelChangedBefore: boolean + channelChanged: boolean + changes: UnifiedOrderDeclarationChange[] +} + function optionalText(value: string | null | undefined) { const normalized = value?.trim() return normalized || null @@ -357,6 +412,8 @@ function normalizeRow(raw: UnifiedOrderResource): UnifiedOrderResource { childCount: integer(raw.relation.childCount, 0), } : null, totalPackageCount: nullableInteger(raw.totalPackageCount), + expectedVolumeCbm: numberValue(raw.expectedVolumeCbm), + channelChanged: Boolean(raw.channelChanged), actualGrossWeightKg: numberValue(raw.actualGrossWeightKg), actualVolumeCbm: numberValue(raw.actualVolumeCbm), totalPalletCount: nullableInteger(raw.totalPalletCount), @@ -413,7 +470,26 @@ export async function getPlatformUnifiedOrder(tenantId: string, orderId: string) { params: { tenantId } }, ) const result = unwrapApiResponse(response) - return { ...result, summary: normalizeRow(result.summary) } + return { + ...result, + summary: normalizeRow(result.summary), + order: { + ...result.order, + order: result.order.order ? { ...result.order.order, version: nullableInteger(result.order.order.version) } : null, + }, + } +} + +export function updatePlatformUnifiedOrderDeclaration( + tenantId: string, + orderId: string, + request: UpdateUnifiedOrderDeclarationRequest, +) { + return http.put, ApiEnvelope>( + `${unifiedOrdersApiPath}/${orderId}/declaration`, + request, + { params: { tenantId } }, + ).then(unwrapApiResponse) } export function downloadPlatformProductImage(tenantId: string, fileId: string) { diff --git a/src/modules/unified-orders/components/OrderDeclarationEditorDialog.test.ts b/src/modules/unified-orders/components/OrderDeclarationEditorDialog.test.ts new file mode 100644 index 00000000..e3bca781 --- /dev/null +++ b/src/modules/unified-orders/components/OrderDeclarationEditorDialog.test.ts @@ -0,0 +1,75 @@ +import ElementPlus, { ElMessageBox } from 'element-plus' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { updatePlatformUnifiedOrderDeclaration } from '@/modules/unified-orders/api/unifiedOrders' +import OrderDeclarationEditorDialog from './OrderDeclarationEditorDialog.vue' + +vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({ updatePlatformUnifiedOrderDeclaration: vi.fn() })) + +const detail = { + summary: { + orderId: '90001', tenantId: '208', inboundCode: 'RK-001', mainStatus: 'WAIT_MEASURE', + receivingWarehouseName: '深圳仓', destinationWarehouseName: '洛杉矶中转仓', + channelCode: 'OLD', channelName: '原渠道', channelChanged: false, + expectedArrivalAt: '2026-09-06T10:00:00', customerRemark: '原备注', + }, + order: { + order: { version: 7, channelCode: 'OLD', channelName: '原渠道', customerRemark: '原备注' }, + cargoes: [{ itemNo: 1, cargoName: '台灯', packageCount: 2, totalQuantity: 20, lengthCm: 40, widthCm: 30, heightCm: 20, grossWeightKg: 18, volumeCbm: 0.048 }], + parties: [], internalNotes: [], statusHistory: [], + }, + timeline: [], +} as never + +function createWrapper() { + return mount(OrderDeclarationEditorDialog, { + props: { modelValue: true, detail }, + global: { + plugins: [ElementPlus], + stubs: { + AppDialog: { + props: ['modelValue', 'confirmLoading'], emits: ['confirm', 'update:modelValue'], + template: '
', + }, + LoadErrorState: { props: ['title', 'message'], template: '
{{ title }} {{ message }}
' }, + ApiErrorMeta: true, + }, + }, + }) +} + +describe('OrderDeclarationEditorDialog', () => { + beforeEach(() => { + vi.mocked(updatePlatformUnifiedOrderDeclaration).mockReset() + vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never) + }) + + it('requires a second confirmation when the channel changes and keeps inbound/warehouse facts read-only', async () => { + vi.mocked(updatePlatformUnifiedOrderDeclaration).mockResolvedValue({ version: 8 } as never) + const wrapper = createWrapper() + expect(wrapper.text()).toContain('国内收货仓和中转仓不可修改,如需变更请重新下单') + expect(wrapper.text()).toContain('RK-001') + const inputs = wrapper.findAll('input') + await inputs[0]!.setValue('NEW') + await wrapper.get('[data-testid="save"]').trigger('click') + await flushPromises() + + expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('现有入仓码和箱唛不会改变'), '确认变更渠道', expect.any(Object)) + expect(updatePlatformUnifiedOrderDeclaration).toHaveBeenCalledWith('208', '90001', expect.objectContaining({ expectedVersion: 7, channelCode: 'NEW' })) + expect(wrapper.emitted('updated')).toHaveLength(1) + expect(wrapper.emitted('update:modelValue')).toContainEqual([false]) + }) + + it('preserves the editor and entered values when the versioned update fails', async () => { + vi.mocked(updatePlatformUnifiedOrderDeclaration).mockRejectedValue(new Error('订单版本已变化,请刷新详情后重试')) + const wrapper = createWrapper() + const inputs = wrapper.findAll('input') + await inputs[1]!.setValue('保留填写的渠道名') + await wrapper.get('[data-testid="save"]').trigger('click') + await flushPromises() + + expect(wrapper.find('.load-error').exists()).toBe(true) + expect((inputs[1]!.element as HTMLInputElement).value).toBe('保留填写的渠道名') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) +}) diff --git a/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue b/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue new file mode 100644 index 00000000..dc9294dd --- /dev/null +++ b/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index 66b05d99..32f62461 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -98,7 +98,7 @@ describe('UnifiedOrdersView contract', () => { 'actions', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName', 'destinationWarehouseName', 'channelName', 'totalPackageCount', 'totalPalletCount', 'requestedPickupAt', 'locationCodes', 'measurementReviewStatus', 'actualGrossWeightKg', 'actualVolumeCbm', 'totalNetWeightKg', 'chargeableWeightKg', 'chargeableVolumeCbm', 'expectedShipDate', - 'expectedArrivalAt', 'feeReviewStatus', 'skuNos', 'totalQuantity', 'expressCompany', 'expressTrackingNo', + 'expectedVolumeCbm', 'expectedArrivalAt', 'feeReviewStatus', 'skuNos', 'totalQuantity', 'expressCompany', 'expressTrackingNo', 'shipmentId', 'referenceId', 'containerNo', 'cabinetNo', 'effectiveSailingAt', 'effectivePortArrivalAt', 'customerRemark', 'latestInternalNote', 'createdAt', 'updatedAt', ] @@ -119,6 +119,7 @@ describe('UnifiedOrdersView contract', () => { expect(columnSource).toContain("key: 'requestedPickupAt', prop: 'requestedPickupAt', label: '预约提货时间'") expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(13) expect(columnSource).toContain("key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到货时间', minWidth: 160, slotName: 'field' }") + expect(columnSource).toContain("key: 'expectedVolumeCbm', prop: 'expectedVolumeCbm', label: '预计体积(m³)', minWidth: 130, slotName: 'field' }") expect(columnSource).toContain("key: 'effectiveSailingAt', prop: 'effectiveSailingAt', label: '开船时间', minWidth: 180, slotName: 'field' }") expect(columnSource).toContain("key: 'effectivePortArrivalAt', prop: 'effectivePortArrivalAt', label: '到港时间', minWidth: 180, slotName: 'field' }") expect(source).toContain('preference-key="unified-orders-table-v2"') @@ -411,6 +412,15 @@ describe('UnifiedOrdersView contract', () => { expect(routeSource).not.toContain('unified-order-workflow') }) + it('only exposes declaration editing on approved editable details and marks channel changes', () => { + expect(source).toContain("detail.value.summary.mainStatus === 'WAIT_MEASURE'") + expect(source).toContain("can('platform:order:declaration:edit')") + expect(source).toContain('编辑客户申报资料') + expect(source).toContain(' { expect(routeSource).toContain("name: 'unified-order-detail-by-inbound-code'") expect(routeSource.indexOf("path: 'orders/inbound/:inboundCode'")).toBeLessThan(routeSource.indexOf("path: 'orders/:orderId'")) diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index f0128024..f3dd6f26 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -19,6 +19,7 @@ import { import { cancelPlatformCoLoadGroup, createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode, type CoLoadBusinessSummary, type CoLoadGroup } from '@/modules/unified-orders/api/coLoadGroups' import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue' import InboundFeeDetailManager from '@/modules/unified-orders/components/InboundFeeDetailManager.vue' +import OrderDeclarationEditorDialog from '@/modules/unified-orders/components/OrderDeclarationEditorDialog.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' @@ -108,6 +109,7 @@ const warehouseLoadError = ref(null) const detail = ref(null) const detailLoading = ref(false) const detailError = ref(null) +const declarationEditorOpen = ref(false) const activeDetailTab = ref('basic') const automaticFee = ref(null) const otherFeeSummary = ref({ currency: 'CNY', approvedReceivable: 0, approvedPayable: 0 }) @@ -281,8 +283,9 @@ const columns = computed(() => [ { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' }, { key: 'receivingWarehouseName', prop: 'receivingWarehouseName', label: '平台收货仓', minWidth: 150, slotName: 'field' }, { key: 'destinationWarehouseName', prop: 'destinationWarehouseName', label: '目的仓', minWidth: 150, slotName: 'field' }, - { key: 'channelName', prop: 'channelName', label: '渠道', minWidth: 140, slotName: 'field' }, + { key: 'channelName', prop: 'channelName', label: '渠道', minWidth: 180, slotName: 'channel' }, { key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', minWidth: 100, slotName: 'field' }, + { key: 'expectedVolumeCbm', prop: 'expectedVolumeCbm', label: '预计体积(m³)', minWidth: 130, slotName: 'field' }, { key: 'totalPalletCount', prop: 'totalPalletCount', label: '总托数', minWidth: 100, slotName: 'field' }, { key: 'requestedPickupAt', prop: 'requestedPickupAt', label: '预约提货时间', minWidth: 160, slotName: 'field' }, { key: 'locationCodes', prop: 'locationCodes', label: '库位位置', minWidth: 160, slotName: 'field', defaultVisible: false }, @@ -360,6 +363,19 @@ function can(permission: string) { return access.can(permission) } +const canEditDeclaration = computed(() => Boolean( + detail.value + && detail.value.summary.mainStatus === 'WAIT_MEASURE' + && detail.value.order.order?.version !== null + && detail.value.order.order?.version !== undefined + && can('platform:order:declaration:edit'), +)) + +async function handleDeclarationUpdated() { + activeDetailTab.value = 'basic' + await loadDetail(true) +} + function customerName(row: UnifiedOrderResource) { return row.customerName?.trim() || customers.value.find((item) => String(item.id) === String(row.tenantId))?.tenantName || '客户名称待补充' } @@ -1260,7 +1276,7 @@ onBeforeUnmount(() => {
目的仓邮编
{{ detail.summary.destinationPostalCode || '—' }}
目的仓联系人
{{ detail.summary.destinationContact || '—' }}
目的仓联系人电话
{{ detail.summary.destinationContactPhone || '—' }}
-
渠道
{{ detail.summary.channelName || '—' }}
+
渠道
{{ detail.summary.channelName || '—' }} 曾变更渠道
业务类型
{{ firstLegBusinessTypeLabel(detail.summary.businessType) }}
贸易条款
{{ firstLegTradeTermLabel(detail.order.order?.tradeTerm) }}
买单 / 退税
{{ firstLegTaxModeLabel(detail.order.order?.taxMode) }}
@@ -1312,7 +1328,7 @@ onBeforeUnmount(() => { + @@ -1625,6 +1642,13 @@ onBeforeUnmount(() => { + + { expect(unifiedOrderStatusLabel('WAIT_SPLIT')).toBe('待分柜') expect(unifiedOrderReviewStatusLabel('APPROVED')).toBe('审核通过') expect(unifiedOrderLoadingStatusLabel('LOADED')).toBe('已配载') + expect(unifiedOrderHistoryActionLabel('DECLARATION_UPDATE')).toBe('客户申报资料变更') }) it('never exposes unknown backend enums', () => { diff --git a/src/modules/unified-orders/status.ts b/src/modules/unified-orders/status.ts index 85f4e3d0..ec75acb8 100644 --- a/src/modules/unified-orders/status.ts +++ b/src/modules/unified-orders/status.ts @@ -47,6 +47,7 @@ const historyActionLabels: Record = { UI_FIXTURE_MEASURE_APPROVE: '测量数据审核通过', UI_FIXTURE_INBOUND_BEGIN: '开始入库', STATUS_UPDATE: '状态更新', + DECLARATION_UPDATE: '客户申报资料变更', } const fulfillmentRoleLabels: Record = {