From fa2a3c39775ce0d8c862c9076e8bd2ed5cea1a8d Mon Sep 17 00:00:00 2001
From: limeng <1131177064@qq.com>
Date: Sat, 7 Nov 2026 15:53:01 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8E=82=E5=AE=B6=E8=AE=B0=E8=B4=A6?=
=?UTF-8?q?=E6=B8=85=E7=90=86=E5=8D=95=E5=8F=B7=E9=99=84=E5=8A=A0=E7=AC=A6?=
=?UTF-8?q?=E5=B9=B6=E6=8C=89=E5=95=86=E5=93=81=E6=88=90=E6=9C=AC=E6=9B=BF?=
=?UTF-8?q?=E4=BB=A3=E7=BC=BA=E5=A4=B1=E8=BF=90=E8=B4=B9=20(#978)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/厂家记账金额规则.md | 7 +-
...SupplierBookkeepingValuationRepository.php | 12 +++-
.../Services/SupplierBookkeepingValuation.php | 50 +++++++++----
.../View/SupplierBookkeepingPageData.php | 2 +-
.../manufacturer-bookkeeping.blade.php | 2 +-
.../Business/SupplierBookkeepingPageTest.php | 34 +++++++++
...upplierBookkeepingShippingImporterTest.php | 18 +++++
...lierBookkeepingValuationRepositoryTest.php | 11 ++-
.../SupplierBookkeepingValuationTest.php | 72 +++++++++++++++++--
9 files changed, 182 insertions(+), 26 deletions(-)
diff --git a/docs/厂家记账金额规则.md b/docs/厂家记账金额规则.md
index 604015e9..3234d053 100644
--- a/docs/厂家记账金额规则.md
+++ b/docs/厂家记账金额规则.md
@@ -7,8 +7,8 @@
1. 对“补偿款项”“记账全款”(兼容“记账全额”),原金额大于0且小于50元,先判断原金额÷商品买家实付合计是否小于40%。命中后金额为0,不扣款,追加备注 `小于50且小于0.4,记账改0`。
2. 未命中的,再用工单 `actual_paid_amount`(页面“实付金额”)作分母,独立判断同样的严格边界。任一轮实付缺失、无效或为0时,只跳过该轮;另一轮仍可正常判断。多商品买家实付缺一项时,不把部分金额当作完整合计。
3. 保留厂家记账配置、代发仓发错货限制和多供应商待确认规则。只有已判定可扣款的剩余工单进入核价,不用核价推翻“不扣款”或“待确认”。
-4. 补偿、全款按原单商品的实际发货数量乘普通商品资料当前成本单价,再加实际包裹运费,与原金额取较小值。成本为当前 `cost_price`,不代表历史月份成本。
-5. 运费损耗按实际运费记账,不受原金额限制;原金额为0也需要匹配。匹配结果确为0时不扣款,不要求商品成本和数量。
+4. 补偿、全款按原单商品的实际发货数量乘普通商品资料当前成本单价,再加实际包裹运费,与原金额取较小值。成本按商品编码精确匹配商品资料的当前 `cost_price`,不代表历史月份成本。
+5. 运费损耗优先按正数实际运费记账,不受原金额限制;原金额为0也需要匹配。原单包裹运费为空或0时,按对应工单商品的当前成本单价×实际发货数量×0.2替代。多个包裹中只替代缺运费的包裹,其余仍取实际运费。替代需要有效商品成本与逐商品发货数量,缺失或数量含糊时待确认;已有正数实际运费不要求商品成本和数量。
金额等于50元或比例等于40%均不满足小额免记账。金额计算使用十进制运算,在最终金额处四舍五入到分。
@@ -17,11 +17,12 @@
- 运费取工作平台月度导出“运费”;5月“系统运费”经用户确认是相同口径。不使用“结算运费”,不借用覆盖不足的现有发货表或其他年份数据。
- 查找时间为工单发起月份及前五个月,右端不含下月1日。例如10月工单读取5月1日至11月1日前的发货记录。六个月未完整导入时待确认。
- 有物流单号时精确匹配该单号或换单前单号,同时核对工单提供的线上订单号;无物流单号时按线上订单号匹配全部相关包裹。合并订单按拆分后的完整单号建立索引。
+- 单号及商品编码匹配前清理前后空白、不可见复制符、单引号、引号、`@` 及其全角形式;保留原始展示值及单号前导0,保留编码内部的 `-`、`+` 等合法内容,不做模糊匹配。
- 不以平均运费替代原单运费,不借同订单其他快递单的运费;多个单号只匹配部分时待确认。
- “未发出”辅助列按用户确认:`#N/A` 表示未命中未发出名单,可正常匹配;返回单号的整个包裹排除,成本、数量和运费均不累计。按订单匹配时仅核算剩余已发出包裹;原单全部未发出或工单明确列出的单号被部分排除时待确认、不计扣款,保留原金额。其他辅助列错误待确认。
- 工单商品须全部匹配。一个单元格合并多个商品编码、仅有总数量时,不推测每个商品的数量,标为待确认。
- 包裹运费只取一次;同包裹同商品的重复行不重复累计数量。运费或数量冲突时待确认,不相加、不擅自选值。
-- 缺发货、有效数量、成本价或运费时待确认,保留原金额,不计扣款合计。明确0运费有效,空运费不能当0。
+- 缺发货、有效数量、成本价或运费时待确认,保留原金额,不计扣款合计;运费损耗的空/0运费按第5条替代,判定说明及备注明确记录“按对应商品成本合计×0.2替代运费”,成本与数量列展示替代部分的依据。补偿/全款仍将明确0运费视为有效,空运费待确认。
- 原记账金额、匹配成本/运费/数量/快递号/期间供页面核对与导出。
## 月度发货导出镜像
diff --git a/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php b/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php
index d73894a0..af4444af 100644
--- a/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php
+++ b/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php
@@ -57,7 +57,7 @@ class SupplierBookkeepingValuationRepository
$shipment['id'] = $record->generation . ':' . $record->source_row;
$shipment['tracking_numbers'] = array_values(array_unique(array_merge(self::numbers($record->express_no), self::numbers($record->old_express_no))));
$shipment['order_numbers'] = self::numbers($record->trade_no);
- $shipment['source_id'] = hash('sha256', $record->express_no . '|' . $record->package_no);
+ $shipment['source_id'] = hash('sha256', self::identifier($record->express_no) . '|' . $record->package_no);
$found[$shipment['id']] = $shipment;
if (count($found) > 20000) throw new RuntimeException('匹配发货记录过多,须缩小工单范围');
}
@@ -121,11 +121,17 @@ class SupplierBookkeepingValuationRepository
public static function numbers(mixed $value): array
{
- return array_values(array_unique(array_filter(array_map('trim', preg_split('/[\r\n,,、;;]+/u', (string) $value) ?: []), static fn ($v) => $v !== '')));
+ return array_values(array_unique(array_filter(array_map(static fn ($v) => self::identifier($v), preg_split('/[\r\n,,、;;]+/u', (string) $value) ?: []), static fn ($v) => $v !== '')));
}
public static function sku(mixed $value): string
{
- return str_replace('_X002B_', '+', mb_strtoupper(trim((string) $value)));
+ return str_replace('_X002B_', '+', mb_strtoupper(self::identifier($value)));
+ }
+
+ /** 只移除单号/商品编码外围的复制附加符,保留内部符号及前导0。 */
+ private static function identifier(mixed $value): string
+ {
+ return preg_replace('/^[\s\p{Cf}\'"‘’“”'"@@]+|[\s\p{Cf}\'"‘’“”'"@@]+$/u', '', (string) $value) ?? trim((string) $value);
}
}
diff --git a/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php b/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php
index 4b99dd3b..5c362ee9 100644
--- a/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php
+++ b/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php
@@ -27,12 +27,14 @@ class SupplierBookkeepingValuation
$byTracking = [];
$byOrder = [];
foreach ($shipments as $shipment) {
- foreach ($shipment['tracking_numbers'] ?? [(string) $shipment['express_no']] as $reference) $byTracking[$reference][] = $shipment;
- foreach ($shipment['order_numbers'] ?? [(string) $shipment['trade_no']] as $reference) $byOrder[$reference][] = $shipment;
+ $shipment['tracking_numbers'] ??= array_values(array_unique(array_merge(Repository::numbers($shipment['express_no']), Repository::numbers($shipment['old_express_no'] ?? ''))));
+ $shipment['order_numbers'] ??= Repository::numbers($shipment['trade_no']);
+ foreach ($shipment['tracking_numbers'] as $reference) $byTracking[$reference][] = $shipment;
+ foreach ($shipment['order_numbers'] as $reference) $byOrder[$reference][] = $shipment;
}
$skus = [];
foreach ($candidates as $row) {
- if ($row['bookkeeping_type'] !== '运费损耗') array_push($skus, ...Repository::numbers($row['product_code'] ?? ''));
+ array_push($skus, ...Repository::numbers($row['product_code'] ?? ''));
}
try {
$costs = $this->repository->costsFor(array_values(array_unique($skus)));
@@ -116,11 +118,22 @@ class SupplierBookkeepingValuation
$packages[$package]['amount'] = (string) $shipment['freight'];
$packages[$package]['has_freight'] = true;
}
+ }
+ $estimatedPackages = $freightOnly ? array_filter($packages, static fn ($package) => !$package['has_freight'] || bccomp($package['amount'], '0', 6) === 0) : [];
+ if ($estimatedPackages !== []) {
+ $shippedSkus = [];
+ foreach ($pool as $shipment) array_push($shippedSkus, ...array_map([Repository::class, 'sku'], Repository::numbers($shipment['barcode'])));
+ if (array_diff(array_keys($wanted), $shippedSkus) !== []) return $this->review($row, '原单发货明细未匹配全部工单商品,无法估算运费');
+ }
+ $packageCosts = [];
+ foreach ($pool as $shipment) {
+ $package = (string) $shipment['source_id'];
+ if ($freightOnly && !isset($estimatedPackages[$package])) continue;
+ if ($wanted === []) return $this->review($row, '工单没有商品编码,无法匹配替代运费所需成本');
$sku = Repository::sku($shipment['barcode']);
- if ($freightOnly) continue;
$components = array_map([Repository::class, 'sku'], Repository::numbers($shipment['barcode']));
if (count($components) > 1 && array_intersect(array_keys($wanted), $components) !== []) return $this->review($row, '多商品包裹只有合计数量,缺少逐商品发货数量');
- if (!$freightOnly && !array_key_exists($sku, $wanted)) continue;
+ if (!array_key_exists($sku, $wanted)) continue;
if (!self::decimal($shipment['qty']) || bccomp((string) $shipment['qty'], '0', 6) <= 0) {
return $this->review($row, '匹配发货数量缺失或无效');
}
@@ -131,25 +144,36 @@ class SupplierBookkeepingValuation
}
$lines[$lineKey] = (string) $shipment['qty'];
$quantity = bcadd($quantity, (string) $shipment['qty'], 6);
- if (!$freightOnly) {
- $price = $costs[$sku] ?? null;
- if (!self::decimal($price) || bccomp((string) $price, '0', 6) <= 0) return $this->review($row, '商品成本价缺失或为0:' . $sku);
- $cost = bcadd($cost, bcmul((string) $price, (string) $shipment['qty'], 6), 6);
- $wanted[$sku] = true;
- }
+ $price = $costs[$sku] ?? null;
+ if (!self::decimal($price) || bccomp((string) $price, '0', 6) <= 0) return $this->review($row, '商品成本价缺失或为0:' . $sku);
+ $lineCost = bcmul((string) $price, (string) $shipment['qty'], 6);
+ $cost = bcadd($cost, $lineCost, 6);
+ $packageCosts[$package] = bcadd($packageCosts[$package] ?? '0', $lineCost, 6);
+ $wanted[$sku] = true;
}
if (!$freightOnly && in_array(false, $wanted, true)) return $this->review($row, '原单发货明细未匹配全部工单商品');
+ foreach (array_keys($estimatedPackages) as $package) {
+ if (!isset($packageCosts[$package])) return $this->review($row, '原单发货商品未匹配替代运费所需成本');
+ $packages[$package]['amount'] = bcmul($packageCosts[$package], '0.2', 6);
+ $packages[$package]['has_freight'] = true;
+ }
$freight = '0';
foreach ($packages as $package) {
if (!$package['has_freight']) return $this->review($row, '原单运费尚未补齐,不能按0记账');
$freight = bcadd($freight, $package['amount'], 6);
}
$row['matched_freight'] = self::money($freight);
- $row['matched_quantity'] = $freightOnly ? '' : rtrim(rtrim($quantity, '0'), '.');
+ $row['matched_quantity'] = $freightOnly && $estimatedPackages === [] ? '' : rtrim(rtrim($quantity, '0'), '.');
$row['matched_tracking_no'] = implode("\n", array_values(array_unique(array_column($pool, 'express_no'))));
if ($freightOnly) {
$row['amount'] = self::money($freight);
- $basis = '运费损耗按原单实际运费记账';
+ if ($estimatedPackages !== []) {
+ $row['matched_cost'] = self::money($cost);
+ $basis = '运费损耗:原单运费缺失或为0,按对应商品成本合计×0.2替代运费';
+ if (count($estimatedPackages) < count($packages)) $basis .= ',另加其余包裹实际运费';
+ } else {
+ $basis = '运费损耗按原单实际运费记账';
+ }
} else {
$row['matched_cost'] = self::money($cost);
$ceiling = self::money(bcadd($cost, $freight, 6));
diff --git a/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php b/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php
index 5f059f16..9c9bc791 100644
--- a/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php
+++ b/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php
@@ -29,7 +29,7 @@ class SupplierBookkeepingPageData
'bookkeeping_reason' => '记账原因', 'reason_level_1' => '记账原因-一级选项', 'reason_level_2' => '记账原因-二级选项',
'bookkeeping_type' => '记账类型', 'executor' => '执行人', 'source' => '记账来源', 'amount' => '记账金额',
'original_amount' => '原记账金额',
- 'matched_cost' => '匹配商品成本', 'matched_freight' => '匹配实际运费', 'matched_quantity' => '匹配发货数量',
+ 'matched_cost' => '匹配商品成本', 'matched_freight' => '核算运费', 'matched_quantity' => '匹配发货数量',
'matched_tracking_no' => '匹配快递单号', 'valuation_window' => '发货匹配期间',
'deduction_status' => '厂家扣款判定', 'supplier_config' => '厂家记账配置', 'deduction_basis' => '判定说明',
'remark' => '记账备注说明', 'attachment' => '上传附件', 'online_order_no' => '线上订单号',
diff --git a/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php b/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php
index 620ef721..35c0063b 100644
--- a/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php
+++ b/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php
@@ -54,7 +54,7 @@
与聚工单「协同工单列表 → 记账」视图一致:工单类型为“记账”;工单状态为待审核、已拒绝、待处理、处理中、已完成(不含已取消);记账原因为“厂家记账”下 12 项与“仓库记账”下 5 项二级原因;发起时间不早于 2026-09-01。同一工单号只取最新一版,状态或原因在聚工单变更后随镜像同步自动更新;多件商品的工单在商品列逐行展示,记账金额只计一次。数据只读,不在此修改。 厂家扣款判定:记账原因为“厂家记账”的,按「厂家记账配置」中供应商的记账方式判定:“正常记账”以及配置表中没有的供应商(含配置不在生效期内)计入扣款,“仅代发仓发错货记账”“不记账”不扣;记账原因为“仓库记账”的,只有订单发货仓库为“某某代发仓”且二级原因为“仓库发错货”时才作为厂家扣款项(供应商配置为“不记账”的除外),其余仓库记账一律不扣。工单未带供应商、或多件商品分属不同供应商且判定不一致的显示“待确认”,不计入扣款金额。接入口径
小额免记账:“补偿款项”“记账全款”依次用买家实付合计、订单实付金额判断,原记账金额小于50元且原记账金额 ÷ 对应实付小于40%时改为0,备注“小于50且小于0.4,记账改0”。金额等于50元、比例等于40%时不免记账;实付缺失、无效或为0的一轮不自动改0。
-剩余可扣款工单按原快递单号匹配实际发货;未提供快递单号时按线上订单号匹配。查找期间为工单发起月份及前五个月(如10月取5—10月)。商品成本取普通商品资料成本单价×匹配商品的实际发货数量;“补偿款项”“记账全款”取原记账金额与商品成本+实际运费的较小值,“运费损耗”按实际运费记账。包裹运费只累计一次;发货、数量、成本或运费缺失时标为待确认,不计入扣款合计。原金额及原备注保留,页面、筛选、合计和导出使用同一结果。
+单号匹配前自动清理前后的单引号、@和空格。剩余可扣款工单按原快递单号匹配实际发货;未提供快递单号时按线上订单号匹配。查找期间为工单发起月份及前五个月(如10月取5—10月)。商品成本按商品编码匹配普通商品资料成本单价,再乘对应商品的实际发货数量;“补偿款项”“记账全款”取原记账金额与商品成本+实际运费的较小值。“运费损耗”优先取实际运费,包裹运费为空或0时按对应商品成本合计×20%替代,并在判定说明中标注。包裹运费只累计一次;缺发货、成本或逐商品数量等无法核算的工单标为待确认,不计入扣款合计。原金额及原备注保留,页面、筛选、合计和导出使用同一结果。
@if($bookkeepingError)