diff --git a/docs/应收应付 b/docs/应收应付 index 11fb033..ccb85d1 100644 --- a/docs/应收应付 +++ b/docs/应收应付 @@ -43,7 +43,9 @@ flowchart TD wipeP --> refreshM[刷新营销金额] paid[客户支付] --> freeze[应收硬冻结] outbound[出库] --> shipOnly[仅更新 ship 件重尺/状态] - shipOnly --> noRecv[不再写应收/应付] + shipOnly --> noRecv[不再写应收] + reconcile[导入对账件重尺] --> downW[写 downstream 件重尺] + downW --> payFinal[锁定当前渠道重算应付 outbound] ``` --- @@ -56,8 +58,9 @@ flowchart TD |------|------|------|------| | **下单** | 预报件重尺 → 计费重 → 写 `forecast` | 同计费重尝试排货,成功则写 `forecast` 应付 | 排货失败不阻断下单,记 `AllocationPendingReason` | | **入库** | 同步只写 receive 重尺/收货时间,状态先 `pendingAllocation`;应收在异步任务 `inbound-billing` 中清空并写 `inbound` | 异步任务内 `TryConfirm` 重排货写应付;失败仍待排货 | **不校验服务渠道、不阻断扫码**;有效账单仍只保留一套 | -| **出库** | **不写应收** | 不重算 | 只更新 ship 件重尺与状态 | -| **已支付** | 禁止重算/加应收/改附加服务触发重算 | 出库不改 | `EnsureReceivableMutable` | +| **出库** | **不写应收** | 不自动重算 | 只更新 ship 件重尺与状态 | +| **已支付** | 禁止重算/加应收/改附加服务触发重算;禁止重跑入库计费 | 仍可重算应付 / 导入对账重 | `EnsureReceivableMutable` | +| **导入对账件重尺** | **不改** | 写 `downstream` 重尺 → 锁定当前渠道重算应付(阶段 `outbound`) | 运单管理:`GET/POST /order/weight/downstream-*`;尺寸可留空回落收货/发货 | ### 3.2 重量口径(硬约束) @@ -65,6 +68,7 @@ flowchart TD 2. 进位规则 → `ChargeableWeight` 3. 应收、应付、排货预览**同一** `ChargeableWeight` 4. **禁止**用 `ActualWeight` 直接乘单价 +5. **有效应收/应付只保留一套**:入库或重算后不得叠算预报+入库;合计按最高计费阶段(出库 > 入库 > 预报) ### 3.3 预报快照(二期) @@ -223,8 +227,11 @@ flowchart TD | `POST /order/price-check` | 查价(可带 additionalServices / DeclaredSum) | | `POST /operation/inbound/create` | 入库:清空后重算应收/应付 inbound | | `POST /operation/outbound/create` | 出库:不写费用 | +| `GET /order/weight/downstream-import-template` | 对账件重尺导入模板 | +| `POST /order/weight/downstream-import` | 导入 downstream 重尺并锁定渠道重算应付 | | `POST /order/fee/recalculate-receivable` | 重算应收(已支付拒绝) | -| `POST /order/fee/recalculate-payable` | 重算应付 | +| `POST /order/fee/recalculate-payable` | 重算应付(已有渠道时锁定渠道,不换渠道) | +| `POST /order/fee/rerun-inbound-billing` | 重跑入库计费(已支付拒绝,应收已锁) | | `POST /order/fee/add` | 手工加费(应收已支付拒绝) | | `POST /order/additional-services/update` | 未支付改附加服务并重算 | | `POST /channel/sales-product/billing/simulate` | 用当前库内卖价做计费模拟 | @@ -243,6 +250,7 @@ flowchart TD | 渠道偏远分区 | `/channel/serviceChannels` | | 下单勾选附加服务 | PC `placeOrder`、H5 `H5PlaceOrderSheet` | | 毛利 / 预报差额 / 改附加服务 | 运单管理 `OrderDetailDrawer` 费用 Tab | +| 导入对账件重尺 | 运单管理「更多」→「导入对账件重尺」 | | 预置字典种子 | `system/dict/dict-batch-import.ts`(下载 OMS 预置字典) | --- @@ -253,7 +261,8 @@ flowchart TD |------|------| | 金额引擎 | `Oms.Application/.../ParcelBillingCalculator.cs` | | 下单/入库/出库编排 | `OrderService.ParcelBilling.cs` | -| 改服务 / 冻结 | `OrderService.OrderEdit.cs` | +| 改服务 / 冻结 / 对账导入 | `OrderService.OrderEdit.cs` | +| 对账件重尺 Excel | `OrderDownstreamWeightImportHelper.cs` | | 查价 | `OrderService.ParcelQuote.cs` | | 计费模拟 | `SalesProductService.BillingSimulate.cs` | | 分区快照/混表校验 | `PriceAreaSnapshotHelper.cs` | @@ -295,6 +304,7 @@ flowchart TD - 卖价项级「计费时机」字段 - 出库改已付应收 - 物理 drop `t_sales_product.PriceAreaId` +- 导入任意重量类型自动改应收(对账导入只动应付) **可后续:** @@ -304,6 +314,7 @@ flowchart TD - 一票多件计费维度 - 发布前更完整的毛利模拟(含多候选渠道对比) - 费用争议走调整单而非改原费用行 +- 对账导入异步任务(超 2000 行)与应付版本审计 --- diff --git a/src/api/order/index.ts b/src/api/order/index.ts index 17eec67..23c1a2d 100644 --- a/src/api/order/index.ts +++ b/src/api/order/index.ts @@ -1981,6 +1981,57 @@ export async function postOrderWeightUpdate(body: { return res.data?.data ?? res.data?.Data ?? res.data } +export type OrderDownstreamWeightImportResult = { + successCount: number + failCount: number + failures: Array<{ rowNumber: number; waybillNo?: string; reason: string }> +} + +/** GET /order/weight/downstream-import-template */ +export async function downloadOrderDownstreamWeightImportTemplate(fileName?: string) { + const res = await axiosInstance.get(orderUrl('/order/weight/downstream-import-template'), { + responseType: 'blob', + }) + const blob = res.data as Blob + const ct = (res.headers['content-type'] || '').toString() + if (ct.includes('application/json')) { + const text = await blob.text() + const json = JSON.parse(text) + throw new Error(json.msg ?? json.message ?? json.Message ?? '下载失败') + } + const name = fileName || '对账件重尺导入模板.xlsx' + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = name.endsWith('.xlsx') ? name : `${name}.xlsx` + a.style.display = 'none' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + +/** POST /order/weight/downstream-import multipart */ +export async function importOrderDownstreamWeights( + file: File +): Promise { + const form = new FormData() + form.append('file', file) + const res = await axiosInstance.post(orderUrl('/order/weight/downstream-import'), form) + assertBusinessOk(res.data) + const d = res.data?.data ?? res.data?.Data ?? res.data ?? {} + const failures = (d.failures ?? d.Failures ?? []).map((item: any) => ({ + rowNumber: Number(item.rowNumber ?? item.RowNumber ?? 0), + waybillNo: String(item.waybillNo ?? item.WaybillNo ?? '') || undefined, + reason: String(item.reason ?? item.Reason ?? ''), + })) + return { + successCount: Number(d.successCount ?? d.SuccessCount ?? 0), + failCount: Number(d.failCount ?? d.FailCount ?? 0), + failures, + } +} + /** POST /order/additional-services/update — 未付款订单更新附加服务 */ export async function postOrderAdditionalServicesUpdate(body: { id: number | string diff --git a/src/constants/pageButtonCatalog.ts b/src/constants/pageButtonCatalog.ts index f586861..f54546d 100644 --- a/src/constants/pageButtonCatalog.ts +++ b/src/constants/pageButtonCatalog.ts @@ -175,6 +175,7 @@ export const PERMISSION_ACTION_LABELS: Record = { AddFee: '添加费用', RecalculateReceivable: '重算应收', RecalculatePayable: '重算应付', + ImportDownstreamWeight: '导入对账件重尺', AddDeclarationRow: '添加申报行', DownstreamForecastResult: '下游预报结果', More: '更多', @@ -564,6 +565,7 @@ const CORE_GROUPS: PageButtonGroup[] = [ { code: 'AddFee', name: '添加费用' }, { code: 'RecalculateReceivable', name: '重算应收' }, { code: 'RecalculatePayable', name: '重算应付' }, + { code: 'ImportDownstreamWeight', name: '导入对账件重尺' }, { code: 'AddDeclarationRow', name: '添加申报行' }, ]), B('Order.BatchJobs', [ diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index 241c435..9b3377e 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -1422,6 +1422,22 @@ export default { 'order.myOrders.actions.createProblematic': 'Create problematic', 'order.myOrders.actions.createClaimed': 'Create claim', 'order.myOrders.actions.reallocate': 'Reallocate', + 'order.waybillManage.importDownstream.action': 'Import reconcile weight', + 'order.waybillManage.importDownstream.modalTitle': 'Import reconcile weight', + 'order.waybillManage.importDownstream.tip': + 'Writes agent-reconciled weight to downstream dimensions and recalculates payable on the current channel only. Receivable is never changed. Length/width/height are optional (falls back to receive/ship). Actual weight is required. Max 2000 rows per file.', + 'order.waybillManage.importDownstream.downloadTemplate': 'Download template', + 'order.waybillManage.importDownstream.submit': 'Import', + 'order.waybillManage.importDownstream.cancel': 'Cancel', + 'order.waybillManage.importDownstream.downloadSuccess': 'Template downloaded', + 'order.waybillManage.importDownstream.downloadFailed': 'Template download failed', + 'order.waybillManage.importDownstream.needFile': 'Please select an Excel file', + 'order.waybillManage.importDownstream.success': 'Import succeeded', + 'order.waybillManage.importDownstream.failed': 'Import failed', + 'order.waybillManage.importDownstream.partialSuccess': 'Some rows failed; see details below', + 'order.waybillManage.importDownstream.allFailed': 'Import failed; check the file', + 'order.waybillManage.importDownstream.resultSummary': '{success} succeeded, {fail} failed', + 'order.waybillManage.importDownstream.failRow': 'Row {row} ({waybill}): {reason}', 'order.myOrders.actions.markProcessed': 'Mark processed', 'order.myOrders.reallocate.modalTitle': 'Reallocate ({count} selected)', 'order.myOrders.reallocate.hint': 'Only recalculates payable and updates the service channel. Receivable and dimensions are unchanged. Paid orders are skipped. Changing channel clears provider service no. / tracking no.; re-forward downstream if needed.', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index 7e848b6..ca1bda3 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -1308,6 +1308,22 @@ export default { 'order.myOrders.actions.reallocate': '重新排货', 'order.myOrders.actions.markProcessed': '标记已处理', 'order.myOrders.actions.more': '更多', + 'order.waybillManage.importDownstream.action': '导入对账件重尺', + 'order.waybillManage.importDownstream.modalTitle': '导入对账件重尺', + 'order.waybillManage.importDownstream.tip': + '按代理对账重量写入下游件重尺(downstream),锁定当前服务渠道重算应付;不改应收。长/宽/高可留空(沿用收货/发货尺寸);实重必填。单次最多 2000 行。', + 'order.waybillManage.importDownstream.downloadTemplate': '下载模板', + 'order.waybillManage.importDownstream.submit': '开始导入', + 'order.waybillManage.importDownstream.cancel': '取消', + 'order.waybillManage.importDownstream.downloadSuccess': '模板下载成功', + 'order.waybillManage.importDownstream.downloadFailed': '模板下载失败', + 'order.waybillManage.importDownstream.needFile': '请选择要上传的 Excel 文件', + 'order.waybillManage.importDownstream.success': '导入成功', + 'order.waybillManage.importDownstream.failed': '导入失败', + 'order.waybillManage.importDownstream.partialSuccess': '部分行导入失败,请查看失败明细', + 'order.waybillManage.importDownstream.allFailed': '导入失败,请检查文件内容', + 'order.waybillManage.importDownstream.resultSummary': '成功 {success} 条,失败 {fail} 条', + 'order.waybillManage.importDownstream.failRow': '第 {row} 行({waybill}):{reason}', 'order.myOrders.reallocate.modalTitle': '重新排货(已选 {count} 单)', 'order.myOrders.reallocate.hint': '仅重算应付并更新服务渠道,不改应收与件重尺;已付款订单会自动跳过。换渠道会清空服务商单号/跟踪号,需自行重新预报下游。', 'order.myOrders.reallocate.schemeLabel': '排货方案', diff --git a/src/utils/feeTotals.ts b/src/utils/feeTotals.ts index 8d6488a..ed5d686 100644 --- a/src/utils/feeTotals.ts +++ b/src/utils/feeTotals.ts @@ -3,6 +3,8 @@ export type FeeRowLike = { type?: string currency?: string amount?: number + costItem?: string + chargingStage?: string } export function normalizeFeeType(row: FeeRowLike): 'receivable' | 'payable' | '' { @@ -11,6 +13,38 @@ export function normalizeFeeType(row: FeeRowLike): 'receivable' | 'payable' | '' return '' } +function getChargingStageRank(stage?: string) { + const s = String(stage ?? '').toLowerCase() + if (s === 'outbound') return 3 + if (s === 'inbound') return 2 + if (s === 'forecast') return 1 + return 0 +} + +/** + * 每个费用类型 + 费用项只保留最高计费阶段(出库 > 入库 > 预报), + * 避免预报与入库明细叠算进合计。 + */ +export function filterEffectiveStageFees(rows: T[]): T[] { + const winners = new Map() + for (const row of rows) { + const feeType = normalizeFeeType(row) || 'unknown' + const costItem = String(row?.costItem ?? '').trim() || '_' + const key = `${feeType}|${costItem}` + const current = winners.get(key) + if (!current) { + winners.set(key, row) + continue + } + const rowStage = String((row as any)?.chargingStage ?? (row as any)?.ChargingStage ?? '') + const curStage = String((current as any)?.chargingStage ?? (current as any)?.ChargingStage ?? '') + if (getChargingStageRank(rowStage) > getChargingStageRank(curStage)) { + winners.set(key, row) + } + } + return [...winners.values()] +} + export function sumFeesByCurrency(list: FeeRowLike[]) { const totals = new Map() for (const row of list) { diff --git a/src/views/order/myOrders/components/OrderDetailDrawer.vue b/src/views/order/myOrders/components/OrderDetailDrawer.vue index 9963b37..37eb284 100644 --- a/src/views/order/myOrders/components/OrderDetailDrawer.vue +++ b/src/views/order/myOrders/components/OrderDetailDrawer.vue @@ -1476,6 +1476,35 @@ function normalizeFeeType(row: any): 'receivable' | 'payable' | '' { return ''; } +function getChargingStageRank(stage?: string) { + const s = String(stage ?? '').toLowerCase(); + if (s === 'outbound') return 3; + if (s === 'inbound') return 2; + if (s === 'forecast') return 1; + return 0; +} + +/** 同费用项只保留最高计费阶段,避免预报+入库叠算 */ +function filterEffectiveStageFees(list: any[]) { + const winners = new Map(); + for (const row of list) { + const feeType = normalizeFeeType(row) || 'unknown'; + const costItem = String(row?.costItem ?? row?.CostItem ?? '').trim() || '_'; + const key = `${feeType}|${costItem}`; + const current = winners.get(key); + if (!current) { + winners.set(key, row); + continue; + } + const rowStage = String(row?.chargingStage ?? row?.ChargingStage ?? ''); + const curStage = String(current?.chargingStage ?? current?.ChargingStage ?? ''); + if (getChargingStageRank(rowStage) > getChargingStageRank(curStage)) { + winners.set(key, row); + } + } + return [...winners.values()]; +} + function sumFeesByCurrency(list: any[]) { const totals = new Map(); for (const row of list) { @@ -1488,11 +1517,11 @@ function sumFeesByCurrency(list: any[]) { } const receivableFeeList = computed(() => - feeList.value.filter((row) => normalizeFeeType(row) === 'receivable') + filterEffectiveStageFees(feeList.value.filter((row) => normalizeFeeType(row) === 'receivable')) ); const payableFeeList = computed(() => - feeList.value.filter((row) => normalizeFeeType(row) === 'payable') + filterEffectiveStageFees(feeList.value.filter((row) => normalizeFeeType(row) === 'payable')) ); const receivableTotals = computed(() => sumFeesByCurrency(receivableFeeList.value)); diff --git a/src/views/order/waybillManage/components/DownstreamWeightImportModal.vue b/src/views/order/waybillManage/components/DownstreamWeightImportModal.vue new file mode 100644 index 0000000..187b5f3 --- /dev/null +++ b/src/views/order/waybillManage/components/DownstreamWeightImportModal.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/src/views/order/waybillManage/components/OrderDetailDrawer.vue b/src/views/order/waybillManage/components/OrderDetailDrawer.vue index 3595094..2346c3c 100644 --- a/src/views/order/waybillManage/components/OrderDetailDrawer.vue +++ b/src/views/order/waybillManage/components/OrderDetailDrawer.vue @@ -1618,6 +1618,35 @@ function normalizeFeeType(row: any): 'receivable' | 'payable' | '' { return ''; } +function getChargingStageRank(stage?: string) { + const s = String(stage ?? '').toLowerCase(); + if (s === 'outbound') return 3; + if (s === 'inbound') return 2; + if (s === 'forecast') return 1; + return 0; +} + +/** 同费用项只保留最高计费阶段,避免预报+入库叠算 */ +function filterEffectiveStageFees(list: any[]) { + const winners = new Map(); + for (const row of list) { + const feeType = normalizeFeeType(row) || 'unknown'; + const costItem = String(row?.costItem ?? row?.CostItem ?? '').trim() || '_'; + const key = `${feeType}|${costItem}`; + const current = winners.get(key); + if (!current) { + winners.set(key, row); + continue; + } + const rowStage = String(row?.chargingStage ?? row?.ChargingStage ?? ''); + const curStage = String(current?.chargingStage ?? current?.ChargingStage ?? ''); + if (getChargingStageRank(rowStage) > getChargingStageRank(curStage)) { + winners.set(key, row); + } + } + return [...winners.values()]; +} + function sumFeesByCurrency(list: any[]) { const totals = new Map(); for (const row of list) { @@ -1630,11 +1659,11 @@ function sumFeesByCurrency(list: any[]) { } const receivableFeeList = computed(() => - feeList.value.filter((row) => normalizeFeeType(row) === 'receivable') + filterEffectiveStageFees(feeList.value.filter((row) => normalizeFeeType(row) === 'receivable')) ); const payableFeeList = computed(() => - feeList.value.filter((row) => normalizeFeeType(row) === 'payable') + filterEffectiveStageFees(feeList.value.filter((row) => normalizeFeeType(row) === 'payable')) ); const receivableTotals = computed(() => sumFeesByCurrency(receivableFeeList.value)); diff --git a/src/views/order/waybillManage/index.vue b/src/views/order/waybillManage/index.vue index 1ef4d6f..18ca4a3 100644 --- a/src/views/order/waybillManage/index.vue +++ b/src/views/order/waybillManage/index.vue @@ -54,6 +54,7 @@ import MarkProblematicModal from './components/MarkProblematicModal.vue'; import MarkClaimedModal from './components/MarkClaimedModal.vue'; import ReallocateModal from './components/ReallocateModal.vue'; + import DownstreamWeightImportModal from './components/DownstreamWeightImportModal.vue'; import ColumnConfigModal from '../components/ColumnConfigModal.vue'; import { buildHotColumns, @@ -1372,6 +1373,11 @@ reallocateModalVisible.value = true; }; + const downstreamWeightImportModalRef = ref | null>(null); + const openDownstreamWeightImport = () => { + downstreamWeightImportModalRef.value?.open(); + }; + function resolveClaimContextFromRow(row: any) { let hasInsurance: boolean | null = null; const hi = row?.hasInsurance ?? row?.HasInsurance; @@ -1472,6 +1478,7 @@ if (key === 'cancelIntercept') return handleCancelIntercept(); if (key === 'delete') return handleDelete(); if (key === 'export') return handleExport(); + if (key === 'importDownstreamWeight') return openDownstreamWeightImport(); if (key === 'modifyOrder') return handleModifyOrder(); }; @@ -1927,10 +1934,10 @@ :disabled="selectedRowCount === 0 || isActionBusy('batchPrint')" @click="handleBatchPrint" >{{ t('order.myOrders.actions.batchPrint') }} - + {{ t('order.myOrders.actions.more') }} @@ -1938,39 +1945,49 @@ @@ -2126,6 +2143,10 @@ :order-ids="exceptionOrderIds" @success="handleExceptionActionSuccess" /> +