CaiWuXiTong/app/Console/Commands/SyncStoreReceiptOrderMatches.php

778 lines
34 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 App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Throwable;
/**
* 业绩明细 → 店铺收款匹配订单明细。
*
* 以「店铺收款账户账单」为基数(LEFT JOIN 语义,账单每笔收款都保留),
* 按「店铺 + 订单号」关联订单侧商品金额,输出严格遵循「一个店铺、一个订单号、一个商品金额」。
*
* 口径(已与业务确认):
* - 账单侧:按「总账备注」筛选(与店铺收款账户账单页同一口径),
* 再按「店铺 + 清洗后订单号」汇总收入金额与收款笔数
* - 订单侧:销售全量按「店铺 + 线上订单号」先聚合求和(一单多品/多子订单在此合并),金额取 goods_count > 0 的有效行
* - 单号清洗:去除日期前缀(如 260630-)、空格、横杠后按核心号匹配
* - 店铺:账单用「线上真实店铺」优先,订单侧用销售全量店铺名,统一把下划线归一为横杠
* - 同一店铺同一订单号多笔收款 → 标注「一单多笔」,商品金额只挂一次
*/
class SyncStoreReceiptOrderMatches extends Command
{
protected $signature = 'store-receipt:order-match:sync
{month : 账单月份,格式 YYYY-MM}
{--platform=taobao-alipay : 收款账户,目前支持 taobao-alipay}
{--chunk=1200 : 单次向上游查询的订单号数量}
{--limit=0 : 仅处理前 N 笔账单(调试用,0 表示全量)}
{--amounts-only : 只回填「优惠金额 / 冲减金额」两列,不重跑订单侧匹配}';
protected $description = '按店铺收款账户账单匹配订单侧商品金额,生成「业绩明细」核对镜像';
/**
* 收款账户页签 → 账单来源与列映射。
*
* 各页签的表结构不一致:淘系/拼多多的订单号列是 online_order_no、金额列是 income_amount;
* 京东系列与抖音是 order_no / income;得物没有 occurred_at,用 bill_date。
* 京东账单与京喜账单共用同一张日账单表,用店铺名关键字拆分。
*/
private const SOURCES = [
'taobao-alipay' => [
'label' => '淘系支付宝', 'table' => 'taobao_alipay_bill_rows',
'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount',
'real_store' => 'online_real_store',
],
'taobao-wechat' => [
'label' => '淘系微信', 'table' => 'taobao_wechat_bill_rows',
'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount',
],
'pinduoduo' => [
'label' => '拼多多', 'table' => 'pdd_settlement_bill_rows',
'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount',
],
'dewu' => [
'label' => '得物', 'table' => 'dewu_settlement_bill_rows',
'order' => 'online_order_no', 'time' => 'bill_date', 'amount' => 'income_amount',
'real_store' => 'online_real_store',
],
'jd-wallet' => [
'label' => '京东网银钱包', 'table' => 'jd_wallet_bill_rows',
'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income',
'periods' => 'jd_wallet_bill_periods',
],
'jd-transactions' => [
'label' => '京东账单', 'table' => 'jd_daily_bill_rows',
'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income',
'store_exclude' => '京喜',
'periods' => 'jd_daily_bill_periods',
],
'jingxi' => [
'label' => '京喜账单', 'table' => 'jd_daily_bill_rows',
'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income',
'store_include' => '京喜',
'periods' => 'jd_daily_bill_periods',
],
'douyin' => [
'label' => '抖音', 'table' => 'douyin_authoritative_bills',
'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income',
'periods' => 'douyin_authoritative_periods',
],
];
/**
* 账单侧筛选口径:总账备注命中以下任一取值即计入。
*
* 与店铺收款账户账单页的「总账备注」筛选完全一致,账单项目清单由
* ec_account_ledger_remarks(平台 + 账单项目 → 总账备注)反查得到。
*/
private const LEDGER_REMARKS = [
'订单收入',
];
/**
* 「优惠金额」列口径:账单的总账备注命中以下取值。
*
* 总账备注优先取「财务规则 › 科目映射表」的财务备注(与店铺收款账户账单页一致),
* 取不到时回落到 ec_account_ledger_remarks。金额不计入收入金额与差额。
*/
private const DISCOUNT_REMARKS = [
'优惠收入',
];
/** 「冲减金额」列口径:账单的总账备注命中以下取值,不参与收入金额与差额计算。 */
private const OFFSET_REMARKS = [
'冲减收入',
];
private const TARGET_TABLE = 'store_receipt_order_match_rows';
private const STATUS_MATCHED = '已匹配';
private const STATUS_UNMATCHED = '未匹配';
private const REMARK_EMPTY_NO = '空单号';
private const REMARK_BAD_FORMAT = '格式不一致';
private const REMARK_NO_ORDER = '订单表无此单';
private const REMARK_NO_AMOUNT = '订单无商品金额';
private const REMARK_SHOP_DIFF = '店铺不一致';
private const REMARK_DUP_ORDER = '订单侧同号多条';
private const REMARK_MULTI_PAY = '一单多笔';
public function handle(UpstreamReadOnlyQueryFactory $factory): int
{
$month = trim((string) $this->argument('month'));
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
throw new InvalidArgumentException('月份格式应为 YYYY-MM,例如 2026-08。');
}
$platform = (string) $this->option('platform');
if (! isset(self::SOURCES[$platform])) {
throw new InvalidArgumentException('暂不支持的收款账户:' . $platform);
}
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$source = self::SOURCES[$platform];
$billTable = (string) $source['table'];
$orderColumn = (string) $source['order'];
$timeColumn = (string) $source['time'];
$amountColumn = (string) $source['amount'];
$realStoreColumn = (string) ($source['real_store'] ?? '');
$chunkSize = max(50, (int) $this->option('chunk'));
$limit = max(0, (int) $this->option('limit'));
$start = Carbon::createFromFormat('Y-m-d', $month . '-01')->startOfDay();
$end = $start->copy()->addMonthNoOverflow();
$reportMonth = $start->toDateString();
$this->info(sprintf(
'账期 %s(%s ~ %s),收款账户 %s(%s)',
$month,
$start->toDateString(),
$end->copy()->subDay()->toDateString(),
$source['label'],
$billTable,
));
// 「总账备注」→ 按平台分组的账单项目集合(淘系页签同页混有淘宝/天猫/阿里巴巴店铺,
// 必须连店铺名前缀一起过滤,才能与页面按行取到的总账备注一致)。
// 收入金额只认 LEDGER_REMARKS;优惠/冲减金额各自单独成列,不参与收入与差额。
$baseGroups = $this->ledgerRemarkGroups($platform);
$discountGroups = $this->ledgerRemarkGroups($platform, self::DISCOUNT_REMARKS);
$offsetGroups = $this->ledgerRemarkGroups($platform, self::OFFSET_REMARKS);
// 总账备注现在优先取科目映射表的财务备注,因此这两列的候选账单项目
// 也要按科目映射表补一遍,否则「优惠收入 / 冲减收入」永远取不到项目。
$promoItems = array_values(array_unique(array_merge(
$this->accountMappingItems($platform, self::DISCOUNT_REMARKS),
$this->accountMappingItems($platform, self::OFFSET_REMARKS),
)));
// 微信账户页签的账单项目带「wx」前缀(wx限时红包代商家垫付扣回 等),
// 科目映射表用的是不带前缀的名字,这里两种写法都带上。
$promoItems = $this->withWxVariants($promoItems);
if ($baseGroups === []) {
$this->error('「总账备注」映射表没有匹配到任何账单项目,请先导入 ec_account_ledger_remarks。');
return self::FAILURE;
}
$this->info('总账备注命中平台分组:' . count($baseGroups) . ' 组');
if ($discountGroups === [] && $offsetGroups === [] && $promoItems === []) {
$this->warn('「优惠收入」「冲减收入」在科目映射表与总账备注映射里都没有对应账单项目,两列将全部为空。');
}
$select = ['store_name', 'bill_item', $orderColumn, $timeColumn, $amountColumn];
if ($realStoreColumn !== '') {
$select[] = $realStoreColumn;
}
$billQuery = DB::connection($connection)->table($billTable)
->select($select)
->where($timeColumn, '>=', $start->toDateTimeString())
->where($timeColumn, '<', $end->toDateTimeString());
if (isset($source['store_include'])) {
$billQuery->where('store_name', 'like', '%' . $source['store_include'] . '%');
}
if (isset($source['store_exclude'])) {
$billQuery->where('store_name', 'not like', '%' . $source['store_exclude'] . '%');
}
// 京东系列与抖音的镜像按代保存历史导入,必须只取与本月重叠的活动代,否则会重复计数。
$periodsTable = (string) ($source['periods'] ?? '');
if ($periodsTable !== '') {
$generations = DB::connection($connection)->table($periodsTable)
->where('period_start', '<', $end->toDateString())
->where('period_end', '>', $start->toDateString())
->pluck('active_generation')
->filter()
->unique()
->values()
->all();
if ($generations === []) {
$this->warn('该账期没有活动代(' . $periodsTable . '),未写入任何数据。');
return self::SUCCESS;
}
$billQuery->whereIn('generation', $generations);
$this->info('活动代:' . count($generations) . ' 个');
}
$billQuery->where(function ($group) use ($baseGroups, $discountGroups, $offsetGroups, $promoItems): void {
LedgerRemarkFilter::applyGroups(
$group,
array_merge($baseGroups, $discountGroups, $offsetGroups),
'bill_item',
static fn ($sub, string $prefix) => $sub->where('store_name', 'like', $prefix . '%'),
static fn ($sub, string $prefix) => $sub->where('store_name', 'not like', $prefix . '%'),
);
if ($promoItems !== []) {
$group->orWhereIn('bill_item', $promoItems);
}
});
$bills = $billQuery->orderBy($timeColumn)->get();
if ($limit > 0) {
$bills = $bills->take($limit);
}
if ($bills->isEmpty()) {
$this->warn('该账期没有符合条件的账单记录,未写入任何数据。');
return self::SUCCESS;
}
/** @var array<string,array<string,mixed>> $groups 账单侧按「店铺 + 订单号」聚合 */
$groups = [];
$paymentTotal = 0;
$incomeTotal = 0.0;
$discountTotal = 0.0;
$offsetTotal = 0.0;
$remarkMap = app(AccountLedgerRemarkMap::class);
$tabPlatform = $remarkMap->defaultPlatform($platform);
$ledgerMap = $remarkMap->map();
$incomeOverlapRows = 0;
$incomeOverlapTotal = 0.0;
foreach ($bills as $bill) {
$storeName = (string) ($bill->store_name ?? '');
$billItem = (string) ($bill->bill_item ?? '');
// 收入金额沿用「总账备注映射」白名单口径,保证既有数字不变;
// 优惠 / 冲减两列按新口径(科目映射表的财务备注优先)取数。
$legacyRemark = $ledgerMap[$remarkMap->platform($storeName, $tabPlatform) ?? ''][$billItem] ?? null;
$remark = $remarkMap->remark($storeName, $tabPlatform, $billItem);
$isPromoRemark = in_array($remark, self::DISCOUNT_REMARKS, true)
|| in_array($remark, self::OFFSET_REMARKS, true);
$isIncomeRemark = in_array($legacyRemark, self::LEDGER_REMARKS, true);
if ($isIncomeRemark && $isPromoRemark) {
// 已经在收入金额里的行:不重复计入优惠/冲减列,单独提示金额。
$incomeOverlapRows++;
$incomeOverlapTotal += (float) $bill->{$amountColumn};
}
$bucket = match (true) {
$isIncomeRemark => 'income',
in_array($remark, self::DISCOUNT_REMARKS, true) => 'discount',
in_array($remark, self::OFFSET_REMARKS, true) => 'offset',
default => null,
};
if ($bucket === null) {
continue;
}
$rawNo = trim((string) ($bill->{$orderColumn} ?? ''));
$core = self::cleanOrderNo($rawNo);
$realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : '';
$shopKey = self::normalizeShop($realStore !== '' ? $realStore : (string) $bill->store_name);
$key = $shopKey . '|' . $core;
if (! isset($groups[$key])) {
$groups[$key] = [
'platform' => (string) $source['label'],
'store_name' => (string) ($bill->store_name ?? ''),
'shop_key' => $shopKey,
'online_order_no' => $rawNo,
'order_core_no' => $core,
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
'payment_count' => 0,
'income_amount' => 0.0,
'discount_amount' => 0.0,
'discount_seen' => false,
'offset_amount' => 0.0,
'offset_seen' => false,
];
}
$amount = (float) $bill->{$amountColumn};
if ($bucket === 'income') {
$groups[$key]['payment_count']++;
$groups[$key]['income_amount'] += $amount;
$paymentTotal++;
$incomeTotal += $amount;
} elseif ($bucket === 'discount') {
$groups[$key]['discount_seen'] = true;
$groups[$key]['discount_amount'] += $amount;
$discountTotal += $amount;
} else {
$groups[$key]['offset_seen'] = true;
$groups[$key]['offset_amount'] += $amount;
$offsetTotal += $amount;
}
}
// 行口径保持不变:仍然是「有收款账单(收入白名单)的店铺 + 订单号」,
// 只有优惠/冲减、没有收款的订单号不单独成行。
$groups = array_filter($groups, static fn (array $group): bool => (int) $group['payment_count'] > 0);
$this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行');
$this->line(' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 2));
if ($incomeOverlapRows > 0) {
$this->line(sprintf(
' 提示:%d 笔(%s 元)按旧口径已计入收入金额,未重复计入优惠/冲减列。',
$incomeOverlapRows,
number_format($incomeOverlapTotal, 2),
));
}
if ((bool) $this->option('amounts-only')) {
return $this->backfillPromoAmounts($month, $source, $groups, $reportMonth);
}
// 订单侧的单号在不同平台形态不同:淘系/京东是无前缀纯数字,
// 拼多多等平台带日期前缀(260630-xxx)。这里同时带上原始号与清洗后的
// 核心号去查订单侧,再统一按核心号聚合,避免漏匹配。
$cores = [];
foreach ($groups as $group) {
foreach ([(string) $group['online_order_no'], (string) $group['order_core_no']] as $candidate) {
$candidate = trim($candidate);
if ($candidate !== '') {
$cores[$candidate] = true;
}
}
}
$upstream = $factory->forSource('operations_replica');
$dwsFrom = $start->copy()->subDays(150)->toDateString();
$dwsTo = $end->copy()->addDays(7)->toDateString();
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(仅 goods_count>0 的有效行) */
$orderAmount = [];
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */
$orderSeen = [];
/** @var array<string,array<string,true>> $coresByCore 核心单号 → 出现过的店铺集合 */
$coresByCore = [];
/** @var array<string,true> $coreSeen 订单侧出现过的核心单号(含店铺名为空的行) */
$coreSeen = [];
$chunks = array_chunk(array_keys($cores), $chunkSize);
$totalChunks = count($chunks);
foreach ($chunks as $index => $chunk) {
$in = implode(',', array_map(static fn (string $no): string => "'" . str_replace("'", "''", $no) . "'", $chunk));
try {
$rows = $upstream->select(
"SELECT sourceno, shop_name, sales_amount, goods_count FROM rpt.dws_sn_sales_full_info_di
WHERE sourceno IN ({$in}) AND stat_date >= '{$dwsFrom}' AND stat_date < '{$dwsTo}'"
);
} catch (Throwable $exception) {
$this->error('订单侧查询失败(第 ' . ($index + 1) . ' 批):' . $exception->getMessage());
return self::FAILURE;
}
foreach ($rows as $row) {
$core = self::cleanOrderNo((string) $row['sourceno']);
if ($core === '') {
continue;
}
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
$key = $shopKey . '|' . $core;
$orderSeen[$key] = true;
$coreSeen[$core] = true;
if ($shopKey !== '') {
$coresByCore[$core][$shopKey] = true;
}
if ((int) ($row['goods_count'] ?? 0) <= 0) {
// 退款/冲减/0 元赠品行不计入商品金额,差额列另行体现。
continue;
}
$orderAmount[$key] = ($orderAmount[$key] ?? 0.0) + (float) $row['sales_amount'];
}
if (($index + 1) % 10 === 0 || $index + 1 === $totalChunks) {
$this->info(sprintf('订单侧进度 %d/%d 批', $index + 1, $totalChunks));
}
}
// 只重建「本账期 + 本平台」的数据,避免把同月其它平台的结果一起清掉。
DB::connection($connection)->table(self::TARGET_TABLE)
->where('report_month', $reportMonth)
->where('platform', (string) $source['label'])
->delete();
$target = DB::connection($connection)->table(self::TARGET_TABLE);
$now = now();
$buffer = [];
$imported = 0;
$stats = ['已匹配' => 0, '未匹配' => 0, '一单多笔' => 0];
$remarked = [];
$goodsTotal = 0.0;
$differenceTotal = 0.0;
foreach ($groups as $group) {
$core = (string) $group['order_core_no'];
$income = round((float) $group['income_amount'], 2);
$remark = '';
$productAmount = null;
$status = self::STATUS_UNMATCHED;
if ($core === '') {
$remark = self::REMARK_EMPTY_NO;
} elseif (preg_match('/^\d{8,}$/', $core) !== 1) {
$remark = self::REMARK_BAD_FORMAT;
} else {
$exactKey = $group['shop_key'] . '|' . $core;
if (isset($orderSeen[$exactKey])) {
if (isset($orderAmount[$exactKey])) {
$status = self::STATUS_MATCHED;
$productAmount = round((float) $orderAmount[$exactKey], 2);
if (count($coresByCore[$core] ?? []) > 1) {
$remark = self::REMARK_DUP_ORDER;
}
} else {
// 订单侧有记录,但全是 0 元/退款行:没有商品金额可比对。
$remark = self::REMARK_NO_AMOUNT;
}
} elseif (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) === 1) {
// 单号一致但店铺名称对不上:仍然带上金额,标注「店铺不一致」便于复核。
$onlyShop = array_key_first($coresByCore[$core]);
$otherKey = $onlyShop . '|' . $core;
if (isset($orderAmount[$otherKey])) {
$status = self::STATUS_MATCHED;
$productAmount = round((float) $orderAmount[$otherKey], 2);
$remark = self::REMARK_SHOP_DIFF;
} else {
$remark = self::REMARK_NO_AMOUNT;
}
} elseif (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) > 1) {
$sum = 0.0;
$hasAmount = false;
foreach (array_keys($coresByCore[$core]) as $shopKey) {
$key2 = $shopKey . '|' . $core;
if (isset($orderAmount[$key2])) {
$sum += (float) $orderAmount[$key2];
$hasAmount = true;
}
}
if ($hasAmount) {
$status = self::STATUS_MATCHED;
$productAmount = round($sum, 2);
$remark = self::REMARK_DUP_ORDER;
} else {
$remark = self::REMARK_NO_AMOUNT;
}
} elseif (isset($coreSeen[$core])) {
// 订单侧只有店铺名为空的记录:按订单号兜底匹配。
$blankKey = '|' . $core;
if (isset($orderAmount[$blankKey])) {
$status = self::STATUS_MATCHED;
$productAmount = round((float) $orderAmount[$blankKey], 2);
} else {
$remark = self::REMARK_NO_AMOUNT;
}
} else {
$remark = self::REMARK_NO_ORDER;
}
}
if ($status === self::STATUS_MATCHED && (int) $group['payment_count'] > 1 && $remark === '') {
$remark = self::REMARK_MULTI_PAY;
}
// 差额口径:(收入金额 + 优惠金额 + 冲减金额)− 订单侧商品金额。
// 优惠/冲减属于同一订单的账单,把它们加回来,差额才只反映真正对不上的部分。
$promoAmount = round((float) ($group['discount_amount'] ?? 0.0) + (float) ($group['offset_amount'] ?? 0.0), 2);
$difference = $productAmount === null ? null : round($income + $promoAmount - $productAmount, 2);
if ($productAmount !== null) {
$goodsTotal += $productAmount;
$differenceTotal += (float) $difference;
}
$stats[$status] = ($stats[$status] ?? 0) + 1;
if ((int) $group['payment_count'] > 1) {
$stats['一单多笔']++;
}
if ($remark !== '') {
$remarked[$remark] = ($remarked[$remark] ?? 0) + 1;
}
$buffer[] = [
'report_month' => $reportMonth,
'platform' => $group['platform'],
'row_key' => md5($reportMonth . '|' . $group['platform'] . '|' . $group['shop_key'] . '|' . $core),
'occurred_date' => $group['occurred_date'],
'store_name' => $group['store_name'],
'online_order_no' => $group['online_order_no'] !== '' ? $group['online_order_no'] : null,
'order_core_no' => $core !== '' ? $core : null,
'payment_count' => (int) $group['payment_count'],
'income_amount' => $income,
'discount_amount' => ! empty($group['discount_seen']) ? round((float) $group['discount_amount'], 2) : null,
'offset_amount' => ! empty($group['offset_seen']) ? round((float) $group['offset_amount'], 2) : null,
'product_amount' => $productAmount,
'difference' => $difference,
'match_status' => $status,
'remark' => $remark !== '' ? $remark : null,
'imported_at' => $now,
];
if (count($buffer) >= 500) {
$target->insertOrIgnore($buffer);
$imported += count($buffer);
$buffer = [];
}
}
if ($buffer !== []) {
$target->insertOrIgnore($buffer);
$imported += count($buffer);
}
$this->info('写入完成,共 ' . $imported . ' 行(账单收款 ' . $paymentTotal . ' 笔)。');
foreach ($stats as $status => $count) {
$this->line(' ' . $status . ':' . $count);
}
foreach ($remarked as $remark => $count) {
$this->line(' 标注 ' . $remark . ':' . $count);
}
$this->line(' 账单收入合计:' . number_format($incomeTotal, 2));
$this->line(' 订单侧商品金额合计:' . number_format($goodsTotal, 2));
$this->line(' 差额合计:' . number_format($differenceTotal, 2));
return self::SUCCESS;
}
/**
* 只回填「优惠金额 / 冲减金额」两列:不重建业绩明细、不查订单侧,
* 先清空该账期该平台的两列,再按 row_key 更新镜像里已存在的行。
*
* @param array<string,mixed> $source
* @param array<string,array<string,mixed>> $groups
*/
private function backfillPromoAmounts(string $month, array $source, array $groups, string $reportMonth): int
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$platform = (string) $source['label'];
$target = static fn () => DB::connection($connection)->table(self::TARGET_TABLE);
$existing = $target()
->where('report_month', $reportMonth)
->where('platform', $platform)
->pluck('row_key')
->flip()
->all();
$target()
->where('report_month', $reportMonth)
->where('platform', $platform)
->update(['discount_amount' => null, 'offset_amount' => null]);
$discounts = [];
$offsets = [];
foreach ($groups as $group) {
if (empty($group['discount_seen']) && empty($group['offset_seen'])) {
continue;
}
$rowKey = md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|' . $group['order_core_no']);
if (! isset($existing[$rowKey])) {
continue;
}
if (! empty($group['discount_seen'])) {
$discounts[$rowKey] = round((float) $group['discount_amount'], 2);
}
if (! empty($group['offset_seen'])) {
$offsets[$rowKey] = round((float) $group['offset_amount'], 2);
}
}
$this->updateAmountColumn($connection, $reportMonth, 'discount_amount', $discounts);
$this->updateAmountColumn($connection, $reportMonth, 'offset_amount', $offsets);
$this->info(sprintf(
'已回填 %s %s:优惠 %d 行(%s)/ 冲减 %d 行(%s),镜像原有 %d 行,未重建业绩明细。',
$month,
$platform,
count($discounts),
number_format(array_sum($discounts), 2),
count($offsets),
number_format(array_sum($offsets), 2),
count($existing),
));
return self::SUCCESS;
}
/**
* 按 row_key 批量更新一列金额:只更新镜像里已有的行,不做插入。
*
* @param array<string,float> $values row_key => 金额
*/
private function updateAmountColumn(string $connection, string $reportMonth, string $column, array $values): void
{
if ($values === []) {
return;
}
foreach (array_chunk($values, 500, true) as $chunk) {
$cases = '';
$bindings = [];
foreach ($chunk as $rowKey => $amount) {
$cases .= ' WHEN ? THEN ?';
$bindings[] = (string) $rowKey;
$bindings[] = $amount;
}
$bindings[] = $reportMonth;
$bindings = array_merge($bindings, array_map('strval', array_keys($chunk)));
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
DB::connection($connection)->update(
sprintf(
'update %s set %s = case row_key%s end where report_month = ? and row_key in (%s)',
self::TARGET_TABLE,
$column,
$cases,
$placeholders,
),
$bindings,
);
}
}
/** 单号清洗:去掉日期前缀(如 260630-)、空格、横杠,得到核心号。 */
public static function cleanOrderNo(string $value): string
{
$value = str_replace("\u{3000}", ' ', trim($value));
if ($value === '') {
return '';
}
// 日期前缀形如「260630-」:只有明确带 6 位数字 + 横杠时才去除,
// 避免误伤本身就是纯数字的订单号(如 5126322747312081812)。
if (preg_match('/^\d{6}\s*[-\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', $value) === 1) {
$value = preg_replace('/^\d{6}\s*[-\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '', $value) ?? $value;
}
$value = preg_replace('/[\s\-\x{FF0D}\x{2014}\x{2013}\x{2010}]+/u', '', $value) ?? '';
return strtoupper($value);
}
/**
* 科目映射表里「财务备注命中给定备注」的账单项目(限本页签涉及的平台)。
*
* @param list<string> $remarks
* @return list<string>
*/
private function accountMappingItems(string $tab, array $remarks): array
{
$map = app(AccountLedgerRemarkMap::class);
$platforms = $map->platformsForTab($tab);
$items = [];
foreach (app(AccountMappingStore::class)->all() as $rule) {
if (! is_array($rule)) {
continue;
}
$platform = trim((string) ($rule['platform'] ?? ''));
$item = trim((string) ($rule['item'] ?? ''));
$memo = trim((string) ($rule['memo'] ?? ''));
if ($platform === '' || $item === '' || ! in_array($platform, $platforms, true)) {
continue;
}
if (! in_array($memo, $remarks, true)) {
continue;
}
$items[$item] = true;
}
$list = array_keys($items);
sort($list);
return $list;
}
/**
* 账单项目名的「wx」前缀变体:科目映射表用不带前缀的名字,
* 微信账户页签的账单带前缀(反之亦然),两种写法都纳入候选。
*
* @param list<string> $items
* @return list<string>
*/
private function withWxVariants(array $items): array
{
$candidates = [];
foreach ($items as $item) {
$item = trim((string) $item);
if ($item === '') {
continue;
}
$candidates[$item] = true;
if (stripos($item, 'wx') === 0) {
$stripped = ltrim(mb_substr($item, 2));
if ($stripped !== '') {
$candidates[$stripped] = true;
}
} else {
$candidates['wx' . $item] = true;
}
}
$list = array_keys($candidates);
sort($list);
return $list;
}
/**
* 「总账备注」集合 → 平台分组条件(复用账单页同一份映射与翻译)。
*
* @param list<string>|null $remarks 为空时取收入白名单(LEDGER_REMARKS)
* @return list<array<string,mixed>>
*/
private function ledgerRemarkGroups(string $tab, ?array $remarks = null): array
{
$map = app(AccountLedgerRemarkMap::class);
$groups = [];
$seen = [];
foreach ($remarks ?? self::LEDGER_REMARKS as $remark) {
foreach ($map->billItemGroupsForTab($tab, $remark) as $group) {
$key = (string) ($group['platform'] ?? '') . '|' . implode(',', (array) ($group['items'] ?? []));
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$groups[] = $group;
}
}
return $groups;
}
/** 店铺名归一:下划线视为横杠,去掉空白。 */
public static function normalizeShop(string $value): string
{
$value = str_replace(["\u{3000}", '_'], [' ', '-'], trim($value));
$value = preg_replace('/\s+/', '', $value) ?? '';
return strtoupper($value);
}
}