CaiWuXiTong/app/Console/Commands/SyncStoreReceiptOrderMatches.php
chenxi e0f89288d2 业绩明细取数口径改为「一个店铺 + 一个订单号 + 一个商品金额」
- 账单侧:账单项目扩到 11 类(交易收款、1688 增值服务系列、交易分账_货款、其它交易收款、
  先采后付账单付款、百补超级版消费者实付款、店铺过户),按「店铺 + 订单号」聚合收入与收款笔数
- 订单侧:销售全量按「店铺 + 线上订单号」先聚合求和(goods_count>0 有效行),
  一单多品/多子订单在聚合阶段合并,商品金额只挂一次
- 匹配:单号清洗(去日期前缀/空格/横杠)后按核心号 + 店铺匹配;店铺以账单「线上真实店铺」
  优先,下划线归一为横杠;订单侧单号一致但店铺不同或同号多店铺时标注异常
- 输出列:发生时间 → 店铺名称 → 线上单号 → 收入金额 → 商品金额 → 差额(收入−商品金额)
  → 匹配状态 → 异常标注;镜像表改为该行口径
- 统计:账单收款笔数、账单订单数、已匹配、未匹配、一单多笔、账单收入合计、
  订单商品金额合计、差额合计(已匹配)
- 未匹配分类:空单号 / 格式不一致 / 订单无商品金额 / 订单表无此单,并可按异常标注筛选

8 月淘系支付宝:118,500 笔收款 → 111,427 行,已匹配 101,005、未匹配 10,422
(订单无商品金额 8,352 / 订单表无此单 2,046 / 空单号 24),一单多笔 6,758。
2026-10-06 14:43:49 +08:00

375 lines
16 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\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 表示全量)}';
protected $description = '按店铺收款账户账单匹配订单侧商品金额,生成「业绩明细」核对镜像';
/** 收款账户 → 账单镜像表 */
private const BILL_TABLES = [
'taobao-alipay' => 'taobao_alipay_bill_rows',
'taobao-wechat' => 'taobao_wechat_bill_rows',
'pinduoduo' => 'pdd_settlement_bill_rows',
];
/** 账单项目:计入收款的账单类型 */
private const BILL_ITEMS = [
'交易收款',
'1688增值服务-诚e赊订单红包',
'1688增值服务-诚e赊买家还款',
'1688增值服务先采后付担保付款',
'1688增值服务先采后付买家还款',
'1688增值服务先采后付收款',
'交易分账_货款',
'其它交易收款',
'先采后付账单付款',
'百补超级版消费者实付款',
'店铺过户',
];
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::BILL_TABLES[$platform])) {
throw new InvalidArgumentException('暂不支持的收款账户:' . $platform);
}
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$billTable = self::BILL_TABLES[$platform];
$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', $month, $start->toDateString(), $end->copy()->subDay()->toDateString(), $billTable));
$bills = DB::connection($connection)->table($billTable)
->selectRaw('store_name, online_real_store, online_order_no, occurred_at, income_amount')
->whereIn('bill_item', self::BILL_ITEMS)
->where('occurred_at', '>=', $start->toDateTimeString())
->where('occurred_at', '<', $end->toDateTimeString())
->orderBy('occurred_at')
->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->online_order_no ?? ''));
$core = self::cleanOrderNo($rawNo);
$shopKey = self::normalizeShop((string) (($bill->online_real_store ?? '') !== '' ? $bill->online_real_store : $bill->store_name));
$key = $shopKey . '|' . $core;
if (! isset($groups[$key])) {
$groups[$key] = [
'store_name' => (string) ($bill->store_name ?? ''),
'shop_key' => $shopKey,
'online_order_no' => $rawNo,
'order_core_no' => $core,
'occurred_date' => substr((string) $bill->occurred_at, 0, 10),
'payment_count' => 0,
'income_amount' => 0.0,
];
}
$groups[$key]['payment_count']++;
$groups[$key]['income_amount'] += (float) $bill->income_amount;
$paymentTotal++;
$incomeTotal += (float) $bill->income_amount;
}
$this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行');
$cores = [];
foreach ($groups as $group) {
if ($group['order_core_no'] !== '') {
$cores[$group['order_core_no']] = 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)
->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,
'row_key' => md5($reportMonth . '|' . $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);
}
/** 店铺名归一:下划线视为横杠,去掉空白。 */
public static function normalizeShop(string $value): string
{
$value = str_replace(["\u{3000}", '_'], [' ', '-'], trim($value));
$value = preg_replace('/\s+/', '', $value) ?? '';
return strtoupper($value);
}
}