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> $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 $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(仅 goods_count>0 的有效行) */ $orderAmount = []; /** @var array $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */ $orderSeen = []; /** @var array> $coresByCore 核心单号 → 出现过的店铺集合 */ $coresByCore = []; /** @var array $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> */ 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); } }