- 以店铺收款账户账单(账单项目=交易收款)为基数,按线上单号关联经营分析副本库 的发货明细与销售全量,补出发货仓库、商品编码、商品数量、商品金额 - 匹配键:账单 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 改写语句导致测试全线失败的问题
298 lines
12 KiB
PHP
298 lines
12 KiB
PHP
<?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;
|
||
}
|
||
}
|