CaiWuXiTong/app/Console/Commands/SyncStoreReceiptOrderMatches.php
chenxi 278f641018 业绩明细账单侧筛选改为「总账备注」口径
账单侧改为按总账备注命中「订单/收入/交易收入/订单收入/商家结算款/跨境订单结算款/消费订单」,
复用 AccountLedgerRemarkMap + LedgerRemarkFilter,与店铺收款账户账单页同一口径,
淘系页签按店铺名前缀分淘宝/天猫/阿里巴巴取各自账单项目;统计条新增「收入为 0 的账单行」。
2026-10-07 09:39:06 +08:00

419 lines
18 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 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',
];
/**
* 账单侧筛选口径:总账备注命中以下任一取值即计入。
*
* 与店铺收款账户账单页的「总账备注」筛选完全一致,账单项目清单由
* 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(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));
// 「总账备注」→ 按平台分组的账单项目集合(淘系页签同页混有淘宝/天猫/阿里巴巴店铺,
// 必须连店铺名前缀一起过滤,才能与页面按行取到的总账备注一致)。
$groups = $this->ledgerRemarkGroups($platform);
if ($groups === []) {
$this->error('「总账备注」映射表没有匹配到任何账单项目,请先导入 ec_account_ledger_remarks。');
return self::FAILURE;
}
$this->info('总账备注命中平台分组:' . count($groups) . ' 组');
$billQuery = DB::connection($connection)->table($billTable)
->selectRaw('store_name, online_real_store, online_order_no, occurred_at, income_amount')
->where('occurred_at', '>=', $start->toDateTimeString())
->where('occurred_at', '<', $end->toDateTimeString());
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('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);
}
/**
* 「总账备注」集合 → 平台分组条件(复用账单页同一份映射与翻译)。
*
* @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);
}
}