fix(种菜损耗): 透明挂钩/虚拟条码整单不扣款;拍A发B 从种菜报销直接挑出发运营

财务确认(2026-10-20):种菜损耗是查种菜报销的单有没有真实发出有价值的货——
- 发货数据中交易单号下出现信誉订单虚拟条码或透明挂钩(含条形码 GG 开头)即整单不扣款;
- 任务备注/卖家备注含拍A发B/拍A发A 的不扣款,且无论是否匹配到发货都从种菜报销中挑出发运营;
- “导出拍A发B(发运营)”直接按「种菜报销」页字段输出;其余真实发货的单按成本价×数量+运费扣款。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
This commit is contained in:
limeng 2026-10-20 10:10:29 +08:00
parent 9b6c3d2477
commit 4e174963e8
6 changed files with 54 additions and 17 deletions

View File

@ -32,6 +32,15 @@ class PlantingWorkOrders extends Controller
default => '-扣款',
};
// 发运营的拍A发B 名单直接按「种菜报销」的字段导出。
if (request()->query('format') === 'reimbursement') {
return $this->csv(
'拍A发B发运营-' . $filters['month'],
PlantingReimbursementPageData::COLUMNS,
static fn () => $data->rows($filters),
);
}
return $this->csv(
PlantingLossPageData::TITLE . '-' . $filters['month'] . $suffix,
$data->exportColumns(),

View File

@ -14,7 +14,7 @@ use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
* 种菜损耗(2026-10-19 财务确认的口径):
* 1. 取 M 月种菜报销明细(完成时间 [M-02, M+1-02));
* 2. 用线上订单号去匹配发货数据(jst_oms_shipping)中 M-1、M 两个月发货的交易单号,匹配到的保留;
* 3. 匹配到的单里,任务备注或卖家备注写了“拍A发B”或“拍A发A”的不扣款,单独列出发给运营;
* 3. 任务备注或卖家备注写了“拍A发B”或“拍A发A”的不扣款,不论是否匹配到发货都单独列出发给运营;
* 4. 实际只发了「信誉订单虚拟条码」「透明挂钩」这类商品的不扣款;
* 5. 其余匹配到的作为扣款项,扣款金额 = Σ 发货商品成本价 × 数量 + 该订单在发货数据中的运费(2026-10-19 更正),
* 成本价按发货条形码(=商品编码)取普通商品资料。未匹配到发货的不计入种菜损耗。
@ -37,8 +37,9 @@ class PlantingLossCalculator
/**
* 信誉订单虚拟条码(种菜单自身的虚拟商品)。发货数据里同一交易单号下若出现它,说明这单本身是按虚拟条码走的,
* 同单号下的其它商品是合并发货的别的订单,整单不扣款(2026-10-19 财务确认:信誉订单虚拟条码不扣款)。
* 透明挂钩同理:同单号下出现透明挂钩即整单不扣款(2026-10-20 财务确认)。
*/
public const VIRTUAL_ORDER_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码'];
public const VIRTUAL_ORDER_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩'];
public const VIRTUAL_ORDER_BARCODES = ['X00006', 'X00002'];
@ -111,7 +112,9 @@ class PlantingLossCalculator
public static function isVirtualOrderItem(string $barcode, string $productName): bool
{
if (in_array(strtoupper(trim($barcode)), self::VIRTUAL_ORDER_BARCODES, true)) {
$code = strtoupper(trim($barcode));
// 透明挂钩条形码以 GG 开头(如 GG003-SZ / GG-xxx),2026-10-20 财务确认。
if (in_array($code, self::VIRTUAL_ORDER_BARCODES, true) || str_starts_with($code, 'GG')) {
return true;
}
foreach (self::VIRTUAL_ORDER_KEYWORDS as $keyword) {
@ -210,11 +213,12 @@ class PlantingLossCalculator
$deductLines !== [] && $freight === null ? '运费缺失' : '',
]));
$keyword = $shipment === null ? null : self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']);
// 拍A发B/拍A发A 不论是否匹配到发货都单独列出发给运营(2026-10-20 财务确认);匹配发货只用于挑出真实发货的单。
$keyword = self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']);
[$result, $basis] = match (true) {
$keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”' . ($shipment === null ? '(未匹配到发货)' : '')],
$shipment === null => [self::RESULT_UNMATCHED, self::shippingLabel($month) . ' 发货数据中无此交易单号'],
$keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”'],
$virtualOrder => [self::RESULT_FREE_GOODS, '发货含信誉订单虚拟条码(同单号其它商品为合并发货的别的订单)'],
$virtualOrder => [self::RESULT_FREE_GOODS, '发货含信誉订单虚拟条码/透明挂钩,整单不扣款'],
$deductLines === [] => [self::RESULT_FREE_GOODS, '只发了虚拟条码/透明挂钩/无实物商品'],
$missingText !== '' => [self::RESULT_DEDUCT, $missingText . ',扣款金额未含缺失部分'],
default => [self::RESULT_DEDUCT, '已匹配发货,按成本价×数量+运费扣款'],

View File

@ -80,7 +80,7 @@ class PlantingLossPageData
$sync = $this->repository->syncStatus();
$shippingSync = $this->calculator->shippingStatus();
$empty = ['count' => 0, 'cost_missing' => 0] + array_fill_keys(self::SUM_KEYS, 0);
$summary = ['total' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
$summary = ['total' => 0, 'matched' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
$items = [];
$error = $this->syncError($sync);
@ -89,6 +89,7 @@ class PlantingLossPageData
$all = $this->calculator->rows($filters['month']);
foreach ($all as $row) {
$summary['total']++;
$summary['matched'] += ($row['shipped_at'] ?? '') !== '' ? 1 : 0;
$this->add($summary[$row['loss_result']], $row);
}
foreach ($this->rows($filters, $all) as $row) {
@ -100,7 +101,7 @@ class PlantingLossPageData
} catch (\Throwable $exception) {
report($exception);
$error = '种菜损耗暂时无法计算(工单镜像或发货数据读取失败),请稍后重试。';
$summary = ['total' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
$summary = ['total' => 0, 'matched' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
$items = [];
}
}

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”的、以及只发了信誉订单虚拟条码/透明挂钩的不扣款,其余按发货商品成本价×数量+该单运费扣款。</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>
@ -31,18 +31,18 @@
<button type="submit" class="pw-primary">查询</button>
<a class="pw-button" href="{{ request()->url() }}">重置</a>
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-loss.export', $exportQuery) }}">导出当前筛选</a>
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-loss.export', ['month' => $plantingFilters['month'], 'result' => Calculator::RESULT_EXEMPT]) }}">导出拍A发B(发运营)</a>
<a class="pw-button" href="{{ route('business.planting-data.planting-loss.export', ['month' => $plantingFilters['month'], 'result' => Calculator::RESULT_EXEMPT, 'format' => 'reimbursement']) }}" title="按种菜报销字段导出当月全部拍A发B/拍A发A">导出拍A发B(发运营)</a>
<details class="pw-column-settings"><summary class="pw-button">列设置</summary><div class="pw-column-menu"><p>勾选显示,拖动调整顺序</p><div data-pw-column-list>@foreach($columns as $key => $label)<label draggable="true" data-column-key="{{ $key }}"><input type="checkbox" value="{{ $key }}" checked> {{ $label }}</label>@endforeach</div><button type="button" class="pw-button" data-pw-reset>恢复默认</button><span data-pw-column-message role="status"></span></div></details>
</form>
@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>
<div><span>当月种菜报销</span><strong>{{ number_format($plantingSummary['total']) }}</strong><small>匹配到发货 {{ number_format($plantingSummary['matched']) }} 单</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_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”(不区分全半角、大小写和空格)的不做扣款,单独列出发给运营。④ 发货数据里该交易单号下出现“信誉订单虚拟条码”(X00006/X00002)的整单不扣款——同单号下的其它商品是合并发货的别的订单;只发了“透明挂钩”“无实物”这类商品的也不扣款,同一单里这类商品不计入扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 + 该订单在「发货数据」中的运费合计;普通商品资料里没有成本价(空或 0)的商品、发货数据里运费为空的订单分别标为“成本价缺失”“运费缺失”,缺失部分不计入扣款金额,补齐后自动重算。未匹配到发货的单不计入种菜损耗,仅供核对。</p></details>
<details class="pw-rules"><summary>计算口径</summary><p>① 取该月种菜报销明细:工单类型“种菜报销”、状态“已完成”,完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59(与「种菜报销」页同一口径)。② 用线上订单号(交易单号)精确匹配「快递应付 → 发货数据」中发货时间在上月 1 日至本月末的交易单号,匹配到的保留,同一单多个包裹/商品合并为一行。③ 任务备注或卖家备注写了“拍A发B”或“拍A发A”(不区分全半角、大小写和空格)的不做扣款,不论是否匹配到发货都单独列出,「导出拍A发B(发运营)」按种菜报销字段导出。④ 发货数据里该交易单号下出现“信誉订单虚拟条码”(X00006/X00002)或透明挂钩(条形码 GG 开头)的整单不扣款(同单号下的其它商品视为合并发货的别的订单);只发了“无实物”商品的也不扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 + 该订单在「发货数据」中的运费合计;普通商品资料里没有成本价(空或 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

@ -107,6 +107,17 @@ class PlantingWorkOrderPagesTest extends FeatureTestCase
$this->assertStringContainsString("GD-EXEMPT,'3316392626048007769,拍A发B", $csv);
$this->assertStringNotContainsString('GD565967381', $csv);
// 发运营:按种菜报销字段导出,忽略损耗页的列设置。
$operations = $this->loginAs()->get(route('business.planting-data.planting-loss.export', [
'company_id' => $this->company->id, 'month' => '2026-09', 'result' => Calculator::RESULT_EXEMPT, 'format' => 'reimbursement',
]));
$operations->assertOk();
$opsCsv = $operations->streamedContent();
$this->assertStringContainsString(implode(',', array_values(PlantingReimbursementPageData::COLUMNS)), $opsCsv);
$this->assertStringContainsString('GD-EXEMPT', $opsCsv);
$this->assertStringNotContainsString('GD565967381', $opsCsv);
$this->assertStringNotContainsString('损耗判定', $opsCsv);
$reimbursements = $this->loginAs()->get(route('business.planting-data.planting-reimbursements.export', [
'company_id' => $this->company->id, 'month' => '2026-09',
]));

View File

@ -150,7 +150,9 @@ class PlantingLossCalculatorTest extends TestCase
$this->assertFalse(Calculator::isFreeGoods('DNZ-01', '儿童学习椅'));
$this->assertTrue(Calculator::isFreeGoods('tcxj', '书柜 晒图有奖励(无实物)'));
$this->assertTrue(Calculator::isVirtualOrderItem('X00006', ''));
$this->assertFalse(Calculator::isVirtualOrderItem('GG003-SZ', '透明挂钩3个'));
$this->assertTrue(Calculator::isVirtualOrderItem('GG003-SZ', '透明挂钩3个'));
$this->assertTrue(Calculator::isVirtualOrderItem('GG-005', '挂钩'));
$this->assertFalse(Calculator::isVirtualOrderItem('DNZ-01', '儿童学习椅'));
$this->assertSame(10050, Calculator::deductionCents('100.5', 1));
$this->assertSame(20100, Calculator::deductionCents('100.5', 2));
}
@ -171,10 +173,13 @@ class PlantingLossCalculatorTest extends TestCase
$this->workOrder(10, 'GD-VIRTUAL', '5555', '2026-09-20 10:00:00');
$this->workOrder(11, 'GD-NOCOST', '6666', '2026-09-21 10:00:00');
$this->workOrder(12, 'GD-MERGED', '7777', '2026-09-22 10:00:00');
$this->workOrder(13, 'GD-HOOK', '8888', '2026-09-23 10:00:00');
// 未匹配到发货但备注拍A发B:同样列入不扣款(发运营)。
$this->workOrder(14, 'GD-PAB-NOSHIP', '9090', '2026-09-24 10:00:00', [], '拍A发B');
// 一单两个包裹:学习椅 ×2 + 透明挂钩(挂钩不扣)。
// 一单两个包裹:学习椅 ×2 + 无实物商品(不计货值)。
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT001', 40.6, 'DNZ-01', '儿童学习椅', 2);
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT002', 10, 'GG003-SZ', '透明挂钩3个');
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT002', 10, 'XS-01', '晒图有奖励(无实物)');
$this->ship('3604408018648198', '2026-09-30 23:59:59');
$this->ship('260901-000000000000001', '2026-09-01 08:00:00');
$this->ship('9999', '2026-07-31 23:59:59'); // 早于上月 1 日
@ -185,16 +190,19 @@ class PlantingLossCalculatorTest extends TestCase
// 合并发货:同一交易单号下有信誉订单虚拟条码 + 别的订单的实物,整单不扣。
$this->ship('7777', '2026-09-11 08:00:00', 'YT777', 0, 'X00006', '信誉订单虚拟条码');
$this->ship('7777', '2026-09-11 08:00:00', 'YT777', 0, 'DNZ-01', '儿童学习椅');
// 实物 + 透明挂钩:整单不扣。
$this->ship('8888', '2026-09-12 08:00:00', 'YT888', 0, 'DNZ-01', '儿童学习椅');
$this->ship('8888', '2026-09-12 08:00:00', 'YT888', 0, 'GG003-SZ', '透明挂钩3个');
$this->ship('6666', '2026-09-10 08:00:00', 'YT666', 0, 'DNZ-02', '组合商品');
$rows = collect($this->app->make(Calculator::class)->rows('2026-09'))->keyBy('work_order_no');
$this->assertSame(['GD-DEDUCT', 'GD-EXEMPT', 'GD-MERGED', 'GD-NOCOST', 'GD-SELLER', 'GD-UNMATCHED', 'GD-VIRTUAL'], $rows->keys()->sort()->values()->all());
$this->assertSame(['GD-DEDUCT', 'GD-EXEMPT', 'GD-HOOK', 'GD-MERGED', 'GD-NOCOST', 'GD-PAB-NOSHIP', 'GD-SELLER', 'GD-UNMATCHED', 'GD-VIRTUAL'], $rows->keys()->sort()->values()->all());
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-DEDUCT']['loss_result']);
$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 = 201.00,挂钩不计货值;加该单运费 3.20 × 2 个包裹 = 6.40。
// 取最新一条普通商品成本价 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']);
@ -204,6 +212,10 @@ class PlantingLossCalculatorTest extends TestCase
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-MERGED']['loss_result']);
$this->assertNull($rows['GD-MERGED']['deduction_amount']);
$this->assertStringContainsString('信誉订单虚拟条码', $rows['GD-MERGED']['loss_basis']);
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-HOOK']['loss_result']);
$this->assertNull($rows['GD-HOOK']['deduction_amount']);
$this->assertSame(Calculator::RESULT_EXEMPT, $rows['GD-PAB-NOSHIP']['loss_result']);
$this->assertSame('备注含“拍A发B”(未匹配到发货)', $rows['GD-PAB-NOSHIP']['loss_basis']);
// 成本价为 0、或只有组合装资料的条码视为成本价缺失。
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-NOCOST']['loss_result']);