0 的有效行 * - 单号清洗:去除日期前缀(如 260630-)、空格、横杠后按核心号匹配 * - 店铺:账单用「线上真实店铺」优先,订单侧用销售全量店铺名,统一把下划线归一为横杠 * - 同一店铺同一订单号多笔收款 → 标注「同单多笔收款」,商品金额只挂一次 */ class SyncStoreReceiptOrderMatches extends Command { protected $signature = 'store-receipt:order-match:sync {month : 账单月份,格式 YYYY-MM} {--platform=taobao-alipay : 收款账户,目前支持 taobao-alipay} {--company=1 : 公司 ID(种菜订单号取自该公司的种菜明细表与聚水潭订单标签明细)} {--chunk=1200 : 单次向上游查询的订单号数量} {--limit=0 : 仅处理前 N 笔账单(调试用,0 表示全量)} {--amounts-only : 只回填「优惠金额 / 冲减金额 / 种菜金额」三列,不重跑订单侧匹配} {--planting-only : 只回填「种菜金额」一列(不读账单,直接按该月已有明细行的订单号刷,一次覆盖全平台)}'; protected $description = '按店铺收款账户账单匹配订单侧商品金额,生成「业绩明细」核对镜像'; /** * 收款账户页签 → 账单来源与列映射。 * * 各页签的表结构不一致:淘系/拼多多的订单号列是 online_order_no、金额列是 income_amount; * 京东系列与抖音是 order_no / income;得物没有 occurred_at,用 bill_date。 * 京东账单与京喜账单共用同一张日账单表,用店铺名关键字拆分。 */ private const SOURCES = [ 'taobao-alipay' => [ 'label' => '淘系支付宝', 'table' => 'taobao_alipay_bill_rows', 'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount', 'real_store' => 'online_real_store', ], 'taobao-wechat' => [ 'label' => '淘系微信', 'table' => 'taobao_wechat_bill_rows', 'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount', ], 'pinduoduo' => [ 'label' => '拼多多', 'table' => 'pdd_settlement_bill_rows', 'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount', ], 'dewu' => [ 'label' => '得物', 'table' => 'dewu_settlement_bill_rows', 'order' => 'online_order_no', 'time' => 'bill_date', 'amount' => 'income_amount', 'real_store' => 'online_real_store', ], 'jd-wallet' => [ 'label' => '京东网银钱包', 'table' => 'jd_wallet_bill_rows', 'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income', 'periods' => 'jd_wallet_bill_periods', ], 'jd-transactions' => [ 'label' => '京东账单', 'table' => 'jd_daily_bill_rows', 'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income', 'store_exclude' => '京喜', 'periods' => 'jd_daily_bill_periods', ], 'jingxi' => [ 'label' => '京喜账单', 'table' => 'jd_daily_bill_rows', 'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income', 'store_include' => '京喜', 'periods' => 'jd_daily_bill_periods', ], 'douyin' => [ 'label' => '抖音', 'table' => 'douyin_authoritative_bills', 'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income', 'periods' => 'douyin_authoritative_periods', ], ]; /** * 账单侧筛选口径:总账备注命中以下任一取值即计入。 * * 与店铺收款账户账单页的「总账备注」筛选完全一致,账单项目清单由 * ec_account_ledger_remarks(平台 + 账单项目 → 总账备注)反查得到。 */ private const LEDGER_REMARKS = [ '订单收入', ]; /** * 「优惠金额」列口径:账单的总账备注命中以下取值。 * * 总账备注优先取「财务规则 › 科目映射表」的财务备注(与店铺收款账户账单页一致), * 取不到时回落到 ec_account_ledger_remarks。金额不计入收入金额与差额。 */ private const DISCOUNT_REMARKS = [ '优惠收入', ]; /** 「冲减金额」列口径:账单的总账备注命中以下取值,不参与收入金额与差额计算。 */ private const OFFSET_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 = '同单多笔收款'; /** 优惠/冲减账单所在的订单没有收款行时,补行用这个标注。 */ private const REMARK_PROMO_ONLY = '无收款账单'; public function handle(): 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::SOURCES[$platform])) { throw new InvalidArgumentException('暂不支持的收款账户:' . $platform); } $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); $source = self::SOURCES[$platform]; $billTable = (string) $source['table']; $orderColumn = (string) $source['order']; $timeColumn = (string) $source['time']; $amountColumn = (string) $source['amount']; $realStoreColumn = (string) ($source['real_store'] ?? ''); $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(%s)', $month, $start->toDateString(), $end->copy()->subDay()->toDateString(), $source['label'], $billTable, )); if ((bool) $this->option('planting-only')) { return $this->backfillPlantingAmounts($reportMonth); } // 「总账备注」→ 按平台分组的账单项目集合(淘系页签同页混有淘宝/天猫/阿里巴巴店铺, // 必须连店铺名前缀一起过滤,才能与页面按行取到的总账备注一致)。 // 收入金额只认 LEDGER_REMARKS;优惠/冲减金额各自单独成列,不参与收入与差额。 $baseGroups = $this->ledgerRemarkGroups($platform); $discountGroups = $this->ledgerRemarkGroups($platform, self::DISCOUNT_REMARKS); $offsetGroups = $this->ledgerRemarkGroups($platform, self::OFFSET_REMARKS); // 总账备注现在优先取科目映射表的财务备注,因此这两列的候选账单项目 // 也要按科目映射表补一遍,否则「优惠收入 / 冲减收入」永远取不到项目。 $promoItems = array_values(array_unique(array_merge( $this->accountMappingItems($platform, self::DISCOUNT_REMARKS), $this->accountMappingItems($platform, self::OFFSET_REMARKS), ))); // 微信账户页签的账单项目带「wx」前缀(wx限时红包代商家垫付扣回 等), // 科目映射表用的是不带前缀的名字,这里两种写法都带上。 $promoItems = $this->withWxVariants($promoItems); if ($baseGroups === []) { $this->error('「总账备注」映射表没有匹配到任何账单项目,请先导入 ec_account_ledger_remarks。'); return self::FAILURE; } $this->info('总账备注命中平台分组:' . count($baseGroups) . ' 组'); if ($discountGroups === [] && $offsetGroups === [] && $promoItems === []) { $this->warn('「优惠收入」「冲减收入」在科目映射表与总账备注映射里都没有对应账单项目,两列将全部为空。'); } $select = ['store_name', 'bill_item', $orderColumn, $timeColumn, $amountColumn]; if ($realStoreColumn !== '') { $select[] = $realStoreColumn; } $billQuery = DB::connection($connection)->table($billTable) ->select($select) ->where($timeColumn, '>=', $start->toDateTimeString()) ->where($timeColumn, '<', $end->toDateTimeString()); if (isset($source['store_include'])) { $billQuery->where('store_name', 'like', '%' . $source['store_include'] . '%'); } if (isset($source['store_exclude'])) { $billQuery->where('store_name', 'not like', '%' . $source['store_exclude'] . '%'); } // 京东系列与抖音的镜像按代保存历史导入,必须只取与本月重叠的活动代,否则会重复计数。 $periodsTable = (string) ($source['periods'] ?? ''); if ($periodsTable !== '') { $generations = DB::connection($connection)->table($periodsTable) ->where('period_start', '<', $end->toDateString()) ->where('period_end', '>', $start->toDateString()) ->pluck('active_generation') ->filter() ->unique() ->values() ->all(); if ($generations === []) { $this->warn('该账期没有活动代(' . $periodsTable . '),未写入任何数据。'); return self::SUCCESS; } $billQuery->whereIn('generation', $generations); $this->info('活动代:' . count($generations) . ' 个'); } $billQuery->where(function ($group) use ($baseGroups, $discountGroups, $offsetGroups, $promoItems): void { LedgerRemarkFilter::applyGroups( $group, array_merge($baseGroups, $discountGroups, $offsetGroups), '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 . '%'), ); if ($promoItems !== []) { $group->orWhereIn('bill_item', $promoItems); } }); $bills = $billQuery->orderBy($timeColumn)->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; $discountTotal = 0.0; $offsetTotal = 0.0; // 种菜订单号:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号(或的关系)。 $plantingCores = $this->plantingOrderCores(); $plantingTotal = 0.0; $plantingRows = 0; $remarkMap = app(AccountLedgerRemarkMap::class); $tabPlatform = $remarkMap->defaultPlatform($platform); $ledgerMap = $remarkMap->map(); $incomeOverlapRows = 0; $incomeOverlapTotal = 0.0; /** @var array $promoRowsByKey 门店+订单 维度的优惠/冲减账单行数(用于统计未挂单行数) */ $promoRowsByKey = []; foreach ($bills as $bill) { $storeName = (string) ($bill->store_name ?? ''); $billItem = (string) ($bill->bill_item ?? ''); // 收入金额沿用「总账备注映射」白名单口径,保证既有数字不变; // 优惠 / 冲减两列按新口径(科目映射表的财务备注优先)取数。 $legacyRemark = $ledgerMap[$remarkMap->platform($storeName, $tabPlatform) ?? ''][$billItem] ?? null; $remark = $remarkMap->remark($storeName, $tabPlatform, $billItem); $isPromoRemark = in_array($remark, self::DISCOUNT_REMARKS, true) || in_array($remark, self::OFFSET_REMARKS, true); $isIncomeRemark = in_array($legacyRemark, self::LEDGER_REMARKS, true); if ($isIncomeRemark && $isPromoRemark) { // 已经在收入金额里的行:不重复计入优惠/冲减列,单独提示金额。 $incomeOverlapRows++; $incomeOverlapTotal += (float) $bill->{$amountColumn}; } $bucket = match (true) { $isIncomeRemark => 'income', in_array($remark, self::DISCOUNT_REMARKS, true) => 'discount', in_array($remark, self::OFFSET_REMARKS, true) => 'offset', default => null, }; if ($bucket === null) { continue; } $rawNo = trim((string) ($bill->{$orderColumn} ?? '')); $core = self::cleanOrderNo($rawNo); $realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : ''; $shopKey = self::normalizeShop($realStore !== '' ? $realStore : (string) $bill->store_name); $key = $shopKey . '|' . $core; if (! isset($groups[$key])) { $groups[$key] = [ 'platform' => (string) $source['label'], 'store_name' => (string) ($bill->store_name ?? ''), 'shop_key' => $shopKey, 'online_order_no' => $rawNo, 'order_core_no' => $core, 'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10), 'payment_count' => 0, 'income_amount' => 0.0, 'discount_amount' => 0.0, 'discount_seen' => false, 'offset_amount' => 0.0, 'offset_seen' => false, ]; } $amount = (float) $bill->{$amountColumn}; if ($bucket === 'income') { $groups[$key]['payment_count']++; $groups[$key]['income_amount'] += $amount; $paymentTotal++; $incomeTotal += $amount; } elseif ($bucket === 'discount') { $groups[$key]['discount_seen'] = true; $groups[$key]['discount_amount'] += $amount; $discountTotal += $amount; $promoRowsByKey[$key] = ($promoRowsByKey[$key] ?? 0) + 1; } else { $groups[$key]['offset_seen'] = true; $groups[$key]['offset_amount'] += $amount; $offsetTotal += $amount; $promoRowsByKey[$key] = ($promoRowsByKey[$key] ?? 0) + 1; } // 种菜金额只标记「这个订单是不是种菜单」,金额仍取该行的收入金额, // 所以在这里按核心单号打个标记,金额等聚合完再取。 if (isset($plantingCores[$core])) { $groups[$key]['planting_seen'] = true; } } // 行口径保持不变:仍然是「有收款账单(收入白名单)的店铺 + 订单号」, // 只有优惠/冲减、没有收款的订单号不单独成行。 $groups = array_filter($groups, static fn (array $group): bool => (int) $group['payment_count'] > 0); // 口径对照:账单口径合计、已挂到订单行的合计、挂不上的行数(账单没有订单号 / 订单没有收款行)。 $attachedDiscount = 0.0; $attachedOffset = 0.0; foreach ($groups as $group) { $attachedDiscount += (float) $group['discount_amount']; $attachedOffset += (float) $group['offset_amount']; } $unattachedRows = 0; foreach ($promoRowsByKey as $key => $count) { if (! isset($groups[$key])) { $unattachedRows += $count; } } // A 方案:挂不到订单行的优惠/冲减也补成行(收入/商品/差额留空),金额不丢。 $promoOnlyGroups = $this->promoOnlyGroups( $bills, $groups, $remarkMap, $ledgerMap, $tabPlatform, $orderColumn, $timeColumn, $amountColumn, $realStoreColumn, ); $this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行'); $this->line(' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 2)); foreach ($groups as $group) { if (! empty($group['planting_seen'])) { $plantingRows++; $plantingTotal += (float) $group['income_amount']; } } $this->line(sprintf( ' 种菜金额:%d 行(识别到 %d 个种菜订单号),合计 %s', $plantingRows, count($plantingCores), number_format($plantingTotal, 2), )); $this->line(sprintf( ' 已挂订单行:优惠 %s / 冲减 %s;未挂到订单行:%s(金额 %s,已补成「无收款账单 / 账单无单号」行)', number_format($attachedDiscount, 2), number_format($attachedOffset, 2), number_format($unattachedRows) . ' 行', number_format(($discountTotal + $offsetTotal) - ($attachedDiscount + $attachedOffset), 2), )); if ($incomeOverlapRows > 0) { $this->line(sprintf( ' 提示:%d 笔(%s 元)按旧口径已计入收入金额,未重复计入优惠/冲减列。', $incomeOverlapRows, number_format($incomeOverlapTotal, 2), )); } if ((bool) $this->option('amounts-only')) { return $this->backfillPromoAmounts($month, $source, $groups, $promoOnlyGroups, $reportMonth); } // 订单侧的单号在不同平台形态不同:淘系/京东是无前缀纯数字, // 拼多多等平台带日期前缀(260630-xxx)。这里同时带上原始号与清洗后的 // 核心号去查订单侧,再统一按核心号聚合,避免漏匹配。 $cores = []; foreach ($groups as $group) { foreach ([(string) $group['online_order_no'], (string) $group['order_core_no']] as $candidate) { $candidate = trim($candidate); if ($candidate !== '') { $cores[$candidate] = true; } } } /** @var array $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(商品金额求和) */ $orderAmount = []; /** @var array $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */ $orderSeen = []; /** @var array> $coresByCore 核心单号 → 出现过的店铺集合 */ $coresByCore = []; /** @var array $coreSeen 订单侧出现过的核心单号(含店铺名为空的行) */ $coreSeen = []; // 订单侧改用「订单原始数据」(企微客户订单 + 聚水潭订单),按线上订单号取商品金额合计。 // 两个来源数据量大(企微 500 万单 / 690 万行),先用临时表收敛候选单号, // 再用 STRAIGHT_JOIN 走 so_id / o_id 索引聚合,避免逐批 IN 查询退化成全表扫描。 $mirror = DB::connection($connection); $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_receipt_order_nos'); $mirror->statement('CREATE TEMPORARY TABLE tmp_receipt_order_nos (no varchar(64) NOT NULL, PRIMARY KEY (no)) ENGINE=InnoDB'); $candidates = array_keys($cores); foreach (array_chunk($candidates, 1000) as $batch) { $mirror->table('tmp_receipt_order_nos')->insertOrIgnore( array_map(static fn (string $no): array => ['no' => $no], $batch) ); } $this->info('订单侧候选单号:' . count($candidates)); $orderRows = []; foreach ([ ['ops_jst_qmcust_orders', 'ops_jst_qmcust_order_items'], ['ops_jst_orders', 'ops_jst_order_item'], ] as [$ordersTable, $itemsTable]) { try { // 同一线上订单号在聚水潭可能对应多个 o_id(拆单/子单),且子单会重复包含 // 同一批子订单商品行;先按「子订单号 + 条码」去重再求和,避免重复计算。 $result = $mirror->select( "SELECT so_id, shop_name, SUM(amount) as amount FROM ( SELECT o.so_id as so_id, o.shop_name as shop_name, CONCAT(COALESCE(NULLIF(i.outer_oi_id, ''), CONCAT('o', o.o_id, '-', i.oi_id)), '|', i.sku_id) as dedup_key, MAX(i.amount) as amount FROM tmp_receipt_order_nos t STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no STRAIGHT_JOIN {$itemsTable} i ON i.o_id = o.o_id GROUP BY o.so_id, o.shop_name, dedup_key ) d GROUP BY so_id, shop_name" ); } catch (Throwable $exception) { $this->error('订单侧查询失败(' . $ordersTable . '):' . $exception->getMessage()); return self::FAILURE; } foreach ($result as $row) { $orderRows[] = (array) $row; } } $this->info('订单侧命中(店铺 × 订单)行数:' . count($orderRows)); foreach ($orderRows as $row) { $core = self::cleanOrderNo((string) $row['so_id']); 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; } // 口径:按「店铺 + 线上订单号」对商品金额求和(一单多品/多子订单在订单侧先合并)。 $orderAmount[$key] = ($orderAmount[$key] ?? 0.0) + (float) $row['amount']; } // 只重建「本账期 + 本平台」的数据,避免把同月其它平台的结果一起清掉。 DB::connection($connection)->table(self::TARGET_TABLE) ->where('report_month', $reportMonth) ->where('platform', (string) $source['label']) ->delete(); $target = DB::connection($connection)->table(self::TARGET_TABLE); $now = now(); $buffer = []; $imported = 0; $stats = ['已匹配' => 0, '未匹配' => 0, self::REMARK_MULTI_PAY => 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; } // 差额口径:(收入金额 + 优惠金额 + 冲减金额)− 订单侧商品金额。 // 优惠/冲减属于同一订单的账单,把它们加回来,差额才只反映真正对不上的部分。 $promoAmount = round((float) ($group['discount_amount'] ?? 0.0) + (float) ($group['offset_amount'] ?? 0.0), 2); $difference = $productAmount === null ? null : round($income + $promoAmount - $productAmount, 2); if ($productAmount !== null) { $goodsTotal += $productAmount; $differenceTotal += (float) $difference; } $stats[$status] = ($stats[$status] ?? 0) + 1; if ((int) $group['payment_count'] > 1) { $stats[self::REMARK_MULTI_PAY]++; } if ($remark !== '') { $remarked[$remark] = ($remarked[$remark] ?? 0) + 1; } $buffer[] = [ 'report_month' => $reportMonth, 'platform' => $group['platform'], 'row_key' => md5($reportMonth . '|' . $group['platform'] . '|' . $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, 'discount_amount' => ! empty($group['discount_seen']) ? round((float) $group['discount_amount'], 2) : null, 'offset_amount' => ! empty($group['offset_seen']) ? round((float) $group['offset_amount'], 2) : null, 'planting_amount' => ! empty($group['planting_seen']) ? $income : null, '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 = []; } } // A 方案补行:挂不到订单行的优惠/冲减也写进业绩明细(收入/商品/差额留空)。 foreach ($promoOnlyGroups as $promoGroup) { $buffer[] = self::promoOnlyRow($reportMonth, (string) $source['label'], $promoGroup, $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; } /** * 只回填「种菜金额」:不读账单,直接按业绩明细里已有的「订单号」判断是不是种菜订单, * 是就把该行的收入金额写进去,不是就清空。适合给已经同步好的账期补这一列。 */ private function backfillPlantingAmounts(string $reportMonth): int { $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); $cores = $this->plantingOrderCores(); if ($cores === []) { $this->warn('没有识别到任何种菜订单号(种菜明细表与聚水潭订单标签明细的交集为空),本列将全部为空。'); } $target = static fn () => DB::connection($connection)->table(self::TARGET_TABLE) ->where('report_month', $reportMonth); $rows = $target()->get(['row_key', 'order_core_no', 'income_amount']); $target()->update(['planting_amount' => null]); $values = []; $total = 0.0; foreach ($rows as $row) { $core = self::orderCoreNo((string) $row->order_core_no); if ($core === '' || ! isset($cores[$core])) { continue; } $amount = round((float) $row->income_amount, 2); $values[(string) $row->row_key] = $amount; $total += $amount; } $this->updateAmountColumn($connection, $reportMonth, 'planting_amount', $values); $this->info(sprintf( '已回填 %s 种菜金额:%d / %d 行,合计 %s(共识别 %d 个种菜订单号)。', $reportMonth, count($values), count($rows), number_format($total, 2), count($cores), )); return self::SUCCESS; } /** * 只回填「优惠金额 / 冲减金额 / 种菜金额」三列:不重建业绩明细、不查订单侧, * 先清空该账期该平台的三列,再按 row_key 更新镜像里已存在的行。 * * @param array $source * @param array> $groups * @param array> $promoOnlyGroups A 方案要补的行 */ private function backfillPromoAmounts(string $month, array $source, array $groups, array $promoOnlyGroups, string $reportMonth): int { $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); $platform = (string) $source['label']; $target = static fn () => DB::connection($connection)->table(self::TARGET_TABLE); $existing = $target() ->where('report_month', $reportMonth) ->where('platform', $platform) ->pluck('row_key') ->flip() ->all(); $target() ->where('report_month', $reportMonth) ->where('platform', $platform) ->update(['discount_amount' => null, 'offset_amount' => null, 'planting_amount' => null]); $discounts = []; $offsets = []; $plantings = []; foreach ($groups as $group) { if (empty($group['discount_seen']) && empty($group['offset_seen']) && empty($group['planting_seen'])) { continue; } $rowKey = md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|' . $group['order_core_no']); if (! isset($existing[$rowKey])) { continue; } if (! empty($group['discount_seen'])) { $discounts[$rowKey] = round((float) $group['discount_amount'], 2); } if (! empty($group['offset_seen'])) { $offsets[$rowKey] = round((float) $group['offset_amount'], 2); } if (! empty($group['planting_seen'])) { $plantings[$rowKey] = round((float) $group['income_amount'], 2); } } $this->updateAmountColumn($connection, $reportMonth, 'discount_amount', $discounts); $this->updateAmountColumn($connection, $reportMonth, 'offset_amount', $offsets); $this->updateAmountColumn($connection, $reportMonth, 'planting_amount', $plantings); // A 方案:本月本平台的补行(payment_count=0 的行)整体重建,保证幂等。 $target() ->where('report_month', $reportMonth) ->where('platform', $platform) ->where('payment_count', 0) ->delete(); $buffer = []; $promoRows = 0; foreach ($promoOnlyGroups as $promoGroup) { $buffer[] = self::promoOnlyRow($reportMonth, $platform, $promoGroup, now()); if (count($buffer) >= 500) { $target()->insertOrIgnore($buffer); $promoRows += count($buffer); $buffer = []; } } if ($buffer !== []) { $target()->insertOrIgnore($buffer); $promoRows += count($buffer); } $this->info(sprintf( '已回填 %s %s:优惠 %d 行(%s)/ 冲减 %d 行(%s)/ 补「无收款账单/无单号」优惠冲减行 %d 行 / 种菜 %d 行(%s),镜像原有 %d 行,未重建业绩明细。', $month, $platform, count($discounts), number_format(array_sum($discounts), 2), count($offsets), number_format(array_sum($offsets), 2), $promoRows, count($plantings), number_format(array_sum($plantings), 2), count($existing), )); return self::SUCCESS; } /** * A 方案:把挂不到订单行的优惠/冲减整理成「补行」。 * * - 有订单号、但该订单没有收款账单(成不了行)→ 一行 = 店铺 + 订单号 * - 账单本身没有订单号 → 一行 = 店铺 + 账单项目(按账期汇总) * * @param iterable $bills * @param array> $groups 已有收款行(已按 payment_count 过滤) * @param array $ledgerMap * @return array> */ private function promoOnlyGroups( iterable $bills, array $groups, AccountLedgerRemarkMap $remarkMap, array $ledgerMap, ?string $tabPlatform, string $orderColumn, string $timeColumn, string $amountColumn, string $realStoreColumn, ): array { $result = []; foreach ($bills as $bill) { $storeName = (string) ($bill->store_name ?? ''); $billItem = (string) ($bill->bill_item ?? ''); $remark = $remarkMap->remark($storeName, $tabPlatform, $billItem); $type = in_array($remark, self::DISCOUNT_REMARKS, true) ? 'discount' : (in_array($remark, self::OFFSET_REMARKS, true) ? 'offset' : null); if ($type === null) { continue; } // 已经算进「收入金额」白名单的行不重复补。 $legacy = $ledgerMap[$remarkMap->platform($storeName, $tabPlatform) ?? ''][$billItem] ?? null; if (in_array($legacy, self::LEDGER_REMARKS, true)) { continue; } $rawNo = trim((string) ($bill->{$orderColumn} ?? '')); $core = self::cleanOrderNo($rawNo); $realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : ''; $shopKey = self::normalizeShop($realStore !== '' ? $realStore : $storeName); if ($core !== '' && isset($groups[$shopKey . '|' . $core])) { // 该订单已有收款行,优惠/冲减已经挂在那一行上。 continue; } $key = $core === '' ? $shopKey . '||' . $billItem : $shopKey . '|' . $core; if (! isset($result[$key])) { $result[$key] = [ 'shop_key' => $shopKey, 'store_name' => $storeName, 'online_order_no' => $rawNo, 'order_core_no' => $core, 'bill_item' => $billItem, 'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10), 'discount_amount' => 0.0, 'discount_seen' => false, 'offset_amount' => 0.0, 'offset_seen' => false, ]; } $amount = (float) $bill->{$amountColumn}; if ($type === 'discount') { $result[$key]['discount_seen'] = true; $result[$key]['discount_amount'] += $amount; } else { $result[$key]['offset_seen'] = true; $result[$key]['offset_amount'] += $amount; } } return $result; } /** * A 方案补行的入库数据:收入/商品/差额留空,只带优惠/冲减金额与原因标注。 * * @param array $group * @return array */ private static function promoOnlyRow(string $reportMonth, string $platform, array $group, mixed $now): array { $core = (string) $group['order_core_no']; $rowKey = $core !== '' ? md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|' . $core) : md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|promo|' . $group['bill_item']); return [ 'report_month' => $reportMonth, 'platform' => $platform, 'row_key' => $rowKey, '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' => 0, 'income_amount' => 0, 'discount_amount' => ! empty($group['discount_seen']) ? round((float) $group['discount_amount'], 2) : null, 'offset_amount' => ! empty($group['offset_seen']) ? round((float) $group['offset_amount'], 2) : null, // 与主行保持完全一致的列集合与顺序,批量插入时列数才不会错位。 'planting_amount' => null, 'product_amount' => null, 'difference' => null, 'match_status' => self::STATUS_UNMATCHED, 'remark' => $core === '' ? self::REMARK_EMPTY_NO : self::REMARK_PROMO_ONLY, 'imported_at' => $now, ]; } /** * 按 row_key 批量更新一列金额:只更新镜像里已有的行,不做插入。 * * @param array $values row_key => 金额 */ private function updateAmountColumn(string $connection, string $reportMonth, string $column, array $values): void { if ($values === []) { return; } foreach (array_chunk($values, 500, true) as $chunk) { $cases = ''; $bindings = []; foreach ($chunk as $rowKey => $amount) { $cases .= ' WHEN ? THEN ?'; $bindings[] = (string) $rowKey; $bindings[] = $amount; } $bindings[] = $reportMonth; $bindings = array_merge($bindings, array_map('strval', array_keys($chunk))); $placeholders = implode(',', array_fill(0, count($chunk), '?')); DB::connection($connection)->update( sprintf( 'update %s set %s = case row_key%s end where report_month = ? and row_key in (%s)', self::TARGET_TABLE, $column, $cases, $placeholders, ), $bindings, ); } } /** 单号清洗:去掉日期前缀(如 260630-)、空格、横杠,得到核心号。 */ public static function cleanOrderNo(string $value): string { return self::orderCoreNo($value); } /** * 种菜订单号集合:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号。 * * 两个条件是「或」:订单号在任意一边出现过就算种菜订单(业务口径已确认)。 * 两边都按键清洗后的「核心单号」比较(去掉日期前缀、空格、横杠),与账单侧同一套口径。 * * @return array */ private function plantingOrderCores(): array { $companyId = (int) $this->option('company'); $positiveLabels = []; foreach (app(JstOrderLabelPlantingRules::class)->map($companyId) as $label => $flag) { if ($flag === '是') { $positiveLabels[] = $label; } } if ($positiveLabels === []) { return []; } // 并集的第一部分:聚水潭订单标签明细里命中种菜映射的订单号 $cores = []; foreach (DB::table('jst_order_labels') ->where('company_id', $companyId) ->whereIn('label', $positiveLabels) ->distinct() ->pluck('order_no') as $orderNo) { $core = self::orderCoreNo((string) $orderNo); if ($core !== '') { $cores[$core] = true; } } // 并集的第二部分:种菜明细表里的订单号 foreach (DB::table('planting_details')->where('company_id', $companyId)->distinct()->pluck('order_no') as $orderNo) { $core = self::orderCoreNo((string) $orderNo); if ($core !== '') { $cores[$core] = true; } } return $cores; } private static function orderCoreNo(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); } /** * 科目映射表里「财务备注命中给定备注」的账单项目(限本页签涉及的平台)。 * * @param list $remarks * @return list */ private function accountMappingItems(string $tab, array $remarks): array { $map = app(AccountLedgerRemarkMap::class); $platforms = $map->platformsForTab($tab); $items = []; foreach (app(AccountMappingStore::class)->all() as $rule) { if (! is_array($rule)) { continue; } $platform = trim((string) ($rule['platform'] ?? '')); $item = trim((string) ($rule['item'] ?? '')); $memo = trim((string) ($rule['memo'] ?? '')); if ($platform === '' || $item === '' || ! in_array($platform, $platforms, true)) { continue; } if (! in_array($memo, $remarks, true)) { continue; } $items[$item] = true; } $list = array_keys($items); sort($list); return $list; } /** * 账单项目名的「wx」前缀变体:科目映射表用不带前缀的名字, * 微信账户页签的账单带前缀(反之亦然),两种写法都纳入候选。 * * @param list $items * @return list */ private function withWxVariants(array $items): array { $candidates = []; foreach ($items as $item) { $item = trim((string) $item); if ($item === '') { continue; } $candidates[$item] = true; if (stripos($item, 'wx') === 0) { $stripped = ltrim(mb_substr($item, 2)); if ($stripped !== '') { $candidates[$stripped] = true; } } else { $candidates['wx' . $item] = true; } } $list = array_keys($candidates); sort($list); return $list; } /** * 「总账备注」集合 → 平台分组条件(复用账单页同一份映射与翻译)。 * * @param list|null $remarks 为空时取收入白名单(LEDGER_REMARKS) * @return list> */ private function ledgerRemarkGroups(string $tab, ?array $remarks = null): array { $map = app(AccountLedgerRemarkMap::class); $groups = []; $seen = []; foreach ($remarks ?? 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); } }