diff --git a/modules/EcommerceFinance/Repositories/SupplierBookkeepingWorkOrderRepository.php b/modules/EcommerceFinance/Repositories/SupplierBookkeepingWorkOrderRepository.php index c9192168..7225a276 100644 --- a/modules/EcommerceFinance/Repositories/SupplierBookkeepingWorkOrderRepository.php +++ b/modules/EcommerceFinance/Repositories/SupplierBookkeepingWorkOrderRepository.php @@ -167,7 +167,7 @@ class SupplierBookkeepingWorkOrderRepository throw new RuntimeException('business_mirror_dataset_not_ready'); } - $key = 'supplier-bookkeeping:rows:v1:' . sha1((string) $status['last_succeeded_at']); + $key = 'supplier-bookkeeping:rows:v2:' . sha1((string) $status['last_succeeded_at']); $cached = Cache::get($key); if (is_array($cached)) { return $cached; @@ -256,7 +256,8 @@ class SupplierBookkeepingWorkOrderRepository $items = $this->items($fields['itemInfo']['value'] ?? null, $fields['itemInfo']['raw'] ?? null); foreach (self::ITEM_FIELDS as $key => $candidates) { $values = array_map(fn (array $item): string => $this->firstOf($item, $candidates), $items); - $row[$key] = implode("\n", array_filter($values, static fn (string $v): bool => $v !== '')); + // 保留买家实付中的空商品行,防止不完整金额被误当作整单实付。 + $row[$key] = implode("\n", $key === 'buyer_paid_amount' ? $values : array_filter($values, static fn (string $v): bool => $v !== '')); } if ($row['supplier'] === '') { $row['supplier'] = $row['header_supplier']; diff --git a/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php b/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php index ea0514ff..acd94765 100644 --- a/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php +++ b/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php @@ -34,6 +34,8 @@ class SupplierBookkeepingDeductionRule public const DROPSHIP_WAREHOUSE_KEYWORD = '代发仓'; public const WAREHOUSE_WRONG_KEYWORD = '仓库发错货'; + public const SMALL_AMOUNT_NOTE = '小于50且小于0.4,记账改0'; + /** * 解析配置表中“厂家是否记账”原文,例如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”。 * @@ -91,7 +93,7 @@ class SupplierBookkeepingDeductionRule /** * @param array $row 厂家记账明细行 * @param array|object> $configs 供应商名称 => 生效中的配置 - * @return array{supplier_config:string,deduction_status:string,deduction_basis:string} + * @return array{supplier_config:string,deduction_status:string,deduction_basis:string,amount?:string,remark?:string} */ public static function decide(array $row, array $configs): array { @@ -106,6 +108,15 @@ class SupplierBookkeepingDeductionRule } $configText = implode("\n", array_map(static fn (?array $config): string => $config === null ? '未在配置表(按正常记账)' : self::describe($config), $found)); + if (self::isSmallCompensation($row)) { + $remark = (string) ($row['remark'] ?? ''); + + return self::result($configText, self::STATUS_SKIP, self::SMALL_AMOUNT_NOTE) + [ + 'amount' => '0.00', + 'remark' => $remark === '' ? self::SMALL_AMOUNT_NOTE : $remark . "\n" . self::SMALL_AMOUNT_NOTE, + ]; + } + if ($level1 === '仓库记账') { $warehouse = (string) ($row['shipping_warehouse'] ?? ''); if (! str_contains($warehouse, self::DROPSHIP_WAREHOUSE_KEYWORD) || ! str_contains($level2, self::WAREHOUSE_WRONG_KEYWORD)) { @@ -133,6 +144,38 @@ class SupplierBookkeepingDeductionRule return self::result($configText, $statuses[0], reset($outcomes)[1]); } + private static function isSmallCompensation(array $row): bool + { + if (! in_array(trim((string) ($row['bookkeeping_type'] ?? '')), ['补偿款项', '记账全款', '记账全额'], true)) { + return false; + } + + $amount = trim((string) ($row['amount'] ?? '')); + if (! preg_match('/^\d+(?:\.\d{1,6})?$/D', $amount) + || bccomp($amount, '0', 6) <= 0 || bccomp($amount, '50', 6) >= 0) { + return false; + } + + // 两轮依次使用商品买家实付合计、工单订单实付;一轮缺失不影响另一轮独立判断。 + foreach (['buyer_paid_amount', 'actual_paid_amount'] as $field) { + $paid = '0'; + foreach (explode("\n", (string) ($row[$field] ?? '')) as $line) { + $line = trim($line); + if (! preg_match('/^\d+(?:\.\d{1,6})?$/D', $line)) { + continue 2; + } + $paid = bcadd($paid, $line, 6); + } + // 交叉相乘避免除零、比例取整和浮点边界误差。 + if (bccomp($paid, '0', 6) > 0 + && bccomp(bcmul($amount, '5', 6), bcmul($paid, '2', 6), 6) < 0) { + return true; + } + } + + return false; + } + /** @return array{0:string,1:string} [判定, 说明] */ private static function outcome(string $level1, ?array $config, string $date): array { diff --git a/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php b/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php index 008ebd33..821a3334 100644 --- a/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php +++ b/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php @@ -27,6 +27,7 @@ class SupplierBookkeepingPageData 'started_at' => '发起时间', 'work_order_no' => '工单编号', 'initiator' => '发起人', 'bookkeeping_reason' => '记账原因', 'reason_level_1' => '记账原因-一级选项', 'reason_level_2' => '记账原因-二级选项', 'bookkeeping_type' => '记账类型', 'executor' => '执行人', 'source' => '记账来源', 'amount' => '记账金额', + 'original_amount' => '原记账金额', 'deduction_status' => '厂家扣款判定', 'supplier_config' => '厂家记账配置', 'deduction_basis' => '判定说明', 'remark' => '记账备注说明', 'attachment' => '上传附件', 'online_order_no' => '线上订单号', 'internal_order_no' => '内部单号', 'actual_paid_amount' => '实付金额', 'carrier' => '物流公司', @@ -40,7 +41,7 @@ class SupplierBookkeepingPageData 'accepting_department' => '受理部门', 'order_shipped_at' => '订单发货时间', ]; - public const MONEY_COLUMNS = ['amount', 'actual_paid_amount']; + public const MONEY_COLUMNS = ['amount', 'original_amount', 'actual_paid_amount']; public function __construct( private readonly Repository $repository, @@ -100,6 +101,7 @@ class SupplierBookkeepingPageData $deduction = $filters['deduction'] ?? ''; $configs = $this->configs->activeConfigs(); foreach ($rows ?? $this->repository->rows() as $row) { + $row['original_amount'] = $row['amount']; // 配置随时可改,判定不进镜像结果缓存,每次按当前配置计算。 $row = Rule::decide($row, $configs) + $row; if ($from !== '' && $row['started_at'] < $from) continue; 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 893a98cc..713c69f0 100644 --- a/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php +++ b/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php @@ -53,6 +53,7 @@
待确认{{ number_format($bookkeepingSummary['review']) }}
接入口径

与聚工单「协同工单列表 → 记账」视图一致:工单类型为“记账”;工单状态为待审核、已拒绝、待处理、处理中、已完成(不含已取消);记账原因为“厂家记账”下 12 项与“仓库记账”下 5 项二级原因;发起时间不早于 2026-09-01。同一工单号只取最新一版,状态或原因在聚工单变更后随镜像同步自动更新;多件商品的工单在商品列逐行展示,记账金额只计一次。数据只读,不在此修改。

厂家扣款判定:记账原因为“厂家记账”的,按「厂家记账配置」中供应商的记账方式判定:“正常记账”以及配置表中没有的供应商(含配置不在生效期内)计入扣款,“仅代发仓发错货记账”“不记账”不扣;记账原因为“仓库记账”的,只有订单发货仓库为“某某代发仓”且二级原因为“仓库发错货”时才作为厂家扣款项(供应商配置为“不记账”的除外),其余仓库记账一律不扣。工单未带供应商、或多件商品分属不同供应商且判定不一致的显示“待确认”,不计入扣款金额。

+

小额免记账:“补偿款项”“记账全款”依次用买家实付合计、订单实付金额判断,原记账金额小于50元且原记账金额 ÷ 对应实付小于40%时改为0,备注“小于50且小于0.4,记账改0”。金额等于50元、比例等于40%时不免记账;实付缺失、无效或为0的一轮不自动改0。原金额及原备注保留,页面、筛选、合计和导出使用同一结果。

@if($bookkeepingError)@endif
diff --git a/tests/Feature/Business/SupplierBookkeepingPageTest.php b/tests/Feature/Business/SupplierBookkeepingPageTest.php index 0fafe593..f3d4ce82 100644 --- a/tests/Feature/Business/SupplierBookkeepingPageTest.php +++ b/tests/Feature/Business/SupplierBookkeepingPageTest.php @@ -95,7 +95,7 @@ class SupplierBookkeepingPageTest extends FeatureTestCase $this->row(['work_order_no' => 'GD-NORMAL', 'supplier' => '鑫呈', 'amount' => '100.00']), $this->row(['work_order_no' => 'GD-NONE', 'supplier' => '尊享', 'amount' => '50.00']), $this->row(['work_order_no' => 'GD-DROP-OTHER', 'supplier' => '曹县洪耀', 'amount' => '30.00']), - $this->row(['work_order_no' => 'GD-DROP-WRONG', 'supplier' => '曹县洪耀', 'amount' => '20.50', 'reason_level_1' => '仓库记账', + $this->row(['work_order_no' => 'GD-DROP-WRONG', 'supplier' => '曹县洪耀', 'amount' => '20.50', 'actual_paid_amount' => '40.00', 'reason_level_1' => '仓库记账', 'reason_level_2' => '仓库发错货(面单照片,实物照片,条形码照片)', 'bookkeeping_reason' => '仓库记账/仓库发错货(面单照片,实物照片,条形码照片)', 'shipping_warehouse' => '曹县洪耀代发仓']), $this->row(['work_order_no' => 'GD-MAIN-WRONG', 'supplier' => '曹县洪耀', 'amount' => '8.00', 'reason_level_1' => '仓库记账', 'reason_level_2' => '仓库发错货(面单照片,实物照片,条形码照片)', 'bookkeeping_reason' => '仓库记账/仓库发错货(面单照片,实物照片,条形码照片)', 'shipping_warehouse' => '常州主仓']), @@ -128,4 +128,48 @@ class SupplierBookkeepingPageTest extends FeatureTestCase $this->withExceptionHandling()->loginAs()->get($this->url(['deduction' => 'x']))->assertStatus(422); } + + public function test_small_compensation_zeroes_page_totals_and_exports_with_original_amount_and_note(): void + { + $this->mockRepository([ + $this->row(['work_order_no' => 'GD-SMALL', 'amount' => '30.00', 'buyer_paid_amount' => '100', 'remark' => '原备注']), + $this->row(['work_order_no' => 'GD-EQUAL-RATIO', 'amount' => '40.00', 'buyer_paid_amount' => '100', 'actual_paid_amount' => '100.00']), + $this->row(['work_order_no' => 'GD-EQUAL-LIMIT', 'amount' => '50.00', 'buyer_paid_amount' => '200']), + ]); + $response = $this->loginAs()->get($this->url())->assertOk(); + $data = app(\Modules\EcommerceFinance\View\SupplierBookkeepingPageData::class)->page(); + $summary = $data['bookkeepingSummary']; + $this->assertSame(9000, $summary['amount_cents']); + $this->assertSame(9000, $summary['deduction_cents']); + $this->assertSame(2, $summary['deduction_count']); + $row = $data['bookkeepingRows']->getCollection()->firstWhere('work_order_no', 'GD-SMALL'); + $this->assertSame('0.00', $row['amount']); + $this->assertSame('30.00', $row['original_amount']); + $this->assertSame("原备注\n小于50且小于0.4,记账改0", $row['remark']); + $response->assertSee('原记账金额')->assertSee('小于50且小于0.4,记账改0'); + $this->get($this->url(['deduction' => '扣款']))->assertOk()->assertDontSee('GD-SMALL')->assertSee('GD-EQUAL-RATIO'); + $this->get($this->url(['deduction' => '不扣款']))->assertOk()->assertSee('GD-SMALL')->assertDontSee('GD-EQUAL-RATIO'); + $csv = $this->get(route('business.supplier-bookkeeping.export', [ + 'company_id' => $this->company->id, 'columns' => 'work_order_no,amount,original_amount,remark', + ]))->assertOk()->streamedContent(); + $this->assertStringContainsString('GD-SMALL,0.00,30.00,', $csv); + $this->assertStringContainsString('小于50且小于0.4,记账改0', $csv); + } + + public function test_second_round_zeroes_by_order_paid_when_buyer_paid_ratio_does_not_hit(): void + { + $this->mockRepository([ + $this->row(['work_order_no' => 'GD-ROUND-TWO', 'amount' => '30.00', 'buyer_paid_amount' => '50.00', 'actual_paid_amount' => '100.00']), + ]); + $this->loginAs()->get($this->url())->assertOk()->assertSee('GD-ROUND-TWO')->assertSee('小于50且小于0.4,记账改0'); + $data = app(\Modules\EcommerceFinance\View\SupplierBookkeepingPageData::class)->page(); + $this->assertSame(0, $data['bookkeepingSummary']['deduction_cents']); + $this->assertSame(0, $data['bookkeepingSummary']['amount_cents']); + $this->get($this->url(['deduction' => '扣款']))->assertOk()->assertDontSee('GD-ROUND-TWO'); + $csv = $this->get(route('business.supplier-bookkeeping.export', [ + 'company_id' => $this->company->id, 'deduction' => '不扣款', + 'columns' => 'work_order_no,amount,original_amount,remark', + ]))->assertOk()->streamedContent(); + $this->assertStringContainsString('GD-ROUND-TWO,0.00,30.00,', $csv); + } } diff --git a/tests/Unit/EcommerceFinance/SupplierBookkeepingDeductionRuleTest.php b/tests/Unit/EcommerceFinance/SupplierBookkeepingDeductionRuleTest.php index 0af010b6..0245941a 100644 --- a/tests/Unit/EcommerceFinance/SupplierBookkeepingDeductionRuleTest.php +++ b/tests/Unit/EcommerceFinance/SupplierBookkeepingDeductionRuleTest.php @@ -84,4 +84,47 @@ class SupplierBookkeepingDeductionRuleTest extends TestCase $this->assertStringContainsString('甲厂:扣款', $result['deduction_basis']); $this->assertSame("正常记账\n不记账", $result['supplier_config']); } + + public function test_small_compensation_uses_bookkeeping_divided_by_buyer_paid_and_preserves_notes(): void + { + foreach (['补偿款项', '记账全款', '记账全额'] as $type) { + $row = $this->row(['bookkeeping_type' => $type, 'amount' => '30.00', 'buyer_paid_amount' => '100', 'remark' => '协商补偿']); + $result = Rule::decide($row, $this->configs()); + $this->assertSame('0.00', $result['amount']); + $this->assertSame(Rule::STATUS_SKIP, $result['deduction_status']); + $this->assertSame("协商补偿\n小于50且小于0.4,记账改0", $result['remark']); + $this->assertSame('30.00', $row['amount']); + } + $multiple = Rule::decide($this->row(['bookkeeping_type' => '补偿款项', 'amount' => '30.00', 'buyer_paid_amount' => "40\n60"]), []); + $this->assertSame('0.00', $multiple['amount']); + } + + public function test_small_compensation_requires_both_strict_limits_and_complete_positive_paid_amount(): void + { + foreach ([ + ['50.00', '200'], ['49.99', '124.975'], ['40.00', '100'], ['39.99', '99'], + ['30.00', '10'], ['30.00', ''], ['30.00', '0'], ['30.00', '-100'], ['30.00', "100\n"], + ['30.00', "\n100"], ['30.00', 'invalid'], ['0.00', '100'], ['-1.00', '100'], ['', '100'], + ] as [$amount, $paid]) { + $result = Rule::decide($this->row(['bookkeeping_type' => '补偿款项', 'amount' => $amount, 'buyer_paid_amount' => $paid]), $this->configs()); + $this->assertArrayNotHasKey('amount', $result, "$amount / $paid"); + } + $hit = Rule::decide($this->row(['bookkeeping_type' => '补偿款项', 'amount' => '39.99', 'buyer_paid_amount' => '100']), $this->configs()); + $this->assertSame('0.00', $hit['amount']); + $freight = Rule::decide($this->row(['bookkeeping_type' => '运费损耗', 'amount' => '10.00', 'buyer_paid_amount' => '100']), $this->configs()); + $this->assertArrayNotHasKey('amount', $freight); + } + + public function test_second_round_uses_order_actual_paid_when_buyer_paid_round_does_not_hit(): void + { + foreach (['10', '', '0', "100\n"] as $buyer) { + $result = Rule::decide($this->row(['bookkeeping_type' => '补偿款项', 'amount' => '30.00', + 'buyer_paid_amount' => $buyer, 'actual_paid_amount' => '100.00']), $this->configs()); + $this->assertSame('0.00', $result['amount']); + $this->assertSame(Rule::STATUS_SKIP, $result['deduction_status']); + } + $equal = Rule::decide($this->row(['bookkeeping_type' => '记账全款', 'amount' => '40.00', + 'buyer_paid_amount' => '50', 'actual_paid_amount' => '100.00']), $this->configs()); + $this->assertArrayNotHasKey('amount', $equal); + } } diff --git a/tests/Unit/EcommerceFinance/SupplierBookkeepingWorkOrderRepositoryTest.php b/tests/Unit/EcommerceFinance/SupplierBookkeepingWorkOrderRepositoryTest.php index 967fa37b..97f983f4 100644 --- a/tests/Unit/EcommerceFinance/SupplierBookkeepingWorkOrderRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/SupplierBookkeepingWorkOrderRepositoryTest.php @@ -162,4 +162,19 @@ class SupplierBookkeepingWorkOrderRepositoryTest extends TestCase $this->assertSame(['GD-2'], array_column(iterator_to_array($page->rows(['started_from' => '2026-09-10', 'supplier' => '胜'] + $filters, $rows), false), 'work_order_no')); $this->assertSame([], iterator_to_array($page->rows(['started_to' => '2026-09-01'] + $filters, $rows), false)); } + + public function test_keeps_item_alignment_when_a_buyer_paid_amount_is_missing(): void + { + $this->state(); + $this->order(1, 'GD-MULTI', [ + self::REASON => '厂家记账/缺货损失', + 'itemInfo' => [ + ['skuId' => 'SKU-A', 'qty' => 1, 'buyerPaidAmount' => 100], + ['skuId' => 'SKU-B', 'qty' => 2], + ], + ]); + $row = $this->repository()->rows()[0]; + $this->assertSame("100\n", $row['buyer_paid_amount']); + $this->assertSame("SKU-A\nSKU-B", $row['product_code']); + } }