feat: 厂家记账按买家与订单实付两轮小额免记账 (#953)

This commit is contained in:
limeng 2026-11-06 15:07:55 +08:00
parent 2e91299c95
commit ebb20fd9ac
7 changed files with 154 additions and 5 deletions

View File

@ -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'];

View File

@ -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<string,mixed> $row 厂家记账明细行
* @param array<string,array<string,mixed>|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
{

View File

@ -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;

View File

@ -53,6 +53,7 @@
<div><span>待确认</span><strong class="{{ $bookkeepingSummary['review'] > 0 ? 'sb-warn-text' : '' }}">{{ number_format($bookkeepingSummary['review']) }}</strong></div>
</div>
<details class="sb-rules"><summary>接入口径</summary><p>与聚工单「协同工单列表 → 记账」视图一致:工单类型为“记账”;工单状态为待审核、已拒绝、待处理、处理中、已完成(不含已取消);记账原因为“厂家记账”下 12 项与“仓库记账”下 5 项二级原因;发起时间不早于 2026-09-01。同一工单号只取最新一版,状态或原因在聚工单变更后随镜像同步自动更新;多件商品的工单在商品列逐行展示,记账金额只计一次。数据只读,不在此修改。</p><p>厂家扣款判定:记账原因为“厂家记账”的,按「厂家记账配置」中供应商的记账方式判定:“正常记账”以及配置表中没有的供应商(含配置不在生效期内)计入扣款,“仅代发仓发错货记账”“不记账”不扣;记账原因为“仓库记账”的,只有订单发货仓库为“某某代发仓”且二级原因为“仓库发错货”时才作为厂家扣款项(供应商配置为“不记账”的除外),其余仓库记账一律不扣。工单未带供应商、或多件商品分属不同供应商且判定不一致的显示“待确认”,不计入扣款金额。</p></details>
<p class="sb-rules">小额免记账:“补偿款项”“记账全款”依次用买家实付合计、订单实付金额判断,原记账金额小于50元且原记账金额 ÷ 对应实付小于40%时改为0,备注“小于50且小于0.4,记账改0”。金额等于50元、比例等于40%时不免记账;实付缺失、无效或为0的一轮不自动改0。原金额及原备注保留,页面、筛选、合计和导出使用同一结果。</p>
@if($bookkeepingError)<div class="sb-error" role="alert">{{ $bookkeepingError }}</div>@endif
<section class="sb-panel" aria-label="厂家记账明细">
<div class="sb-table-scroll"><table><thead><tr>

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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']);
}
}