CaiWuXiTong/app/Console/Commands/SyncStoreReceiptOrderMatches.php
chenxi 085ea33ff4 业绩明细订单侧切换为「订单原始数据」口径(方案B)
订单侧来源改为企微客户订单 + 聚水潭订单(与「订单数据 → 原始数据」同一来源),
按「店铺 + 线上订单号」对商品金额求和;取数改用临时表 + STRAIGHT_JOIN 避免大表全扫。
2026-10-08 14:05:08 +08:00

517 lines
23 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\LedgerRemarkFilter;
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 表示全量)}';
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 = [
'订单收入',
];
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(): 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,
));
// 「总账备注」→ 按平台分组的账单项目集合(淘系页签同页混有淘宝/天猫/阿里巴巴店铺,
// 必须连店铺名前缀一起过滤,才能与页面按行取到的总账备注一致)。
$groups = $this->ledgerRemarkGroups($platform);
if ($groups === []) {
$this->error('「总账备注」映射表没有匹配到任何账单项目,请先导入 ec_account_ledger_remarks。');
return self::FAILURE;
}
$this->info('总账备注命中平台分组:' . count($groups) . ' 组');
$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) . ' 个');
}
LedgerRemarkFilter::applyGroups(
$billQuery,
$groups,
'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 . '%'),
);
$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;
foreach ($bills as $bill) {
$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,
];
}
$groups[$key]['payment_count']++;
$groups[$key]['income_amount'] += (float) $bill->{$amountColumn};
$paymentTotal++;
$incomeTotal += (float) $bill->{$amountColumn};
}
$this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行');
// 订单侧的单号在不同平台形态不同:淘系/京东是无前缀纯数字,
// 拼多多等平台带日期前缀(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;
}
}
}
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(商品金额求和) */
$orderAmount = [];
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */
$orderSeen = [];
/** @var array<string,array<string,true>> $coresByCore 核心单号 → 出现过的店铺集合 */
$coresByCore = [];
/** @var array<string,true> $coreSeen 订单侧出现过的核心单号(含店铺名为空的行) */
$coreSeen = [];
// 订单侧改用「订单原始数据」(企微客户订单 + 聚水潭订单),按线上订单号取商品金额合计。
// 两个来源数据量大(企微 500 万单 / 690 万行),先用临时表收敛候选单号,
// 再用 STRAIGHT_JOIN 走 so_id / o_id 索引聚合,避免逐批 IN 查询退化成全表扫描。
$mirror = DB::connection($connection);
$mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_receipt_order_nos');
$mirror->statement('CREATE TEMPORARY TABLE tmp_receipt_order_nos (no varchar(64) NOT NULL, PRIMARY KEY (no)) ENGINE=InnoDB');
$candidates = array_keys($cores);
foreach (array_chunk($candidates, 1000) as $batch) {
$mirror->table('tmp_receipt_order_nos')->insertOrIgnore(
array_map(static fn (string $no): array => ['no' => $no], $batch)
);
}
$this->info('订单侧候选单号:' . count($candidates));
$orderRows = [];
foreach ([
['ops_jst_qmcust_orders', 'ops_jst_qmcust_order_items'],
['ops_jst_orders', 'ops_jst_order_item'],
] as [$ordersTable, $itemsTable]) {
try {
$result = $mirror->select(
"SELECT o.so_id as so_id, o.shop_name as shop_name, SUM(i.amount) as amount
FROM tmp_receipt_order_nos t
STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no
STRAIGHT_JOIN {$itemsTable} i ON i.o_id = o.o_id
GROUP BY o.so_id, o.shop_name"
);
} catch (Throwable $exception) {
$this->error('订单侧查询失败(' . $ordersTable . '):' . $exception->getMessage());
return self::FAILURE;
}
foreach ($result as $row) {
$orderRows[] = (array) $row;
}
}
$this->info('订单侧命中(店铺 × 订单)行数:' . count($orderRows));
foreach ($orderRows as $row) {
$core = self::cleanOrderNo((string) $row['so_id']);
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;
}
// 口径:按「店铺 + 线上订单号」对商品金额求和(一单多品/多子订单在订单侧先合并)。
$orderAmount[$key] = ($orderAmount[$key] ?? 0.0) + (float) $row['amount'];
}
// 只重建「本账期 + 本平台」的数据,避免把同月其它平台的结果一起清掉。
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;
}
$difference = $productAmount === null ? null : round($income - $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,
'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;
}
/** 单号清洗:去掉日期前缀(如 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);
}
/**
* 「总账备注」集合 → 平台分组条件(复用账单页同一份映射与翻译)。
*
* @return list<array<string,mixed>>
*/
private function ledgerRemarkGroups(string $tab): array
{
$map = app(AccountLedgerRemarkMap::class);
$groups = [];
$seen = [];
foreach (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);
}
}