10 月生产数据里,部分种菜单的交易单号在发货数据中与大量合并发货的别的订单商品挂在一起(同一交易单号下几十个实物+信誉订单虚拟条码), 被错算为扣款。财务确认信誉订单虚拟条码不扣款:该单号下出现信誉订单虚拟条码(X00006/X00002)即整单归入不扣款; “晒图有奖励(无实物)”等无实物商品也不计扣款。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
333 lines
14 KiB
PHP
333 lines
14 KiB
PHP
<?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 财务确认:信誉订单虚拟条码不扣款)。
|
||
*/
|
||
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
|
||
{
|
||
if (in_array(strtoupper(trim($barcode)), self::VIRTUAL_ORDER_BARCODES, true)) {
|
||
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 ? '运费缺失' : '',
|
||
]));
|
||
|
||
$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 . '”'],
|
||
$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;
|
||
}
|
||
}
|