CaiWuXiTong/modules/EcommerceFinance/Services/PlantingLossCalculator.php
limeng 4e174963e8 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
2026-10-20 10:10:29 +08:00

337 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository;
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”的不扣款,不论是否匹配到发货都单独列出发给运营;
* 4. 实际只发了「信誉订单虚拟条码」「透明挂钩」这类商品的不扣款;
* 5. 其余匹配到的作为扣款项,扣款金额 = Σ 发货商品成本价 × 数量 + 该订单在发货数据中的运费(2026-10-19 更正),
* 成本价按发货条形码(=商品编码)取普通商品资料。未匹配到发货的不计入种菜损耗。
*/
class PlantingLossCalculator
{
public const RESULT_DEDUCT = '扣款';
public const RESULT_EXEMPT = '不扣款(拍A发B/拍A发A)';
public const RESULT_FREE_GOODS = '不扣款(虚拟条码/透明挂钩)';
public const RESULT_UNMATCHED = '未匹配发货';
public const RESULTS = [self::RESULT_DEDUCT, self::RESULT_EXEMPT, self::RESULT_FREE_GOODS, self::RESULT_UNMATCHED];
/** 发货商品名称或条码含这些字样的不计扣款。 */
public const FREE_GOODS_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩', '无实物'];
/**
* 信誉订单虚拟条码(种菜单自身的虚拟商品)。发货数据里同一交易单号下若出现它,说明这单本身是按虚拟条码走的,
* 同单号下的其它商品是合并发货的别的订单,整单不扣款(2026-10-19 财务确认:信誉订单虚拟条码不扣款)。
* 透明挂钩同理:同单号下出现透明挂钩即整单不扣款(2026-10-20 财务确认)。
*/
public const VIRTUAL_ORDER_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩'];
public const VIRTUAL_ORDER_BARCODES = ['X00006', 'X00002'];
public const SKU_DATASET = 'operations_replica.rpt.jst_sku';
private const CHUNK = 1000;
private const CACHE_SECONDS = 600;
public function __construct(
private readonly PlantingReimbursementRepository $reimbursements,
private readonly ShippingOrderRepository $shipping,
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {}
/** 发货数据同步状态(与「发货数据」页面同一来源)。 */
public function shippingStatus(): array
{
return $this->shipping->syncStatus();
}
/**
* 发货数据匹配窗口:报销月份的上月 1 日 00:00 至下月 1 日 00:00。
*
* @return array{from:string,to:string}
*/
public static function shippingWindow(string $month): array
{
PlantingReimbursementRepository::window($month);
$start = CarbonImmutable::createFromFormat('!Y-m', $month);
return [
'from' => $start->subMonthNoOverflow()->format('Y-m-d H:i:s'),
'to' => $start->addMonthNoOverflow()->format('Y-m-d H:i:s'),
];
}
/** 例:2026-09 → “2026-08~2026-09”。 */
public static function shippingLabel(string $month): string
{
$window = self::shippingWindow($month);
return substr($window['from'], 0, 7) . '~' . $month;
}
/** 命中的免扣字样(拍A发B / 拍A发A),未命中返回 null。全角、大小写、空格均兼容。 */
public static function exemptionKeyword(string ...$remarks): ?string
{
foreach ($remarks as $remark) {
$text = mb_strtoupper((string) preg_replace('/\s+/u', '', mb_convert_kana($remark, 'as')));
if (preg_match('/拍A发([AB])/u', $text, $match) === 1) {
return '拍A发' . $match[1];
}
}
return null;
}
public static function isFreeGoods(string $barcode, string $productName): bool
{
foreach (self::FREE_GOODS_KEYWORDS as $keyword) {
if (str_contains($productName, $keyword) || str_contains($barcode, $keyword)) {
return true;
}
}
return false;
}
public static function isVirtualOrderItem(string $barcode, string $productName): bool
{
$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) {
if (str_contains($productName, $keyword)) {
return true;
}
}
return false;
}
/** 成本价(元,字符串)× 数量 → 分。 */
public static function deductionCents(string $costPrice, int $qty): int
{
return (int) round((float) $costPrice * 100 * $qty);
}
/**
* 当月全部种菜报销及其发货匹配、扣款判定(含未匹配,页面按结果筛选)。
*
* @return list<array<string,mixed>>
*/
public function rows(string $month): array
{
$key = 'planting-loss:rows:v3:' . $month;
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
$reimbursements = $this->reimbursements->rows($month);
$window = self::shippingWindow($month);
$shipments = $this->shipments(array_column($reimbursements, 'online_order_no'), $window['from'], $window['to']);
$costs = $this->costPrices(array_merge(...array_map(
static fn (array $shipment): array => array_column($shipment['lines'], 'barcode'),
array_values($shipments),
) ?: [[]]));
$rows = [];
foreach ($reimbursements as $row) {
$rows[] = $this->judge($row, $shipments[$row['online_order_no']] ?? null, $costs, $month);
}
try {
Cache::put($key, $rows, self::CACHE_SECONDS);
} catch (\Throwable $exception) {
report($exception);
}
return $rows;
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed>|null $shipment
* @param array<string,string> $costs 条码 => 成本价
* @return array<string,mixed>
*/
private function judge(array $row, ?array $shipment, array $costs, string $month): array
{
$deductLines = [];
$missingCost = [];
$cents = 0;
$virtualOrder = false;
foreach ($shipment['lines'] ?? [] as $line) {
if (self::isVirtualOrderItem($line['barcode'], $line['product_name'])) {
$virtualOrder = true;
}
}
foreach ($virtualOrder ? [] : ($shipment['lines'] ?? []) as $line) {
if (self::isFreeGoods($line['barcode'], $line['product_name'])) {
continue;
}
$cost = $costs[$line['barcode']] ?? null;
if ($cost === null) {
$missingCost[] = $line['barcode'] !== '' ? $line['barcode'] : '(无条码)';
$deductLines[] = $line['barcode'] . ' × ' . $line['qty'] . ' 成本价缺失';
continue;
}
$lineCents = self::deductionCents($cost, $line['qty']);
$cents += $lineCents;
$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 ? '运费缺失' : '',
]));
// 拍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) . ' 发货数据中无此交易单号'],
$virtualOrder => [self::RESULT_FREE_GOODS, '发货含信誉订单虚拟条码/透明挂钩,整单不扣款'],
$deductLines === [] => [self::RESULT_FREE_GOODS, '只发了虚拟条码/透明挂钩/无实物商品'],
$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 && $missingText !== '';
$row['shipped_at'] = $shipment['shipped_at'] ?? '';
$row['express_no'] = $shipment['express_no'] ?? '';
$row['warehouse_name'] = $shipment['warehouse_name'] ?? '';
$row['shipped_barcode'] = implode("\n", array_column($shipment['lines'] ?? [], 'barcode'));
$row['shipped_product_name'] = implode("\n", array_column($shipment['lines'] ?? [], 'product_name'));
$row['shipped_qty'] = $shipment === null ? '' : (string) array_sum(array_column($shipment['lines'], 'qty'));
$row['shipped_amount'] = $shipment['amount'] ?? null;
$row['shipped_freight'] = $shipment['freight'] ?? null;
return $row;
}
/**
* @param list<string> $tradeNos
* @return array<string,array{shipped_at:string,express_no:string,warehouse_name:string,amount:?string,freight:?string,lines:list<array{barcode:string,product_name:string,qty:int}>}>
*/
private function shipments(array $tradeNos, string $from, string $to): array
{
$tradeNos = array_values(array_unique(array_filter(array_map('trim', $tradeNos), static fn (string $v): bool => $v !== '')));
$db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
$acc = [];
foreach (array_chunk($tradeNos, self::CHUNK) as $chunk) {
$records = $db->table('jst_oms_shipping')
->whereIn('trade_no', $chunk)
->where('ship_time', '>=', $from)
->where('ship_time', '<', $to)
->orderBy('id')
->get(['trade_no', 'ship_time', 'express_no', 'warehouse_name', 'barcode', 'product_name', 'qty', 'amount', 'freight']);
foreach ($records as $record) {
$trade = (string) $record->trade_no;
$item = &$acc[$trade];
$shipTime = substr((string) $record->ship_time, 0, 19);
$item['shipped_at'] = isset($item['shipped_at']) ? min($item['shipped_at'], $shipTime) : $shipTime;
foreach (['express_no', 'warehouse_name'] as $key) {
$value = trim((string) $record->{$key});
if ($value !== '') {
$item[$key][$value] = true;
}
}
foreach (['amount', 'freight'] as $key) {
if ($record->{$key} !== null) {
$item[$key] = ($item[$key] ?? 0) + (int) round((float) $record->{$key} * 100);
}
}
$barcode = trim((string) $record->barcode);
$lineKey = $barcode . '|' . trim((string) $record->product_name);
$item['lines'][$lineKey] ??= ['barcode' => $barcode, 'product_name' => trim((string) $record->product_name), 'qty' => 0];
$item['lines'][$lineKey]['qty'] += max(1, (int) $record->qty);
unset($item);
}
}
$result = [];
foreach ($acc as $trade => $item) {
$result[$trade] = [
'shipped_at' => $item['shipped_at'],
'express_no' => implode("\n", array_keys($item['express_no'] ?? [])),
'warehouse_name' => implode("\n", array_keys($item['warehouse_name'] ?? [])),
'amount' => isset($item['amount']) ? number_format($item['amount'] / 100, 2, '.', '') : null,
'freight' => isset($item['freight']) ? number_format($item['freight'] / 100, 2, '.', '') : null,
'lines' => array_values($item['lines']),
];
}
return $result;
}
/**
* 普通商品资料的成本价:条形码(商品编码)= ops_jst_sku.sku_id,同编码取最新一条。
*
* @param list<string> $barcodes
* @return array<string,string>
*/
private function costPrices(array $barcodes): array
{
$barcodes = array_values(array_unique(array_filter($barcodes, static fn (string $v): bool => $v !== '')));
if ($barcodes === []) {
return [];
}
$connection = $this->mirror->connection();
$table = $this->datasets->get(self::SKU_DATASET)->targetTable();
$costs = [];
foreach (array_chunk($barcodes, self::CHUNK) as $chunk) {
$records = $connection->table($table)
->whereIn('sku_id', $chunk)
->where('sku_type', 'normal')
->orderByDesc('autoid')
->get(['sku_id', 'cost_price']);
foreach ($records as $record) {
$sku = (string) $record->sku_id;
if (isset($costs[$sku])) {
continue;
}
// 成本价为空或 0 视为缺失,避免把未维护成本的商品算成 0 元扣款。
if (is_numeric($record->cost_price) && (float) $record->cost_price > 0) {
$costs[$sku] = rtrim(rtrim(number_format((float) $record->cost_price, 4, '.', ''), '0'), '.');
}
}
}
return $costs;
}
}