From 5cb1ca307d45271576c18bed0db589b99c1f503e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 4 Aug 2026 21:01:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/router/index.test.ts | 17 + src/app/router/index.ts | 6 + src/core/auth/auth.test.ts | 25 + src/core/auth/auth.ts | 10 +- src/core/navigation/adminNavigation.test.ts | 13 +- src/core/navigation/adminNavigation.ts | 15 +- .../customers/pages/CustomersView.test.ts | 4 + src/modules/customers/pages/CustomersView.vue | 43 +- .../api/platformCarrierRates.test.ts | 116 +++ .../carrier-rates/api/platformCarrierRates.ts | 124 +++ .../pages/CarrierRatesView.test.ts | 98 +++ .../carrier-rates/pages/CarrierRatesView.vue | 641 ++++++++++++++ .../customer-rules/CustomerRulesView.test.ts | 86 ++ .../customer-rules/CustomerRulesView.vue | 496 +++++++++++ .../fba-pricing/customer-rules/api.test.ts | 82 ++ src/modules/fba-pricing/customer-rules/api.ts | 59 ++ .../fba-pricing/customer-rules/types.ts | 49 ++ src/modules/fba-pricing/routes.ts | 24 + .../PlatformFirstLegOrdersPanel.test.ts | 12 +- .../PlatformFirstLegOrdersPanel.vue | 26 +- src/modules/iam/api/platformIam.ts | 2 +- .../InboundWorkflowWorkspace.test.ts | 61 ++ .../components/InboundWorkflowWorkspace.vue | 213 +++++ .../inbounds/core/InboundHistoryPanel.vue | 76 ++ .../inbounds/core/InboundSummaryPanel.test.ts | 23 + .../inbounds/core/InboundSummaryPanel.vue | 124 +++ src/modules/inbounds/core/api.test.ts | 86 ++ src/modules/inbounds/core/api.ts | 127 +++ src/modules/inbounds/core/types.ts | 153 ++++ .../inbounds/fee/InboundFeePanel.test.ts | 237 +++++ src/modules/inbounds/fee/InboundFeePanel.vue | 477 +++++++++++ src/modules/inbounds/fee/api.test.ts | 73 ++ src/modules/inbounds/fee/api.ts | 35 + src/modules/inbounds/fee/index.ts | 13 + src/modules/inbounds/fee/types.ts | 54 ++ .../InboundMeasurementPanel.test.ts | 255 ++++++ .../measurement/InboundMeasurementPanel.vue | 635 ++++++++++++++ src/modules/inbounds/measurement/api.test.ts | 75 ++ src/modules/inbounds/measurement/api.ts | 46 + src/modules/inbounds/measurement/index.ts | 4 + src/modules/inbounds/measurement/types.ts | 131 +++ .../pages/InboundOrderDetailView.test.ts | 23 + .../inbounds/pages/InboundOrderDetailView.vue | 65 ++ .../inbounds/pages/InboundOrdersView.test.ts | 261 ++++++ .../inbounds/pages/InboundOrdersView.vue | 474 ++++++++++ .../review/MeasurementReviewPanel.test.ts | 94 ++ .../review/MeasurementReviewPanel.vue | 342 ++++++++ src/modules/inbounds/review/api.test.ts | 47 + src/modules/inbounds/review/api.ts | 100 +++ src/modules/inbounds/review/index.ts | 3 + src/modules/inbounds/review/types.ts | 123 +++ src/modules/inbounds/routes.ts | 24 + .../inbounds/weight/api/weightOcr.test.ts | 100 +++ src/modules/inbounds/weight/api/weightOcr.ts | 123 +++ .../weight/components/WeightOcrPanel.test.ts | 49 ++ .../weight/components/WeightOcrPanel.vue | 530 ++++++++++++ src/modules/inbounds/weight/index.ts | 3 + src/modules/inbounds/weight/types.ts | 62 ++ .../counterparties/api/platformPartners.ts | 2 +- .../master-data/counterparties/index.ts | 1 + .../counterparties/pages/PartnersView.test.ts | 20 +- .../counterparties/pages/PartnersView.vue | 132 +-- .../api/platformExpressCompanies.ts | 2 +- .../pages/ExpressCompaniesView.test.ts | 12 +- .../pages/ExpressCompaniesView.vue | 129 +-- src/modules/master-data/index.ts | 1 + .../locations/api/platformLocations.test.ts | 112 +++ .../locations/api/platformLocations.ts | 148 ++++ .../locations/pages/LocationsView.test.ts | 310 +++++++ .../locations/pages/LocationsView.vue | 808 ++++++++++++++++++ .../pallets/api/platformPallets.test.ts | 77 ++ .../pallets/api/platformPallets.ts | 103 +++ .../pallets/pages/PalletsView.test.ts | 317 +++++++ .../pallets/pages/PalletsView.vue | 565 ++++++++++++ src/modules/warehouse-resources/routes.ts | 24 + src/modules/warehouses/index.ts | 1 + src/shared/components/QueryToolbar.vue | 12 +- 77 files changed, 9914 insertions(+), 131 deletions(-) create mode 100644 src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.test.ts create mode 100644 src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.ts create mode 100644 src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts create mode 100644 src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue create mode 100644 src/modules/fba-pricing/customer-rules/CustomerRulesView.test.ts create mode 100644 src/modules/fba-pricing/customer-rules/CustomerRulesView.vue create mode 100644 src/modules/fba-pricing/customer-rules/api.test.ts create mode 100644 src/modules/fba-pricing/customer-rules/api.ts create mode 100644 src/modules/fba-pricing/customer-rules/types.ts create mode 100644 src/modules/fba-pricing/routes.ts create mode 100644 src/modules/inbounds/components/InboundWorkflowWorkspace.test.ts create mode 100644 src/modules/inbounds/components/InboundWorkflowWorkspace.vue create mode 100644 src/modules/inbounds/core/InboundHistoryPanel.vue create mode 100644 src/modules/inbounds/core/InboundSummaryPanel.test.ts create mode 100644 src/modules/inbounds/core/InboundSummaryPanel.vue create mode 100644 src/modules/inbounds/core/api.test.ts create mode 100644 src/modules/inbounds/core/api.ts create mode 100644 src/modules/inbounds/core/types.ts create mode 100644 src/modules/inbounds/fee/InboundFeePanel.test.ts create mode 100644 src/modules/inbounds/fee/InboundFeePanel.vue create mode 100644 src/modules/inbounds/fee/api.test.ts create mode 100644 src/modules/inbounds/fee/api.ts create mode 100644 src/modules/inbounds/fee/index.ts create mode 100644 src/modules/inbounds/fee/types.ts create mode 100644 src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts create mode 100644 src/modules/inbounds/measurement/InboundMeasurementPanel.vue create mode 100644 src/modules/inbounds/measurement/api.test.ts create mode 100644 src/modules/inbounds/measurement/api.ts create mode 100644 src/modules/inbounds/measurement/index.ts create mode 100644 src/modules/inbounds/measurement/types.ts create mode 100644 src/modules/inbounds/pages/InboundOrderDetailView.test.ts create mode 100644 src/modules/inbounds/pages/InboundOrderDetailView.vue create mode 100644 src/modules/inbounds/pages/InboundOrdersView.test.ts create mode 100644 src/modules/inbounds/pages/InboundOrdersView.vue create mode 100644 src/modules/inbounds/review/MeasurementReviewPanel.test.ts create mode 100644 src/modules/inbounds/review/MeasurementReviewPanel.vue create mode 100644 src/modules/inbounds/review/api.test.ts create mode 100644 src/modules/inbounds/review/api.ts create mode 100644 src/modules/inbounds/review/index.ts create mode 100644 src/modules/inbounds/review/types.ts create mode 100644 src/modules/inbounds/routes.ts create mode 100644 src/modules/inbounds/weight/api/weightOcr.test.ts create mode 100644 src/modules/inbounds/weight/api/weightOcr.ts create mode 100644 src/modules/inbounds/weight/components/WeightOcrPanel.test.ts create mode 100644 src/modules/inbounds/weight/components/WeightOcrPanel.vue create mode 100644 src/modules/inbounds/weight/index.ts create mode 100644 src/modules/inbounds/weight/types.ts create mode 100644 src/modules/master-data/counterparties/index.ts create mode 100644 src/modules/master-data/index.ts create mode 100644 src/modules/warehouse-resources/locations/api/platformLocations.test.ts create mode 100644 src/modules/warehouse-resources/locations/api/platformLocations.ts create mode 100644 src/modules/warehouse-resources/locations/pages/LocationsView.test.ts create mode 100644 src/modules/warehouse-resources/locations/pages/LocationsView.vue create mode 100644 src/modules/warehouse-resources/pallets/api/platformPallets.test.ts create mode 100644 src/modules/warehouse-resources/pallets/api/platformPallets.ts create mode 100644 src/modules/warehouse-resources/pallets/pages/PalletsView.test.ts create mode 100644 src/modules/warehouse-resources/pallets/pages/PalletsView.vue create mode 100644 src/modules/warehouse-resources/routes.ts create mode 100644 src/modules/warehouses/index.ts diff --git a/src/app/router/index.test.ts b/src/app/router/index.test.ts index baa2c89d..76e8990b 100644 --- a/src/app/router/index.test.ts +++ b/src/app/router/index.test.ts @@ -18,6 +18,23 @@ describe('admin router access metadata', () => { expect(warehouseRoute?.meta.permission).toBe('platform:warehouse:list') }) + it('protects phase-two inbound operations and master data with deployed permissions', () => { + const expected = [ + ['inbound-orders', '/inbounds', 'platform:firstleg:inbound:list'], + ['inbound-order-detail', '/inbounds/:inboundId', 'platform:firstleg:inbound:view'], + ['fba-customer-rules', '/first-leg-pricing/customer-rules', 'firstleg:fba-pricing:rule:view'], + ['fba-carrier-rates', '/first-leg-pricing/carrier-rates', 'firstleg:fba-pricing:rate:view'], + ['locations', '/master-data/locations', 'platform:location:list'], + ['pallets', '/master-data/pallets', 'platform:pallet:list'], + ] + + for (const [name, path, permission] of expected) { + const route = router.getRoutes().find((item) => item.name === name) + expect(route?.path).toBe(path) + expect(route?.meta.permission).toBe(permission) + } + }) + it('protects express company records with a stable master-data permission', () => { const route = router.getRoutes().find((item) => item.name === 'express-companies') diff --git a/src/app/router/index.ts b/src/app/router/index.ts index 29483107..21610ce4 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -4,8 +4,11 @@ import { decideRouteAccess } from '@/core/routing/accessDecision' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { downloadRoutes } from '@/modules/downloads/routes' import { firstLegOrderRoutes } from '@/modules/first-leg-orders/routes' +import { inboundRoutes } from '@/modules/inbounds/routes' +import { fbaPricingRoutes } from '@/modules/fba-pricing/routes' import { iamRoutes } from '@/modules/iam/routes' import { masterDataRoutes } from '@/modules/master-data/routes' +import { warehouseResourceRoutes } from '@/modules/warehouse-resources/routes' import { useAuthStore } from '@/core/auth/auth' import { customerRoutes } from '@/modules/customers/routes' import { warehouseRoutes } from '@/modules/warehouses/routes' @@ -35,6 +38,9 @@ const router = createRouter({ ...customerRoutes, ...warehouseRoutes, ...firstLegOrderRoutes, + ...inboundRoutes, + ...fbaPricingRoutes, + ...warehouseResourceRoutes, ...masterDataRoutes, ...downloadRoutes, ...iamRoutes, diff --git a/src/core/auth/auth.test.ts b/src/core/auth/auth.test.ts index a16ef94a..4d0e2aac 100644 --- a/src/core/auth/auth.test.ts +++ b/src/core/auth/auth.test.ts @@ -129,6 +129,31 @@ describe('admin auth session', () => { expect(auth.isAuthenticated).toBe(true) }) + it('refreshes a valid persisted session so newly granted permissions appear after reload', async () => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]') + const refreshedToken = token({ + permissions: ['platform:warehouse:list', 'platform:firstleg:inbound:list'], + }) + mocks.post.mockResolvedValue(loginResponse(refreshedToken, 'refresh-rotated')) + mocks.listCurrentPlatformPermissionCodes.mockResolvedValue([ + 'platform:warehouse:list', + 'platform:firstleg:inbound:list', + ]) + const auth = useAuthStore() + + await expect(auth.restoreSession()).resolves.toBe(true) + + expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' }) + expect([...auth.permissionCodes]).toEqual([ + 'platform:warehouse:list', + 'platform:firstleg:inbound:list', + ]) + expect(localStorage.getItem('cc-admin-permission-codes')) + .toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]') + }) + it('does not let an in-flight refresh restore a session after logout', async () => { localStorage.setItem('cc-admin-token', token()) localStorage.setItem('cc-admin-refresh-token', 'refresh-old') diff --git a/src/core/auth/auth.ts b/src/core/auth/auth.ts index 78cbd677..1a36d44e 100644 --- a/src/core/auth/auth.ts +++ b/src/core/auth/auth.ts @@ -162,7 +162,15 @@ export const useAuthStore = defineStore('admin-auth', () => { } async function restoreSession() { - if (isAuthenticated.value) return true + if (isAuthenticated.value) { + if (!refreshToken.value) return true + try { + await refreshSession() + return true + } catch { + return false + } + } if (!refreshToken.value) return false try { await refreshSession() diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 0453858b..4b8f4495 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -8,15 +8,26 @@ import { } from './adminNavigation' describe('adminNavigation', () => { - it('keeps only implemented phase-one menus visible while retaining prototype nodes as comments', () => { + it('exposes implemented phase-one and phase-two menus while retaining future prototype nodes as comments', () => { expect(adminNavigation.map((item) => item.title)).toEqual([ '客户管理', '国内仓', '头程订单', + '头程计费', '通用', '系统管理', ]) expect(adminNavigation[0].children?.map((item) => item.title)).toEqual(['客户列表']) + expect(adminNavigation.find((item) => item.key === 'domestic-warehouse')?.children?.map((item) => item.title)).toEqual([ + '仓库管理', + '入库单', + '库位管理', + '托盘管理', + ]) + expect(adminNavigation.find((item) => item.key === 'first-leg-pricing')?.children?.map((item) => item.title)).toEqual([ + '客户计费规则', + '船司 FBA 价格', + ]) expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toContain('快递公司') expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toContain('下载中心') expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([ diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 486bb6f5..43878398 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -3,6 +3,7 @@ import { Building2, ContactRound, ClipboardList, + CircleDollarSign, Database, Download, KeyRound, @@ -52,7 +53,9 @@ export const adminNavigation: AdminNavigationNode[] = [ iconColor: '#52c41a', children: [ { key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' }, - // { key: 'inbound-list', title: '入库单列表', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-list' }, + { key: 'inbound-list', title: '入库单', icon: ClipboardList, path: '/inbounds', permission: 'platform:firstleg:inbound:list' }, + { key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' }, + { key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' }, // { key: 'inbound-dedup', title: '入库单测量', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-dedup' }, // { key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/prototype/domestic-warehouse/inbound-measure' }, // { key: 'inbound-receive', title: '接收审核', icon: FileCheck2, path: '/prototype/domestic-warehouse/inbound-receive' }, @@ -68,6 +71,16 @@ export const adminNavigation: AdminNavigationNode[] = [ path: '/orders/first-leg', permission: 'platform:order:list', }, + { + key: 'first-leg-pricing', + title: '头程计费', + icon: CircleDollarSign, + iconColor: '#13a8a8', + children: [ + { key: 'fba-customer-rules', title: '客户计费规则', icon: Database, path: '/first-leg-pricing/customer-rules', permission: 'firstleg:fba-pricing:rule:view' }, + { key: 'fba-carrier-rates', title: '船司 FBA 价格', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' }, + ], + }, /* { key: 'overseas-delivery', title: '海外派送', diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index d32ec000..db6013db 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -199,6 +199,10 @@ describe('CustomersView', () => { }) it('saves customer data and selected qualifications with one action', async () => { + vi.mocked(listTenantQualifications).mockResolvedValue([{ + id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID', + certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null, + }]) mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() tableButtonByText('编辑')?.click() diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index 74dc5058..5d482180 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -108,10 +108,7 @@ const rules: FormRules = { tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }], legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }], legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }], - unifiedSocialCreditCode: [ - { required: true, message: '请输入统一社会信用代码', trigger: 'blur' }, - { pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' }, - ], + unifiedSocialCreditCode: [{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' }], contactName: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }], contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }], contactEmail: [{ type: 'email', message: '请输入有效邮箱地址', trigger: 'blur' }], @@ -151,6 +148,8 @@ function newQualificationDrafts(): QualificationDraft[] { })) } const qualifications = ref(newQualificationDrafts()) +const creditCodeEstablished = ref(false) +const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/ const customerColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', minWidth: 190, slotName: 'actions' }, @@ -174,6 +173,20 @@ const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_ const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || '')) const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading)) const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file)) +const selectedBusinessLicense = computed(() => qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')?.file) +const creditCodeReadyForSave = computed(() => creditCodeEstablished.value + || (Boolean(selectedBusinessLicense.value) && unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || ''))) +const requiredQualificationPackageComplete = computed(() => creditCodeReadyForSave.value + && qualifications.value.filter((item) => item.required).every((item) => item.uploaded || Boolean(item.file))) +const missingQualificationPackageItems = computed(() => { + const businessLicense = qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE') + const legalPersonId = qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID') + const missing: string[] = [] + if (!businessLicense?.uploaded && !businessLicense?.file) missing.push('营业执照') + if (!creditCodeReadyForSave.value) missing.push('18位统一社会信用代码(需随营业执照保存)') + if (!legalPersonId?.uploaded && !legalPersonId?.file) missing.push('法人身份证') + return missing +}) const hasUnsavedChanges = computed(() => Boolean(editingId.value) && JSON.stringify(form.value) !== savedFormSnapshot.value) const pendingReviewByTenantId = computed(() => new Map(pendingReviews.value.map((review) => [String(review.tenantId), review]))) function displayText(value?: string | null) { return value?.trim() || '—' } @@ -302,7 +315,7 @@ function clearQualificationPreviews() { } function resetEditor() { clearQualificationPreviews() - form.value = emptyForm(); qualifications.value = newQualificationDrafts(); qualificationLoadError.value = ''; savedFormSnapshot.value = '' + form.value = emptyForm(); qualifications.value = newQualificationDrafts(); creditCodeEstablished.value = false; qualificationLoadError.value = ''; savedFormSnapshot.value = '' } function openCreate() { editorLoadSequence += 1 @@ -352,6 +365,7 @@ async function openEdit(row: PlatformTenantResource) { if (sequence !== editorLoadSequence || editingId.value !== id || !editorOpen.value) return editorStatus.value = tenant.status form.value = mapTenantToForm(tenant) + creditCodeEstablished.value = unifiedSocialCreditCodePattern.test(tenant.unifiedSocialCreditCode || '') savedFormSnapshot.value = JSON.stringify(form.value) await loadEditorQualifications(row.id, sequence) await nextTick(() => formRef.value?.clearValidate()) @@ -430,6 +444,7 @@ async function uploadSelectedQualifications(tenantId: string) { draft.uploaded, ) draft.uploaded = true + if (draft.type === 'BUSINESS_LICENSE') creditCodeEstablished.value = true draft.uploadedName = draft.file?.name || '已登记文件' draft.documentUrl = uploaded.documentUrl draft.file = null @@ -442,6 +457,11 @@ async function uploadSelectedQualifications(tenantId: string) { async function saveCustomer() { if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return + const qualificationPackageTouched = hasSelectedQualifications.value || Boolean(form.value.unifiedSocialCreditCode) + if (qualificationPackageTouched && !requiredQualificationPackageComplete.value) { + ElMessage.warning(`请完整保存必填业务项:${missingQualificationPackageItems.value.join('、')}`) + return + } saving.value = true try { const hadSelectedQualifications = hasSelectedQualifications.value @@ -450,7 +470,7 @@ async function saveCustomer() { const { unifiedSocialCreditCode: _immutable, ...payload } = form.value saved = await updatePlatformTenant(editingId.value, payload) } else { - saved = await createPlatformTenant(form.value) + saved = await createPlatformTenant({ ...form.value, unifiedSocialCreditCode: undefined }) editingId.value = String(saved.id) } editorStatus.value = saved.status @@ -459,7 +479,7 @@ async function saveCustomer() { if (failures.length) { ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`) } else if (hadSelectedQualifications) { - ElMessage.success('客户资料和已选资质已保存,尚未提交审核') + ElMessage.success('营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核') } else { ElMessage.success('客户资料已保存,尚未提交审核') } @@ -477,6 +497,10 @@ async function submitReview() { if (hasUnsavedChanges.value) { ElMessage.warning('客户资料有未保存的修改,请先保存资料'); return } if (uploadingQualifications.value) { ElMessage.warning('资质文件正在上传,请稍候再提交审核'); return } if (hasSelectedQualifications.value) { ElMessage.warning('存在尚未保存的已选文件,请先点击“保存资料和资质”'); return } + if (!creditCodeEstablished.value || !unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || '')) { + ElMessage.warning('请先完整保存营业执照、18位统一社会信用代码和法人身份证') + return + } const missing = qualifications.value.filter((item) => item.required && !item.uploaded) for (const item of missing) item.error = `${item.label}为提交审核必填文件` if (missing.length) { ElMessage.warning(`请先上传${missing.map((item) => item.label).join('、')}`); return } @@ -627,7 +651,6 @@ onMounted(async () => { -
@@ -643,7 +666,8 @@ onMounted(async () => { -

资质文件

先选择并上传文件;上传成功不会创建审核单,点击“提交审核”并确认后才会进入审核。

{{ statusMeta(editorStatus).label }}
+

企业认证必填业务项

营业执照、18位统一社会信用代码、法人身份证需共同完整保存;货代资质证书和银行开户许可证选填。

{{ statusMeta(editorStatus).label }}
+ {{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照文件共同保存。' }}
文件类型上传文件有效期
@@ -706,6 +730,7 @@ onMounted(async () => { .customer-company-cell { display:flex; min-width:0; align-items:center; gap:10px; } .customer-company-cell > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:7px; background:#eef4ff; color:var(--cc-primary); } .customer-company-cell strong { overflow:hidden; text-overflow:ellipsis; } .capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:8px; } .capital-input :deep(.el-input-number),.capital-input :deep(.el-select) { width:100%; } .qualification-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } .qualification-heading h3 { margin-bottom:4px; } .qualification-heading p,.qualification-hint { margin:0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint { margin-top:12px; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); } +.qualification-credit-code { max-width:420px; margin-top:16px; } .qualification-credit-code small { display:block; margin-top:6px; color:var(--cc-muted); font-size:11px; line-height:1.5; } .qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:180px minmax(300px,1fr) 180px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; } .qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); } .qualification-optional { padding:2px 5px; border-radius:4px; background:var(--el-fill-color-light); color:var(--cc-muted); font-size:10px; font-weight:500; line-height:1.4; } diff --git a/src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.test.ts b/src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.test.ts new file mode 100644 index 00000000..311925ee --- /dev/null +++ b/src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), +})) + +vi.mock('@/shared/api/http', () => ({ + http: mocks, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +import { + createPlatformCarrierRateVersion, + getPlatformCarrierRate, + listPlatformCarrierRates, + retirePlatformCarrierRate, +} from './platformCarrierRates' + +const payload = { + id: '9007199254740995', + tenantId: '10001', + rateCode: 'RATE-US-WEST', + rateVersion: 2, + originWarehouseId: '20001', + originWarehouseName: '义乌收货仓', + businessType: 'FBA_SEA', + channelCode: 'US-WEST-SEA', + carrierId: '30001', + carrierName: '示例船司', + destinationCode: 'ONT8', + chargeUnit: 'KG' as const, + unitPrice: 12.5, + currency: 'CNY', + additionalFee: 80, + effectiveFrom: '2026-08-01T00:00:00', + effectiveTo: null, + status: 'ACTIVE' as const, + remark: null, + createdBy: '10', + createdAt: '2026-08-01T10:00:00', + updatedBy: null, + updatedAt: null, + version: 1, +} + +describe('platformCarrierRates api', () => { + beforeEach(() => vi.clearAllMocks()) + + it('queries the real carrier-rate path and keeps every long id as a string', async () => { + mocks.get.mockResolvedValue({ data: [payload] }) + + const result = await listPlatformCarrierRates({ customerId: ' 10001 ', rateCode: ' RATE-US ' }) + + expect(mocks.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates', { + params: { tenantId: '10001', rateCode: 'RATE-US' }, + }) + expect(result[0]).toMatchObject({ + id: '9007199254740995', + tenantId: '10001', + originWarehouseId: '20001', + carrierId: '30001', + }) + }) + + it('omits the compatibility tenant parameter for an all-customer list query', async () => { + mocks.get.mockResolvedValue({ data: [payload] }) + + await listPlatformCarrierRates() + await listPlatformCarrierRates({ customerId: '', rateCode: ' ' }) + + expect(mocks.get).toHaveBeenNthCalledWith(1, '/platform/v1/fba-pricing/carrier-rates', { params: {} }) + expect(mocks.get).toHaveBeenNthCalledWith(2, '/platform/v1/fba-pricing/carrier-rates', { params: {} }) + }) + + it('loads a version detail with tenant scope', async () => { + mocks.get.mockResolvedValue({ data: payload }) + await getPlatformCarrierRate('10001', '9001') + expect(mocks.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/9001', { + params: { tenantId: '10001' }, + }) + }) + + it('creates immutable versions and retires an active version', async () => { + mocks.post.mockResolvedValue({ data: payload }) + mocks.delete.mockResolvedValue({ data: null }) + const request = { + rateCode: 'RATE-US-WEST', + originWarehouseId: '20001', + originWarehouseName: '义乌收货仓', + businessType: 'FBA_SEA', + channelCode: 'US-WEST-SEA', + carrierId: '30001', + carrierName: '示例船司', + destinationCode: 'ONT8', + chargeUnit: 'KG' as const, + unitPrice: 12.5, + currency: 'CNY', + additionalFee: 80, + effectiveFrom: '2026-08-01T00:00:00', + effectiveTo: null, + remark: null, + } + + await createPlatformCarrierRateVersion('10001', request) + await retirePlatformCarrierRate('10001', '9001') + + expect(mocks.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/versions', request, { + params: { tenantId: '10001' }, + }) + expect(mocks.delete).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/9001', { + params: { tenantId: '10001' }, + }) + }) +}) diff --git a/src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.ts b/src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.ts new file mode 100644 index 00000000..d0f6d387 --- /dev/null +++ b/src/modules/fba-pricing/carrier-rates/api/platformCarrierRates.ts @@ -0,0 +1,124 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type CarrierRateChargeUnit = 'KG' | 'CBM' +export type CarrierRateStatus = 'ACTIVE' | 'INACTIVE' + +export interface PlatformCarrierRateResource { + id: string + tenantId: string + rateCode: string + rateVersion: number + originWarehouseId: string + originWarehouseName: string + businessType: string + channelCode: string + carrierId: string + carrierName: string + destinationCode: string + chargeUnit: CarrierRateChargeUnit + unitPrice: number + currency: string + additionalFee: number + effectiveFrom: string + effectiveTo: string | null + status: CarrierRateStatus + remark: string | null + createdBy: string | null + createdAt: string | null + updatedBy: string | null + updatedAt: string | null + version: number | null +} + +interface PlatformCarrierRatePayload extends Omit { + id: string | number + tenantId: string | number + originWarehouseId: string | number + carrierId: string | number + createdBy?: string | number | null + updatedBy?: string | number | null +} + +export interface CreatePlatformCarrierRateVersionRequest { + rateCode: string + originWarehouseId: string + originWarehouseName: string + businessType: string + channelCode: string + carrierId: string + carrierName: string + destinationCode: string + chargeUnit: CarrierRateChargeUnit + unitPrice: number + currency: string + additionalFee: number + effectiveFrom: string + effectiveTo: string | null + remark: string | null +} + +export interface PlatformCarrierRateQuery { + customerId?: string + rateCode?: string +} + +const basePath = '/platform/v1/fba-pricing/carrier-rates' + +function nullableId(value: string | number | null | undefined) { + return value == null ? null : String(value) +} + +function normalizeCarrierRate(value: PlatformCarrierRatePayload): PlatformCarrierRateResource { + return { + ...value, + id: String(value.id), + tenantId: String(value.tenantId), + originWarehouseId: String(value.originWarehouseId), + carrierId: String(value.carrierId), + createdBy: nullableId(value.createdBy), + updatedBy: nullableId(value.updatedBy), + effectiveTo: value.effectiveTo ?? null, + remark: value.remark ?? null, + createdAt: value.createdAt ?? null, + updatedAt: value.updatedAt ?? null, + version: value.version ?? null, + } +} + +export async function listPlatformCarrierRates(query: PlatformCarrierRateQuery = {}) { + const customerId = query.customerId?.trim() + const rateCode = query.rateCode?.trim() + const response = await http.get, ApiEnvelope>( + basePath, + { params: { ...(customerId ? { tenantId: customerId } : {}), ...(rateCode ? { rateCode } : {}) } }, + ) + return unwrapApiResponse(response).map(normalizeCarrierRate) +} + +export async function getPlatformCarrierRate(tenantId: string, id: string) { + const response = await http.get, ApiEnvelope>( + `${basePath}/${id}`, + { params: { tenantId } }, + ) + return normalizeCarrierRate(unwrapApiResponse(response)) +} + +export async function createPlatformCarrierRateVersion( + tenantId: string, + payload: CreatePlatformCarrierRateVersionRequest, +) { + const response = await http.post, ApiEnvelope>( + `${basePath}/versions`, + payload, + { params: { tenantId } }, + ) + return normalizeCarrierRate(unwrapApiResponse(response)) +} + +export async function retirePlatformCarrierRate(tenantId: string, id: string) { + const response = await http.delete, ApiEnvelope>(`${basePath}/${id}`, { + params: { tenantId }, + }) + return unwrapApiResponse(response) +} diff --git a/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts b/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts new file mode 100644 index 00000000..4be83e8c --- /dev/null +++ b/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts @@ -0,0 +1,98 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(join(process.cwd(), 'src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue'), 'utf8') +const queryToolbarSource = readFileSync(join(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8') + +describe('CarrierRatesView source contract', () => { + it('keeps operation column first and covers every required business field', () => { + const customerColumn = "key: 'customer', label: '所属企业'" + const destinationColumn = "key: 'destinationCode', prop: 'destinationCode'" + expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf(customerColumn)) + expect(source.indexOf(customerColumn)).toBeLessThan(source.indexOf(destinationColumn)) + expect(source.indexOf(destinationColumn)).toBeLessThan(source.indexOf("key: 'rate'")) + for (const label of ['起运仓', '业务类型', '渠道编码', '船司', 'FBA 目的仓', '计费单位', '单价', '币种', '固定附加费', '有效期', '备注', '生效时间', '结束时间']) { + expect(source).toContain(label) + } + expect(source).toContain("placeholder: 'FBA 目的仓编码'") + expect(source).toContain("placeholder: '渠道编码'") + }) + + it('implements immutable version actions with exact backend permissions', () => { + expect(source).toContain("firstleg:fba-pricing:rate:edit") + expect(source).toContain('创建版本') + expect(source).toContain('新版本') + expect(source).toContain('停用') + expect(source).not.toContain('编辑价格') + }) + + it('keeps the customer company scope in the query toolbar without exposing tenant concepts', () => { + expect(source).toContain("key: 'customerId'") + expect(source.indexOf("key: 'customerId'")).toBeLessThan(source.indexOf("key: 'rateCode'")) + expect(source).toContain("'aria-label': '所属企业'") + expect(source).toContain('listPlatformTenants') + expect(source).toContain('loadedCustomerId') + expect(source).not.toContain('PlatformTenantSelector') + expect(source).not.toContain('selectedTenantId') + expect(source).not.toContain('租户') + }) + + it('uses semantic compact filter widths and keeps the customer selection empty by default', () => { + for (const field of [ + "key: 'customerId',\n type: 'select',\n width: 280", + "key: 'rateCode', type: 'input', width: 220", + "key: 'destinationCode', type: 'input', width: 220", + "key: 'channelCode', type: 'input', width: 220", + "key: 'chargeUnit', type: 'select', width: 180", + "key: 'status', type: 'select', width: 160", + ]) expect(source).toContain(field) + expect(source).toContain("customerId: ''") + expect(source).toContain("placeholder: customerLoadError.value ? '企业列表加载失败' : '全部客户'") + expect(source).not.toContain(':search-on-reset="false"') + expect(source).not.toContain('请先选择所属企业') + expect(source).not.toContain('请选择所属企业并查询价格数据') + expect(source).not.toContain('customers.value[0]') + }) + + it('loads all customers initially and after reset while preserving row scope for record actions', () => { + expect(source).toContain('void loadRateData()') + expect(source).toContain("listPlatformCarrierRates(customerId ? { customerId } : {})") + expect(source).toContain('loadRateData(customerId)') + expect(source).toContain('getPlatformCarrierRate(String(row.tenantId), row.id)') + expect(source).toContain('retirePlatformCarrierRate(String(row.tenantId), row.id)') + expect(source).toContain('customerLabel(detail.tenantId)') + }) + + it('inherits QueryToolbar wrapping and full-width mobile controls', () => { + expect(queryToolbarSource).toContain('flex-wrap: wrap') + expect(queryToolbarSource).toContain('@media (max-width: 720px)') + expect(queryToolbarSource).toContain('width: 100%') + expect(source).not.toContain('--query-field-width') + }) + + it('guards unsaved work, failures and narrow screens', () => { + expect(source).toContain('useUnsavedWorkRegistration') + expect(source).toContain('LoadErrorState') + expect(source).toContain('ApiErrorMeta') + expect(source).toContain('@media (max-width: 720px)') + }) + + it('reuses the first-leg order list primitives for status, empty state and pagination', () => { + expect(source).toContain("import StatusBadge from '@/shared/components/StatusBadge.vue'") + expect(source).toContain('') + expect(source).not.toContain('') + expect(source).not.toContain(' { + expect(source).not.toContain('createRouter') + expect(source).not.toContain('adminNavigation') + for (const unsupportedCapability of ['导入', '导出', '全部船期', '参考时效', '最晚进仓时间']) { + expect(source).not.toContain(unsupportedCapability) + } + }) +}) diff --git a/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue b/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue new file mode 100644 index 00000000..a4d033e4 --- /dev/null +++ b/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue @@ -0,0 +1,641 @@ + + + + + diff --git a/src/modules/fba-pricing/customer-rules/CustomerRulesView.test.ts b/src/modules/fba-pricing/customer-rules/CustomerRulesView.test.ts new file mode 100644 index 00000000..319563de --- /dev/null +++ b/src/modules/fba-pricing/customer-rules/CustomerRulesView.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync('src/modules/fba-pricing/customer-rules/CustomerRulesView.vue', 'utf8') +const queryToolbarSource = readFileSync('src/shared/components/QueryToolbar.vue', 'utf8') + +describe('CustomerRulesView', () => { + it('keeps the operation column first and exposes version-only actions', () => { + const table = source.indexOf('const columns') + const operation = source.indexOf("label: '操作'", table) + const ruleCode = source.indexOf("label: '规则版本'", table) + expect(operation).toBeGreaterThan(0) + expect(operation).toBeLessThan(ruleCode) + expect(source).toContain('新建版本') + expect(source).toContain('停用规则') + expect(source).not.toContain('编辑规则') + }) + + it('covers customer scope, permission, unsaved work, error and responsive states', () => { + expect(source).not.toContain('PlatformTenantSelector') + expect(source).toContain("key: 'customerId'") + expect(source).toContain("'aria-label': '所属企业'") + expect(source).toContain('listPlatformTenants') + expect(source).toContain("firstleg:fba-pricing:rule:edit") + expect(source).toContain('useUnsavedWorkRegistration') + expect(source).toContain('LoadErrorState') + expect(source).toContain('detailError') + expect(source).toContain('@retry="retryDetail"') + expect(source).not.toContain('请选择客户') + expect(source).not.toContain('请选择所属企业') + expect(source).not.toContain('当前接口要求选择客户') + expect(source).not.toContain('租户') + expect(source).toContain('@media (max-width: 480px)') + }) + + it('uses compact semantic filter widths and keeps the customer filter empty by default', () => { + expect(source).toContain("customerId: '', ruleCode: '', customerName: '', chargeMode: '', status: ''") + expect(source).toContain("width: 280,\n placeholder: customerLoadError.value ? '客户列表加载失败' : '全部客户'") + expect(source).toContain("key: 'ruleCode', type: 'input', width: 220") + expect(source).toContain("key: 'customerName', type: 'input', width: 260") + expect(source).toContain("key: 'chargeMode', type: 'select', width: 180") + expect(source).toContain("key: 'status', type: 'select', width: 160") + expect(source).not.toContain('customers.value[0]') + }) + + it('resets to all customers, uses full-width mobile fields and allows an empty customer filter', () => { + expect(queryToolbarSource).toContain("for (const field of props.fields) next[field.key] = resetValue(field)") + expect(queryToolbarSource).toContain("if (field.type === 'select') return '全部'") + expect(queryToolbarSource).toContain('@media (max-width: 720px)') + expect(queryToolbarSource).toContain('{ width: 100%; }') + expect(source).not.toContain('if (!selectedCustomerId.value)') + expect(source).toContain('...(selectedCustomerId.value ? { tenantId: selectedCustomerId.value } : {})') + expect(source).toContain('void loadRules()') + expect(source).toContain("const tableEmptyText = '暂无符合筛选条件的客户计费规则'") + }) + + it('uses each record scope for single-rule actions', () => { + expect(source).toContain('getCustomerPricingRule(row.tenantId, row.id)') + expect(source).toContain('editorScopeCustomerId.value = source?.tenantId || selectedCustomerId.value') + expect(source).toContain('disableCustomerPricingRule(row.tenantId, row.id)') + }) + + it('renders the complete pricing rule contract', () => { + for (const label of ['适用客户', '业务类型', '计费方式', '税务模式', '换算系数', '舍入规则', '币种', '有效期']) { + expect(source).toContain(label) + } + expect(source).toContain('QueryToolbar') + expect(source).toContain('DataTable') + expect(source).toContain('AppDrawer') + }) + + it('matches the backend status and field constraints', () => { + expect(source).toContain("{ label: '已停用', value: 'INACTIVE' }") + expect(source).not.toContain("value: 'DISABLED'") + expect(source).toContain('maxlength="50"') + }) + + it('uses the mature admin list skeleton and grouped detail sections', () => { + expect(source).toContain('class="business-page customer-rules-page"') + expect(source).toContain('class="surface-panel table-panel"') + expect(source).toContain('class="panel-heading"') + for (const heading of ['规则归属', '计费口径', '版本有效期']) { + expect(source).toContain(`

${heading}

`) + } + }) +}) diff --git a/src/modules/fba-pricing/customer-rules/CustomerRulesView.vue b/src/modules/fba-pricing/customer-rules/CustomerRulesView.vue new file mode 100644 index 00000000..d41ed11f --- /dev/null +++ b/src/modules/fba-pricing/customer-rules/CustomerRulesView.vue @@ -0,0 +1,496 @@ + + + + + diff --git a/src/modules/fba-pricing/customer-rules/api.test.ts b/src/modules/fba-pricing/customer-rules/api.test.ts new file mode 100644 index 00000000..85d5b7b3 --- /dev/null +++ b/src/modules/fba-pricing/customer-rules/api.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const http = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), +})) + +vi.mock('@/shared/api/http', () => ({ + http, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +import { + createCustomerPricingRuleVersion, + disableCustomerPricingRule, + getCustomerPricingRule, + listCustomerPricingRules, +} from './api' + +const rawRule = { + id: 9007199254740993n.toString(), + tenantId: 10001, + ruleCode: 'FBA-TAX-KG', + customerId: 20002, + customerName: '测试企业', + businessType: 'FBA', + chargeMode: 'KG', + taxMode: 'TAX_INCLUDED', + conversionFactor: 167, + roundingScale: 3, + roundingMode: 'CEILING', + currency: 'CNY', + effectiveFrom: '2026-08-04T00:00:00', + effectiveTo: null, + ruleVersion: 1, + status: 'ACTIVE', + remark: null, + createdAt: null, + updatedAt: null, +} + +describe('customer pricing rules api', () => { + beforeEach(() => vi.clearAllMocks()) + + it('omits an empty customer scope and rule code for the all-customer list', async () => { + http.get.mockResolvedValue({ data: [rawRule] }) + + await listCustomerPricingRules({ tenantId: ' ', ruleCode: ' ' }) + + expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules', { params: {} }) + }) + + it('keeps every long id as a string and sends the real list query', async () => { + http.get.mockResolvedValue({ data: [rawRule] }) + + const result = await listCustomerPricingRules({ tenantId: '10001', ruleCode: ' FBA ' }) + + expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules', { + params: { tenantId: '10001', ruleCode: 'FBA' }, + }) + expect(result[0]).toMatchObject({ id: '9007199254740993', tenantId: '10001', customerId: '20002' }) + }) + + it('uses detail, version creation and disable endpoints from the controller', async () => { + http.get.mockResolvedValue({ data: rawRule }) + http.post.mockResolvedValue({ data: rawRule }) + http.delete.mockResolvedValue({ data: null }) + + await getCustomerPricingRule('10001', '9') + await createCustomerPricingRuleVersion('10001', { + ruleCode: 'FBA-TAX-KG', customerId: '20002', customerName: '测试企业', businessType: 'FBA', + chargeMode: 'KG', taxMode: 'TAX_INCLUDED', conversionFactor: 167, roundingScale: 3, + roundingMode: 'CEILING', currency: 'CNY', effectiveFrom: '2026-08-04T00:00:00', + }) + await disableCustomerPricingRule('10001', '9') + + expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules/9', { params: { tenantId: '10001' } }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules/versions', expect.any(Object), { params: { tenantId: '10001' } }) + expect(http.delete).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules/9', { params: { tenantId: '10001' } }) + }) +}) diff --git a/src/modules/fba-pricing/customer-rules/api.ts b/src/modules/fba-pricing/customer-rules/api.ts new file mode 100644 index 00000000..326ebd44 --- /dev/null +++ b/src/modules/fba-pricing/customer-rules/api.ts @@ -0,0 +1,59 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { + CreateCustomerRuleVersionRequest, + CustomerPricingRule, + CustomerRuleQuery, +} from './types' + +type RawRule = Omit & { + id: string | number + tenantId: string | number + customerId: string | number +} + +function normalizeRule(rule: RawRule): CustomerPricingRule { + return { + ...rule, + id: String(rule.id), + tenantId: String(rule.tenantId), + customerId: String(rule.customerId), + } +} + +export async function listCustomerPricingRules(query: CustomerRuleQuery): Promise { + const tenantId = query.tenantId?.trim() + const ruleCode = query.ruleCode?.trim() + const response = await http.get>('/platform/v1/fba-pricing/customer-rules', { + params: { + ...(tenantId ? { tenantId } : {}), + ...(ruleCode ? { ruleCode } : {}), + }, + }) + return unwrapApiResponse(response).map(normalizeRule) +} + +export async function getCustomerPricingRule(tenantId: string, id: string): Promise { + const response = await http.get>(`/platform/v1/fba-pricing/customer-rules/${id}`, { + params: { tenantId }, + }) + return normalizeRule(unwrapApiResponse(response)) +} + +export async function createCustomerPricingRuleVersion( + tenantId: string, + request: CreateCustomerRuleVersionRequest, +): Promise { + const response = await http.post>( + '/platform/v1/fba-pricing/customer-rules/versions', + request, + { params: { tenantId } }, + ) + return normalizeRule(unwrapApiResponse(response)) +} + +export async function disableCustomerPricingRule(tenantId: string, id: string): Promise { + const response = await http.delete>(`/platform/v1/fba-pricing/customer-rules/${id}`, { + params: { tenantId }, + }) + unwrapApiResponse(response) +} diff --git a/src/modules/fba-pricing/customer-rules/types.ts b/src/modules/fba-pricing/customer-rules/types.ts new file mode 100644 index 00000000..8e54b3e8 --- /dev/null +++ b/src/modules/fba-pricing/customer-rules/types.ts @@ -0,0 +1,49 @@ +export type CustomerRuleChargeMode = 'KG' | 'CBM' +export type CustomerRuleStatus = 'ACTIVE' | 'INACTIVE' | string + +export interface CustomerPricingRule { + id: string + tenantId: string + ruleCode: string + customerId: string + customerName: string + businessType: string + chargeMode: CustomerRuleChargeMode + taxMode: string + conversionFactor: number + roundingScale: number + roundingMode: string + currency: string + effectiveFrom: string + effectiveTo: string | null + ruleVersion: number | null + status: CustomerRuleStatus + remark: string | null + createdAt: string | null + updatedAt: string | null +} + +export interface CustomerRuleQuery { + tenantId?: string + ruleCode?: string +} + +export interface CreateCustomerRuleVersionRequest { + ruleCode: string + customerId: string + customerName: string + businessType: string + chargeMode: CustomerRuleChargeMode + taxMode: string + conversionFactor: number + roundingScale: number + roundingMode: string + currency: string + effectiveFrom: string + effectiveTo?: string | null + remark?: string | null +} + +export interface CustomerRuleFormModel extends CreateCustomerRuleVersionRequest { + customerId: string +} diff --git a/src/modules/fba-pricing/routes.ts b/src/modules/fba-pricing/routes.ts new file mode 100644 index 00000000..b003eb49 --- /dev/null +++ b/src/modules/fba-pricing/routes.ts @@ -0,0 +1,24 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const fbaPricingRoutes = [ + { + path: 'first-leg-pricing/customer-rules', + name: 'fba-customer-rules', + component: () => import('./customer-rules/CustomerRulesView.vue'), + meta: { + title: '客户计费规则', + description: '维护 FBA 客户计费规则和生效版本', + permission: 'firstleg:fba-pricing:rule:view', + }, + }, + { + path: 'first-leg-pricing/carrier-rates', + name: 'fba-carrier-rates', + component: () => import('./carrier-rates/pages/CarrierRatesView.vue'), + meta: { + title: '船司 FBA 价格', + description: '维护起运仓、船司和 FBA 目的仓价格版本', + permission: 'firstleg:fba-pricing:rate:view', + }, + }, +] satisfies RouteRecordRaw[] diff --git a/src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.test.ts b/src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.test.ts index ddf561e8..2b0f4ba0 100644 --- a/src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.test.ts +++ b/src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.test.ts @@ -10,7 +10,7 @@ describe('PlatformFirstLegOrdersPanel review queue', () => { expect(source).toContain("tenantId: filters.value.tenantId || undefined") expect(source).toContain('listPlatformTenants') expect(source).toContain("key: 'tenantId'") - expect(source).toContain('tenantName((row as PlatformFirstLegOrderResource).tenantId)') + expect(source).toContain('customerDisplay(row as PlatformFirstLegOrderResource).name') expect(source).toContain('result.pages') }) @@ -34,6 +34,16 @@ describe('PlatformFirstLegOrdersPanel review queue', () => { expect(source).toContain("minWidth: 224, fixed: 'left'") }) + it('prioritizes order status and removes noisy customer and time metadata', () => { + const statusColumn = source.indexOf("{ key: 'status', label: '状态'") + const orderColumn = source.indexOf("{ key: 'orderInfo', label: '订单信息'") + expect(statusColumn).toBeGreaterThan(0) + expect(statusColumn).toBeLessThan(orderColumn) + expect(source).toContain("replace('T', ' ').slice(0, 16)") + expect(source).toContain('客户编码 ${code}') + expect(source).not.toContain('customerName }} · {{ (row as PlatformFirstLegOrderResource).tenantId') + }) + it('passes the order tenant through detail and lifecycle actions', () => { expect(source).toContain('tenantId: String(order.tenantId)') expect(source).toContain('getPlatformFirstLegOrder(tenantId, orderId)') diff --git a/src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue b/src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue index 936fd084..5bd57ed0 100644 --- a/src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue +++ b/src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue @@ -65,14 +65,15 @@ const queryFields = computed(() => [ { key: 'tenantId', type: 'select', + width: 160, placeholder: tenantLoadError.value ? '企业列表加载失败' : '全部企业', props: { 'aria-label': '企业客户', filterable: true, disabled: Boolean(tenantLoadError.value) }, options: tenants.value.map((tenant) => ({ value: String(tenant.id), label: `${tenant.tenantName}(${tenant.tenantCode || tenant.id})` })), }, - { key: 'keyword', type: 'input', placeholder: '订单号或发货客户名称', props: { 'aria-label': '订单关键词' } }, { key: 'status', type: 'select', + width: 120, placeholder: '全部状态', props: { 'aria-label': '订单状态' }, options: [ @@ -86,18 +87,19 @@ const queryFields = computed(() => [ { value: 'REJECTED', label: '已驳回(旧契约)' }, ], }, + { key: 'keyword', type: 'input', placeholder: '订单号或发货客户名称', props: { 'aria-label': '订单关键词' } }, { key: 'dateRange', type: 'daterange', props: { 'aria-label': '订单创建日期' } }, ]) const orderColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', minWidth: 224, fixed: 'left', slotName: 'actions' }, + { key: 'status', label: '状态', width: 108, slotName: 'status' }, { key: 'orderInfo', label: '订单信息', minWidth: 210, slotName: 'orderInfo' }, { key: 'customer', label: '客户', minWidth: 190, showOverflowTooltip: true, slotName: 'customer' }, { key: 'transport', label: '运输方案', minWidth: 180, slotName: 'transport' }, { key: 'destination', label: '目的地', minWidth: 150, slotName: 'destination' }, { key: 'receivingWarehouse', label: '平台收货仓', minWidth: 180, slotName: 'receivingWarehouse' }, { key: 'express', label: '履约快递', minWidth: 160, slotName: 'express' }, - { key: 'status', label: '状态', width: 120, slotName: 'status' }, ] const expressRules: FormRules = { @@ -145,11 +147,23 @@ function transportLabel(order: PlatformFirstLegOrderResource) { } function formattedDate(value: string | null | undefined) { - return value ? value.replace('T', ' ') : '-' + return value ? value.replace('T', ' ').slice(0, 16) : '-' +} + +function tenantFor(tenantId: number | string) { + return tenants.value.find((tenant) => String(tenant.id) === String(tenantId)) } function tenantName(tenantId: number | string) { - return tenants.value.find((tenant) => String(tenant.id) === String(tenantId))?.tenantName || `企业 ${tenantId}` + return tenantFor(tenantId)?.tenantName || `企业 ${tenantId}` +} + +function customerDisplay(order: PlatformFirstLegOrderResource) { + const tenant = tenantFor(order.tenantId) + const name = tenant?.legalName?.trim() || tenant?.tenantName?.trim() || order.customerName?.trim() || '未命名企业' + const code = tenant?.tenantCode?.trim() + const secondary = code ? `客户编码 ${code}` : order.customerName?.trim() + return { name, secondary: secondary && secondary !== name ? secondary : '' } } async function loadTenants() { @@ -535,13 +549,13 @@ onMounted(() => void loadTenants())
+ - + - diff --git a/src/modules/iam/api/platformIam.ts b/src/modules/iam/api/platformIam.ts index 6461fdf3..d25ab046 100644 --- a/src/modules/iam/api/platformIam.ts +++ b/src/modules/iam/api/platformIam.ts @@ -53,7 +53,7 @@ export interface CreatePlatformTenantRequest { tenantName: string legalName: string legalRepresentative: string - unifiedSocialCreditCode: string + unifiedSocialCreditCode?: string contactName: string contactPhone: string contactEmail: string diff --git a/src/modules/inbounds/components/InboundWorkflowWorkspace.test.ts b/src/modules/inbounds/components/InboundWorkflowWorkspace.test.ts new file mode 100644 index 00000000..c0a236af --- /dev/null +++ b/src/modules/inbounds/components/InboundWorkflowWorkspace.test.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/components/InboundWorkflowWorkspace.vue'), 'utf8') + +describe('InboundWorkflowWorkspace', () => { + it('assembles every phase-two inbound domain behind one server-backed workspace', () => { + expect(source).toContain('InboundSummaryPanel') + expect(source).toContain('InboundHistoryPanel') + expect(source).toContain('InboundMeasurementPanel') + expect(source).toContain('WeightOcrPanel') + expect(source).toContain('MeasurementReviewPanel') + expect(source).toContain('InboundFeePanel') + expect(source).toContain('getPlatformInbound') + expect(source).toContain('StatusBadge') + expect(source).toContain('label="基本信息"') + expect(source).toContain('label="货物与测量"') + expect(source).toContain('label="测量审核"') + expect(source).toContain('label="费用快照"') + expect(source).toContain('label="托盘与记录"') + }) + + it('uses the established first-leg detail density without nested framed surfaces', () => { + expect(source).toContain('padding: 20px 24px') + expect(source).toContain('border-bottom: 1px solid var(--cc-border)') + expect(source).not.toContain('border-radius: 10px') + expect(source).not.toContain(' { + expect(source).toContain('function handleDomainChanged()') + expect(source).toContain('async function refreshWorkspace()') + expect(source).toContain('revision.value += 1') + expect(source).toContain('void refreshWorkspace()') + expect(source).toContain('@click="refreshWorkspace"') + expect(source).toContain('@changed="handleDomainChanged"') + }) + + it('uses customer context in the page layer and maps it to compatible domain APIs', () => { + expect(source).toContain('customerId: string') + expect(source).toContain('getPlatformInbound(props.customerId, props.inboundId)') + expect(source).toContain(':tenant-id="customerId"') + expect(source).toContain('缺少所属企业信息') + expect(source).not.toContain('请选择企业') + expect(source).not.toContain('租户') + }) + + it('does not mount the review domain without its required measurement data permission', () => { + expect(source).toContain("access.can('platform:firstleg:inbound:measure:view')") + expect(source).toContain('v-if="canViewMeasurement"') + expect(source).toContain('无测量审核查看权限') + }) + + it('keeps pallet and location results read-only in the overview', () => { + expect(source).toContain(':pallet-locations="detail.palletLocations"') + expect(source).toContain('label="托盘与记录"') + expect(source).not.toContain('reserveLocation') + expect(source).not.toContain('allocatePallet') + }) +}) diff --git a/src/modules/inbounds/components/InboundWorkflowWorkspace.vue b/src/modules/inbounds/components/InboundWorkflowWorkspace.vue new file mode 100644 index 00000000..8917c6c6 --- /dev/null +++ b/src/modules/inbounds/components/InboundWorkflowWorkspace.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/src/modules/inbounds/core/InboundHistoryPanel.vue b/src/modules/inbounds/core/InboundHistoryPanel.vue new file mode 100644 index 00000000..3c40c65b --- /dev/null +++ b/src/modules/inbounds/core/InboundHistoryPanel.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/modules/inbounds/core/InboundSummaryPanel.test.ts b/src/modules/inbounds/core/InboundSummaryPanel.test.ts new file mode 100644 index 00000000..090e40b0 --- /dev/null +++ b/src/modules/inbounds/core/InboundSummaryPanel.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/core/InboundSummaryPanel.vue'), 'utf8') + +describe('InboundSummaryPanel', () => { + it('organizes the aggregate detail into the prototype-aligned backend-backed sections', () => { + expect(source).toContain('基本信息') + expect(source).toContain('仓库与运输') + expect(source).toContain('测量与计费') + expect(source).toContain('入库资源') + expect(source).toContain('summary.deliveryWarehouseName') + expect(source).toContain('summary.chargeableWeightKg') + expect(source).toContain('summary.locationCodes') + expect(source).toContain('summary.notificationStatus') + }) + + it('does not restore excluded receiving review or customer fee confirmation actions', () => { + expect(source).not.toContain('接收审核') + expect(source).not.toContain('客户确认费用') + }) +}) diff --git a/src/modules/inbounds/core/InboundSummaryPanel.vue b/src/modules/inbounds/core/InboundSummaryPanel.vue new file mode 100644 index 00000000..6b7483e4 --- /dev/null +++ b/src/modules/inbounds/core/InboundSummaryPanel.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/src/modules/inbounds/core/api.test.ts b/src/modules/inbounds/core/api.test.ts new file mode 100644 index 00000000..5a4d4c9f --- /dev/null +++ b/src/modules/inbounds/core/api.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { getPlatformInbound, listPlatformInbounds } from './api' + +vi.mock('@/shared/api/http', () => ({ + http: { get: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +describe('platform inbound query API', () => { + beforeEach(() => vi.mocked(http.get).mockReset()) + + it('omits the backend tenant filter for an all-customer list query', async () => { + vi.mocked(http.get).mockResolvedValue({ + code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 }, + }) + + await listPlatformInbounds(undefined, { page: 1, size: 20 }) + + expect(http.get).toHaveBeenCalledWith('/platform/inbounds', { params: { page: 1, size: 20 } }) + }) + + it('uses the implemented non-v1 list path and normalizes page values and IDs', async () => { + vi.mocked(http.get).mockResolvedValue({ + code: '200', message: 'success', data: { + records: [{ id: 19001, orderId: 18001, customerId: 10001, totalPackageCount: '6', holdFlag: 'false' }], + page: '1', size: '20', total: '1', pages: '1', + }, + }) + + await expect(listPlatformInbounds('10001', { + page: 1, size: 20, tab: 'WAIT_MEASURE', inboundCode: 'RK', holdFlag: false, + })).resolves.toMatchObject({ + records: [{ id: '19001', orderId: '18001', customerId: '10001', totalPackageCount: 6, holdFlag: false }], + total: 1, pages: 1, + }) + expect(http.get).toHaveBeenCalledWith('/platform/inbounds', { + params: { tenantId: '10001', page: 1, size: 20, tab: 'WAIT_MEASURE', inboundCode: 'RK', holdFlag: false }, + }) + }) + + it('loads a tenant-scoped aggregate detail and keeps nested IDs as strings', async () => { + vi.mocked(http.get).mockResolvedValue({ + code: '200', message: 'success', data: { + summary: { id: 19001, orderId: 18001 }, publicRemark: null, + palletLocations: [{ palletId: 21001, locationId: 22001 }], files: [{ fileId: 23001, uploadedBy: 2 }], + internalNotes: [{ id: 24001, createdBy: 2 }], statusHistory: [{ operatorId: 2 }], + }, + }) + + await expect(getPlatformInbound('10001', '19001')).resolves.toMatchObject({ + summary: { id: '19001', orderId: '18001' }, + palletLocations: [{ palletId: '21001', locationId: '22001' }], + files: [{ fileId: '23001', uploadedBy: '2' }], + internalNotes: [{ id: '24001', createdBy: '2' }], + statusHistory: [{ operatorId: '2' }], + }) + expect(http.get).toHaveBeenCalledWith('/platform/inbounds/19001', { params: { tenantId: '10001' } }) + }) + + it('passes the implemented order, process, measurement and fee review filters without renaming them', async () => { + vi.mocked(http.get).mockResolvedValue({ + code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 }, + }) + + await listPlatformInbounds('10001', { + page: 1, + size: 20, + processStatus: 'MEASURE_PENDING', + orderStatus: 'APPROVED', + measureReviewStatus: 'PENDING', + feeStatus: 'UNPRICED', + feeReviewStatus: 'PENDING', + warehouseId: '9007199254740993', + deliveryWarehouseId: '9007199254740995', + }) + + expect(http.get).toHaveBeenCalledWith('/platform/inbounds', { + params: { + tenantId: '10001', page: 1, size: 20, processStatus: 'MEASURE_PENDING', + orderStatus: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: 'PENDING', + warehouseId: '9007199254740993', deliveryWarehouseId: '9007199254740995', + }, + }) + }) +}) diff --git a/src/modules/inbounds/core/api.ts b/src/modules/inbounds/core/api.ts new file mode 100644 index 00000000..31e4aa54 --- /dev/null +++ b/src/modules/inbounds/core/api.ts @@ -0,0 +1,127 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { + InboundFileSummary, + InboundInternalNote, + InboundListQuery, + InboundOrderDetail, + InboundOrderSummary, + InboundPageResult, + InboundPalletLocation, + InboundStatusHistory, +} from './types' + +const basePath = '/platform/inbounds' + +function id(value: unknown): string | null { + return value === null || value === undefined ? null : String(value) +} + +function numberValue(value: unknown): number | null { + if (value === null || value === undefined || value === '') return null + const parsed = typeof value === 'number' ? value : Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +function integerValue(value: unknown): number | null { + const parsed = numberValue(value) + return parsed === null ? null : Math.trunc(parsed) +} + +function boolValue(value: unknown): boolean | null { + if (value === null || value === undefined) return null + if (typeof value === 'boolean') return value + if (value === 'true' || value === 1 || value === '1') return true + if (value === 'false' || value === 0 || value === '0') return false + return null +} + +export function normalizeInboundSummary(raw: InboundOrderSummary): InboundOrderSummary { + return { + ...raw, + id: String(raw.id), + orderId: id(raw.orderId), + customerId: id(raw.customerId), + companyId: id(raw.companyId), + warehouseId: id(raw.warehouseId), + deliveryWarehouseId: id(raw.deliveryWarehouseId), + destinationWarehouseId: id(raw.destinationWarehouseId), + version: integerValue(raw.version), + customsInspection: boolValue(raw.customsInspection), + holdFlag: boolValue(raw.holdFlag), + totalPackageCount: integerValue(raw.totalPackageCount), + actualGrossWeightKg: numberValue(raw.actualGrossWeightKg), + actualNetWeightKg: numberValue(raw.actualNetWeightKg), + actualVolumeCbm: numberValue(raw.actualVolumeCbm), + volumeWeightKg: numberValue(raw.volumeWeightKg), + chargeableWeightKg: numberValue(raw.chargeableWeightKg), + chargeableVolumeCbm: numberValue(raw.chargeableVolumeCbm), + palletCount: integerValue(raw.palletCount), + } +} + +function normalizePage(raw: InboundPageResult): InboundPageResult { + return { + records: (raw.records || []).map(normalizeInboundSummary), + page: integerValue(raw.page) || 1, + size: integerValue(raw.size) || 20, + total: integerValue(raw.total) || 0, + pages: integerValue(raw.pages) || 0, + } +} + +function normalizePallet(raw: InboundPalletLocation): InboundPalletLocation { + return { + ...raw, + allocationNo: integerValue(raw.allocationNo), + warehouseId: id(raw.warehouseId), + palletId: id(raw.palletId), + locationId: id(raw.locationId), + palletCapacityCbm: numberValue(raw.palletCapacityCbm), + allocatedVolumeCbm: numberValue(raw.allocatedVolumeCbm), + allocatedPackageCount: integerValue(raw.allocatedPackageCount), + } +} + +function normalizeFile(raw: InboundFileSummary): InboundFileSummary { + return { ...raw, fileId: id(raw.fileId), uploadedBy: id(raw.uploadedBy), fileVersion: integerValue(raw.fileVersion) } +} + +function normalizeNote(raw: InboundInternalNote): InboundInternalNote { + return { ...raw, id: id(raw.id), createdBy: id(raw.createdBy) } +} + +function normalizeHistory(raw: InboundStatusHistory): InboundStatusHistory { + return { ...raw, operatorId: id(raw.operatorId) } +} + +function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail { + return { + ...raw, + summary: normalizeInboundSummary(raw.summary), + palletLocations: (raw.palletLocations || []).map(normalizePallet), + files: (raw.files || []).map(normalizeFile), + internalNotes: (raw.internalNotes || []).map(normalizeNote), + statusHistory: (raw.statusHistory || []).map(normalizeHistory), + } +} + +function queryParams(tenantId: string | undefined, query: InboundListQuery) { + return Object.fromEntries( + Object.entries({ tenantId, ...query }).filter(([, value]) => value !== undefined && value !== ''), + ) +} + +export async function listPlatformInbounds(tenantId: string | undefined, query: InboundListQuery) { + const response = await http.get, ApiEnvelope>(basePath, { + params: queryParams(tenantId, query), + }) + return normalizePage(unwrapApiResponse(response)) +} + +export async function getPlatformInbound(tenantId: string, inboundId: string) { + const response = await http.get, ApiEnvelope>( + `${basePath}/${inboundId}`, + { params: { tenantId } }, + ) + return normalizeDetail(unwrapApiResponse(response)) +} diff --git a/src/modules/inbounds/core/types.ts b/src/modules/inbounds/core/types.ts new file mode 100644 index 00000000..ccb440ef --- /dev/null +++ b/src/modules/inbounds/core/types.ts @@ -0,0 +1,153 @@ +export type InboundListTab = 'WAIT_MEASURE' | 'WAIT_REVIEW' | 'WAIT_FEE_SEND' | 'INBOUNDED' + +export type InboundProcessStatus = + | 'WAIT_MEASURE' + | 'MEASURING' + | 'MEASURE_PENDING' + | 'MEASURE_REJECTED' + | 'WAIT_PRICING' + | 'FEE_CONFIRMING' + | 'INBOUNDING' + | 'INBOUNDED' + | 'VOIDED' + +export type InboundFeeStatus = 'UNPRICED' | 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'VOIDED' | string + +export interface InboundOrderSummary { + id: string + orderId: string | null + orderNo: string | null + inboundCode: string | null + processStatus: InboundProcessStatus | string + orderStatusSnapshot: string | null + measureReviewStatus: string | null + feeStatus: InboundFeeStatus | null + feeReviewStatus: string | null + notificationStatus: string | null + version: number | null + expectedArrivalAt: string | null + customerId: string | null + customerName: string | null + companyId: string | null + companyName: string | null + warehouseId: string | null + warehouseName: string | null + deliveryWarehouseId: string | null + deliveryWarehouseName: string | null + businessType: string | null + channelCode: string | null + channelName: string | null + taxMode: string | null + containerMode: string | null + customsInspection: boolean | null + destinationType: string | null + destinationWarehouseId: string | null + destinationCode: string | null + destinationName: string | null + holdFlag: boolean | null + currentHoldReason: string | null + totalPackageCount: number | null + actualGrossWeightKg: number | null + actualNetWeightKg: number | null + actualVolumeCbm: number | null + volumeWeightKg: number | null + chargeableWeightKg: number | null + chargeableVolumeCbm: number | null + palletCount: number | null + locationCodes: string | null + measureCompletedAt: string | null + inboundedAt: string | null + expressCompanyCode: string | null + expressCompanyName: string | null + expressTrackingNo: string | null + loadingStatus: string | null + containerNo: string | null + cabinetNo: string | null + vesselName: string | null + voyageNo: string | null + sailingAt: string | null + portArrivalAt: string | null + createdAt: string | null + updatedAt: string | null +} + +export interface InboundPalletLocation { + allocationNo: number | null + warehouseId: string | null + palletId: string | null + palletCode: string | null + palletCapacityCbm: number | null + allocatedVolumeCbm: number | null + allocatedPackageCount: number | null + locationId: string | null + locationCode: string | null + reservationNo: string | null + allocationStatus: string | null +} + +export interface InboundFileSummary { + fileId: string | null + fileType: string | null + fileNameSnapshot: string | null + visibility: string | null + fileVersion: number | null + uploadedBy: string | null + uploadedAt: string | null +} + +export interface InboundInternalNote { + id: string | null + content: string | null + createdBy: string | null + createdByName: string | null + createdAt: string | null +} + +export interface InboundStatusHistory { + fromStatus: string | null + toStatus: string | null + action: string | null + reason: string | null + operatorId: string | null + operatorName: string | null + occurredAt: string | null +} + +export interface InboundOrderDetail { + summary: InboundOrderSummary + publicRemark: string | null + palletLocations: InboundPalletLocation[] + files: InboundFileSummary[] + internalNotes: InboundInternalNote[] + statusHistory: InboundStatusHistory[] +} + +export interface InboundPageResult { + records: InboundOrderSummary[] + page: number + size: number + total: number + pages: number +} + +export interface InboundListQuery { + page: number + size: number + tab?: InboundListTab + inboundCode?: string + orderNo?: string + processStatus?: string + orderStatus?: string + measureReviewStatus?: string + customerName?: string + destinationCode?: string + feeStatus?: string + feeReviewStatus?: string + customerId?: string + holdFlag?: boolean + companyId?: string + warehouseId?: string + deliveryWarehouseId?: string + expectedArrivalFrom?: string + expectedArrivalTo?: string +} diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts new file mode 100644 index 00000000..8614624f --- /dev/null +++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts @@ -0,0 +1,237 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { useAuthStore } from '@/core/auth/auth' +import { + adjustPublishedInboundFee, + getLatestInboundFee, +} from './api' +import type { InboundFeeSnapshot } from './types' +import InboundFeePanel from './InboundFeePanel.vue' + +vi.mock('./api', async () => { + const actual = await vi.importActual('./api') + return { + ...actual, + getLatestInboundFee: vi.fn(), + adjustPublishedInboundFee: vi.fn(), + } +}) + +const snapshot: InboundFeeSnapshot = { + feeId: '9007199254740993', feeNo: 'FBAF-88-2', feeVersion: 2, feeStatus: 'SENT', + inboundId: '88', inboundCode: 'RK202608040088', + chargeRuleId: '101', ruleCode: 'RULE-FBA-KG', chargeRuleVersion: 3, + carrierRateId: '202', rateCode: 'RATE-LAX', carrierRateVersion: 5, + customerId: '303', customerName: '测试客户', originWarehouseId: '404', originWarehouseName: '义乌仓', + businessType: 'FBA_SEA', channelCode: 'SEA-TAX', carrierId: '505', carrierName: '测试船司', + destinationCode: 'LAX9', taxMode: 'TAX_INCLUDED', chargeMode: 'KG', + conversionFactor: 167, roundingScale: 2, roundingMode: 'HALF_UP', + actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, chargeableQuantity: 133.6, + chargeUnit: 'KG', unitPrice: 8.5, subtotalAmount: 1135.6, additionalAmount: 100, + totalAmount: 1235.6, currency: 'CNY', + rateEffectiveFrom: '2026-08-01T00:00:00', rateEffectiveTo: null, + calculatedAt: '2026-08-04T10:00:00', confirmedAt: '2026-08-04T10:01:00', sentAt: '2026-08-04T10:02:00', + nextProcessStatus: 'INBOUNDED', +} + +enableAutoUnmount(afterEach) + +function setupPermissions(permissions: string[]) { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.token = 'token' + auth.permissions = permissions + useAccessPreviewStore().initializeAfterLogin() + return pinia +} + +function mountPanel(permissions = ['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust']) { + const pinia = setupPermissions(permissions) + return mount(InboundFeePanel, { + attachTo: document.body, + props: { customerId: '10001', inboundId: '88' }, + global: { plugins: [pinia, ElementPlus] }, + }) +} + +describe('InboundFeePanel', () => { + beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' + vi.mocked(getLatestInboundFee).mockResolvedValue(snapshot) + vi.mocked(adjustPublishedInboundFee).mockResolvedValue({ ...snapshot, feeVersion: 3, feeNo: 'FBAF-88-3' }) + }) + + it('shows the immutable calculation basis, versions, amount, and timestamps', async () => { + mountPanel() + await flushPromises() + + expect(getLatestInboundFee).toHaveBeenCalledWith('10001', '88') + expect(document.body.textContent).not.toContain('租户') + expect(document.body.textContent).toContain('对客费用总额 · CNY') + expect(document.body.textContent).toContain('¥1,235.60') + expect(document.body.textContent).toContain('基础运费小计 ¥1,135.60') + expect(document.body.textContent).toContain('附加费 ¥100.00') + expect(document.body.textContent).toContain('133.6 KG') + expect(document.body.textContent).toContain('按重量计费') + expect(document.body.textContent).toContain('¥8.50 / KG') + expect(document.body.textContent).toContain('RULE-FBA-KG · V3') + expect(document.body.textContent).toContain('RATE-LAX · V5') + expect(document.body.textContent).toContain('发布给客户') + expect(document.body.textContent).not.toContain('重新试算') + expect(document.body.textContent).not.toContain('发送费用') + }) + + it('follows the admin detail interaction pattern without list-only scaffolding', async () => { + const wrapper = mountPanel() + await flushPromises() + + expect(wrapper.get('button[aria-label="刷新费用快照"]').classes()).toContain('is-circle') + expect(wrapper.get('.version-status .el-tag').text()).toBe('已发布') + expect(wrapper.find('.query-toolbar').exists()).toBe(false) + expect(wrapper.find('.data-table').exists()).toBe(false) + expect(wrapper.find('.surface-panel').exists()).toBe(false) + }) + + it('keeps the legacy integration prop compatible while the page layer uses customer context', async () => { + const pinia = setupPermissions(['firstleg:fba-pricing:view']) + mount(InboundFeePanel, { + props: { tenantId: 'legacy-10001', inboundId: '88' }, + global: { plugins: [pinia, ElementPlus] }, + }) + await flushPromises() + + expect(getLatestInboundFee).toHaveBeenCalledWith('legacy-10001', '88') + expect(document.body.textContent).not.toContain('租户') + }) + + it('keeps the prototype-aligned detail order and renders long values with pending timestamps', async () => { + const longRuleCode = `RULE-${'LONG-'.repeat(20)}` + const longCarrierName = `船司${'超长名称'.repeat(20)}` + vi.mocked(getLatestInboundFee).mockResolvedValue({ + ...snapshot, + ruleCode: longRuleCode, + carrierName: longCarrierName, + confirmedAt: null, + sentAt: null, + }) + + const wrapper = mountPanel() + await flushPromises() + + const content = wrapper.find('.fee-content').text() + expect(wrapper.findAll('.fee-summary > div > span').map((node) => node.text())).toEqual([ + '对客费用总额 · CNY', + '最终计费量', + '快照版本', + ]) + expect(wrapper.findAll('.calculation-flow > div > span').map((node) => node.text())).toEqual([ + '实际重量', + '实际体积', + '折算量', + '取大后的计费量', + ]) + expect(wrapper.findAll('.fee-detail-grid h4').map((node) => node.text())).toEqual([ + '客户计费规则', + '船司 FBA 价格', + ]) + expect(content).toContain(longRuleCode) + expect(content).toContain(longCarrierName) + const pendingTimelineItems = wrapper.findAll('.fee-timeline li.pending') + expect(pendingTimelineItems).toHaveLength(2) + expect(pendingTimelineItems.map((item) => item.find('strong').text())).toEqual(['—', '—']) + }) + + it('does not request or expose fees without the view permission', async () => { + mountPanel([]) + await flushPromises() + + expect(getLatestInboundFee).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('没有查看入库费用的权限') + }) + + it('treats A0404 as a neutral not-yet-generated state', async () => { + vi.mocked(getLatestInboundFee).mockRejectedValue(new ApiResponseError('费用快照不存在', 'A0404')) + mountPanel(['firstleg:fba-pricing:view']) + await flushPromises() + + expect(document.body.textContent).toContain('尚未生成费用快照') + expect(document.body.textContent).toContain('测量审核完成后') + expect(document.querySelector('[role="alert"]')).toBeNull() + }) + + it('shows adjustment only for authorized published inbound fees', async () => { + mountPanel(['firstleg:fba-pricing:view']) + await flushPromises() + expect(document.querySelector('[data-testid="adjust-fee-button"]')).toBeNull() + + document.body.innerHTML = '' + mountPanel() + await flushPromises() + expect(document.querySelector('[data-testid="adjust-fee-button"]')).not.toBeNull() + }) + + it('submits a reasoned adjustment and emits the refreshed snapshot', async () => { + const wrapper = mountPanel() + await flushPromises() + ;(document.querySelector('[data-testid="adjust-fee-button"]') as HTMLButtonElement).click() + await flushPromises() + + const numbers = [...document.querySelectorAll('.adjustment-form input')] + numbers[0]!.value = '1200' + numbers[0]!.dispatchEvent(new Event('input', { bubbles: true })) + numbers[0]!.dispatchEvent(new Event('change', { bubbles: true })) + numbers[1]!.value = '80' + numbers[1]!.dispatchEvent(new Event('input', { bubbles: true })) + numbers[1]!.dispatchEvent(new Event('change', { bubbles: true })) + const reason = document.querySelector('.adjustment-form textarea')! + reason.value = '补充已确认的附加费用' + reason.dispatchEvent(new Event('input', { bubbles: true })) + reason.dispatchEvent(new Event('blur', { bubbles: true })) + + const confirm = [...document.querySelectorAll('button')] + .find((button) => button.textContent?.includes('确认调整并生成新版本')) + confirm?.click() + await flushPromises() + + expect(adjustPublishedInboundFee).toHaveBeenCalledWith( + '10001', + '88', + { subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费用' }, + expect.stringMatching(/^fee-adjust-/), + ) + expect(wrapper.emitted('changed')?.[0]?.[0]).toMatchObject({ feeVersion: 3 }) + }) + + it('refreshes the latest snapshot after a conflict while retaining the dialog', async () => { + vi.mocked(adjustPublishedInboundFee).mockRejectedValue( + new ApiResponseError('费用已被其他操作员修改,请刷新后重试', 'A0409', 409), + ) + mountPanel() + await flushPromises() + ;(document.querySelector('[data-testid="adjust-fee-button"]') as HTMLButtonElement).click() + await flushPromises() + + const numbers = [...document.querySelectorAll('.adjustment-form input')] + numbers[0]!.value = '1200' + numbers[0]!.dispatchEvent(new Event('input', { bubbles: true })) + numbers[1]!.value = '80' + numbers[1]!.dispatchEvent(new Event('input', { bubbles: true })) + const reason = document.querySelector('.adjustment-form textarea')! + reason.value = '并发场景价格修正' + reason.dispatchEvent(new Event('input', { bubbles: true })) + reason.dispatchEvent(new Event('blur', { bubbles: true })) + ;[...document.querySelectorAll('button')] + .find((button) => button.textContent?.includes('确认调整并生成新版本'))?.click() + await flushPromises() + + expect(getLatestInboundFee).toHaveBeenCalledTimes(2) + expect(document.body.textContent).toContain('系统已刷新最新快照') + expect(document.body.textContent).toContain('当前输入仍保留') + }) +}) diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue new file mode 100644 index 00000000..5b57e0a5 --- /dev/null +++ b/src/modules/inbounds/fee/InboundFeePanel.vue @@ -0,0 +1,477 @@ + + + + + diff --git a/src/modules/inbounds/fee/api.test.ts b/src/modules/inbounds/fee/api.test.ts new file mode 100644 index 00000000..dad208c8 --- /dev/null +++ b/src/modules/inbounds/fee/api.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError, http } from '@/shared/api/http' +import { + adjustPublishedInboundFee, + createFeeAdjustmentIdempotencyKey, + getLatestInboundFee, +} from './api' + +vi.mock('@/shared/api/http', async () => { + const actual = await vi.importActual('@/shared/api/http') + return { ...actual, http: { get: vi.fn(), put: vi.fn() } } +}) + +const snapshot = { + feeId: '9007199254740993', + feeNo: 'FBAF-88-2', + feeVersion: 2, + feeStatus: 'SENT', + inboundId: '88', + totalAmount: 1280, +} + +describe('inbound fee API', () => { + beforeEach(() => vi.clearAllMocks()) + + it('loads the latest immutable fee snapshot with string IDs', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: snapshot }) + + await expect(getLatestInboundFee('10001', '88')).resolves.toEqual(snapshot) + expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/latest', { + params: { tenantId: '10001' }, + }) + }) + + it('adjusts a published snapshot with the required idempotency key', async () => { + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, feeVersion: 3 } }) + const payload = { subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费' } + + await expect(adjustPublishedInboundFee('10001', '88', payload, 'fee-adjust-1')) + .resolves.toMatchObject({ feeVersion: 3 }) + expect(http.put).toHaveBeenCalledWith( + '/platform/v1/inbounds/88/fees/published', + payload, + { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-adjust-1' } }, + ) + }) + + it('preserves backend conflict metadata for stale-version recovery', async () => { + vi.mocked(http.put).mockResolvedValue({ + code: 'A0409', message: '费用已被其他操作员修改,请刷新后重试', data: null, + requestId: 'request-fee-409', traceId: 'trace-fee-409', + }) + + const error = await adjustPublishedInboundFee( + '10001', + '88', + { subtotalAmount: 1200, additionalAmount: 80, reason: '价格修正' }, + 'fee-adjust-2', + ).catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiResponseError) + expect(error).toMatchObject({ code: 'A0409', requestId: 'request-fee-409', traceId: 'trace-fee-409' }) + }) + + it('creates bounded unique keys for adjustment retries', () => { + const first = createFeeAdjustmentIdempotencyKey() + const second = createFeeAdjustmentIdempotencyKey() + expect(first).toMatch(/^fee-adjust-/) + expect(first.length).toBeLessThanOrEqual(128) + expect(second).not.toBe(first) + }) +}) + diff --git a/src/modules/inbounds/fee/api.ts b/src/modules/inbounds/fee/api.ts new file mode 100644 index 00000000..0c9260cc --- /dev/null +++ b/src/modules/inbounds/fee/api.ts @@ -0,0 +1,35 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types' + +function feeBasePath(inboundId: string) { + return `/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees` +} + +export async function getLatestInboundFee(tenantId: string, inboundId: string) { + const response = await http.get>( + `${feeBasePath(inboundId)}/latest`, + { params: { tenantId } }, + ) + return unwrapApiResponse(response) +} + +export async function adjustPublishedInboundFee( + tenantId: string, + inboundId: string, + payload: AdjustPublishedInboundFeeRequest, + idempotencyKey: string, +) { + const response = await http.put>( + `${feeBasePath(inboundId)}/published`, + payload, + { params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } }, + ) + return unwrapApiResponse(response) +} + +export function createFeeAdjustmentIdempotencyKey() { + const uuid = globalThis.crypto?.randomUUID?.() + if (uuid) return `fee-adjust-${uuid}` + return `fee-adjust-${Date.now()}-${Math.random().toString(36).slice(2, 12)}` +} + diff --git a/src/modules/inbounds/fee/index.ts b/src/modules/inbounds/fee/index.ts new file mode 100644 index 00000000..5740593f --- /dev/null +++ b/src/modules/inbounds/fee/index.ts @@ -0,0 +1,13 @@ +export { default as InboundFeePanel } from './InboundFeePanel.vue' +export { + adjustPublishedInboundFee, + createFeeAdjustmentIdempotencyKey, + getLatestInboundFee, +} from './api' +export type { + AdjustPublishedInboundFeeRequest, + InboundChargeMode, + InboundFeeSnapshot, + InboundFeeStatus, +} from './types' + diff --git a/src/modules/inbounds/fee/types.ts b/src/modules/inbounds/fee/types.ts new file mode 100644 index 00000000..ddb9ac08 --- /dev/null +++ b/src/modules/inbounds/fee/types.ts @@ -0,0 +1,54 @@ +export type InboundFeeStatus = 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'SUPERSEDED' | string +export type InboundChargeMode = 'KG' | 'CBM' | string + +export interface InboundFeeSnapshot { + feeId: string + feeNo: string + feeVersion: number + feeStatus: InboundFeeStatus + inboundId: string + inboundCode: string | null + chargeRuleId: string + ruleCode: string + chargeRuleVersion: number + carrierRateId: string + rateCode: string + carrierRateVersion: number + customerId: string + customerName: string + originWarehouseId: string + originWarehouseName: string + businessType: string + channelCode: string + carrierId: string + carrierName: string + destinationCode: string + taxMode: string + chargeMode: InboundChargeMode + conversionFactor: number + roundingScale: number + roundingMode: string + actualWeightKg: number + actualVolumeCbm: number + convertedQuantity: number + chargeableQuantity: number + chargeUnit: string + unitPrice: number | null + subtotalAmount: number + additionalAmount: number + totalAmount: number + currency: string + rateEffectiveFrom: string + rateEffectiveTo: string | null + calculatedAt: string + confirmedAt: string | null + sentAt: string | null + nextProcessStatus: string | null +} + +export interface AdjustPublishedInboundFeeRequest { + subtotalAmount: number + additionalAmount: number + reason: string +} + diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts new file mode 100644 index 00000000..c58959c0 --- /dev/null +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts @@ -0,0 +1,255 @@ +import ElementPlus, { ElMessageBox } from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import { useAuthStore } from '@/core/auth/auth' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { + getInboundMeasurement, + saveInboundMeasurementDraft, + submitInboundMeasurement, +} from './api' +import type { InboundMeasurementDetail } from './types' +import InboundMeasurementPanel from './InboundMeasurementPanel.vue' + +vi.mock('./api', () => ({ + getInboundMeasurement: vi.fn(), + saveInboundMeasurementDraft: vi.fn(), + submitInboundMeasurement: vi.fn(), +})) + +class ResizeObserverStub implements ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +function createMemoryStorage(): Storage { + const values = new Map() + return { + get length() { return values.size }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + } +} + +function measurement(overrides: Partial = {}): InboundMeasurementDetail { + return { + inboundId: '7001', + inboundCode: 'RK202608040001', + companyName: '测试企业', + channelCode: 'SEA-FBA', + channelName: '美西海运', + processStatus: 'MEASURING', + measureReviewStatus: null, + recommendedPalletType: 'STANDARD', + reviewReason: null, + reviewerId: null, + reviewerName: null, + measurementId: '9001', + sourceType: 'THIRD_PARTY', + measurementStatus: 'RECEIVED', + version: 2, + originalDocumentNo: 'DOC-001', + originalGrossWeightKg: 118.5, + originalVolumeCbm: 0.8, + actualPackageCount: 10, + actualGrossWeightKg: 120, + actualVolumeCbm: 0.6, + volumeWeightKg: 100.2, + actualGoodsName: '蓝牙音箱', + items: [{ + id: '9101', + itemNo: '001', + boxSpecification: '50×40×30cm', + packageCount: 10, + lengthCm: 50, + widthCm: 40, + heightCm: 30, + girthCm: 190, + unitWeightKg: 12, + unitVolumeCbm: 0.06, + totalWeightKg: 120, + totalVolumeCbm: 0.6, + volumeWeightKg: 100.2, + intelligentImageFileId: '88001', + overLimitWarning: null, + outerLengthCm: null, + outerWidthCm: null, + outerHeightCm: null, + remark: '设备自动回传', + }], + weightResults: [], + ...overrides, + } +} + +async function mountPanel(permissions = [ + 'platform:firstleg:inbound:measure:view', + 'platform:firstleg:inbound:measure:edit', + 'platform:firstleg:inbound:measure:submit', +]) { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.token = 'token' + auth.permissions = permissions + useAccessPreviewStore().initializeAfterLogin() + const wrapper = mount(InboundMeasurementPanel, { + props: { customerId: '10001', inboundId: '7001' }, + attachTo: document.body, + global: { plugins: [pinia, ElementPlus] }, + }) + await flushPromises() + return wrapper +} + +enableAutoUnmount(afterEach) +afterAll(() => vi.unstubAllGlobals()) + +describe('InboundMeasurementPanel', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('localStorage', createMemoryStorage()) + vi.stubGlobal('sessionStorage', createMemoryStorage()) + document.body.innerHTML = '' + vi.mocked(getInboundMeasurement).mockReset().mockResolvedValue(measurement()) + vi.mocked(saveInboundMeasurementDraft).mockReset().mockResolvedValue({ measurementId: '9001', version: 3, status: 'DRAFT' }) + vi.mocked(submitInboundMeasurement).mockReset().mockResolvedValue({ + measurementId: '9001', version: 2, processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', + recommendedPalletType: 'STANDARD', feeId: null, feeNo: null, feeStatus: null, + }) + vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) + }) + + it('loads third-party facts and renders server-calculated values separately', async () => { + const wrapper = await mountPanel() + + expect(getInboundMeasurement).toHaveBeenCalledWith('10001', '7001') + expect(wrapper.text()).toContain('第三方设备') + expect(wrapper.text()).toContain('RK202608040001') + expect(wrapper.text()).toContain('箱规周长') + expect(wrapper.text()).toContain('190 cm') + expect(wrapper.text()).toContain('0.6 CBM') + expect((wrapper.find('input[placeholder="请输入本箱规的补充说明"]').element as HTMLInputElement).value).toBe('设备自动回传') + }) + + it('follows the prototype grouping, labels, and editable field order', async () => { + const wrapper = await mountPanel() + const text = wrapper.text() + + expect(text).toContain('原单数据') + expect(text).toContain('入库单测量数据明细') + expect(text).toContain('实测总箱数') + expect(text).toContain('实测总毛重') + expect(text).toContain('实测总体积') + expect(text).toContain('实测总材积重') + expect(text).not.toContain('租户') + + const labels = wrapper.findAll('.measurement-item__grid label > span').map((label) => label.text()) + expect(labels.slice(0, 8)).toEqual([ + '明细编号 *', + '箱规 *', + '箱规长(cm)*', + '箱规宽(cm)*', + '箱规高(cm)*', + '箱数 *', + '每箱毛重(kg)*', + '智能图片文件 ID', + ]) + }) + + it('reuses the established first-leg detail surface and status patterns', async () => { + const wrapper = await mountPanel() + + expect(wrapper.classes()).toContain('surface-panel') + expect(wrapper.find('.measurement-panel__heading').classes()).toContain('panel-heading') + expect(wrapper.find('.status-badge').text()).toBe('测量中') + }) + + it('saves only editable raw fields with the current measurement version', async () => { + const wrapper = await mountPanel() + await wrapper.find('input[placeholder="请输入实际货物名称"]').setValue('智能音箱') + const save = wrapper.findAll('button').find((button) => button.text().includes('保存草稿')) + await save?.trigger('click') + await flushPromises() + + expect(saveInboundMeasurementDraft).toHaveBeenCalledOnce() + expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({ + measurementId: '9001', + version: 2, + actualGoodsName: '智能音箱', + items: [expect.objectContaining({ + itemNo: '001', packageCount: 10, lengthCm: 50, intelligentImageFileId: '88001', + })], + })) + const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0][2] + expect(payload.items[0]).not.toHaveProperty('totalVolumeCbm') + }) + + it('preserves the edited form when the backend reports a version conflict', async () => { + vi.mocked(saveInboundMeasurementDraft).mockRejectedValueOnce( + new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 200, null, 'req-conflict', 'trace-conflict'), + ) + const wrapper = await mountPanel() + const goodsInput = wrapper.find('input[placeholder="请输入实际货物名称"]') + await goodsInput.setValue('保留我的修改') + const save = wrapper.findAll('button').find((button) => button.text().includes('保存草稿')) + await save?.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('数据版本已变化,当前修改尚未丢失') + expect(wrapper.text()).toContain('req-conflict') + expect((goodsInput.element as HTMLInputElement).value).toBe('保留我的修改') + expect(getInboundMeasurement).toHaveBeenCalledTimes(1) + }) + + it('requires the dirty draft to be saved before submitting for review', async () => { + const wrapper = await mountPanel() + await wrapper.find('input[placeholder="请输入实际货物名称"]').setValue('尚未保存') + const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核')) + await submit?.trigger('click') + await flushPromises() + + expect(submitInboundMeasurement).not.toHaveBeenCalled() + expect(ElMessageBox.confirm).not.toHaveBeenCalled() + }) + + it('submits the saved version and emits changed after confirmation', async () => { + const wrapper = await mountPanel() + const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核')) + await submit?.trigger('click') + await flushPromises() + + expect(ElMessageBox.confirm).toHaveBeenCalledWith( + expect.stringContaining('准备托盘与库位占用'), + '提交测量审核', + expect.objectContaining({ confirmButtonText: '确认提交' }), + ) + expect(submitInboundMeasurement).toHaveBeenCalledWith('10001', '7001', { measurementId: '9001', version: 2 }) + expect(wrapper.emitted('changed')).toHaveLength(1) + }) + + it('clears the previous customer context before loading the next one', async () => { + const wrapper = await mountPanel() + vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ inboundId: '7002', inboundCode: 'RK202608040002', companyName: '第二企业' })) + + await wrapper.setProps({ customerId: '10002', inboundId: '7002' }) + await flushPromises() + + expect(getInboundMeasurement).toHaveBeenLastCalledWith('10002', '7002') + expect(wrapper.text()).toContain('RK202608040002') + expect(wrapper.text()).not.toContain('测试企业') + }) + + it('renders a permission-specific read-only state without requesting data', async () => { + const wrapper = await mountPanel([]) + + expect(wrapper.text()).toContain('无测量查看权限') + expect(getInboundMeasurement).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue new file mode 100644 index 00000000..2ebe6b8f --- /dev/null +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue @@ -0,0 +1,635 @@ + + + + + diff --git a/src/modules/inbounds/measurement/api.test.ts b/src/modules/inbounds/measurement/api.test.ts new file mode 100644 index 00000000..efb37d89 --- /dev/null +++ b/src/modules/inbounds/measurement/api.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement } from './api' +import type { SaveInboundMeasurementDraftRequest } from './types' + +vi.mock('@/shared/api/http', () => ({ + http: { get: vi.fn(), put: vi.fn(), post: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +const draft: SaveInboundMeasurementDraftRequest = { + measurementId: '9001', + version: 2, + originalDocumentNo: 'DOC-01', + originalGrossWeightKg: 120, + originalVolumeCbm: 1.2, + actualGoodsName: '蓝牙音箱', + items: [{ + itemNo: '001', + boxSpecification: '50×40×30cm', + packageCount: 10, + lengthCm: 50, + widthCm: 40, + heightCm: 30, + unitWeightKg: 12, + intelligentImageFileId: '88001', + overLimitWarning: null, + outerLengthCm: null, + outerWidthCm: null, + outerHeightCm: null, + remark: null, + }], +} + +describe('inbound measurement API', () => { + beforeEach(() => { + vi.mocked(http.get).mockReset() + vi.mocked(http.put).mockReset() + vi.mocked(http.post).mockReset() + }) + + it('maps the selected customer to the backend tenant compatibility parameter', async () => { + const detail = { inboundId: '7001', items: [], weightResults: [] } + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail }) + + await expect(getInboundMeasurement('10001', '7001')).resolves.toEqual(detail) + expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/inbounds/7001/measurement', { + params: { tenantId: '10001' }, + }) + }) + + it('saves a versioned draft without inventing an idempotency header', async () => { + const saved = { measurementId: '9001', version: 3, status: 'DRAFT' } + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: saved }) + + await expect(saveInboundMeasurementDraft('10001', '7001', draft)).resolves.toEqual(saved) + expect(http.put).toHaveBeenCalledWith( + '/platform/v1/first-leg/inbounds/7001/measurement/draft', + draft, + { params: { tenantId: '10001' } }, + ) + }) + + it('submits the server measurement id and optimistic-lock version', async () => { + const submitted = { measurementId: '9001', version: 3, processStatus: 'MEASURE_PENDING' } + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: submitted }) + + await expect(submitInboundMeasurement('10001', '7001', { measurementId: '9001', version: 3 })).resolves.toEqual(submitted) + expect(http.post).toHaveBeenCalledWith( + '/platform/v1/first-leg/inbounds/7001/measurement/submit', + { measurementId: '9001', version: 3 }, + { params: { tenantId: '10001' } }, + ) + }) +}) diff --git a/src/modules/inbounds/measurement/api.ts b/src/modules/inbounds/measurement/api.ts new file mode 100644 index 00000000..7bdc558d --- /dev/null +++ b/src/modules/inbounds/measurement/api.ts @@ -0,0 +1,46 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { + InboundMeasurementDetail, + InboundMeasurementDraftSaved, + InboundMeasurementSubmitted, + SaveInboundMeasurementDraftRequest, + SubmitInboundMeasurementRequest, +} from './types' + +function measurementPath(inboundId: string) { + return `/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/measurement` +} + +export async function getInboundMeasurement(customerId: string, inboundId: string) { + const response = await http.get, ApiEnvelope>( + measurementPath(inboundId), + { params: { tenantId: customerId } }, + ) + return unwrapApiResponse(response) +} + +export async function saveInboundMeasurementDraft( + customerId: string, + inboundId: string, + payload: SaveInboundMeasurementDraftRequest, +) { + const response = await http.put, ApiEnvelope>( + `${measurementPath(inboundId)}/draft`, + payload, + { params: { tenantId: customerId } }, + ) + return unwrapApiResponse(response) +} + +export async function submitInboundMeasurement( + customerId: string, + inboundId: string, + payload: SubmitInboundMeasurementRequest, +) { + const response = await http.post, ApiEnvelope>( + `${measurementPath(inboundId)}/submit`, + payload, + { params: { tenantId: customerId } }, + ) + return unwrapApiResponse(response) +} diff --git a/src/modules/inbounds/measurement/index.ts b/src/modules/inbounds/measurement/index.ts new file mode 100644 index 00000000..839eb812 --- /dev/null +++ b/src/modules/inbounds/measurement/index.ts @@ -0,0 +1,4 @@ +export { default as InboundMeasurementPanel } from './InboundMeasurementPanel.vue' +export * from './api' +export type * from './types' + diff --git a/src/modules/inbounds/measurement/types.ts b/src/modules/inbounds/measurement/types.ts new file mode 100644 index 00000000..7d7f63e5 --- /dev/null +++ b/src/modules/inbounds/measurement/types.ts @@ -0,0 +1,131 @@ +export type MeasurementIdentifier = string + +export type InboundMeasurementProcessStatus = + | 'WAIT_MEASURE' + | 'MEASURING' + | 'MEASURE_PENDING' + | 'MEASURE_REJECTED' + | 'WAIT_PRICING' + | 'FEE_CONFIRMING' + | 'INBOUNDING' + | 'INBOUNDED' + | 'VOIDED' + +export interface InboundMeasurementItem { + id: MeasurementIdentifier + itemNo: string + boxSpecification: string + packageCount: number + lengthCm: number + widthCm: number + heightCm: number + girthCm: number | null + unitWeightKg: number + unitVolumeCbm: number | null + totalWeightKg: number | null + totalVolumeCbm: number | null + volumeWeightKg: number | null + intelligentImageFileId: MeasurementIdentifier | null + overLimitWarning: string | null + outerLengthCm: number | null + outerWidthCm: number | null + outerHeightCm: number | null + remark: string | null +} + +export interface InboundMeasurementWeightResult { + id: MeasurementIdentifier + fileId: MeasurementIdentifier + ocrProvider: string | null + ocrRawText: string | null + ocrWeightValue: number | null + ocrWeightUnit: string | null + confidence: number | null + adjustedWeightValue: number | null + adjustedWeightUnit: string | null + finalWeightKg: number | null + recognitionStatus: string + adjustmentReason: string | null + adjustedBy: MeasurementIdentifier | null + adjustedAt: string | null + confirmedBy: MeasurementIdentifier | null + confirmedAt: string | null + version: number +} + +export interface InboundMeasurementDetail { + inboundId: MeasurementIdentifier + inboundCode: string + companyName: string + channelCode: string | null + channelName: string | null + processStatus: InboundMeasurementProcessStatus + measureReviewStatus: string | null + recommendedPalletType: string | null + reviewReason: string | null + reviewerId: MeasurementIdentifier | null + reviewerName: string | null + measurementId: MeasurementIdentifier | null + sourceType: string | null + measurementStatus: string | null + version: number | null + originalDocumentNo: string | null + originalGrossWeightKg: number | null + originalVolumeCbm: number | null + actualPackageCount: number | null + actualGrossWeightKg: number | null + actualVolumeCbm: number | null + volumeWeightKg: number | null + actualGoodsName: string | null + items: InboundMeasurementItem[] + weightResults: InboundMeasurementWeightResult[] +} + +export interface SaveInboundMeasurementItemRequest { + itemNo: string + boxSpecification: string + packageCount: number + lengthCm: number + widthCm: number + heightCm: number + unitWeightKg: number + intelligentImageFileId: MeasurementIdentifier | null + overLimitWarning: string | null + outerLengthCm: number | null + outerWidthCm: number | null + outerHeightCm: number | null + remark: string | null +} + +export interface SaveInboundMeasurementDraftRequest { + measurementId: MeasurementIdentifier | null + version: number + originalDocumentNo: string | null + originalGrossWeightKg: number | null + originalVolumeCbm: number | null + actualGoodsName: string | null + items: SaveInboundMeasurementItemRequest[] +} + +export interface InboundMeasurementDraftSaved { + measurementId: MeasurementIdentifier + version: number + status: string +} + +export interface SubmitInboundMeasurementRequest { + measurementId: MeasurementIdentifier + version: number +} + +export interface InboundMeasurementSubmitted { + measurementId: MeasurementIdentifier + version: number + processStatus: string + measureReviewStatus: string + recommendedPalletType: string | null + feeId: MeasurementIdentifier | null + feeNo: string | null + feeStatus: string | null +} + diff --git a/src/modules/inbounds/pages/InboundOrderDetailView.test.ts b/src/modules/inbounds/pages/InboundOrderDetailView.test.ts new file mode 100644 index 00000000..60f73b00 --- /dev/null +++ b/src/modules/inbounds/pages/InboundOrderDetailView.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrderDetailView.vue'), 'utf8') + +describe('InboundOrderDetailView', () => { + it('maps the compatible route parameter to customer context without a detail-page selector', () => { + expect(source).toContain('route.query.tenantId') + expect(source).toContain('const customerId = computed') + expect(source).toContain("name: 'inbound-orders'") + expect(source).toContain(':customer-id="customerId"') + expect(source).toContain(':inbound-id="inboundId"') + expect(source).toContain('缺少所属企业信息,请从入库单列表重新打开') + expect(source).not.toContain('PlatformTenantSelector') + expect(source).not.toContain('租户') + expect(source).toContain('

入库详情

') + expect(source).toContain('货物测量、审核、费用及托盘库位记录') + expect(source).toContain('class="surface-panel detail-page"') + expect(source).toContain('class="detail-page-actions"') + expect(source).toContain('返回入库单列表') + }) +}) diff --git a/src/modules/inbounds/pages/InboundOrderDetailView.vue b/src/modules/inbounds/pages/InboundOrderDetailView.vue new file mode 100644 index 00000000..f9d0d2ca --- /dev/null +++ b/src/modules/inbounds/pages/InboundOrderDetailView.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/modules/inbounds/pages/InboundOrdersView.test.ts b/src/modules/inbounds/pages/InboundOrdersView.test.ts new file mode 100644 index 00000000..687c2502 --- /dev/null +++ b/src/modules/inbounds/pages/InboundOrdersView.test.ts @@ -0,0 +1,261 @@ +import ElementPlus from 'element-plus' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { createPinia, setActivePinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import InboundOrdersView from './InboundOrdersView.vue' +import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' +import QueryToolbar from '@/shared/components/QueryToolbar.vue' +import DataTable from '@/shared/components/DataTable.vue' +import { listPlatformWarehouses } from '@/modules/warehouses' +import { getPlatformInbound, listPlatformInbounds } from '../core/api' +import type { InboundOrderDetail, InboundOrderSummary } from '../core/types' + +vi.mock('../core/api', () => ({ + getPlatformInbound: vi.fn(), + listPlatformInbounds: vi.fn(), +})) + +vi.mock('@/modules/warehouses', () => ({ + listPlatformWarehouses: vi.fn(), +})) + +vi.mock('@/modules/iam', () => ({ + listPlatformTenants: vi.fn(), +})) + +const customers: PlatformTenantResource[] = [ + { id: '10001', tenantCode: 'CUS-001', tenantName: '示例企业', status: 'ACTIVE', legalName: '示例企业有限公司', legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null }, + { id: '10002', tenantCode: 'CUS-002', tenantName: '第二客户', status: 'ACTIVE', legalName: null, legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null }, +] + +const summary: InboundOrderSummary = { + id: '19001', orderId: '18001', orderNo: 'ORD-001', inboundCode: 'RK-001', processStatus: 'WAIT_MEASURE', + orderStatusSnapshot: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: null, + notificationStatus: null, version: 1, expectedArrivalAt: '2026-08-04T10:00:00', customerId: '10001', customerName: '示例客户', + companyId: '10001', companyName: '示例企业', warehouseId: '20001', warehouseName: '义乌收货仓', deliveryWarehouseId: null, + deliveryWarehouseName: null, businessType: 'FBA', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null, + customsInspection: false, destinationType: 'FBA', destinationWarehouseId: '30001', destinationCode: 'ONT8', destinationName: 'ONT8', + holdFlag: false, currentHoldReason: null, totalPackageCount: 6, actualGrossWeightKg: 120, actualNetWeightKg: 110, + actualVolumeCbm: 1.2, volumeWeightKg: 200, chargeableWeightKg: 200, chargeableVolumeCbm: null, palletCount: 1, locationCodes: 'A-01', + measureCompletedAt: null, inboundedAt: null, expressCompanyCode: null, expressCompanyName: null, expressTrackingNo: null, + loadingStatus: null, containerNo: null, cabinetNo: null, vesselName: null, voyageNo: null, sailingAt: null, portArrivalAt: null, + createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00', +} + +const detail: InboundOrderDetail = { + summary, + publicRemark: '请优先安排测量', + palletLocations: [{ allocationNo: 1, warehouseId: '20001', palletId: '21001', palletCode: 'P-001', palletCapacityCbm: 2, allocatedVolumeCbm: 1.2, allocatedPackageCount: 6, locationId: '22001', locationCode: 'A-01', reservationNo: 'RES-1', allocationStatus: 'PREPARED' }], + files: [], internalNotes: [], statusHistory: [{ fromStatus: null, toStatus: 'WAIT_MEASURE', action: 'CREATE', reason: null, operatorId: '2', operatorName: '管理员', occurredAt: '2026-08-01T10:00:00' }], +} + +enableAutoUnmount(afterEach) + +function mountPage() { + return mount(InboundOrdersView, { + attachTo: document.body, + global: { plugins: [ElementPlus] }, + }) +} + +async function selectCustomer(wrapper: ReturnType, customerId = '10001') { + await flushPromises() + const toolbar = wrapper.findComponent(QueryToolbar) + toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId }) + await flushPromises() + return toolbar +} + +describe('InboundOrdersView', () => { + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 1, pages: 1 }) + vi.mocked(getPlatformInbound).mockResolvedValue(detail) + vi.mocked(listPlatformTenants).mockResolvedValue({ records: customers, page: 1, size: 200, total: 2, pages: 1 }) + vi.mocked(listPlatformWarehouses).mockResolvedValue({ + records: [ + { id: '20001', tenantId: '10001', warehouseCode: 'YIWU', warehouseName: '义乌收货仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }, + { id: 20002, tenantId: '10001', warehouseCode: 'B001', warehouseName: '交货仓 B001', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }, + ], + page: 1, size: 200, total: 2, pages: 1, + }) + }) + + it('loads the customer-scoped list and renders the first-column view action', async () => { + const wrapper = mountPage() + await flushPromises() + const toolbar = wrapper.findComponent(QueryToolbar) + const customerField = (toolbar.props('fields') as Array<{ key: string; placeholder?: string }>).find((field) => field.key === 'customerId') + expect(customerField?.placeholder).toBe('全部客户') + expect((toolbar.props('modelValue') as { customerId: string }).customerId).toBe('') + expect(listPlatformInbounds).toHaveBeenCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 })) + expect(listPlatformWarehouses).not.toHaveBeenCalled() + const requestsBeforeSearch = vi.mocked(listPlatformInbounds).mock.calls.length + toolbar.vm.$emit('search') + await flushPromises() + expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeSearch + 1) + expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 })) + + await selectCustomer(wrapper) + expect(listPlatformInbounds).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 })) + expect(listPlatformWarehouses).toHaveBeenCalledWith('10001', 1, 200) + expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 }) + expect(document.body.textContent).toContain('RK-001') + expect(document.body.textContent).toContain('查看') + expect(document.body.textContent).toContain('所属企业 示例企业有限公司') + expect(document.body.textContent).not.toContain('租户') + expect(wrapper.find('.inbound-tabs .is-active').text()).toContain('全部') + }) + + it('uses backend-supported warehouse IDs from tenant-scoped options without coercing Long IDs', async () => { + const wrapper = mountPage() + const toolbar = await selectCustomer(wrapper) + toolbar.vm.$emit('update:modelValue', { + ...toolbar.props('modelValue'), + warehouseId: '20001', + deliveryWarehouseId: '20002', + }) + toolbar.vm.$emit('search') + await flushPromises() + expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ + warehouseId: '20001', + deliveryWarehouseId: '20002', + })) + }) + + it('keeps common filters compact and clears hidden advanced filters when collapsed', async () => { + const wrapper = mountPage() + const toolbar = await selectCustomer(wrapper) + const fieldKeys = () => (toolbar.props('fields') as Array<{ key: string }>).map((field) => field.key) + const widths = Object.fromEntries((toolbar.props('fields') as Array<{ key: string; width?: number }>).map((field) => [field.key, field.width])) + expect(widths.customerId).toBe(260) + expect(widths.processStatus).toBe(160) + expect(widths.inboundCode).toBe(220) + expect(widths.expectedArrivalRange).toBe(320) + expect(fieldKeys()).toContain('expectedArrivalRange') + expect(fieldKeys()).not.toContain('feeReviewStatus') + + const advancedButton = () => wrapper.findAll('button').find((button) => button.text().includes('更多筛选') || button.text().includes('收起筛选')) + await advancedButton()?.trigger('click') + expect(fieldKeys()).toContain('feeReviewStatus') + + toolbar.vm.$emit('update:modelValue', { + ...toolbar.props('modelValue'), + feeReviewStatus: 'PENDING', + destinationCode: 'ONT8', + }) + await flushPromises() + await advancedButton()?.trigger('click') + await flushPromises() + expect(fieldKeys()).not.toContain('feeReviewStatus') + expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ + feeReviewStatus: undefined, + destinationCode: undefined, + })) + }) + + it('passes the selected backend tab and opens an aggregate read-only detail', async () => { + const wrapper = mountPage() + await selectCustomer(wrapper) + ;[...document.querySelectorAll('.inbound-tab')].find((button) => button.textContent?.includes('待审核'))?.click() + await flushPromises() + expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ tab: 'WAIT_REVIEW', page: 1 })) + ;[...document.querySelectorAll('button')].filter((button) => button.textContent?.trim() === '查看').at(-1)?.click() + await flushPromises() + expect(getPlatformInbound).toHaveBeenCalledWith('10001', '19001') + expect(document.body.textContent).toContain('托盘与库位') + expect(document.body.textContent).toContain('请优先安排测量') + wrapper.unmount() + }) + + it('uses the app header as the page title and exposes compact accessible status tabs', async () => { + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('.inbound-page-heading').exists()).toBe(false) + expect(wrapper.text()).not.toContain('所有处理均进入单票工作台完成') + const tabList = wrapper.find('[role="tablist"]') + const tabButtons = tabList.findAll('[role="tab"]') + const queryToolbar = wrapper.findComponent(QueryToolbar) + expect(tabList.attributes('aria-label')).toBe('入库单处理状态') + expect(tabList.element.parentElement?.classList.contains('table-toolbar')).toBe(true) + expect(queryToolbar.element.compareDocumentPosition(tabList.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(tabButtons).toHaveLength(5) + expect(wrapper.find('.table-toolbar').text()).toContain('共 1 条 · 所属企业 全部客户') + expect(wrapper.find('.table-toolbar').text()).toContain('刷新') + expect(wrapper.text()).not.toContain('全部入库单') + expect(tabButtons[0].attributes('aria-selected')).toBe('true') + expect(tabButtons[0].attributes('tabindex')).toBe('0') + await tabButtons[0].trigger('keydown', { key: 'ArrowRight' }) + await flushPromises() + expect(tabButtons[1].attributes('aria-selected')).toBe('true') + expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ tab: 'WAIT_MEASURE', page: 1 })) + }) + + it('shows an actionable list error and recovers on retry', async () => { + vi.mocked(listPlatformInbounds).mockRejectedValueOnce(new Error('network down')).mockResolvedValueOnce({ records: [summary], page: 1, size: 20, total: 1, pages: 1 }) + const wrapper = mountPage() + await flushPromises() + expect(document.body.textContent).toContain('入库单暂时无法加载') + ;[...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '重新加载')?.click() + await flushPromises() + expect(listPlatformInbounds).toHaveBeenCalledTimes(2) + expect(document.body.textContent).toContain('RK-001') + }) + + it('clears the selected list and detail when the selected customer changes', async () => { + const wrapper = mountPage() + const toolbar = await selectCustomer(wrapper) + toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId: '10002' }) + await flushPromises() + expect(listPlatformInbounds).toHaveBeenLastCalledWith('10002', expect.objectContaining({ page: 1 })) + expect(listPlatformWarehouses).toHaveBeenLastCalledWith('10002', 1, 200) + }) + + it('resets to all customers without sending an empty backend tenant ID', async () => { + const wrapper = mountPage() + const toolbar = await selectCustomer(wrapper) + const requestsBeforeReset = vi.mocked(listPlatformInbounds).mock.calls.length + const resetButton = wrapper.findAll('button').find((button) => button.text().includes('重置')) + await resetButton?.trigger('click') + await flushPromises() + expect((toolbar.props('modelValue') as { customerId: string }).customerId).toBe('') + expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeReset + 1) + expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1 })) + expect(document.body.textContent).not.toContain('请选择所属企业') + expect(document.body.textContent).not.toContain('请选择客户') + }) + + it('loads another full-data page without a customer filter', async () => { + vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 60, pages: 3 }) + const wrapper = mountPage() + await flushPromises() + wrapper.findComponent(DataTable).vm.$emit('update:page', 2) + await flushPromises() + expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 2, size: 20 })) + }) + + it('keeps the operation column first and follows the prototype field order with backend-backed columns', () => { + const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrdersView.vue'), 'utf8') + const toolbarSource = readFileSync(resolve(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8') + const columnSource = source.slice(source.indexOf('const columns:')) + const operation = columnSource.indexOf("key: 'actions'") + const inboundCode = columnSource.indexOf("key: 'inboundCode'") + const orderStatus = columnSource.indexOf("key: 'orderStatus'") + const measureReview = columnSource.indexOf("key: 'measureReviewStatus'") + const feeStatus = columnSource.indexOf("key: 'feeStatus'") + const customer = columnSource.indexOf("key: 'customerName'") + const warehouse = columnSource.indexOf("key: 'warehouseName'") + expect(operation).toBeGreaterThan(-1) + expect(operation).toBeLessThan(inboundCode) + expect(inboundCode).toBeLessThan(orderStatus) + expect(orderStatus).toBeLessThan(measureReview) + expect(measureReview).toBeLessThan(feeStatus) + expect(feeStatus).toBeLessThan(customer) + expect(customer).toBeLessThan(warehouse) + expect(columnSource).toContain("fixed: 'left'") + expect(toolbarSource).toMatch(/@media \(max-width: 720px\)[\s\S]*width: 100%/) + }) +}) diff --git a/src/modules/inbounds/pages/InboundOrdersView.vue b/src/modules/inbounds/pages/InboundOrdersView.vue new file mode 100644 index 00000000..e4cb75a8 --- /dev/null +++ b/src/modules/inbounds/pages/InboundOrdersView.vue @@ -0,0 +1,474 @@ + + + + + diff --git a/src/modules/inbounds/review/MeasurementReviewPanel.test.ts b/src/modules/inbounds/review/MeasurementReviewPanel.test.ts new file mode 100644 index 00000000..27b017af --- /dev/null +++ b/src/modules/inbounds/review/MeasurementReviewPanel.test.ts @@ -0,0 +1,94 @@ +import ElementPlus, { ElMessageBox } from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { useAuthStore } from '@/core/auth/auth' +import DataTable from '@/shared/components/DataTable.vue' +import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory } from './api' +import MeasurementReviewPanel from './MeasurementReviewPanel.vue' + +vi.mock('./api', () => ({ + approveMeasurement: vi.fn(), + getMeasurementReviewDetail: vi.fn(), + listMeasurementReviewHistory: vi.fn(), + rejectMeasurement: vi.fn(), + releaseRejectedMeasurement: vi.fn(), +})) + +const detail = { + inboundId: '90001', inboundCode: 'RK90001', companyName: '柏悦客户', channelCode: 'SEA', channelName: '海运', + processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: 'STANDARD', reviewReason: null, reviewerId: null, reviewerName: null, + measurementId: '70001', sourceType: 'DEVICE', measurementStatus: 'SUBMITTED', version: 4, + originalDocumentNo: 'ORD-001', originalGrossWeightKg: 100, originalVolumeCbm: 2, + actualPackageCount: 10, actualGrossWeightKg: 110, actualVolumeCbm: 2.2, volumeWeightKg: 367.4, actualGoodsName: '测试货物', + items: [{ id: '1', itemNo: '001', boxSpecification: '60×40×30', packageCount: 10, lengthCm: 60, widthCm: 40, heightCm: 30, girthCm: 240, unitWeightKg: 11, unitVolumeCbm: 0.072, totalWeightKg: 110, totalVolumeCbm: 0.72, volumeWeightKg: 120.24, intelligentImageFileId: '30001', overLimitWarning: '周长超过渠道限制,请人工核验尺寸后再作出审核决定', outerLengthCm: 61, outerWidthCm: 41, outerHeightCm: 31, remark: '外箱存在轻微变形' }], + weightResults: [{ id: '2', fileId: 'file-1', ocrProvider: 'OCR', ocrRawText: '设备屏幕原始识别内容 110.5 KG,包含一段需要自动换行展示的较长文本', ocrWeightValue: 110.5, ocrWeightUnit: 'KG', confidence: 0.98, adjustedWeightValue: 110, adjustedWeightUnit: 'KG', finalWeightKg: 110, recognitionStatus: 'CONFIRMED', adjustmentReason: '去除托盘重量后采用净值', adjustedBy: '20001', adjustedAt: '2026-08-04T09:58:00', confirmedBy: '20002', confirmedAt: '2026-08-04T10:00:00', version: 1 }], +} + +enableAutoUnmount(afterEach) + +describe('MeasurementReviewPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + const auth = useAuthStore() + auth.token = 'token' + auth.permissions = ['firstleg:inbound:measurement-review', 'firstleg:inbound:measurement-review-history', 'firstleg:inbound:exception-release'] + useAccessPreviewStore().initializeAfterLogin() + vi.mocked(getMeasurementReviewDetail).mockResolvedValue(detail) + vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + }) + + it('loads and compares original, device and OCR data without exposing a normal fee action', async () => { + const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + + expect(wrapper.text()).toContain('原单申报') + expect(wrapper.text()).toContain('设备实测') + expect(wrapper.text()).toContain('称重 OCR') + expect(wrapper.text()).toContain('外箱长 × 宽 × 高(cm)') + expect(wrapper.text()).toContain('周长超过渠道限制') + expect(wrapper.text()).toContain('OCR 原文:设备屏幕原始识别内容') + expect(wrapper.text()).toContain('调整原因:去除托盘重量后采用净值') + expect(wrapper.findComponent(DataTable).exists()).toBe(true) + expect(wrapper.text()).not.toContain('租户') + expect(wrapper.text()).toContain('通过并完成入库') + expect(wrapper.text()).not.toContain('确认费用') + expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001') + }) + + it('shows the rejected release action only when the permission and rejected state exist', async () => { + vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '重量图片不清晰' }) + const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(wrapper.text()).toContain('异常放行') + expect(wrapper.text()).toContain('重量图片不清晰') + + const auth = useAuthStore() + auth.permissions = ['firstleg:inbound:measurement-review-history'] + useAccessPreviewStore().initializeAfterLogin() + await wrapper.vm.$nextTick() + expect(wrapper.findAll('button').some((button) => button.text().includes('异常放行'))).toBe(false) + }) + + it('emits changed after a successful review write', async () => { + vi.mocked(approveMeasurement).mockResolvedValue({ feeId: '1', feeNo: 'FEE-1', feeVersion: 1, feeStatus: 'SENT', inboundId: '90001', totalAmount: 100, currency: 'CNY', nextProcessStatus: 'INBOUNDED' }) + vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) + const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) + await flushPromises() + const buttons = wrapper.findAll('button') + const approve = buttons.find((button) => button.text().includes('通过并完成入库')) + expect(approve).toBeDefined() + await approve?.trigger('click') + await flushPromises() + + expect(ElMessageBox.confirm).toHaveBeenCalledWith( + expect.stringContaining('自动试算并发布费用'), + '确认测量审核通过', + expect.objectContaining({ confirmButtonText: '通过并完成入库' }), + ) + expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 4 }) + expect(wrapper.emitted('changed')).toHaveLength(1) + }) +}) diff --git a/src/modules/inbounds/review/MeasurementReviewPanel.vue b/src/modules/inbounds/review/MeasurementReviewPanel.vue new file mode 100644 index 00000000..669faac8 --- /dev/null +++ b/src/modules/inbounds/review/MeasurementReviewPanel.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/src/modules/inbounds/review/api.test.ts b/src/modules/inbounds/review/api.test.ts new file mode 100644 index 00000000..cd0e8eb6 --- /dev/null +++ b/src/modules/inbounds/review/api.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { + approveMeasurement, + getMeasurementReviewDetail, + listMeasurementReviewHistory, + rejectMeasurement, + releaseRejectedMeasurement, +} from './api' + +vi.mock('@/shared/api/http', () => ({ + http: { get: vi.fn(), post: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +describe('measurement review API', () => { + beforeEach(() => { + vi.mocked(http.get).mockReset() + vi.mocked(http.post).mockReset() + }) + + it('keeps the measurement detail and review history prefixes from the backend controllers', async () => { + vi.mocked(http.get) + .mockResolvedValueOnce({ code: '200', message: 'success', data: { measurementId: '7' } }) + .mockResolvedValueOnce({ code: '200', message: 'success', data: { records: [], page: '1', size: '20', total: '0', pages: '0' } }) + + await getMeasurementReviewDetail('10001', '90001') + await listMeasurementReviewHistory('10001', '90001') + + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/inbounds/90001/measurement', { params: { tenantId: '10001' } }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/90001/measurement-review-history', { params: { tenantId: '10001', page: 1, size: 20 } }) + }) + + it('normalizes string pagination and sends idempotency headers for review writes', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '3', pages: '1' } }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null }) + + await expect(listMeasurementReviewHistory('10001', '90001', 2, 20)).resolves.toMatchObject({ page: 2, total: 3 }) + await approveMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 4 }, 'approve-key') + await rejectMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 4, reason: '箱规照片不清晰' }, 'reject-key') + await releaseRejectedMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 5, reason: '客户确认后放行' }, 'release-key') + + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/inbounds/90001/measurements/approve', { measurementId: '7', expectedVersion: 4 }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'approve-key' } }) + expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/90001/measurements/reject', { measurementId: '7', expectedVersion: 4, reason: '箱规照片不清晰' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'reject-key' } }) + expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/inbounds/90001/measurements/exception-release', { measurementId: '7', expectedVersion: 5, reason: '客户确认后放行' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'release-key' } }) + }) +}) diff --git a/src/modules/inbounds/review/api.ts b/src/modules/inbounds/review/api.ts new file mode 100644 index 00000000..c9180b15 --- /dev/null +++ b/src/modules/inbounds/review/api.ts @@ -0,0 +1,100 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { + ExceptionReleaseRequest, + FeeSnapshotAfterApprove, + MeasurementReviewDetail, + MeasurementReviewHistoryPage, + MeasurementReviewHistoryPayload, + MeasurementReviewRequest, +} from './types' + +async function getData(path: string, params: Record) { + const response = await http.get, ApiEnvelope>(path, { params }) + return unwrapApiResponse(response) +} + +function idempotencyKey() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID() + return `review-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +async function postReview(path: string, tenantId: string, body: unknown, key = idempotencyKey()) { + const response = await http.post, ApiEnvelope>(path, body, { + params: { tenantId }, + headers: { 'Idempotency-Key': key }, + }) + return unwrapApiResponse(response) +} + +function paginationNumber(value: number | string, fallback: number) { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : fallback +} + +export function getMeasurementReviewDetail(tenantId: string, inboundId: string) { + return getData( + `/platform/v1/first-leg/inbounds/${inboundId}/measurement`, + { tenantId }, + ) +} + +export async function listMeasurementReviewHistory( + tenantId: string, + inboundId: string, + page = 1, + size = 20, +) { + const result = await getData( + `/platform/v1/inbounds/${inboundId}/measurement-review-history`, + { tenantId, page, size }, + ) + return { + ...result, + page: paginationNumber(result.page, page), + size: paginationNumber(result.size, size), + total: paginationNumber(result.total, 0), + pages: paginationNumber(result.pages, 0), + } satisfies MeasurementReviewHistoryPage +} + +export function approveMeasurement( + tenantId: string, + inboundId: string, + request: MeasurementReviewRequest, + key?: string, +) { + return postReview( + `/platform/v1/inbounds/${inboundId}/measurements/approve`, + tenantId, + request, + key, + ) +} + +export function rejectMeasurement( + tenantId: string, + inboundId: string, + request: MeasurementReviewRequest, + key?: string, +) { + return postReview( + `/platform/v1/inbounds/${inboundId}/measurements/reject`, + tenantId, + request, + key, + ) +} + +export function releaseRejectedMeasurement( + tenantId: string, + inboundId: string, + request: ExceptionReleaseRequest, + key?: string, +) { + return postReview( + `/platform/v1/inbounds/${inboundId}/measurements/exception-release`, + tenantId, + request, + key, + ) +} diff --git a/src/modules/inbounds/review/index.ts b/src/modules/inbounds/review/index.ts new file mode 100644 index 00000000..369df2dd --- /dev/null +++ b/src/modules/inbounds/review/index.ts @@ -0,0 +1,3 @@ +export { default as MeasurementReviewPanel } from './MeasurementReviewPanel.vue' +export * from './api' +export type * from './types' diff --git a/src/modules/inbounds/review/types.ts b/src/modules/inbounds/review/types.ts new file mode 100644 index 00000000..766e0dc2 --- /dev/null +++ b/src/modules/inbounds/review/types.ts @@ -0,0 +1,123 @@ +export type MeasurementReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | string + +export interface MeasurementReviewItem { + id: string + itemNo: string + boxSpecification: string | null + packageCount: number | null + lengthCm: number | null + widthCm: number | null + heightCm: number | null + girthCm: number | null + unitWeightKg: number | null + unitVolumeCbm: number | null + totalWeightKg: number | null + totalVolumeCbm: number | null + volumeWeightKg: number | null + intelligentImageFileId: string | null + overLimitWarning: string | null + outerLengthCm: number | null + outerWidthCm: number | null + outerHeightCm: number | null + remark: string | null +} + +export interface MeasurementReviewWeightResult { + id: string + fileId: string | null + ocrProvider: string | null + ocrRawText: string | null + ocrWeightValue: number | null + ocrWeightUnit: string | null + confidence: number | null + adjustedWeightValue: number | null + adjustedWeightUnit: string | null + finalWeightKg: number | null + recognitionStatus: string | null + adjustmentReason: string | null + adjustedBy: string | null + adjustedAt: string | null + confirmedBy: string | null + confirmedAt: string | null + version: number +} + +export interface MeasurementReviewDetail { + inboundId: string + inboundCode: string | null + companyName: string | null + channelCode: string | null + channelName: string | null + processStatus: string | null + measureReviewStatus: MeasurementReviewStatus | null + recommendedPalletType: string | null + reviewReason: string | null + reviewerId: string | null + reviewerName: string | null + measurementId: string + sourceType: string | null + measurementStatus: string | null + version: number + originalDocumentNo: string | null + originalGrossWeightKg: number | null + originalVolumeCbm: number | null + actualPackageCount: number | null + actualGrossWeightKg: number | null + actualVolumeCbm: number | null + volumeWeightKg: number | null + actualGoodsName: string | null + items: MeasurementReviewItem[] + weightResults: MeasurementReviewWeightResult[] +} + +export interface MeasurementReviewRecord { + id: string + tenantId: string + inboundId: string + measurementId: string + reviewResult: string | null + reviewReason: string | null + exceptionRelease: boolean + releaseReason: string | null + allowRejectedConfig: boolean + reviewerName: string | null + reviewedAt: string | null +} + +export interface MeasurementReviewHistoryPage { + records: MeasurementReviewRecord[] + page: number + size: number + total: number + pages: number +} + +export interface MeasurementReviewHistoryPayload extends Omit { + page: number | string + size: number | string + total: number | string + pages: number | string +} + +export interface MeasurementReviewRequest { + measurementId: string + expectedVersion: number + reason?: string +} + +export interface ExceptionReleaseRequest { + measurementId: string + expectedVersion: number + reason: string +} + +export interface FeeSnapshotAfterApprove { + feeId: string | null + feeNo: string | null + feeVersion: number | null + feeStatus: string | null + inboundId: string | null + totalAmount: number | null + currency: string | null + nextProcessStatus: string | null +} diff --git a/src/modules/inbounds/routes.ts b/src/modules/inbounds/routes.ts new file mode 100644 index 00000000..bf75c74b --- /dev/null +++ b/src/modules/inbounds/routes.ts @@ -0,0 +1,24 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const inboundRoutes = [ + { + path: 'inbounds', + name: 'inbound-orders', + component: () => import('./pages/InboundOrdersView.vue'), + meta: { + title: '入库单', + description: '查询头程入库单并跟进测量、审核、费用和入库进度', + permission: 'platform:firstleg:inbound:list', + }, + }, + { + path: 'inbounds/:inboundId', + name: 'inbound-order-detail', + component: () => import('./pages/InboundOrderDetailView.vue'), + meta: { + title: '入库作业详情', + description: '处理测量、称重、审核并查看费用和托盘库位结果', + permission: 'platform:firstleg:inbound:view', + }, + }, +] satisfies RouteRecordRaw[] diff --git a/src/modules/inbounds/weight/api/weightOcr.test.ts b/src/modules/inbounds/weight/api/weightOcr.test.ts new file mode 100644 index 00000000..b8381ca9 --- /dev/null +++ b/src/modules/inbounds/weight/api/weightOcr.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { + confirmWeightResult, + correctWeightResult, + createWeightResult, + downloadWeightImage, + getWeightWorkspace, + retryWeightRecognition, + uploadWeightImage, +} from './weightOcr' + +vi.mock('@/shared/api/http', () => ({ + http: { get: vi.fn(), post: vi.fn(), put: vi.fn() }, + ApiResponseError: class ApiResponseError extends Error {}, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +describe('inbound weight OCR API', () => { + beforeEach(() => { + vi.mocked(http.get).mockReset() + vi.mocked(http.post).mockReset() + vi.mocked(http.put).mockReset() + }) + + it('loads the measurement workspace using the real firstleg path', async () => { + const detail = { inboundId: '9007199254740993', weightResults: [] } + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail }) + + await expect(getWeightWorkspace('10001', '9007199254740993')).resolves.toEqual(detail) + expect(http.get).toHaveBeenCalledWith( + '/platform/v1/first-leg/inbounds/9007199254740993/measurement', + { params: { tenantId: '10001' } }, + ) + }) + + it('uploads and retries against the file service without converting long IDs', async () => { + const result = { fileId: '9007199254740995', recognitionStatus: 'SUCCESS' } + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: result }) + const file = new File(['image'], 'weight.png', { type: 'image/png' }) + + await expect(uploadWeightImage('10001', '9007199254740993', file)).resolves.toEqual(result) + const [uploadPath, body] = vi.mocked(http.post).mock.calls[0]! + expect(uploadPath).toBe('/platform/v1/files/measurements/10001/9007199254740993') + expect(body).toBeInstanceOf(FormData) + expect((body as FormData).get('file')).toBe(file) + + await retryWeightRecognition('10001', '9007199254740993', '9007199254740995') + expect(http.post).toHaveBeenLastCalledWith( + '/platform/v1/files/measurements/10001/9007199254740993/9007199254740995/recognize', + ) + }) + + it('registers, corrects, and confirms a weight result using the implemented contracts', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1' } }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', version: 1 } }) + + const createPayload = { + fileId: '9007199254740995', + ocrProvider: 'FILE_WEIGHT_OCR', + ocrRawText: null, + ocrWeightValue: 12.5, + ocrWeightUnit: 'KG', + confidence: 0.98, + } + await createWeightResult('10001', '20001', createPayload) + expect(http.post).toHaveBeenNthCalledWith( + 1, + '/platform/v1/first-leg/inbounds/20001/weight-results', + createPayload, + { params: { tenantId: '10001' } }, + ) + + const correction = { version: 0, adjustedWeightValue: 12.8, adjustedWeightUnit: 'KG' as const, adjustmentReason: '读数模糊' } + await correctWeightResult('10001', '20001', '30001', correction) + expect(http.put).toHaveBeenCalledWith( + '/platform/v1/first-leg/inbounds/20001/weight-results/30001', + correction, + { params: { tenantId: '10001' } }, + ) + + await confirmWeightResult('10001', '20001', '30001', { version: 1 }) + expect(http.post).toHaveBeenLastCalledWith( + '/platform/v1/first-leg/inbounds/20001/weight-results/30001/confirm', + { version: 1 }, + { params: { tenantId: '10001' } }, + ) + }) + + it('downloads an authenticated image as a blob', async () => { + const image = new Blob(['png'], { type: 'image/png' }) + vi.mocked(http.get).mockResolvedValue(image) + await expect(downloadWeightImage('10001', '20001', '40001')).resolves.toBe(image) + expect(http.get).toHaveBeenCalledWith( + '/platform/v1/files/measurements/10001/20001/40001', + { responseType: 'blob' }, + ) + }) +}) + diff --git a/src/modules/inbounds/weight/api/weightOcr.ts b/src/modules/inbounds/weight/api/weightOcr.ts new file mode 100644 index 00000000..07c54ec0 --- /dev/null +++ b/src/modules/inbounds/weight/api/weightOcr.ts @@ -0,0 +1,123 @@ +import axios from 'axios' +import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { + ConfirmWeightResultRequest, + CorrectWeightResultRequest, + CreateWeightResultRequest, + WeightImageUploadResult, + WeightResultResource, + WeightWorkspaceResource, +} from '../types' + +const measurementBasePath = '/platform/v1/first-leg/inbounds' +const imageBasePath = '/platform/v1/files/measurements' +const maxErrorEnvelopeBytes = 1024 * 1024 + +function segment(value: string) { + return encodeURIComponent(value) +} + +async function parseBlobError(blob: Blob, status?: number) { + if (!blob.type.toLowerCase().includes('json')) return null + if (blob.size > maxErrorEnvelopeBytes) { + return new ApiResponseError('图片接口返回了超出限制的 JSON 响应', 'INVALID_IMAGE_RESPONSE', status) + } + try { + const payload = JSON.parse(await blob.text()) as Partial> + return new ApiResponseError( + payload.code === '200' ? '图片接口未返回图片内容' : payload.message || '称重图片读取失败', + payload.code === '200' ? 'INVALID_IMAGE_RESPONSE' : payload.code || (status ? `HTTP_${status}` : 'IMAGE_FAILED'), + status, + payload.data, + payload.requestId, + payload.traceId, + ) + } catch { + return new ApiResponseError('图片接口返回了无法识别的响应', 'INVALID_IMAGE_RESPONSE', status) + } +} + +export async function getWeightWorkspace(tenantId: string, inboundId: string) { + const response = await http.get, ApiEnvelope>( + `${measurementBasePath}/${segment(inboundId)}/measurement`, + { params: { tenantId } }, + ) + return unwrapApiResponse(response) +} + +export async function uploadWeightImage(tenantId: string, inboundId: string, file: File) { + const body = new FormData() + body.append('file', file) + const response = await http.post, ApiEnvelope>( + `${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}`, + body, + ) + return unwrapApiResponse(response) +} + +export async function retryWeightRecognition(tenantId: string, inboundId: string, fileId: string) { + const response = await http.post, ApiEnvelope>( + `${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}/${segment(fileId)}/recognize`, + ) + return unwrapApiResponse(response) +} + +export async function downloadWeightImage(tenantId: string, inboundId: string, fileId: string) { + try { + const blob = await http.get( + `${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}/${segment(fileId)}`, + { responseType: 'blob' }, + ) + const responseError = await parseBlobError(blob, 200) + if (responseError) throw responseError + return blob + } catch (error) { + if (axios.isAxiosError(error) && error.response?.data instanceof Blob) { + const responseError = await parseBlobError(error.response.data, error.response.status) + if (responseError) throw responseError + } + throw error + } +} + +export async function createWeightResult( + tenantId: string, + inboundId: string, + payload: CreateWeightResultRequest, +) { + const response = await http.post, ApiEnvelope>( + `${measurementBasePath}/${segment(inboundId)}/weight-results`, + payload, + { params: { tenantId } }, + ) + return unwrapApiResponse(response) +} + +export async function correctWeightResult( + tenantId: string, + inboundId: string, + resultId: string, + payload: CorrectWeightResultRequest, +) { + const response = await http.put, ApiEnvelope>( + `${measurementBasePath}/${segment(inboundId)}/weight-results/${segment(resultId)}`, + payload, + { params: { tenantId } }, + ) + return unwrapApiResponse(response) +} + +export async function confirmWeightResult( + tenantId: string, + inboundId: string, + resultId: string, + payload: ConfirmWeightResultRequest, +) { + const response = await http.post, ApiEnvelope>( + `${measurementBasePath}/${segment(inboundId)}/weight-results/${segment(resultId)}/confirm`, + payload, + { params: { tenantId } }, + ) + return unwrapApiResponse(response) +} + diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts b/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts new file mode 100644 index 00000000..3bb3b263 --- /dev/null +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/weight/components/WeightOcrPanel.vue'), 'utf8') + +describe('WeightOcrPanel contract', () => { + it('uses customer language at the page boundary while retaining a compatibility input', () => { + expect(source).toContain("defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()") + expect(source).toContain("const customerId = computed(() => props.customerId || props.tenantId || '')") + expect(source).toContain("defineEmits<{ changed: [] }>()") + expect(source).toContain("defineExpose({ reload: loadWorkspace })") + const template = source.match(/