CaiWuXiTong/app/Console/Commands/SyncStoreReceiptOrderMatches.php
chenxi 08aaed9241 业绩明细新增「店铺收款匹配订单明细」页面及同步命令
- 以店铺收款账户账单(账单项目=交易收款)为基数,按线上单号关联经营分析副本库
  的发货明细与销售全量,补出发货仓库、商品编码、商品数量、商品金额
- 匹配键:账单 online_order_no = 发货明细 so_id = 销售全量 sourceno(淘宝/天猫主订单号);
  同一订单多笔收款按订单号汇总,商品金额取 goods_count>0 的有效行
- 新增镜像表 store_receipt_order_match_rows 与命令
  store-receipt:order-match:sync <YYYY-MM>(分批查询上游,订单号每批 1000 条)
- 页面:月份/店铺名称/线上单号/匹配状态筛选、匹配统计条、分页、CSV 导出
- 修正宽精度迁移在 sqlite 测试库上执行 MySQL 改写语句导致测试全线失败的问题
2026-10-06 13:42:30 +08:00

298 lines
12 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;
/**
* 业绩明细 → 店铺收款匹配订单明细。
*
* 以「店铺收款账户账单」中账单项目 = 交易收款 的记录为基数,
* 按线上单号(淘宝/天猫主订单号)关联经营分析副本库的订单原始数据,
* 补出发货仓库、商品编码、商品数量、商品金额,写入共享业务镜像供页面只读展示。
*
* 关联口径(已与业务确认):
* - 账单 online_order_no = 聚水潭发货明细 rpt.Jst_SendData_View_extension.so_id
* = 销售全量 rpt.dws_sn_sales_full_info_di.sourceno
* - 同一订单可能有多笔「交易收款」(定金/尾款等),账单收入按线上单号汇总
* - 商品金额取销售全量中 goods_count > 0 的行求和,退款/冲减行不计入
*/
class SyncStoreReceiptOrderMatches extends Command
{
protected $signature = 'store-receipt:order-match:sync
{month : 账单月份,格式 YYYY-MM}
{--platform=taobao-alipay : 收款账户,目前支持 taobao-alipay}
{--chunk=1000 : 单次向上游查询的订单号数量}
{--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 TARGET_TABLE = 'store_receipt_order_match_rows';
private const STATUS_MATCHED = '已匹配';
private const STATUS_AMOUNT_DIFF = '金额不一致';
private const STATUS_NO_AMOUNT = '有订单无商品金额';
private const STATUS_UNMATCHED = '订单未匹配';
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(20, (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));
$aggregate = DB::connection($connection)->table($billTable)
->selectRaw('online_order_no, SUM(income_amount) as income_amount, COUNT(*) as payment_count, MIN(occurred_at) as first_occurred_at, MAX(store_name) as store_name')
->where('bill_item', '交易收款')
->where('occurred_at', '>=', $start->toDateTimeString())
->where('occurred_at', '<', $end->toDateTimeString())
->whereNotNull('online_order_no')
->where('online_order_no', '<>', '')
->groupBy('online_order_no')
->orderBy('first_occurred_at')
->get();
if ($limit > 0) {
$aggregate = $aggregate->take($limit);
}
$orders = [];
foreach ($aggregate as $row) {
$no = trim((string) $row->online_order_no);
if ($no === '') {
continue;
}
$orders[$no] = [
'online_order_no' => $no,
'income_amount' => round((float) $row->income_amount, 2),
'payment_count' => (int) $row->payment_count,
'occurred_date' => substr((string) $row->first_occurred_at, 0, 10),
'store_name' => (string) ($row->store_name ?? ''),
];
}
if ($orders === []) {
$this->warn('该账期没有「交易收款」记录,未写入任何数据。');
return self::SUCCESS;
}
$this->info('账单侧订单数:' . count($orders));
$upstream = $factory->forSource('operations_replica');
DB::connection($connection)->table(self::TARGET_TABLE)
->where('report_month', $reportMonth)
->delete();
$target = DB::connection($connection)->table(self::TARGET_TABLE);
$dwsFrom = $start->copy()->subDays(150)->toDateString();
$dwsTo = $end->copy()->addDays(7)->toDateString();
$now = now();
$buffer = [];
$imported = 0;
$stats = [];
$chunks = array_chunk(array_keys($orders), $chunkSize);
$totalChunks = count($chunks);
foreach ($chunks as $index => $chunk) {
$in = implode(',', array_map(static fn (string $no): string => "'" . str_replace("'", "''", $no) . "'", $chunk));
$viewRows = [];
try {
foreach ($upstream->select(
"SELECT so_id, sku_ids, qty, wareHoursName FROM rpt.Jst_SendData_View_extension WHERE so_id IN ({$in})"
) as $row) {
$viewRows[(string) $row['so_id']][] = $row;
}
} catch (Throwable $exception) {
$this->error('发货明细查询失败(第 ' . ($index + 1) . ' 批):' . $exception->getMessage());
return self::FAILURE;
}
$salesRows = [];
try {
foreach ($upstream->select(
"SELECT sourceno, skuoid, sales_amount, goods_count FROM rpt.dws_sn_sales_full_info_di
WHERE sourceno IN ({$in}) AND stat_date >= '{$dwsFrom}' AND stat_date < '{$dwsTo}'"
) as $row) {
$salesRows[(string) $row['sourceno']][] = $row;
}
} catch (Throwable $exception) {
$this->error('销售明细查询失败(第 ' . ($index + 1) . ' 批):' . $exception->getMessage());
return self::FAILURE;
}
foreach ($chunk as $no) {
foreach ($this->buildRows($orders[$no], $viewRows[$no] ?? [], $salesRows[$no] ?? [], $reportMonth, $now) as $row) {
$buffer[] = $row;
$stats[$row['match_status']] = ($stats[$row['match_status']] ?? 0) + 1;
}
}
if (count($buffer) >= 500) {
$target->insertOrIgnore($buffer);
$imported += count($buffer);
$buffer = [];
}
if (($index + 1) % 25 === 0 || $index + 1 === $totalChunks) {
$this->info(sprintf('进度 %d/%d 批,已写入 %d 行', $index + 1, $totalChunks, $imported + count($buffer)));
}
}
if ($buffer !== []) {
$target->insertOrIgnore($buffer);
$imported += count($buffer);
}
$this->info('写入完成,共 ' . $imported . ' 行(订单 ' . count($orders) . ' 笔)。');
foreach ($stats as $status => $count) {
$this->line(' ' . $status . ':' . $count . ' 行');
}
return self::SUCCESS;
}
/**
* 单笔账单展开为明细行:发货明细的每个商品行一行,金额取销售全量同条码的有效行。
*
* @param array<string,mixed> $order
* @param array<int,array<string,mixed>> $viewRows
* @param array<int,array<string,mixed>> $salesRows
* @return array<int,array<string,mixed>>
*/
private function buildRows(array $order, array $viewRows, array $salesRows, string $reportMonth, Carbon $now): array
{
$income = (float) $order['income_amount'];
$positive = array_values(array_filter(
$salesRows,
static fn (array $row): bool => (int) ($row['goods_count'] ?? 0) > 0
));
$goodsAmount = $positive === []
? null
: round(array_sum(array_map(static fn (array $row): float => (float) $row['sales_amount'], $positive)), 2);
if ($goodsAmount !== null && abs($goodsAmount - $income) < 0.005) {
$status = self::STATUS_MATCHED;
} elseif ($viewRows !== [] || $salesRows !== []) {
$status = $goodsAmount === null ? self::STATUS_NO_AMOUNT : self::STATUS_AMOUNT_DIFF;
} else {
$status = self::STATUS_UNMATCHED;
}
$base = [
'report_month' => $reportMonth,
'occurred_date' => $order['occurred_date'],
'store_name' => $order['store_name'],
'online_order_no' => $order['online_order_no'],
'payment_count' => $order['payment_count'],
'income_amount' => $income,
'match_status' => $status,
'imported_at' => $now,
];
if ($viewRows === []) {
// 没有发货明细时,用销售全量的商品行兜底(仓库留空)。
if ($positive === []) {
return [$base + [
'row_key' => md5($reportMonth . '|' . $order['online_order_no'] . '|none'),
'warehouse' => null,
'product_code' => null,
'quantity' => null,
'product_amount' => null,
]];
}
$rows = [];
$seen = [];
foreach ($positive as $row) {
$code = mb_strtoupper(trim((string) $row['skuoid']));
if ($code === '' || isset($seen[$code])) {
continue;
}
$seen[$code] = true;
$rows[] = $base + [
'row_key' => md5($reportMonth . '|' . $order['online_order_no'] . '|' . $code),
'warehouse' => null,
'product_code' => $code,
'quantity' => (float) $row['goods_count'],
'product_amount' => round((float) $row['sales_amount'], 2),
];
}
return $rows !== [] ? $rows : [$base + [
'row_key' => md5($reportMonth . '|' . $order['online_order_no'] . '|none'),
'warehouse' => null,
'product_code' => null,
'quantity' => null,
'product_amount' => null,
]];
}
$amountByCode = [];
foreach ($positive as $row) {
$code = mb_strtoupper(trim((string) $row['skuoid']));
if ($code === '' || isset($amountByCode[$code])) {
continue;
}
$amountByCode[$code] = round((float) $row['sales_amount'], 2);
}
$rows = [];
$seen = [];
foreach ($viewRows as $view) {
$code = mb_strtoupper(trim((string) $view['sku_ids']));
$warehouse = trim((string) ($view['wareHoursName'] ?? ''));
$key = $code . '|' . $warehouse;
if ($key === '|' || isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$rows[] = $base + [
'row_key' => md5($reportMonth . '|' . $order['online_order_no'] . '|' . $key),
'warehouse' => $warehouse !== '' ? $warehouse : null,
'product_code' => $code !== '' ? $code : null,
'quantity' => is_numeric($view['qty'] ?? null) ? (float) $view['qty'] : null,
'product_amount' => $amountByCode[$code] ?? null,
];
}
return $rows;
}
}