Compare commits

...

1 Commits

Author SHA1 Message Date
cc9015cea6 fix(种菜损耗): 扣款金额改为 成本价×数量 + 该单发货运费
财务更正口径:种菜损耗扣款 = Σ 发货条码在普通商品资料的成本价 × 数量 + 该订单在发货数据中的运费合计,
不再按成本价×1.2;运费为空的订单标"运费缺失",缺失部分不计入扣款金额。

涉及:金额计算展示(只读);不涉及迁移、权限和上游写入。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-10-19 16:45:33 +08:00
5 changed files with 36 additions and 22 deletions

View File

@ -16,7 +16,7 @@ use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
* 2. 用线上订单号去匹配发货数据(jst_oms_shipping)中 M-1、M 两个月发货的交易单号,匹配到的保留;
* 3. 匹配到的单里,任务备注或卖家备注写了“拍A发B”或“拍A发A”的不扣款,单独列出发给运营;
* 4. 实际只发了「信誉订单虚拟条码」「透明挂钩」这类商品的不扣款;
* 5. 其余匹配到的作为扣款项,扣款金额 = Σ 发货商品成本价 × 数量 × 1.2(成本价+成本价×20%),
* 5. 其余匹配到的作为扣款项,扣款金额 = Σ 发货商品成本价 × 数量 + 该订单在发货数据中的运费(2026-10-19 更正),
* 成本价按发货条形码(=商品编码)取普通商品资料。未匹配到发货的不计入种菜损耗。
*/
class PlantingLossCalculator
@ -34,9 +34,6 @@ class PlantingLossCalculator
/** 发货商品名称或条码含这些字样的不计扣款。 */
public const FREE_GOODS_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩'];
/** 扣款 = 成本价 × (1 + 20%)。 */
public const MARKUP_PERCENT = 20;
public const SKU_DATASET = 'operations_replica.rpt.jst_sku';
private const CHUNK = 1000;
@ -104,10 +101,10 @@ class PlantingLossCalculator
return false;
}
/** 成本价(元,字符串)× 数量 × 1.2 → 分。 */
/** 成本价(元,字符串)× 数量 → 分。 */
public static function deductionCents(string $costPrice, int $qty): int
{
return (int) round((float) $costPrice * 100 * $qty * (100 + self::MARKUP_PERCENT) / 100);
return (int) round((float) $costPrice * 100 * $qty);
}
/**
@ -168,23 +165,37 @@ class PlantingLossCalculator
}
$lineCents = self::deductionCents($cost, $line['qty']);
$cents += $lineCents;
$deductLines[] = sprintf('%s × %d × %s × 1.2 = %s', $line['barcode'], $line['qty'], $cost, number_format($lineCents / 100, 2, '.', ''));
$deductLines[] = sprintf('%s × %d × %s = %s', $line['barcode'], $line['qty'], $cost, number_format($lineCents / 100, 2, '.', ''));
}
$freight = $shipment['freight'] ?? null;
if ($deductLines !== []) {
if ($freight === null) {
$deductLines[] = '运费缺失';
} else {
$cents += (int) round((float) $freight * 100);
$deductLines[] = '运费 ' . $freight;
}
}
$missing = array_values(array_unique($missingCost));
$missingText = implode(';', array_filter([
$missing === [] ? '' : '成本价缺失:' . implode('、', $missing),
$deductLines !== [] && $freight === null ? '运费缺失' : '',
]));
$keyword = $shipment === null ? null : self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']);
[$result, $basis] = match (true) {
$shipment === null => [self::RESULT_UNMATCHED, self::shippingLabel($month) . ' 发货数据中无此交易单号'],
$keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”'],
$deductLines === [] => [self::RESULT_FREE_GOODS, '只发了虚拟条码/透明挂钩'],
$missingCost !== [] => [self::RESULT_DEDUCT, '成本价缺失:' . implode('、', array_unique($missingCost)) . ',扣款金额未含该商品'],
default => [self::RESULT_DEDUCT, '已匹配发货,按成本价×1.2扣款'],
$missingText !== '' => [self::RESULT_DEDUCT, $missingText . ',扣款金额未含缺失部分'],
default => [self::RESULT_DEDUCT, '已匹配发货,按成本价×数量+运费扣款'],
};
$row['loss_result'] = $result;
$row['loss_basis'] = $basis;
$row['deduction_amount'] = $result === self::RESULT_DEDUCT ? number_format($cents / 100, 2, '.', '') : null;
$row['deduction_detail'] = $result === self::RESULT_DEDUCT ? implode("\n", $deductLines) : '';
$row['cost_missing'] = $result === self::RESULT_DEDUCT && $missingCost !== [];
$row['cost_missing'] = $result === self::RESULT_DEDUCT && $missingText !== '';
$row['shipped_at'] = $shipment['shipped_at'] ?? '';
$row['express_no'] = $shipment['express_no'] ?? '';
$row['warehouse_name'] = $shipment['warehouse_name'] ?? '';

View File

@ -6,7 +6,7 @@ use Illuminate\Pagination\LengthAwarePaginator;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
use Modules\EcommerceFinance\Services\PlantingLossCalculator as Calculator;
/** 种菜数据汇总 → 种菜损耗:种菜报销明细匹配近两个月发货数据,拍A发B/拍A发A、只发虚拟条码/透明挂钩的不扣款,其余按成本价×1.2 扣款。 */
/** 种菜数据汇总 → 种菜损耗:种菜报销明细匹配近两个月发货数据,拍A发B/拍A发A、只发虚拟条码/透明挂钩的不扣款,其余按成本价×数量+运费扣款。 */
class PlantingLossPageData
{
use PlantingPageFilters;
@ -25,7 +25,7 @@ class PlantingLossPageData
];
public const COLUMNS = [
'loss_result' => '损耗判定', 'deduction_amount' => '扣款金额', 'loss_basis' => '判定说明', 'deduction_detail' => '扣款计算(条码×数量×成本价×1.2)',
'loss_result' => '损耗判定', 'deduction_amount' => '扣款金额', 'loss_basis' => '判定说明', 'deduction_detail' => '扣款计算(条码×数量×成本价+运费)',
'completed_at' => '报销完成时间', 'work_order_no' => '工单编号', 'platform' => '平台', 'shop_name' => '店铺名称',
'online_order_no' => '线上订单号', 'task_remark' => '任务备注', 'seller_remark' => '卖家备注',
'actual_paid_amount' => '实付金额', 'task_payment' => '任务付款金额', 'reimbursement_amount' => '最终报销金额',

View File

@ -13,7 +13,7 @@
@endphp
<div class="pw-page" data-preference-key="planting-loss:{{ company_id() }}:{{ optional(user())->id }}">
<div class="pw-heading">
<p>{{ $plantingFilters['month'] }} 月种菜报销明细(完成时间 {{ substr($plantingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subDay()->format('Y-m-d') }})的线上订单号,匹配 {{ $shippingLabel }} 发货数据(发货时间 {{ substr($plantingShippingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingShippingWindow['to'])->subDay()->format('Y-m-d') }})中的交易单号。匹配到的单中,任务备注或卖家备注写了“拍A发B / 拍A发A”的、以及只发了信誉订单虚拟条码/透明挂钩的不扣款,其余按发货商品成本价×1.2 扣款。</p>
<p>{{ $plantingFilters['month'] }} 月种菜报销明细(完成时间 {{ substr($plantingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subDay()->format('Y-m-d') }})的线上订单号,匹配 {{ $shippingLabel }} 发货数据(发货时间 {{ substr($plantingShippingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingShippingWindow['to'])->subDay()->format('Y-m-d') }})中的交易单号。匹配到的单中,任务备注或卖家备注写了“拍A发B / 拍A发A”的、以及只发了信誉订单虚拟条码/透明挂钩的不扣款,其余按发货商品成本价×数量+该单运费扣款。</p>
<div class="pw-tags">
<span class="pw-tag {{ $plantingSync['code'] === 'ready' ? '' : 'warn' }}">{{ $syncLabels[$plantingSync['code']] ?? $plantingSync['code'] }}@if($plantingSync['last_succeeded_at']) · {{ \Illuminate\Support\Str::of($plantingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span>
<span class="pw-tag {{ ($plantingShippingSync['code'] ?? '') === 'succeeded' ? '' : 'warn' }}">{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}@if($plantingShippingSync['last_succeeded_at'] ?? null) · {{ \Illuminate\Support\Str::of($plantingShippingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span>
@ -37,12 +37,12 @@
@php $deduct = $plantingSummary[Calculator::RESULT_DEDUCT]; $exempt = $plantingSummary[Calculator::RESULT_EXEMPT]; $free = $plantingSummary[Calculator::RESULT_FREE_GOODS]; $unmatched = $plantingSummary[Calculator::RESULT_UNMATCHED]; @endphp
<div class="pw-metrics">
<div><span>当月种菜报销</span><strong>{{ number_format($plantingSummary['total']) }}</strong><small>匹配到发货 {{ number_format($deduct['count'] + $exempt['count'] + $free['count']) }} 单</small></div>
<a href="{{ $resultUrl(Calculator::RESULT_DEDUCT) }}" class="is-key {{ $plantingFilters['result'] === Calculator::RESULT_DEDUCT ? 'is-active' : '' }}"><span>扣款金额(元)</span><strong>{{ $money($deduct['deduction_amount']) }}</strong><small>扣款 {{ number_format($deduct['count']) }} 单@if($deduct['cost_missing'] > 0) · <b class="pw-warn-text">{{ number_format($deduct['cost_missing']) }} 单成本价缺失</b>@endif</small></a>
<a href="{{ $resultUrl(Calculator::RESULT_DEDUCT) }}" class="is-key {{ $plantingFilters['result'] === Calculator::RESULT_DEDUCT ? 'is-active' : '' }}"><span>扣款金额(元)</span><strong>{{ $money($deduct['deduction_amount']) }}</strong><small>扣款 {{ number_format($deduct['count']) }} 单@if($deduct['cost_missing'] > 0) · <b class="pw-warn-text">{{ number_format($deduct['cost_missing']) }} 单成本价/运费缺失</b>@endif</small></a>
<a href="{{ $resultUrl(Calculator::RESULT_EXEMPT) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_EXEMPT ? 'is-active' : '' }}"><span>不扣款(拍A发B/拍A发A)</span><strong>{{ number_format($exempt['count']) }}</strong><small>需发运营 · 最终报销 {{ $money($exempt['reimbursement_amount']) }}</small></a>
<a href="{{ $resultUrl(Calculator::RESULT_FREE_GOODS) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_FREE_GOODS ? 'is-active' : '' }}"><span>不扣款(虚拟条码/透明挂钩)</span><strong>{{ number_format($free['count']) }}</strong><small>最终报销 {{ $money($free['reimbursement_amount']) }}</small></a>
<a href="{{ $resultUrl(Calculator::RESULT_UNMATCHED) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_UNMATCHED ? 'is-active' : '' }}"><span>未匹配发货(不计入)</span><strong>{{ number_format($unmatched['count']) }}</strong><small>最终报销 {{ $money($unmatched['reimbursement_amount']) }}</small></a>
</div>
<details class="pw-rules"><summary>计算口径</summary><p>① 取该月种菜报销明细:工单类型“种菜报销”、状态“已完成”,完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59(与「种菜报销」页同一口径)。② 用线上订单号(交易单号)精确匹配「快递应付 → 发货数据」中发货时间在上月 1 日至本月末的交易单号,匹配到的保留,同一单多个包裹/商品合并为一行。③ 匹配到的单中,任务备注或卖家备注写了“拍A发B”或“拍A发A”(不区分全半角、大小写和空格)的不做扣款,单独列出发给运营。④ 实际只发了“信誉订单虚拟条码”“透明挂钩”这类商品的不扣款;同一单里这类商品也不计入扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 × 1.2(成本价 + 成本价×20%);普通商品资料里没有成本价(空或 0)的商品标为“成本价缺失”,不计入扣款金额,需补齐成本价后自动重算。未匹配到发货的单不计入种菜损耗,仅供核对。</p></details>
<details class="pw-rules"><summary>计算口径</summary><p>① 取该月种菜报销明细:工单类型“种菜报销”、状态“已完成”,完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59(与「种菜报销」页同一口径)。② 用线上订单号(交易单号)精确匹配「快递应付 → 发货数据」中发货时间在上月 1 日至本月末的交易单号,匹配到的保留,同一单多个包裹/商品合并为一行。③ 匹配到的单中,任务备注或卖家备注写了“拍A发B”或“拍A发A”(不区分全半角、大小写和空格)的不做扣款,单独列出发给运营。④ 实际只发了“信誉订单虚拟条码”“透明挂钩”这类商品的不扣款;同一单里这类商品也不计入扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 + 该订单在「发货数据」中的运费合计;普通商品资料里没有成本价(空或 0)的商品、发货数据里运费为空的订单分别标为“成本价缺失”“运费缺失”,缺失部分不计入扣款金额,补齐后自动重算。未匹配到发货的单不计入种菜损耗,仅供核对。</p></details>
@if(($plantingShippingSync['code'] ?? '') !== 'succeeded' && ! $plantingError)<div class="pw-warn">发货数据当前状态为“{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}”{{ ($plantingShippingSync['warning'] ?? '') !== '' ? ':' . $plantingShippingSync['warning'] : '' }},匹配结果可能不完整。</div>@endif
@if($plantingError)<div class="pw-error" role="alert">{{ $plantingError }}</div>@endif
<section class="pw-panel" aria-label="种菜损耗明细">

View File

@ -23,6 +23,7 @@ class PlantingWorkOrderPagesTest extends FeatureTestCase
'task_empty_package_fee' => '0.00', 'reimbursement_amount' => '39.79', 'payee_name' => '黄文梳',
'loss_result' => Calculator::RESULT_DEDUCT, 'loss_basis' => '已匹配发货,备注无拍A发B/拍A发A',
'shipped_amount' => '40.60', 'shipped_freight' => '3.20', 'express_no' => 'YT001',
'deduction_amount' => '207.40', 'deduction_detail' => "DNZ-01 × 2 × 100.5 = 201.00\n运费 6.40", 'cost_missing' => false,
], $patch);
}
@ -78,7 +79,9 @@ class PlantingWorkOrderPagesTest extends FeatureTestCase
->assertSee('GD565967381')
->assertDontSee('GD-EXEMPT')
->assertDontSee('GD-NONE')
->assertSee('2026-08-01');
->assertSee('2026-08-01')
->assertSee('207.40')
->assertSee('DNZ-01 × 2 × 100.5 = 201.00');
$this->loginAs()->get($this->url('planting-loss', ['month' => '2026-09', 'result' => Calculator::RESULT_EXEMPT]))
->assertOk()->assertSee('GD-EXEMPT')->assertDontSee('GD565967381');

View File

@ -148,8 +148,8 @@ class PlantingLossCalculatorTest extends TestCase
$this->assertTrue(Calculator::isFreeGoods('XN001', '信誉订单虚拟条码'));
$this->assertTrue(Calculator::isFreeGoods('GG003-SZ', '透明挂钩3个'));
$this->assertFalse(Calculator::isFreeGoods('DNZ-01', '儿童学习椅'));
$this->assertSame(12060, Calculator::deductionCents('100.5', 1));
$this->assertSame(24120, Calculator::deductionCents('100.5', 2));
$this->assertSame(10050, Calculator::deductionCents('100.5', 1));
$this->assertSame(20100, Calculator::deductionCents('100.5', 2));
}
public function test_loss_rows_match_shipments_and_split_exempt_from_deduct(): void
@ -187,9 +187,9 @@ class PlantingLossCalculatorTest extends TestCase
$this->assertSame("YT001\nYT002", $rows['GD-DEDUCT']['express_no']);
$this->assertSame('50.60', $rows['GD-DEDUCT']['shipped_amount']);
$this->assertSame('3', $rows['GD-DEDUCT']['shipped_qty']);
// 取最新一条普通商品成本价 100.5:100.5 × 2 × 1.2 = 241.20,挂钩不计。
$this->assertSame('241.20', $rows['GD-DEDUCT']['deduction_amount']);
$this->assertSame('DNZ-01 × 2 × 100.5 × 1.2 = 241.20', $rows['GD-DEDUCT']['deduction_detail']);
// 取最新一条普通商品成本价 100.5:100.5 × 2 = 201.00,挂钩不计货值;加该单运费 3.20 × 2 个包裹 = 6.40。
$this->assertSame('207.40', $rows['GD-DEDUCT']['deduction_amount']);
$this->assertSame("DNZ-01 × 2 × 100.5 = 201.00\n运费 6.40", $rows['GD-DEDUCT']['deduction_detail']);
$this->assertFalse($rows['GD-DEDUCT']['cost_missing']);
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-VIRTUAL']['loss_result']);
@ -197,7 +197,7 @@ class PlantingLossCalculatorTest extends TestCase
// 成本价为 0、或只有组合装资料的条码视为成本价缺失。
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-NOCOST']['loss_result']);
$this->assertSame('0.00', $rows['GD-NOCOST']['deduction_amount']);
$this->assertSame('6.40', $rows['GD-NOCOST']['deduction_amount']);
$this->assertTrue($rows['GD-NOCOST']['cost_missing']);
$this->assertStringContainsString('ZERO、DNZ-02', $rows['GD-NOCOST']['loss_basis']);
$this->assertNull($rows['GD-EXEMPT']['deduction_amount']);