diff --git a/AGENTS.md b/AGENTS.md index ff5d0315..2d807981 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ | 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖 | | 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 | | 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` | -| 财务与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;柜级公共费用按分摊组统一处理 | +| 财务与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;柜费入口 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | | 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 创建柜次时自动生成唯一预装计划,加入入仓码即同步进入该计划,页面不提供创建计划或推送预装操作。预装详情先调用 `/loading-plans/{planId}/lifecycle`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关按 `loadingPlanId` 展示、逐资料完成,全买单柜使用 `complete-all-buy`;后端为 `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `file/` | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 | diff --git a/src/modules/container-loading/components/SmartLoadingDialog.test.ts b/src/modules/container-loading/components/SmartLoadingDialog.test.ts new file mode 100644 index 00000000..9090f471 --- /dev/null +++ b/src/modules/container-loading/components/SmartLoadingDialog.test.ts @@ -0,0 +1,30 @@ +import ElementPlus from 'element-plus' +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it } from 'vitest' +import SmartLoadingDialog from './SmartLoadingDialog.vue' + +describe('SmartLoadingDialog', () => { + afterEach(() => { document.body.innerHTML = '' }) + + it('expands to the viewport and can return to the normal dialog size', async () => { + const wrapper = mount(SmartLoadingDialog, { + attachTo: document.body, + props: { modelValue: true }, + global: { + plugins: [ElementPlus], + stubs: { GlobalSmartLoadingView: { template: '
智能装柜工作台
' } }, + }, + }) + await flushPromises() + + expect(wrapper.findAll('.smart-loading-dialog__warehouses [role="tab"]').map((tab) => tab.text())).toEqual(['义乌仓', '东莞仓']) + const expand = wrapper.get('button[aria-label="展开全屏"]') + await expand.trigger('click') + expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull() + expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true) + + await wrapper.get('button[aria-label="退出全屏"]').trigger('click') + expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).toBeNull() + expect(wrapper.find('button[aria-label="展开全屏"]').exists()).toBe(true) + }) +}) diff --git a/src/modules/container-loading/components/SmartLoadingDialog.vue b/src/modules/container-loading/components/SmartLoadingDialog.vue index 9542f7fb..eca851e0 100644 --- a/src/modules/container-loading/components/SmartLoadingDialog.vue +++ b/src/modules/container-loading/components/SmartLoadingDialog.vue @@ -1,8 +1,25 @@ diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index 1007ec75..fd7bc406 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -168,6 +168,13 @@ describe('CustomersView', () => { vi.mocked(showExportTaskCreatedDialog).mockResolvedValue(false) }) + it('waits for the client workspace to confirm impersonation session persistence', () => { + expect(customersSource).toContain("searchParams.set('impersonationOrigin', window.location.origin)") + expect(customersSource).toContain("event.data?.type !== 'impersonation-complete'") + expect(customersSource).toContain("event.data?.type === 'impersonation-failed'") + expect(customersSource).toContain("ElMessage.success('客户工作台登录成功')") + }) + it('searches the customer list by customer name only', () => { expect(customersSource).toContain("label: '客户名称', placeholder: '请输入客户名称'") expect(customersSource).not.toContain('公司名称、客户编号或联系人') diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index b10b2bcf..0fa5c493 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -777,12 +777,19 @@ async function submitImpersonation() { impersonationError.value = getApiErrorDetails(new Error('未配置客户工作台地址'), '暂未配置客户工作台地址,无法安全跳转登录') return } - let targetOrigin: string - try { targetOrigin = new URL(clientUrl, window.location.origin).origin } catch { + let targetUrl: URL + try { + targetUrl = new URL(clientUrl, window.location.origin) + targetUrl.pathname = `${targetUrl.pathname.replace(/\/+$/, '')}/login` + targetUrl.search = '' + targetUrl.hash = '' + targetUrl.searchParams.set('impersonationOrigin', window.location.origin) + } catch { impersonationError.value = getApiErrorDetails(new Error('客户工作台地址无效'), '客户工作台地址配置无效,无法安全跳转登录') return } - const popup = window.open(clientUrl, '_blank') + const targetOrigin = targetUrl.origin + const popup = window.open(targetUrl.toString(), '_blank') if (!popup) { impersonationError.value = getApiErrorDetails(new Error('浏览器拦截了新窗口'), '浏览器拦截了客户工作台窗口,请允许弹窗后重试') return @@ -792,27 +799,39 @@ async function submitImpersonation() { let handoffPending = false try { const ticket = await createPlatformTenantImpersonation(impersonationTenant.value.id, { reason: impersonationForm.reason.trim() }) - const onReady = (event: MessageEvent) => { - if (event.source !== popup || event.origin !== targetOrigin || event.data?.source !== 'chongchong-client' || event.data?.type !== 'impersonation-ready') return - popup.postMessage({ source: 'chongchong-admin', type: 'impersonation-ticket', code: ticket.code }, targetOrigin) + let ticketSent = false + const onMessage = (event: MessageEvent) => { + if (event.source !== popup || event.origin !== targetOrigin || event.data?.source !== 'chongchong-client') return + if (event.data?.type === 'impersonation-ready' && !ticketSent) { + ticketSent = true + popup.postMessage({ source: 'chongchong-admin', type: 'impersonation-ticket', code: ticket.code }, targetOrigin) + return + } + if (event.data?.type === 'impersonation-failed') { + cleanup() + impersonationSubmitting.value = false + impersonationError.value = getApiErrorDetails(new Error('客户工作台未写入代登录会话'), '客户工作台登录写入失败,请关闭窗口后重试') + return + } + if (event.data?.type !== 'impersonation-complete') return cleanup() impersonationSubmitting.value = false - ElMessage.success('客户工作台已打开') + ElMessage.success('客户工作台登录成功') impersonationDialogOpen.value = false } const timeout = window.setTimeout(() => { cleanup() impersonationSubmitting.value = false - impersonationError.value = getApiErrorDetails(new Error('客户工作台未完成安全握手'), '客户工作台未完成安全握手,请关闭窗口后重试') - }, 10000) + impersonationError.value = getApiErrorDetails(new Error('客户工作台未确认会话写入'), '客户工作台未确认登录成功,请关闭窗口后重试') + }, 15000) const cleanup = () => { window.clearTimeout(timeout) - window.removeEventListener('message', onReady) + window.removeEventListener('message', onMessage) if (impersonationCleanup === cleanup) impersonationCleanup = null } impersonationCleanup?.() impersonationCleanup = cleanup - window.addEventListener('message', onReady) + window.addEventListener('message', onMessage) handoffPending = true } catch (error) { popup.close() diff --git a/src/modules/dimension-config/api.ts b/src/modules/dimension-config/api.ts index 45d2e42e..9a5f2cd8 100644 --- a/src/modules/dimension-config/api.ts +++ b/src/modules/dimension-config/api.ts @@ -1,6 +1,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import type { CustomerDimensionConfig, + CustomerDimensionCalculationMode, CustomerDimensionRule, UpdateCustomerDimensionConfigRequest, } from './types' @@ -23,6 +24,7 @@ function normalizeRule(rule: RawRule): CustomerDimensionRule { function normalizeConfig(config: RawConfig): CustomerDimensionConfig { return { tenantId: String(config.tenantId), + defaultCalculationMode: config.defaultCalculationMode === 'MULTIPLY' ? 'MULTIPLY' : 'ADD', rules: config.rules.map(normalizeRule), } } @@ -43,3 +45,15 @@ export async function updateCustomerDimensionConfig( }) return normalizeConfig(unwrapApiResponse(response)) } + +export async function updateCustomerDimensionDefaultCalculationMode( + tenantId: string, + defaultCalculationMode: CustomerDimensionCalculationMode, +): Promise { + const response = await http.put>( + `${dimensionConfigPath}/default-calculation-mode`, + { defaultCalculationMode }, + { params: { tenantId } }, + ) + return normalizeConfig(unwrapApiResponse(response)) +} diff --git a/src/modules/dimension-config/index.ts b/src/modules/dimension-config/index.ts index 607ecb99..69235887 100644 --- a/src/modules/dimension-config/index.ts +++ b/src/modules/dimension-config/index.ts @@ -1,10 +1,12 @@ export { getCustomerDimensionConfig, updateCustomerDimensionConfig, + updateCustomerDimensionDefaultCalculationMode, } from './api' export type { CustomerDimensionBoxType, + CustomerDimensionCalculationMode, CustomerDimensionConfig, CustomerDimensionConfigFormModel, CustomerDimensionRule, diff --git a/src/modules/dimension-config/types.ts b/src/modules/dimension-config/types.ts index ce3dbe08..df5e81af 100644 --- a/src/modules/dimension-config/types.ts +++ b/src/modules/dimension-config/types.ts @@ -1,4 +1,5 @@ export type CustomerDimensionBoxType = 'SMALL' | 'MEDIUM' | 'LARGE' | 'EXTRA_LARGE' +export type CustomerDimensionCalculationMode = 'ADD' | 'MULTIPLY' export interface CustomerDimensionRule { id: string @@ -20,6 +21,7 @@ export interface CustomerDimensionRule { export interface CustomerDimensionConfig { tenantId: string + defaultCalculationMode: CustomerDimensionCalculationMode rules: CustomerDimensionRule[] } diff --git a/src/modules/downloads/api/platformExportTasks.ts b/src/modules/downloads/api/platformExportTasks.ts index f340038f..c76fa186 100644 --- a/src/modules/downloads/api/platformExportTasks.ts +++ b/src/modules/downloads/api/platformExportTasks.ts @@ -28,6 +28,9 @@ export interface PlatformExportTask { contentType: string | null sizeBytes: number | null failureMessage: string | null + attemptCount: number + maxAttempts: number + nextRetryAt: string | null requestedBy: string requestedUsername: string | null requestedAt: string diff --git a/src/modules/downloads/pages/DownloadCenterView.test.ts b/src/modules/downloads/pages/DownloadCenterView.test.ts index 209075d2..fc5ec843 100644 --- a/src/modules/downloads/pages/DownloadCenterView.test.ts +++ b/src/modules/downloads/pages/DownloadCenterView.test.ts @@ -30,7 +30,8 @@ const task: PlatformExportTask = { id: '9', taskNo: 'EXP9', exportType: 'CUSTOMER_LIST', format: 'XLSX', taskName: '客户列表', status: 'SUCCEEDED', totalRows: 12, processedRows: 12, fileName: '客户列表.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', sizeBytes: 2048, - failureMessage: null, requestedBy: '1', requestedUsername: 'admin', requestedAt: '2026-08-03T10:00:00', + failureMessage: null, attemptCount: 1, maxAttempts: 3, nextRetryAt: null, + requestedBy: '1', requestedUsername: 'admin', requestedAt: '2026-08-03T10:00:00', startedAt: '2026-08-03T10:00:01', completedAt: '2026-08-03T10:00:02', expiresAt: '2026-08-10T10:00:02', } @@ -85,7 +86,7 @@ describe('DownloadCenterView', () => { expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:export') }) - it('shows retry only for failed tasks with the source export permission', async () => { + it('shows retry for failed tasks with the source export permission', async () => { vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [{ ...task, status: 'FAILED', failureMessage: 'IAM timeout' }], page: 1, size: 20, total: 1, pages: 1 }) const pinia = setActivePinia(createPinia()) const auth = useAuthStore() @@ -102,6 +103,27 @@ describe('DownloadCenterView', () => { expect(retryPlatformExportTask).toHaveBeenCalledWith('9') }) + it('shows waiting retry and allows an immediate retry during automatic backoff', async () => { + vi.mocked(listPlatformExportTasks).mockResolvedValue({ + records: [{ ...task, status: 'PENDING', processedRows: 0, failureMessage: '头程服务查询失败', attemptCount: 2, nextRetryAt: '2026-09-03T10:34:28' }], + page: 1, size: 20, total: 1, pages: 1, + }) + const pinia = setActivePinia(createPinia()) + const auth = useAuthStore() + auth.token = 'token' + auth.permissions = ['platform:download:list', 'platform:customer:export'] + useAccessPreviewStore().initializeAfterLogin() + mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + expect(document.body.textContent).toContain('等待重试') + const retry = [...document.querySelectorAll('button')] + .find((item) => item.textContent?.trim() === '立即重试') + retry?.click() + await flushPromises() + expect(retryPlatformExportTask).toHaveBeenCalledWith('9') + }) + it('labels and retries Finance tasks with their exact source export permission', async () => { const financeTask = { ...task, diff --git a/src/modules/downloads/pages/DownloadCenterView.vue b/src/modules/downloads/pages/DownloadCenterView.vue index c5b3badf..2ba8b3b1 100644 --- a/src/modules/downloads/pages/DownloadCenterView.vue +++ b/src/modules/downloads/pages/DownloadCenterView.vue @@ -222,6 +222,13 @@ function statusMeta(status: ExportTaskStatus) { return values[status] ?? { label: status || '未知状态', type: 'info' as const } } +function taskStatusMeta(task: PlatformExportTask) { + if (task.status === 'PENDING' && task.attemptCount > 0) { + return { label: '等待重试', type: 'warning' as const } + } + return statusMeta(task.status) +} + function formatDate(value: string | null) { if (!value) return '—' const date = new Date(value) @@ -237,7 +244,12 @@ function formatSize(bytes: number | null) { function canRetry(task: PlatformExportTask) { const permission = exportTypeMeta[task.exportType]?.retryPermission - return task.status === 'FAILED' && permission !== undefined && access.can(permission) + const retryableStatus = task.status === 'FAILED' || (task.status === 'PENDING' && task.attemptCount > 0) + return retryableStatus && permission !== undefined && access.can(permission) +} + +function retryLabel(task: PlatformExportTask) { + return task.status === 'PENDING' ? '立即重试' : '重试' } watch([page, pageSize], () => { void loadTasks() }) @@ -275,10 +287,10 @@ onBeforeUnmount(() => { - + @@ -309,7 +321,7 @@ onBeforeUnmount(() => {

{{ detailTask.taskName }}

{{ typeLabel(detailTask.exportType) }} · {{ detailTask.format }}

- {{ statusMeta(detailTask.status).label }} + {{ taskStatusMeta(detailTask).label }}
任务编号
{{ detailTask.taskNo }}
@@ -317,6 +329,8 @@ onBeforeUnmount(() => {
申请时间
{{ formatDate(detailTask.requestedAt) }}
开始时间
{{ formatDate(detailTask.startedAt) }}
完成时间
{{ formatDate(detailTask.completedAt) }}
+
执行次数
{{ detailTask.attemptCount }} / {{ detailTask.maxAttempts }}
+
下次重试
{{ detailTask.status === 'PENDING' && detailTask.attemptCount > 0 ? formatDate(detailTask.nextRetryAt) : '—' }}
过期时间
{{ formatDate(detailTask.expiresAt) }}
处理数据
{{ detailTask.processedRows }} 行
文件大小
{{ formatSize(detailTask.sizeBytes) || '—' }}
@@ -330,6 +344,11 @@ onBeforeUnmount(() => { - + + + + + + + + + + + + + @@ -626,6 +735,18 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may +
+

仓库建议数据

+ + + + + + + + + +
@@ -639,6 +760,21 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may + + + + + + + +
+
长 × 宽 × 高
{{ formatNumber(suggestionItem.lengthCm) }} × {{ formatNumber(suggestionItem.widthCm) }} × {{ formatNumber(suggestionItem.heightCm) }} cm
+
箱规周长
{{ formatNumber(suggestionGirth) }} cm
+
单箱体积
{{ formatNumber(suggestionItem.serverValues.unitVolumeCbm, 6) }} m³
+
总体积
{{ formatNumber(suggestionTotalVolume, 6) }} m³
+
+
+
@@ -717,6 +853,8 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may .measurement-declaration h4 { margin: 0 0 10px; color: #344054; font-size: 12px; font-weight: 700; } .measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; } .measurement-section__heading > div { min-width: 0; } +.measurement-section-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }.measurement-section-actions :deep(.el-button) { margin-left: 0; } +.warehouse-suggestion-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }.warehouse-suggestion-form :deep(.el-select),.warehouse-suggestion-form :deep(.el-input-number) { width: 100%; }.warehouse-suggestion-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }.suggestion-preview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 4px 0 0; border: 1px solid var(--cc-border); border-radius: 6px; }.suggestion-preview div { padding: 12px; border-right: 1px solid var(--cc-border); }.suggestion-preview div:last-child { border-right: 0; }.suggestion-preview dt { color: var(--cc-muted); font-size: 11px; }.suggestion-preview dd { margin: 5px 0 0; color: var(--cc-text); font-size: 13px; font-weight: 650; } .measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; } .measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; } .measurement-form label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; } @@ -738,6 +876,7 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may .measurement-table :deep(.el-table__cell) { padding: 7px 0; } .measurement-table :deep(.cell) { line-height: 1.35; } .measurement-table :deep(.measurement-table__row--error > td.el-table__cell) { background: var(--el-color-danger-light-9); } +.warehouse-suggestion-table { display: grid; gap: 10px; margin-top: 18px; }.warehouse-suggestion-table h3 { margin: 0; color: var(--cc-text); font-size: 14px; } .measurement-box-code { font-weight: 650; } .measurement-count-editor { width: 104px !important; } .measurement-row-details { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px 20px; margin: 0; padding: 4px 18px 8px 62px; } diff --git a/src/modules/inbounds/measurement/types.ts b/src/modules/inbounds/measurement/types.ts index 4484c21d..389d638a 100644 --- a/src/modules/inbounds/measurement/types.ts +++ b/src/modules/inbounds/measurement/types.ts @@ -13,6 +13,7 @@ export type InboundMeasurementProcessStatus = export interface InboundMeasurementItem { id: MeasurementIdentifier + inboundDetailId?: MeasurementIdentifier boxCode: string | null itemNo: string boxSpecification: string @@ -33,6 +34,8 @@ export interface InboundMeasurementItem { outerLengthCm: number | null outerWidthCm: number | null outerHeightCm: number | null + chargeRuleId?: MeasurementIdentifier | null + coefficientMode?: 'ADD' | 'MULTIPLY' | null remark: string | null } @@ -93,21 +96,24 @@ export interface InboundMeasurementDetail { } export interface SaveInboundMeasurementItemRequest { - boxCode: string | null + inboundDetailId?: MeasurementIdentifier + boxCode?: string | null itemNo: string boxSpecification: string packageCount: number lengthCm: number widthCm: number heightCm: number - unitWeightKg: number + unitWeightKg?: number intelligentImageFileId: MeasurementIdentifier | null - machineNo: string | null - measuredAt: string | null + machineNo?: string | null + measuredAt?: string | null overLimitWarning: string | null outerLengthCm: number | null outerWidthCm: number | null outerHeightCm: number | null + chargeRuleId?: MeasurementIdentifier | null + coefficientMode?: 'ADD' | 'MULTIPLY' | null remark: string | null } diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index 32f62461..c1c6bfb7 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -297,6 +297,14 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain('') expect(source).toContain("{ key: 'girthCm', label: '箱规周长(cm)' }") + expect(source).toContain('label="#" prop="itemNo"') + expect(source).toContain("const actualMeasuredColumns = measuredColumns.filter((column) => column.key !== 'itemNo')") + expect(source).toContain('v-for="column in actualMeasuredColumns"') + expect(source).toContain('仓库修改外箱(长)') + expect(source).toContain('仓库修改外箱(宽)') + expect(source).toContain('仓库修改外箱(高)') + expect(source).toContain('saveWarehouseOuterDimensions') + expect(source).toContain('相关数值已重新计算') expect(source).toContain('runMoreAction($event, row as UnifiedOrderResource)') expect(source).toContain(':aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"') expect(source).toContain(':title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"') @@ -313,8 +321,37 @@ describe('UnifiedOrdersView contract', () => { expect(source).not.toContain('formatNumber(2 * (width + height))') }) + it('shows the matched dimension addition and enabled multiplier in calculation choices', () => { + expect(source).toContain('getCustomerDimensionConfig') + expect(source).toContain('matchingDimensionRule') + expect(source).toContain('`原尺寸 + ${values[0]}cm`') + expect(source).toContain('`原尺寸 × ${rule.lengthFactor}`') + expect(source).toContain('label="箱规尺寸" value="ADD"') + expect(source).toContain('label="箱规系数" value="MULTIPLY"') + }) + + it('keeps the three section calculation modes independent and recalculates their display rows', () => { + expect(source).toContain("measurementSectionModes = reactive>") + expect(source).toContain(':model-value="measurementSectionModes.customer"') + expect(source).toContain(':model-value="measurementSectionModes.outer"') + expect(source).toContain(':model-value="measurementSectionModes.actual"') + expect(source).toContain("updateMeasurementSectionMode('customer', $event)") + expect(source).toContain("updateMeasurementSectionMode('outer', $event)") + expect(source).toContain("updateMeasurementSectionMode('actual', $event)") + expect(source).toContain('calculatedMeasurementRow(row, measurementSectionModes.customer, true)') + expect(source).toContain('}, measurementSectionModes.outer)') + expect(source).toContain('calculatedMeasurementRow(row as unknown as Record, measurementSectionModes.actual)') + expect(source).toContain('rawDimensions.map((value) => value + rule.edgeAdditionCm)') + expect(source).toContain('matchedSpecificationCoefficient(chargeRule, calculationSource)') + expect(source).toContain('listCustomerChargeRules(customerId, tenantId)') + expect(source).toContain('matchedDimensions.map((value) => roundedUp(value * specificationCoefficient, 3))') + expect(source).not.toContain('[chargeRule.lengthFactor, chargeRule.widthFactor, chargeRule.heightFactor]') + expect(source).not.toContain('column.key === \'coefficientMode\'') + expect(source).not.toContain('@change="updateMeasurementCalculationMode(null, $event)"') + }) + it('binds dimension and weight evidence to their formal server records without mixing galleries', () => { - expect(source).toContain(' { it('matches the prototype table grid and distinguishes container identifiers', () => { expect(source).toContain('row-key="orderId" border stripe') - expect(source).toContain(']*border stripe/g)).toHaveLength(2) - expect(source).toContain(' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' -import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Download, Eye, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue' +import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Download, Eye, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, Send, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue' import { useRoute, useRouter } from 'vue-router' import { getPlatformUnifiedOrder, @@ -57,12 +57,20 @@ import { } from '@/modules/unified-orders/status' import { downloadWeightImage, + calculateInboundFee, + confirmInboundFee, + createFeeActionIdempotencyKey, getInboundMeasurement, + saveInboundMeasurementDraft, + getLatestInboundFee, InboundFeePanel, InboundMeasurementDialog, MeasurementEvidenceThumbnail, + sendInboundFee, type InboundMeasurementDetail, } from '@/modules/inbounds' +import { listCustomerChargeRules, type CustomerChargeRule } from '@/modules/fba-pricing' +import { getCustomerDimensionConfig, type CustomerDimensionConfig, type CustomerDimensionRule } from '@/modules/dimension-config' import { OrderBasicInfoIcon, OrderCargoInfoIcon, @@ -151,14 +159,44 @@ const measurementDetail = ref(null) const measurementLoading = ref(false) const measurementError = ref(null) const measurementLoadedKey = ref('') +const measurementChargeRules = ref([]) +const measurementChargeRulesLoading = ref(false) +const measurementChargeRulesLoadedKey = ref('') +const measurementDimensionConfig = ref(null) +const measurementDimensionConfigLoading = ref(false) +const measurementDimensionConfigLoadedKey = ref('') +const measurementStrategySavingId = ref('') +const measurementOuterSavingId = ref('') const measurementPreviewOpen = ref(false) const measurementPreviewLoading = ref(false) const measurementPreviewError = ref(null) const measurementPreviewUrl = ref('') const measurementPreviewTitle = ref('测量图片') const measurementCollapsed = reactive({ customer: false, outer: false, actual: false }) +type MeasurementSection = keyof typeof measurementCollapsed +type MeasurementCalculationMode = 'ACTUAL' | 'ADD' | 'MULTIPLY' +const measurementSectionModes = reactive>({ + customer: 'ACTUAL', outer: 'ACTUAL', actual: 'ACTUAL', +}) const cargoImagePreviewUrls = new Set() const measurementDialogOpen = ref(false) +const openWarehouseSuggestionOnMeasurementLoad = ref(false) +const confirmFeeSending = ref(false) +const feeConfirmOpen = ref(false) +const feeConfirmWeight = ref(null) +const feeConfirmVolume = ref(null) +const feeConfirmRows = computed(() => { + const summary = detail.value?.summary + const measurement = summary?.measurement + const actualWeight = Number(measurement?.actualGrossWeightKg) + const actualVolume = Number(measurement?.actualVolumeCbm) + const customerTotals = measurementTotals(customerMeasurementRows.value) + return [ + { source: '客户下单数据', count: summary?.totalPackageCount, weight: customerTotals.gross, volume: customerTotals.volume ?? summary?.expectedVolumeCbm }, + { source: '外箱规数据', count: measurementTotals(outerMeasurementRows.value).count, weight: measurementTotals(outerMeasurementRows.value).gross, volume: measurementTotals(outerMeasurementRows.value).volume }, + { source: '实际测量数据', count: measurementDetail.value?.actualPackageCount, weight: actualWeight, volume: actualVolume }, + ] +}) const smartLoadingOpen = ref(false) const measurementOrder = ref(null) const loadingAssociation = ref(null) @@ -213,8 +251,140 @@ const customerMeasurementColumns = [ ] const measuredColumns = [ ...customerMeasurementColumns.slice(0, 7), { key: 'girthCm', label: '箱规周长(cm)' }, { key: 'totalWeightKg', label: '总毛重(T.K.GW)' }, - { key: 'totalVolumeCbm', label: '总体积(T.CBM)' }, { key: 'volumeWeightKg', label: '总材积' }, { key: 'remark', label: '备注' }, + { key: 'totalVolumeCbm', label: '总体积(T.CBM)' }, { key: 'volumeWeightKg', label: '总材积' }, + { key: 'remark', label: '备注' }, ] +const outerColumns = measuredColumns +const actualMeasuredColumns = measuredColumns.filter((column) => column.key !== 'itemNo') + +function sortedDimensions(row: Record) { + const values = [Number(row.lengthCm), Number(row.widthCm), Number(row.heightCm)] + return values.every((value) => Number.isFinite(value) && value > 0) + ? values.sort((left, right) => right - left) + : null +} + +function matchingDimensionRule(row: Record): CustomerDimensionRule | null { + const measured = sortedDimensions(row) + if (!measured) return null + return [...(measurementDimensionConfig.value?.rules || [])] + .sort((left, right) => ( + left.innerLengthCm * left.innerWidthCm * left.innerHeightCm + - right.innerLengthCm * right.innerWidthCm * right.innerHeightCm + )) + .find((rule) => { + const inner = [rule.innerLengthCm, rule.innerWidthCm, rule.innerHeightCm].sort((left, right) => right - left) + return measured.every((value, index) => value <= inner[index]) + }) || null +} + +function addCoefficientLabel(row?: Record) { + const rows = row ? [row] : (measurementDetail.value?.items || []) as unknown as Record[] + const values = [...new Set(rows.map((item) => matchingDimensionRule(item)?.edgeAdditionCm).filter((value) => value != null))] + if (values.length === 1) return `原尺寸 + ${values[0]}cm` + if (measurementDimensionConfigLoading.value) return '原尺寸 + 加载中…' + return values.length > 1 ? '原尺寸 + 按箱规匹配' : '原尺寸 + 未匹配箱规' +} + +function multiplyCoefficientLabel() { + const rule = measurementChargeRules.value.find((item) => item.defaultRule && item.status === 'ACTIVE') + if (rule) return `原尺寸 × ${rule.lengthFactor}` + return measurementChargeRulesLoading.value ? '原尺寸 × 加载中…' : '原尺寸 × 未启用系数' +} + +function matchedSpecificationCoefficient(rule: CustomerChargeRule, row: Record) { + const tiers = [...(rule.remark || '').matchAll( + /(SMALL|MEDIUM|LARGE|EXTRA_LARGE):(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+(?:\.\d+)?);系数:(\d+(?:\.\d+)?)/g, + )].map((match) => ({ + dimensions: [Number(match[2]), Number(match[3]), Number(match[4])].sort((left, right) => right - left), + coefficient: Number(match[5]), + })).filter((tier) => tier.dimensions.every((value) => Number.isFinite(value) && value > 0) + && Number.isFinite(tier.coefficient) && tier.coefficient > 0) + .sort((left, right) => left.dimensions.reduce((total, value) => total * value, 1) + - right.dimensions.reduce((total, value) => total * value, 1)) + if (!tiers.length) return Number(rule.lengthFactor || 1) + const measured = [Number(row.lengthCm), Number(row.widthCm), Number(row.heightCm)] + .sort((left, right) => right - left) + const matched = tiers.find((tier) => measured.every((value, index) => value <= tier.dimensions[index])) + return (matched || tiers[tiers.length - 1]).coefficient +} + +const activeMeasurementChargeRule = computed(() => measurementChargeRules.value + .find((rule) => rule.defaultRule && rule.status === 'ACTIVE') || null) +const multiplyModeDisabled = computed(() => !measurementChargeRulesLoading.value && !activeMeasurementChargeRule.value) + +function roundedUp(value: number, digits: number) { + const factor = 10 ** digits + return Math.ceil((value - Number.EPSILON) * factor) / factor +} + +function calculatedMeasurementRow( + source: Record, + mode: MeasurementCalculationMode, + customerSource = false, +) { + const calculationSource = !customerSource && mode !== 'ACTUAL' + ? { + ...source, + lengthCm: source.outerLengthCm ?? source.lengthCm, + widthCm: source.outerWidthCm ?? source.widthCm, + heightCm: source.outerHeightCm ?? source.heightCm, + } + : source + const rule = matchingDimensionRule(calculationSource) + const rawDimensions = [Number(calculationSource.lengthCm), Number(calculationSource.widthCm), Number(calculationSource.heightCm)] + if (!rawDimensions.every((value) => Number.isFinite(value) && value > 0)) return { ...source } + + const matchedDimensions = mode === 'ADD' && rule + ? rawDimensions.map((value) => value + rule.edgeAdditionCm) + : rawDimensions + const chargeRule = activeMeasurementChargeRule.value + const specificationCoefficient = mode === 'MULTIPLY' && chargeRule + ? matchedSpecificationCoefficient(chargeRule, calculationSource) + : 1 + const [lengthCm, widthCm, heightCm] = matchedDimensions.map((value) => roundedUp(value * specificationCoefficient, 3)) + const packageCount = Number(source.packageCount) + const unitVolumeCbm = mode === 'ADD' && rule + ? Math.round((lengthCm * widthCm * heightCm / 1_000_000) * 1000) / 1000 + : roundedUp(lengthCm * widthCm * heightCm / 1_000_000, 4) + const totalVolumeCbm = Number.isFinite(packageCount) ? roundedUp(unitVolumeCbm * packageCount, 4) : null + const sourceUnitWeight = customerSource + ? Number(source.grossWeightKg) / packageCount + : Number(source.unitWeightKg) + const unitWeightKg = Number.isFinite(sourceUnitWeight) ? sourceUnitWeight : null + const totalWeightKg = unitWeightKg != null && Number.isFinite(packageCount) ? unitWeightKg * packageCount : null + return { + ...source, + lengthCm, widthCm, heightCm, unitWeightKg, unitVolumeCbm, totalWeightKg, totalVolumeCbm, + girthCm: roundedUp(lengthCm + 2 * (widthCm + heightCm), 3), + volumeWeightKg: totalVolumeCbm == null ? null : roundedUp(totalVolumeCbm * 167, 3), + remark: customerSource ? source.cargoRemark : source.remark, + } +} + +const customerMeasurementRows = computed(() => (detail.value?.order.cargoes || []).map((row) => ( + calculatedMeasurementRow(row, measurementSectionModes.customer, true) +))) +const outerMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => { + const source = row as unknown as Record + return calculatedMeasurementRow({ + ...source, + lengthCm: source.outerLengthCm ?? source.lengthCm, + widthCm: source.outerWidthCm ?? source.widthCm, + heightCm: source.outerHeightCm ?? source.heightCm, + }, measurementSectionModes.outer) +})) +const actualMeasurementRows = computed(() => (measurementDetail.value?.items || []).map((row) => ( + calculatedMeasurementRow(row as unknown as Record, measurementSectionModes.actual) +))) + +function updateMeasurementSectionMode(section: MeasurementSection, mode: MeasurementCalculationMode) { + if (mode === 'MULTIPLY' && !activeMeasurementChargeRule.value) { + ElMessage.warning('该客户尚未启用系数配置,请先到客户计费规则中设置默认规则') + return + } + measurementSectionModes[section] = mode +} const statusTabs = computed(() => [ { value: '' as const, label: '全部' }, @@ -529,7 +699,12 @@ function feeReviewLabel(value: string | null | undefined) { return unifiedOrderReviewStatusLabel(value) } -function measurementValue(row: Record, key: string, customerSource = false) { +function measurementValue(row: Record, key: string, customerSource = false, outerSource = false) { + if (key === 'coefficientMode') return measurementCalculationLabel(row) + if (outerSource && ['lengthCm', 'widthCm', 'heightCm'].includes(key)) { + const outerValue = row[`outer${key[0].toUpperCase()}${key.slice(1)}`] + return outerValue == null ? '—' : formatNumber(Number(outerValue), 6) + } if (customerSource && key === 'unitWeightKg') { const total = Number(row.grossWeightKg) const count = Number(row.packageCount) @@ -546,6 +721,191 @@ function measurementValue(row: Record, key: string, customerSou return typeof value === 'number' ? formatNumber(value, 6) : String(value) } +function measurementCalculationLabel(row: Record) { + if (measurementCalculationModeForRow(row) === 'MULTIPLY') { + return `乘以${measurementChargeRuleName(row.chargeRuleId)}` + } + if (measurementCalculationModeForRow(row) === 'ACTUAL') return '实测原尺寸(不调整)' + return addCoefficientLabel(row).replace('原尺寸 + ', '加') +} + +function measurementCalculationModeForRow(row: Record): MeasurementCalculationMode { + if (row.coefficientMode === 'MULTIPLY') return 'MULTIPLY' + if (row.coefficientMode === 'ADD') return 'ADD' + const itemNo = normalizedIdentifier(row.itemNo) + const measuredItem = measurementDetail.value?.items.find((item) => normalizedIdentifier(item.itemNo) === itemNo) + return measuredItem?.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : measuredItem?.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL' +} + +async function updateMeasurementCalculationMode(row: Record | null, mode: Exclude) { + const summary = detail.value?.summary + const measurement = measurementDetail.value + if (!summary?.inboundId || !measurement?.measurementId || measurement.version == null || !can('platform:firstleg:inbound:measure:edit')) return + const itemId = normalizedIdentifier(row?.id) + const itemNo = normalizedIdentifier(row?.itemNo) + const targetIds = new Set(measurement.items + .filter((item) => !row || (itemId ? String(item.id) === itemId : normalizedIdentifier(item.itemNo) === itemNo)) + .map((item) => String(item.id))) + if (!targetIds.size) { + ElMessage.warning('当前数据尚未关联实测明细,暂不能设置计算方式') + return + } + measurementStrategySavingId.value = row ? (itemId || `item-${itemNo}`) : 'all' + try { + await Promise.all([loadMeasurementChargeRules(), loadMeasurementDimensionConfig()]) + const activeRule = measurementChargeRules.value.find((rule) => rule.defaultRule && rule.status === 'ACTIVE') + const requiresMultiply = measurement.items.some((item) => ( + targetIds.has(String(item.id)) ? mode : item.coefficientMode + ) === 'MULTIPLY') + if (requiresMultiply && !activeRule) { + ElMessage.warning('该客户尚未启用系数配置,请先到客户计费规则中设置默认规则') + return + } + await saveInboundMeasurementDraft(String(summary.tenantId), String(summary.inboundId), { + measurementId: String(measurement.measurementId), + version: measurement.version, + actualGoodsName: measurement.actualGoodsName, + items: measurement.items.map((item) => ({ + inboundDetailId: item.inboundDetailId, + boxCode: item.boxCode, + itemNo: item.itemNo, + boxSpecification: item.boxSpecification, + packageCount: item.packageCount, + lengthCm: item.lengthCm, + widthCm: item.widthCm, + heightCm: item.heightCm, + unitWeightKg: item.unitWeightKg, + intelligentImageFileId: item.intelligentImageFileId, + machineNo: item.machineNo, + measuredAt: item.deviceMeasuredAt, + overLimitWarning: item.overLimitWarning, + outerLengthCm: item.outerLengthCm, + outerWidthCm: item.outerWidthCm, + outerHeightCm: item.outerHeightCm, + chargeRuleId: (targetIds.has(String(item.id)) ? mode : item.coefficientMode) === 'MULTIPLY' + ? activeRule!.id + : null, + coefficientMode: targetIds.has(String(item.id)) ? mode : item.coefficientMode || 'ADD', + remark: item.remark, + })), + }) + measurementLoadedKey.value = '' + await loadMeasurementDetails() + ElMessage.success(`${row ? '当前数据' : '全部测量数据'}的计算方式已更新,体积已重新计算`) + } catch (error) { + measurementLoadedKey.value = '' + await loadMeasurementDetails() + ElMessage.error(getApiErrorDetails(error, '计算方式更新失败').message) + } finally { + measurementStrategySavingId.value = '' + } +} + +async function saveWarehouseOuterDimensions(row: Record) { + const summary = detail.value?.summary + const measurement = measurementDetail.value + const editableMeasurementStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED']) + const currentStatus = editableMeasurementStatuses.has(measurement?.processStatus || '') + ? measurement!.processStatus + : summary?.mainStatus || measurement?.processStatus || '' + if (measurement && !editableMeasurementStatuses.has(currentStatus)) { + const statusLabel = orderStatusLabel(summary?.mainStatus) || measurementStatusLabel(currentStatus) + ElMessage.warning(`当前状态为“${statusLabel}”,该状态不可以修改测量数据`) + return + } + if (!summary?.inboundId || !measurement?.measurementId || measurement.version == null + || !can('platform:firstleg:inbound:measure:edit')) return + const dimensions = [row.outerLengthCm, row.outerWidthCm, row.outerHeightCm].map(Number) + if (dimensions.some((value) => !Number.isFinite(value) || value <= 0)) { + ElMessage.warning('仓库修改外箱长、宽、高必须同时填写且大于 0') + return + } + measurementOuterSavingId.value = String(row.id) + try { + await saveInboundMeasurementDraft(String(summary.tenantId), String(summary.inboundId), { + measurementId: String(measurement.measurementId), + version: measurement.version, + actualGoodsName: measurement.actualGoodsName, + items: measurement.items.map((item) => ({ + inboundDetailId: item.inboundDetailId, + boxCode: item.boxCode, + itemNo: item.itemNo, + boxSpecification: item.boxSpecification, + packageCount: item.packageCount, + lengthCm: item.lengthCm, + widthCm: item.widthCm, + heightCm: item.heightCm, + unitWeightKg: item.unitWeightKg, + intelligentImageFileId: item.intelligentImageFileId, + machineNo: item.machineNo, + measuredAt: item.deviceMeasuredAt, + overLimitWarning: item.overLimitWarning, + outerLengthCm: String(item.id) === String(row.id) ? dimensions[0] : item.outerLengthCm, + outerWidthCm: String(item.id) === String(row.id) ? dimensions[1] : item.outerWidthCm, + outerHeightCm: String(item.id) === String(row.id) ? dimensions[2] : item.outerHeightCm, + chargeRuleId: item.chargeRuleId, + coefficientMode: item.coefficientMode || 'ADD', + remark: item.remark, + })), + }) + measurementLoadedKey.value = '' + await loadMeasurementDetails() + ElMessage.success(`序号 ${row.itemNo || '—'} 的外箱规已更新,相关数值已重新计算`) + } catch (error) { + measurementLoadedKey.value = '' + await loadMeasurementDetails() + ElMessage.error(getApiErrorDetails(error, '仓库外箱尺寸保存失败').message) + } finally { + measurementOuterSavingId.value = '' + } +} + +function measurementChargeRuleName(ruleId: unknown) { + const normalizedRuleId = normalizedIdentifier(ruleId) + const selected = normalizedRuleId + ? measurementChargeRules.value.find((rule) => rule.id === normalizedRuleId) + : null + const fallback = measurementChargeRules.value.find((rule) => rule.defaultRule && rule.status === 'ACTIVE') + || measurementChargeRules.value.find((rule) => rule.status === 'ACTIVE') + const rule = selected || fallback + if (rule) { + const remark = rule.remark?.trim() + return `系数 ${rule.lengthFactor}${remark ? ` · ${remark}` : ''}${rule.defaultRule ? '(默认)' : ''}` + } + if (measurementChargeRulesLoading.value) return '加载中…' + return normalizedRuleId || '未配置' +} + +async function loadMeasurementChargeRules() { + const tenantId = normalizedIdentifier(detail.value?.summary.tenantId) + const customerId = normalizedIdentifier(detail.value?.summary.customerId) || tenantId + const loadedKey = `${tenantId}:${customerId}` + if (!customerId || measurementChargeRulesLoadedKey.value === loadedKey || measurementChargeRulesLoading.value) return + measurementChargeRulesLoading.value = true + try { + measurementChargeRules.value = await listCustomerChargeRules(customerId, tenantId) + measurementChargeRulesLoadedKey.value = loadedKey + } catch { + measurementChargeRules.value = [] + } finally { + measurementChargeRulesLoading.value = false + } +} + +async function loadMeasurementDimensionConfig() { + const tenantId = normalizedIdentifier(detail.value?.summary.tenantId) + if (!tenantId || measurementDimensionConfigLoadedKey.value === tenantId || measurementDimensionConfigLoading.value) return + measurementDimensionConfigLoading.value = true + try { + measurementDimensionConfig.value = await getCustomerDimensionConfig(tenantId) + measurementDimensionConfigLoadedKey.value = tenantId + } catch { + measurementDimensionConfig.value = null + } finally { + measurementDimensionConfigLoading.value = false + } +} + interface MeasurementTotals { count: number gross: number @@ -608,8 +968,16 @@ async function loadMeasurementDetails() { measurementLoading.value = true measurementError.value = null try { - const response = await getInboundMeasurement(String(summary.tenantId), String(summary.inboundId)) + const [response] = await Promise.all([ + getInboundMeasurement(String(summary.tenantId), String(summary.inboundId)), + loadMeasurementChargeRules(), + loadMeasurementDimensionConfig(), + ]) if (serial !== measurementRequestSerial) return + const configuredMode = measurementDimensionConfig.value?.defaultCalculationMode + measurementSectionModes.outer = configuredMode === 'MULTIPLY' && activeMeasurementChargeRule.value + ? 'MULTIPLY' + : 'ADD' measurementDetail.value = response measurementLoadedKey.value = key } catch (error) { @@ -837,6 +1205,64 @@ function openMeasurement(row: UnifiedOrderResource) { measurementDialogOpen.value = true } +function openFeeConfirmDialog() { + const summary = detail.value?.summary + const reviewStatus = String(summary?.measurement?.reviewStatus ?? '').trim().toUpperCase() + if (reviewStatus !== 'APPROVED') { + ElMessage.warning('测量审核通过后才能确认并发送费用') + return + } + if (!summary?.inboundId) { + ElMessage.warning('当前单据缺少入仓信息,暂不能发送费用') + return + } + if (confirmFeeSending.value) { + return + } + feeConfirmWeight.value = Number(summary.measurement?.actualGrossWeightKg) || null + feeConfirmVolume.value = Number(summary.measurement?.actualVolumeCbm) || null + feeConfirmOpen.value = true +} + +async function executeConfirmAndSendFee() { + const summary = detail.value?.summary + if (!summary?.inboundId) return + const selectedWeightKg = Number(feeConfirmWeight.value) + const selectedVolumeCbm = Number(feeConfirmVolume.value) + if (!(selectedWeightKg > 0) || !(selectedVolumeCbm > 0)) { + ElMessage.warning('计费毛重和计费体积必须大于 0') + return + } + confirmFeeSending.value = true + try { + const tenantId = String(summary.tenantId) + const inboundId = String(summary.inboundId) + let snapshot = await getLatestInboundFee(tenantId, inboundId) + if (snapshot.feeStatus === 'CALCULATED') { + snapshot = await calculateInboundFee(tenantId, inboundId, { + selectedWeightKg, + selectedVolumeCbm, + weightSource: 'ACTUAL', + volumeSource: 'ACTUAL', + }, createFeeActionIdempotencyKey('calculate')) + snapshot = await confirmInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('confirm')) + } + if (snapshot.feeStatus === 'CONFIRMED') { + await sendInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('send')) + ElMessage.success('费用已确认并发送给客户') + feeConfirmOpen.value = false + await loadDetail(true) + return + } + if (snapshot.feeStatus === 'SENT') ElMessage.info('该费用已经发送给客户,无需重复发送') + else ElMessage.warning('当前没有可发送的试算费用,请先在费用信息中完成费用试算') + } catch (error) { + ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message) + } finally { + confirmFeeSending.value = false + } +} + function handleOrderSelectionChange(rows: UnifiedOrderResource[]) { selectedOrders.value = rows selectedApprovalOrders.value = rows.filter(canApprove) @@ -1021,6 +1447,7 @@ function handleMeasurementSubmitted() { function clearMeasurementOrder() { measurementOrder.value = null + openWarehouseSuggestionOnMeasurementLoad.value = false } type UnifiedOrderRowAction = 'note' | 'approve' | 'measurement' @@ -1163,6 +1590,17 @@ async function loadDetail(force = false) { measurementDetail.value = null measurementError.value = null measurementLoadedKey.value = '' + measurementSectionModes.customer = 'ACTUAL' + measurementSectionModes.outer = 'ACTUAL' + measurementSectionModes.actual = 'ACTUAL' + if (measurementChargeRulesLoadedKey.value !== tenantId) { + measurementChargeRules.value = [] + measurementChargeRulesLoadedKey.value = '' + } + if (measurementDimensionConfigLoadedKey.value !== tenantId) { + measurementDimensionConfig.value = null + measurementDimensionConfigLoadedKey.value = '' + } measurementPreviewOpen.value = false releaseMeasurementPreview() releaseCargoImagePreviews() @@ -1226,6 +1664,22 @@ onBeforeUnmount(() => {