0 的有效行 * - 单号匹配:优先完整线上单号精确匹配;仅当核心号唯一时允许兼容回退,多个完整单号共用核心号时禁止合并 * - 店铺:账单用「线上真实店铺」优先,订单侧用销售全量店铺名,统一把下划线归一为横杠 * - 同一店铺同一订单号多笔收款 → 标注「同单多笔收款」,商品金额只挂一次 */ 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 : 只回填「种菜金额 / 标签」(不读账单,直接按该月已有明细行的订单号刷,一次覆盖全平台)} {--dry-run : 只计算并打印统计,不写库(用于评估口径调整的影响)}'; 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', ], ]; /** 账单行上可作「业务备注关键词」的列(各平台账单表列名不一致,按存在的列取)。 */ private const BILL_REMARK_COLUMNS = [ 'custom_remark', 'finance_remark', 'second_remark', 'business_type', 'order_type', 'channel', 'settle_remark', ]; /** 账单行上可作「商品名称关键词」的列。 */ private const BILL_GOODS_COLUMNS = ['goods_name', 'product_name']; /** * 账单侧筛选口径:总账备注命中以下任一取值即计入。 * * 与店铺收款账户账单页的「总账备注」筛选完全一致,账单项目清单由 * ec_account_ledger_remarks(平台 + 账单项目 → 总账备注)反查得到。 */ private const LEDGER_REMARKS = [ '订单收入', ]; /** * 「优惠金额」列口径:账单的总账备注命中以下取值。 * * 总账备注优先取「财务规则 › 科目映射表」的财务备注(与店铺收款账户账单页一致), * 取不到时回落到 ec_account_ledger_remarks。金额不计入收入,但计入差额公式。 */ private const DISCOUNT_REMARKS = [ '优惠收入', ]; /** 「冲减金额」列口径:只认普通冲减收入,不包含违约赔付。 */ private const OFFSET_REMARKS = [ '冲减收入', ]; /** 「违约赔付(冲减收入)」独立列口径,计算差额时单独加回一次。 */ private const DEFAULT_COMPENSATION_REMARKS = [ '违约赔付(冲减收入)', ]; /** 「退款金额」列口径:账单的总账备注命中以下取值,单独列示,不参与收入金额与差额计算。 */ private const REFUND_REMARKS = [ '退款', ]; /** 这些收款账户不进入业绩明细,收、支都不取。 */ private const EXCLUDED_TARGET_SOURCES = [ 'jd-wallet', ]; private const TARGET_TABLE = 'store_receipt_order_match_rows'; private const METRIC_DETAIL_TABLE = 'store_receipt_order_match_metric_details'; private const STATUS_MATCHED = '已匹配'; private const STATUS_UNMATCHED = '未匹配'; private const PLANTING_LABEL_REIMBURSED = '种菜报销'; private const PLANTING_LABEL_UNREIMBURSED = '种菜未报销'; /** * 异常标注取值(页面「匹配状态」筛选与导出共用)。 * * 分三类,命名统一成「哪里出问题」: * 单号问题:账单无单号 / 单号格式异常 * 订单问题:订单表无此单 / 订单无商品金额 * 匹配提示:店铺名不一致 / 订单侧同号多条 / 同单多笔收款 */ private const REMARK_EMPTY_NO = '账单无单号'; private const REMARK_BAD_FORMAT = '单号格式异常'; private const REMARK_NO_ORDER = '订单表无此单'; private const REMARK_NO_AMOUNT = '订单无商品金额'; /** 比对基准为「订单实付金额」时,订单侧有记录但没有实付金额的标注。 */ private const REMARK_NO_PAID = '订单无实付金额'; private const REMARK_SHOP_DIFF = '店铺名不一致'; private const REMARK_DUP_ORDER = '订单侧同号多条'; private const REMARK_MULTI_PAY = '同单多笔收款'; /** 优惠/冲减账单所在的订单没有收款行时,补行用这个标注。 */ private const REMARK_PROMO_ONLY = '无收款账单'; /** * 1688 采销专用账户(按账单店铺名识别): * 这个账户收的是采销/供货结算款,不是店铺销售单收款,订单侧本来就没有对应的销售单, * 所以未匹配原因单独成档,避免和真实丢单混在「订单表无此单」里。 */ private const CAIXIAO_STORE_KEYWORD = '采销专用'; private const REMARK_EMPTY_NO_CAIXIAO = '账单无单号(采销收款)'; private const REMARK_NO_ORDER_CAIXIAO = '1688采销收款(非销售单)'; /** 双口径兜底命中「卖家实收」时写入的标识(页面「未匹配原因」列展示)。 */ private const REMARK_SELLER_INCOME_BASIS = '订单实付按卖家实收核对'; /** 实收缺失时退回「应付/已付」核对命中的标识。 */ private const REMARK_PAYABLE_BASIS = '订单实付按应付/已付核对'; public function handle(): int { if ((bool) $this->option('dry-run')) return $this->syncRows(); // Share the aggregate lock with the writer, including manually invoked commands. $lock = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock('store-report:paid-sales', 10800); if (! $lock->get()) { $this->warn('业绩明细或销量汇总正在处理,请稍后重试。'); return 75; } try { $result = $this->syncRows(); if ($result === self::SUCCESS && ! (bool) $this->option('planting-only')) { $this->refreshCrossMonthBalances( trim((string) $this->argument('month')), (string) $this->option('platform'), ); } } finally { $lock->release(); } if ($result === self::SUCCESS && ! (bool) $this->option('planting-only')) { $this->refreshStoreReportPaidSales(); } return $result; } /** * 同一平台、店铺、交易单号在相邻月份的收入/优惠/冲减/违约赔付合并后, * 与订单实付(各月重复为同一订单级金额,取最大值)在 2 分钱内对平, * 才把涉及的月份标记为“跨月对平”。未对平不标记。 */ private function refreshCrossMonthBalances(string $month, string $platform): void { $source = self::SOURCES[$platform] ?? null; if ($source === null || in_array($platform, self::EXCLUDED_TARGET_SOURCES, true)) { return; } $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); if (! Schema::connection($connection)->hasColumn(self::TARGET_TABLE, 'cross_month_balanced')) { return; } $start = Carbon::createFromFormat('Y-m-d', $month . '-01')->startOfMonth(); $label = (string) $source['label']; $table = DB::connection($connection)->getQueryGrammar()->wrapTable(self::TARGET_TABLE); $net = 'COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)' . ' + COALESCE(offset_amount, 0) + COALESCE(default_compensation_amount, 0)'; $affected = 0; // 分别重算上个月、本月、下个月。每行只根据自身前后各一个月判断, // 后续同步新月份时也能同步修正相邻月份,不会因只重置整段范围留下旧标记。 foreach ([ $start->copy()->subMonthNoOverflow(), $start->copy(), $start->copy()->addMonthNoOverflow(), ] as $targetMonth) { $targetDate = $targetMonth->toDateString(); $rangeStart = $targetMonth->copy()->subMonthNoOverflow()->toDateString(); $rangeEnd = $targetMonth->copy()->addMonthsNoOverflow(2)->toDateString(); $affected += DB::connection($connection)->transaction( function () use ($connection, $table, $net, $label, $targetDate, $rangeStart, $rangeEnd): int { DB::connection($connection)->update( "UPDATE {$table} SET cross_month_balanced = 0 WHERE platform = ? AND report_month = ?", [$label, $targetDate], ); return DB::connection($connection)->update( "UPDATE {$table} AS target JOIN ( SELECT platform, store_name, online_order_no FROM {$table} WHERE platform = ? AND report_month >= ? AND report_month < ? AND COALESCE(store_name, '') <> '' AND COALESCE(online_order_no, '') <> '' GROUP BY platform, store_name, online_order_no HAVING COUNT(DISTINCT report_month) >= 2 AND SUM(ABS(COALESCE(income_amount, 0)) + ABS(COALESCE(discount_amount, 0)) + ABS(COALESCE(offset_amount, 0)) + ABS(COALESCE(default_compensation_amount, 0))) > 0.005 AND MAX(COALESCE(paid_amount, 0)) > 0.005 AND ABS(SUM({$net}) - MAX(COALESCE(paid_amount, 0))) <= 0.02 ) AS balanced ON balanced.platform = target.platform AND balanced.store_name = target.store_name AND balanced.online_order_no = target.online_order_no SET target.cross_month_balanced = 1 WHERE target.platform = ? AND target.report_month = ?", [$label, $rangeStart, $rangeEnd, $label, $targetDate], ); } ); } $this->line(sprintf(' 跨月合并对平:重算 %s 前后月,标记 %d 行', $start->format('Y-m'), $affected)); } private function syncRows(): int { // 账单逐行读取,但全月订单聚合与匹配索引仍可能超过 1G(拼多多 / 淘系支付宝), // 容器默认 memory_limit=512M 会在写入阶段 OOM 中断,这里统一放宽,免去手工加 -d 参数。 if ((int) ini_get('memory_limit') > 0) { @ini_set('memory_limit', '2G'); } $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(); if (in_array($platform, self::EXCLUDED_TARGET_SOURCES, true)) { $deleted = 0; if (! (bool) $this->option('dry-run')) { $deleted = DB::connection($connection)->table(self::TARGET_TABLE) ->where('report_month', $reportMonth) ->where('platform', (string) $source['label']) ->delete(); DB::connection($connection)->table(self::METRIC_DETAIL_TABLE) ->where('report_month', $reportMonth) ->where('platform', (string) $source['label']) ->delete(); } $this->warn(sprintf( '%s 已从业绩明细排除,%s 删除镜像行 %d 行(收、支均不取)。', (string) $source['label'], $month, $deleted, )); return self::SUCCESS; } $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); $defaultCompensationGroups = $this->ledgerRemarkGroups($platform, self::DEFAULT_COMPENSATION_REMARKS); $refundGroups = $this->ledgerRemarkGroups($platform, self::REFUND_REMARKS); // 总账备注优先取科目映射表的财务备注,因此候选账单项目也要按映射表补一遍。 $promoItems = array_values(array_unique(array_merge( $this->accountMappingItems($platform, self::DISCOUNT_REMARKS), $this->accountMappingItems($platform, self::OFFSET_REMARKS), $this->accountMappingItems($platform, self::DEFAULT_COMPENSATION_REMARKS), $this->accountMappingItems($platform, self::REFUND_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 === [] && $defaultCompensationGroups === [] && $refundGroups === [] && $promoItems === []) { $this->warn('「优惠收入」「冲减收入」「违约赔付(冲减收入)」「退款」都没有对应账单项目,相关列将全部为空。'); } $select = ['store_name', 'bill_item', $orderColumn, $timeColumn, $amountColumn]; // 「冲减金额」按行金额(收入侧 − 支出侧)取数,账单表有支出列时一并取出。 // 淘系/拼多多/得物的支出列叫 expense_amount,京东系列(京东账单/京喜/京东网银钱包)与抖音叫 expense, // 两种写法都要认,否则支出侧的退款/扣回会被当成 0。 $expenseColumn = ''; if (Schema::connection($connection)->hasColumn($billTable, 'expense_amount')) { $expenseColumn = 'expense_amount'; } elseif (Schema::connection($connection)->hasColumn($billTable, 'expense')) { $expenseColumn = 'expense'; } if ($expenseColumn !== '') { $select[] = $expenseColumn; } if ($realStoreColumn !== '') { $select[] = $realStoreColumn; } // 科目映射表的「业务备注 / 商品名称关键词」要用行上的这些列(没有的列不取) foreach (array_merge(self::BILL_REMARK_COLUMNS, self::BILL_GOODS_COLUMNS) as $detailColumn) { if (Schema::connection($connection)->hasColumn($billTable, $detailColumn)) { $select[] = $detailColumn; } } $select = array_values(array_unique($select)); $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, $defaultCompensationGroups, $refundGroups, $promoItems): void { LedgerRemarkFilter::applyGroups( $group, array_merge($baseGroups, $discountGroups, $offsetGroups, $defaultCompensationGroups, $refundGroups), '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); } }); if ($limit > 0) { $billQuery->limit($limit); } // 种菜与备注映射先用正常连接读取,账单游标使用独立的不缓冲连接。 $plantingCores = $this->plantingOrderCores(); $plantingReimbursementCores = $this->plantingReimbursementCores(); $plantingUnreimbursedCores = $this->plantingUnreimbursedCores(); $remarkMap = app(AccountLedgerRemarkMap::class); $tabPlatform = $remarkMap->defaultPlatform($platform); [ 'groups' => $groups, 'promoOnlyGroups' => $promoOnlyGroups, 'paymentTotal' => $paymentTotal, 'incomeTotal' => $incomeTotal, 'discountTotal' => $discountTotal, 'offsetTotal' => $offsetTotal, 'defaultCompensationTotal' => $defaultCompensationTotal, 'refundTotal' => $refundTotal, 'moneyRemarkRows' => $moneyRemarkRows, 'attachedDiscount' => $attachedDiscount, 'attachedOffset' => $attachedOffset, 'attachedDefaultCompensation' => $attachedDefaultCompensation, 'unattachedRows' => $unattachedRows, 'billCount' => $billCount, ] = $this->aggregateBills( $this->streamBillRows($billQuery->orderBy($timeColumn)), $source, $orderColumn, $timeColumn, $amountColumn, $expenseColumn, $realStoreColumn, $plantingCores, $plantingReimbursementCores, $plantingUnreimbursedCores, $remarkMap, $tabPlatform, ); if ($billCount === 0) { $this->warn('该账期没有符合条件的账单记录,未写入任何数据。'); return self::SUCCESS; } $plantingTotal = 0.0; $plantingRows = 0; $plantingReimbursementTotal = 0.0; $plantingReimbursementRows = 0; $plantingUnreimbursedTotal = 0.0; $plantingUnreimbursedRows = 0; $this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行'); $this->line( ' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 2) . ',违约赔付(冲减收入)合计:' . number_format($defaultCompensationTotal, 2) . ',退款金额合计:' . number_format($refundTotal, 2) ); foreach ($groups as $group) { if (! empty($group['planting_seen'])) { $plantingRows++; $plantingTotal += (float) $group['income_amount']; } if (! empty($group['planting_reimbursement_seen'])) { $plantingReimbursementRows++; $plantingReimbursementTotal += (float) $group['income_amount']; } elseif (! empty($group['planting_unreimbursed_seen'])) { $plantingUnreimbursedRows++; $plantingUnreimbursedTotal += (float) $group['income_amount']; } } $this->line(sprintf( ' 种菜金额:%d 行(识别到 %d 个种菜订单号),合计 %s', $plantingRows, count($plantingCores), number_format($plantingTotal, 2), )); $this->line(sprintf( ' 标签「种菜报销」:%d 行(按种菜明细表订单号),收入合计 %s', $plantingReimbursementRows, number_format($plantingReimbursementTotal, 2), )); $this->line(sprintf( ' 标签「种菜未报销」:%d 行(按聚水潭种菜标签),收入合计 %s', $plantingUnreimbursedRows, number_format($plantingUnreimbursedTotal, 2), )); $this->line(sprintf( ' 已挂订单行:优惠 %s / 冲减 %s / 违约赔付 %s;未挂到订单行:%s(金额 %s,已补成「无收款账单 / 账单无单号」行)', number_format($attachedDiscount, 2), number_format($attachedOffset, 2), number_format($attachedDefaultCompensation, 2), number_format($unattachedRows) . ' 行', number_format( ($discountTotal + $offsetTotal + $defaultCompensationTotal) - ($attachedDiscount + $attachedOffset + $attachedDefaultCompensation), 2 ), )); unset($plantingCores); if ((bool) $this->option('amounts-only')) { $result = $this->backfillPromoAmounts($month, $source, $groups, $promoOnlyGroups, $reportMonth); if ($result === self::SUCCESS && ! (bool) $this->option('dry-run')) { $this->replaceMetricDetails($reportMonth, (string) $source['label'], $groups, $promoOnlyGroups, now()); $this->verifyMetricDetailTotals($reportMonth, (string) $source['label']); } return $result; } // 订单侧的单号在不同平台形态不同:淘系/京东是无前缀纯数字, // 拼多多等平台带日期前缀(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>> $identitiesByCore 核心单号 → 店铺 → 完整线上单号 */ $identitiesByCore = []; /** @var array $coreSeen 订单侧出现过的核心单号(含店铺名为空的行) */ $coreSeen = []; /** @var array $orderVirtualPaid 虚拟条码(X0000x)明细行的实付金额合计(店铺 + 完整线上单号) */ $orderVirtualPaid = []; // 订单侧改用「订单原始数据」(企微客户订单 + 聚水潭订单),按线上订单号取商品金额合计。 // 两个来源数据量大(企微 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); unset($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)); unset($candidates, $batch); $orderRows = []; $paidRows = []; // 第 5 项是「卖家实收」口径列:聚水潭订单表有 seller_income_amount,企微订单表没有,退回 pay_amount。 foreach ([ ['ops_jst_qmcust_orders', 'ops_jst_qmcust_order_items', 'pay_amount', 'ops_jst_qmcust', 'pay_amount'], ['ops_jst_orders', 'ops_jst_order_item', 'buyer_paid_amount', 'ops_jst', 'seller_income_amount'], ] as [$ordersTable, $itemsTable, $paidColumn, $tag, $paidAltColumn]) { try { // 商品金额取数优先级:主单(link_o_id 为空/0)→ 原单行(merge_so_id 为空)→ 全部行。 // - 拆单:主单优先,子单会把主单的商品行再列一遍,按「子订单号 + 条码」的去重键拦不住; // - 合单发货(拼多多/京喜/抖音/京东等):原单行与合并汇总行共用同一线上单号、互相 link, // 没有「主单」。此时整单退化为全部行会把被合并进来的另一单商品算到本单号上; // 优先只取原单行(merge_so_id 为空),原单行不存在时再退化为全部行。 $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_receipt_main_nos'); $mirror->statement('CREATE TEMPORARY TABLE tmp_receipt_main_nos (no varchar(64) NOT NULL, PRIMARY KEY(no)) ENGINE=InnoDB'); $mirror->statement( "INSERT IGNORE INTO tmp_receipt_main_nos (no) SELECT DISTINCT o.so_id as no FROM tmp_receipt_order_nos t STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no WHERE o.link_o_id IS NULL OR o.link_o_id = 0" ); $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_receipt_own_nos'); $mirror->statement('CREATE TEMPORARY TABLE tmp_receipt_own_nos (no varchar(64) NOT NULL, PRIMARY KEY(no)) ENGINE=InnoDB'); $mirror->statement( "INSERT IGNORE INTO tmp_receipt_own_nos (no) SELECT DISTINCT o.so_id as no FROM tmp_receipt_order_nos t STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no WHERE o.merge_so_id IS NULL OR o.merge_so_id = ''" ); // 同一线上订单号在聚水潭可能对应多个 o_id(拆单/子单),且子单会重复包含 // 同一批子订单商品行;先按「子订单号 + 条码」去重再求和,避免重复计算。 // 虚拟条码(X0000x:信誉订单/虚拟订单)不是真实商品,不计入商品金额。 // 同一批虚拟条码行的实付单独汇总,用于从订单实付里扣除(金额与实付保持同一口径)。 $result = $mirror->select( "SELECT so_id, shop_name, SUM(amount) as amount, SUM(virtual_paid) as virtual_paid 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(CASE WHEN UPPER(i.sku_id) LIKE 'X0000%' THEN 0 ELSE i.amount END) as amount, MAX(CASE WHEN UPPER(i.sku_id) LIKE 'X0000%' THEN COALESCE(i.buyer_paid_amount, 0) ELSE 0 END) as virtual_paid 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 LEFT JOIN tmp_receipt_main_nos m ON m.no = o.so_id LEFT JOIN tmp_receipt_own_nos w ON w.no = o.so_id WHERE (o.link_o_id IS NULL OR o.link_o_id = 0) OR (m.no IS NULL AND (o.merge_so_id IS NULL OR o.merge_so_id = '')) OR (m.no IS NULL AND w.no IS NULL) GROUP BY o.so_id, o.shop_name, dedup_key ) d GROUP BY so_id, shop_name" ); // 订单实付:取订单级字段(明细行上的实付会重复);优先级与商品金额一致: // 主单(link_o_id 为空)→ 原单行(merge_so_id 为空)→ 兜底取该单号下的最大值。 // 合单发货两行互相 link、没有主单,直接取最大值会取到合并汇总行(含被合并的另一单), // 必须让原单行优先;实付、卖家实收、应付三个字段同一套规则。 $paid = $mirror->select( "SELECT so_id, shop_name, COALESCE( MAX(CASE WHEN link_o_id IS NULL OR link_o_id = 0 THEN paid END), MAX(CASE WHEN merge_so_id IS NULL OR merge_so_id = '' THEN paid END), MAX(paid) ) as paid , COALESCE( MAX(CASE WHEN link_o_id IS NULL OR link_o_id = 0 THEN paid_alt END), MAX(CASE WHEN merge_so_id IS NULL OR merge_so_id = '' THEN paid_alt END), MAX(paid_alt) ) as paid_alt , COALESCE( MAX(CASE WHEN link_o_id IS NULL OR link_o_id = 0 THEN paid_pay END), MAX(CASE WHEN merge_so_id IS NULL OR merge_so_id = '' THEN paid_pay END), MAX(paid_pay) ) as paid_pay FROM ( SELECT o.so_id as so_id, o.shop_name as shop_name, o.link_o_id as link_o_id, o.merge_so_id as merge_so_id, o.{$paidColumn} as paid, o.{$paidAltColumn} as paid_alt, o.pay_amount as paid_pay FROM tmp_receipt_order_nos t STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no ) x GROUP BY so_id, shop_name" ); } catch (Throwable $exception) { $this->error('订单侧查询失败(' . $ordersTable . '):' . $exception->getMessage()); return self::FAILURE; } foreach ($result as $row) { $orderRows[] = (array) $row; } foreach ($paid as $row) { $paidRows[] = (array) $row; } unset($result, $paid, $row); } $this->info('订单侧命中(店铺 × 订单)行数:' . count($orderRows) . ',实付行数:' . count($paidRows)); /** @var array $orderPaid 订单侧实付金额(店铺 + 完整线上单号) */ $orderPaid = []; /** @var array $orderPaidAlt 订单侧「卖家实收」金额(双口径兜底用) */ $orderPaidAlt = []; /** @var array $orderPaidPay 订单侧「应付」金额(实收缺失时的退化口径) */ $orderPaidPay = []; foreach ($paidRows as $row) { $rawNo = (string) $row['so_id']; $core = self::cleanOrderNo($rawNo); $identity = self::orderIdentity($rawNo); if ($core === '') { continue; } $shopKey = self::normalizeShop((string) ($row['shop_name'] ?? '')); $key = $shopKey . '|' . $identity; $orderPaid[$key] = ($orderPaid[$key] ?? 0.0) + (float) $row['paid']; $orderPaidAlt[$key] = ($orderPaidAlt[$key] ?? 0.0) + (float) ($row['paid_alt'] ?? 0.0); $orderPaidPay[$key] = ($orderPaidPay[$key] ?? 0.0) + (float) ($row['paid_pay'] ?? 0.0); // 订单实付命中同样算「订单侧存在」:部分订单(退款流程 / 拆单 / 商品明细未同步)没有商品明细行, // 若只按商品明细判断存在性,这些单会被误判「订单表无此单」(实付明明在)。 // 商品金额数组仍只由商品明细构建:无明细时商品金额保持为空、按口径标「订单无商品金额」。 $orderSeen[$key] = true; $coreSeen[$core] = true; $identitiesByCore[$core][$shopKey][$identity] = true; if ($shopKey !== '') { $coresByCore[$core][$shopKey] = true; } } unset($paidRows, $row); foreach ($orderRows as $row) { $rawNo = (string) $row['so_id']; $core = self::cleanOrderNo($rawNo); $identity = self::orderIdentity($rawNo); if ($core === '') { continue; } $shopKey = self::normalizeShop((string) ($row['shop_name'] ?? '')); $key = $shopKey . '|' . $identity; $orderSeen[$key] = true; $coreSeen[$core] = true; $identitiesByCore[$core][$shopKey][$identity] = true; if ($shopKey !== '') { $coresByCore[$core][$shopKey] = true; } // 口径:按「店铺 + 线上订单号」对商品金额求和(一单多品/多子订单在订单侧先合并)。 $orderAmount[$key] = ($orderAmount[$key] ?? 0.0) + (float) $row['amount']; // 虚拟条码行的实付:从订单实付里扣除,和商品金额保持同一口径。 $orderVirtualPaid[$key] = ($orderVirtualPaid[$key] ?? 0.0) + (float) ($row['virtual_paid'] ?? 0.0); } unset($orderRows, $row); // 只重建「本账期 + 本平台」的数据,避免把同月其它平台的结果一起清掉。 $dryRun = (bool) $this->option('dry-run'); if (! $dryRun) { app(\Modules\EcommerceFinance\Services\StoreReportPaidSalesChanges::class)->markPeriod($reportMonth, (string) $source['label']); DB::connection($connection)->table(self::TARGET_TABLE) ->where('report_month', $reportMonth) ->where('platform', (string) $source['label']) ->delete(); DB::connection($connection)->table(self::METRIC_DETAIL_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; $paidTotal = 0.0; $differenceTotal = 0.0; // 订单实付=订单级实付 − 虚拟条码(X0000x)明细行实付,下限 0;纯信誉/虚拟订单归零。 // 一次算好整张表,主循环里只做查表。 $orderPaidNet = []; foreach ($orderPaid as $paidKey => $paidValue) { $orderPaidNet[$paidKey] = round(max((float) $paidValue - (float) ($orderVirtualPaid[$paidKey] ?? 0.0), 0.0), 2); } unset($orderPaid, $orderVirtualPaid); // 比对基准(差额 / 匹配状态 / 差额异常备注):paid=订单实付金额,product=订单商品金额(旧口径)。 $basis = self::differenceBasis(); foreach ($groups as $group) { $core = (string) $group['order_core_no']; $income = round((float) $group['income_amount'], 2); $remark = ''; $productAmount = null; $paidAmount = null; $status = self::STATUS_UNMATCHED; // 两个口径各跑一遍同一条取数链(同店同号 → 店铺名不一致 → 同号多条 → 空店铺兜底): // 商品金额用于「商品金额」列,订单实付用于「订单实付金额」列与比对基准。 [$productStatus, $productAmount, $productRemark] = $this->matchOrderSide( (string) $group['online_order_no'], $core, (string) $group['shop_key'], $orderAmount, $orderSeen, $coreSeen, $coresByCore, $identitiesByCore, self::REMARK_NO_AMOUNT, ); [$paidStatus, $paidAmount, $paidRemark] = $this->matchOrderSide( (string) $group['online_order_no'], $core, (string) $group['shop_key'], $orderPaidNet, $orderSeen, $coreSeen, $coresByCore, $identitiesByCore, self::REMARK_NO_PAID, ); $status = $basis === 'product' ? $productStatus : $paidStatus; $remark = $basis === 'product' ? $productRemark : $paidRemark; // 1688 采销专用账户的未匹配原因单独成档(采销收款不是店铺销售单)。 $remark = self::caixiaoRemark((string) $group['store_name'], $remark); $basisAmount = $basis === 'product' ? $productAmount : $paidAmount; 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) + (float) ($group['default_compensation_amount'] ?? 0.0), 2 ); $difference = $basisAmount === null ? null : round($income + $promoAmount - $basisAmount, 2); // 双口径兜底:账单(收入+优惠+冲减)能对上「卖家实收」时,按实收核对—— // 差额归零、订单实付列显示实收金额。用于京东这类「账单收款=卖家实收」的平台, // 避免把口径差当成对不上的差额。 if ($basis === 'paid' && $difference !== null && abs($difference) > 0.005) { // 原因优先:这里只登记「口径能对上」的标识,不动差额(差额与原因由页面按 // 「先备注规则、后口径兜底」的顺序判定),避免把有明确原因的行悄悄抹平。 { [$altStatus, $altAmount] = $this->matchOrderSide( (string) $group['online_order_no'], $core, (string) $group['shop_key'], $orderPaidAlt, $orderSeen, $coreSeen, $coresByCore, $identitiesByCore, self::REMARK_NO_PAID, ); $altMatched = $altStatus === self::STATUS_MATCHED && $altAmount !== null; $altValue = $altMatched ? (float) $altAmount : 0.0; // 实收有值且能对上 → 按实收核对;实收缺失(0/空)时退回「应付/已付」核对(拆单主单实收常为空)。 $payAmount = null; // 实收缺失、或实收对不上账单时,都退回去试「应付/已付」(拆单主单实收为空、 // 或主单没写实收而子单写了值的情况都覆盖到)。 if (! ($altMatched && abs($income + $promoAmount - $altValue) <= 0.02)) { [$payStatus, $payValue] = $this->matchOrderSide( (string) $group['online_order_no'], $core, (string) $group['shop_key'], $orderPaidPay, $orderSeen, $coreSeen, $coresByCore, $identitiesByCore, self::REMARK_NO_PAID, ); if ($payStatus === self::STATUS_MATCHED && $payValue !== null) { $payAmount = (float) $payValue; } } if ($altMatched && $altValue > 0.005 && abs($income + $promoAmount - $altValue) <= 0.02) { $remark = self::REMARK_SELLER_INCOME_BASIS; } elseif ($payAmount !== null && abs($income + $promoAmount - $payAmount) <= 0.02) { $remark = self::REMARK_PAYABLE_BASIS; } } } if ($productAmount !== null) { $goodsTotal += $productAmount; } if ($paidAmount !== null) { $paidTotal += $paidAmount; } if ($basisAmount !== null) { $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' => self::groupRowKey( $reportMonth, (string) $group['platform'], (string) $group['shop_key'], (string) $group['order_identity'] ), '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, 'default_compensation_amount' => ! empty($group['default_compensation_seen']) ? round((float) $group['default_compensation_amount'], 2) : null, 'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null, 'planting_amount' => ! empty($group['planting_seen']) ? $income : null, 'planting_label' => self::plantingLabel($group), 'product_amount' => $productAmount, 'paid_amount' => $paidAmount, 'difference' => $difference, 'match_status' => $status, 'remark' => $remark !== '' ? $remark : null, 'imported_at' => $now, ]; if (count($buffer) >= 500) { if (! $dryRun) { $this->markPaidSalesRows($buffer); $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) { if (! $dryRun) { $this->markPaidSalesRows($buffer); $target->insertOrIgnore($buffer); } $imported += count($buffer); $buffer = []; } } if ($buffer !== []) { if (! $dryRun) { $this->markPaidSalesRows($buffer); $target->insertOrIgnore($buffer); } $imported += count($buffer); } if (! $dryRun) { $this->replaceMetricDetails( $reportMonth, (string) $source['label'], $groups, $promoOnlyGroups, $now, ); $this->verifyMetricDetailTotals($reportMonth, (string) $source['label']); } $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($paidTotal, 2)); $this->line(' 差额合计:' . number_format($differenceTotal, 2)); // 重建后自检:相关账单行存在时,至少应有一列被写入,避免旧代码静默丢列。 $promoFilled = (int) DB::connection($connection)->table(self::TARGET_TABLE) ->where('report_month', $reportMonth) ->where('platform', (string) $source['label']) ->where(function ($query): void { $query->whereNotNull('discount_amount') ->orWhereNotNull('offset_amount') ->orWhereNotNull('default_compensation_amount') ->orWhereNotNull('refund_amount'); }) ->count(); if ($promoItems !== [] && $promoFilled === 0 && $moneyRemarkRows > 0) { $this->error(sprintf( '重建后自检不通过:%s %s 的优惠、冲减、违约赔付、退款金额一行都没写,但本月账单里有 %d 行命中了相关备注(科目映射表里有 %d 个相关账单项目)。' . '请确认使用最新代码(优惠金额逻辑见 c8a035a1)重跑,或补跑 store-receipt:order-match:sync --amounts-only。', $month, (string) $source['label'], $moneyRemarkRows, count($promoItems), )); return self::FAILURE; } // 业绩明细变了,顺手刷新店铺维度报表的销量汇总(销量=这里的订单实付/实收金额)。 // 汇总失败不影响业绩明细本身,只提示一次,避免把已写入的账期数据判成失败。 return self::SUCCESS; } /** 在写入前记录受影响日期;与汇总任务共用锁,避免消费未完成的变更。 */ private function markPaidSalesRows(array $rows): void { app(\Modules\EcommerceFinance\Services\StoreReportPaidSalesChanges::class)->markDates(array_column($rows, 'occurred_date')); } /** 按最新业绩明细刷新店铺销量汇总,失败只提示。 */ private function refreshStoreReportPaidSales(): void { try { $result = app(StoreReportMirrorSynchronizer::class)->syncPaidSales(); } catch (Throwable $exception) { $this->warn('店铺维度报表销量汇总刷新失败:' . $exception->getMessage()); return; } if ($result['status'] === 'skipped') { return; } if ($result['status'] !== 'succeeded') { $this->warn('店铺维度报表销量汇总刷新失败(可在界面稍后再试或联系开发排查)。'); return; } $this->line($result['changed'] ? sprintf(' 店铺维度报表销量汇总:已重建 %d 行。', $result['stored_rows']) : sprintf(' 店铺维度报表销量汇总:业绩明细无变化,沿用 %d 行。', $result['stored_rows'])); } /** 只回填「种菜金额 / 标签」:按种菜明细表与聚水潭种菜标签的并集判断。 */ private function backfillPlantingAmounts(string $reportMonth): int { if ((bool) $this->option('dry-run')) { $this->info('只读模式:跳过种菜金额写入。'); return self::SUCCESS; } $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); $cores = $this->plantingOrderCores(); $reimbursementCores = $this->plantingReimbursementCores(); $unreimbursedCores = $this->plantingUnreimbursedCores(); 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, 'planting_label' => null, ]); $values = []; $labels = []; $total = 0.0; $reimbursementRows = 0; $unreimbursedRows = 0; foreach ($rows as $row) { $core = self::orderCoreNo((string) $row->order_core_no); if ($core === '') { continue; } $amount = round((float) $row->income_amount, 2); if (isset($cores[$core])) { $values[(string) $row->row_key] = $amount; $total += $amount; } if (isset($reimbursementCores[$core])) { $labels[(string) $row->row_key] = self::PLANTING_LABEL_REIMBURSED; $reimbursementRows++; } elseif (isset($unreimbursedCores[$core])) { $labels[(string) $row->row_key] = self::PLANTING_LABEL_UNREIMBURSED; $unreimbursedRows++; } } $this->updateColumnValues($connection, $reportMonth, 'planting_amount', $values); $this->updateColumnValues($connection, $reportMonth, 'planting_label', $labels); $this->info(sprintf( '已回填 %s:种菜金额 %d / %d 行,合计 %s(识别 %d 个种菜订单号);标签「种菜报销」%d 行(识别 %d 个种菜明细订单号);标签「种菜未报销」%d 行(识别 %d 个聚水潭种菜标签订单号)。', $reportMonth, count($values), count($rows), number_format($total, 2), count($cores), $reimbursementRows, count($reimbursementCores), $unreimbursedRows, count($unreimbursedCores), )); 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 { if ((bool) $this->option('dry-run')) { $this->info('只读计算完成;未写入金额或汇总。'); return self::SUCCESS; } $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); $platform = (string) $source['label']; app(\Modules\EcommerceFinance\Services\StoreReportPaidSalesChanges::class)->markPeriod($reportMonth, $platform); $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, 'default_compensation_amount' => null, 'refund_amount' => null, 'planting_amount' => null, 'planting_label' => null, ]); $discounts = []; $offsets = []; $defaultCompensations = []; $refunds = []; $plantings = []; $plantingLabels = []; foreach ($groups as $group) { if (empty($group['discount_seen']) && empty($group['offset_seen']) && empty($group['default_compensation_seen']) && empty($group['refund_seen']) && empty($group['planting_seen']) && empty($group['planting_reimbursement_seen']) && empty($group['planting_unreimbursed_seen'])) { continue; } $rowKey = self::groupRowKey( $reportMonth, $platform, (string) $group['shop_key'], (string) $group['order_identity'] ); 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['default_compensation_seen'])) { $defaultCompensations[$rowKey] = round((float) $group['default_compensation_amount'], 2); } if (! empty($group['refund_seen'])) { $refunds[$rowKey] = round((float) $group['refund_amount'], 2); } if (! empty($group['planting_seen'])) { $plantings[$rowKey] = round((float) $group['income_amount'], 2); } if (! empty($group['planting_reimbursement_seen'])) { $plantingLabels[$rowKey] = self::PLANTING_LABEL_REIMBURSED; } elseif (! empty($group['planting_unreimbursed_seen'])) { $plantingLabels[$rowKey] = self::PLANTING_LABEL_UNREIMBURSED; } } $this->updateColumnValues($connection, $reportMonth, 'discount_amount', $discounts); $this->updateColumnValues($connection, $reportMonth, 'offset_amount', $offsets); $this->updateColumnValues($connection, $reportMonth, 'default_compensation_amount', $defaultCompensations); $this->updateColumnValues($connection, $reportMonth, 'refund_amount', $refunds); $this->updateColumnValues($connection, $reportMonth, 'planting_amount', $plantings); $this->updateColumnValues($connection, $reportMonth, 'planting_label', $plantingLabels); // 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) { $this->markPaidSalesRows($buffer); $target()->insertOrIgnore($buffer); $promoRows += count($buffer); $buffer = []; } } if ($buffer !== []) { $this->markPaidSalesRows($buffer); $target()->insertOrIgnore($buffer); $promoRows += count($buffer); } // 只回填金额列时也要把「差额」跟着刷新,否则页面上差额列与优惠/冲减列对不上。 $basisColumn = self::differenceBasis() === 'product' ? 'product_amount' : 'paid_amount'; $target() ->where('report_month', $reportMonth) ->where('platform', $platform) ->whereNotNull($basisColumn) ->update([ 'difference' => DB::raw( 'ROUND(income_amount' . ' + COALESCE(discount_amount, 0)' . ' + COALESCE(offset_amount, 0)' . ' + COALESCE(default_compensation_amount, 0)' . ' - ' . $basisColumn . ', 2)' ), ]); $this->info(sprintf( '已回填 %s %s:优惠 %d 行(%s)/ 冲减 %d 行(%s)/ 违约赔付 %d 行(%s)/ 补「无收款账单/无单号」抵减行 %d 行 / 种菜 %d 行(%s)/ 标签「种菜报销」%d 行 / 标签「种菜未报销」%d 行,镜像原有 %d 行,未重建业绩明细。', $month, $platform, count($discounts), number_format(array_sum($discounts), 2), count($offsets), number_format(array_sum($offsets), 2), count($defaultCompensations), number_format(array_sum($defaultCompensations), 2), $promoRows, count($plantings), number_format(array_sum($plantings), 2), count(array_filter($plantingLabels, static fn (string $label): bool => $label === self::PLANTING_LABEL_REIMBURSED)), count(array_filter($plantingLabels, static fn (string $label): bool => $label === self::PLANTING_LABEL_UNREIMBURSED)), count($existing), )); return self::SUCCESS; } /** * 独立只读游标只保留当前行;普通镜像连接仍可在备注匹配中查询。 * 不更改全局 PDO 状态,结束或异常时关闭专用连接。 * * @return \Generator */ private function streamBillRows(\Illuminate\Database\Query\Builder $query): \Generator { $connection = $query->getConnection(); if (! $connection instanceof \Illuminate\Database\MySqlConnection) { yield from $query->cursor(); return; } $config = $connection->getConfig(); $config['options'][\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY] = false; $stream = app('db.factory')->make($config, 'receipt_bill_stream'); try { yield from $stream->cursor($query->toSql(), $query->getBindings()); } finally { $stream->disconnect(); } } /** * 按原有金额与补行口径单遍聚合,不保存原始账单集合。 * * @param iterable $bills * @param array $source * @param array $plantingCores * @param array $plantingReimbursementCores * @param array $plantingUnreimbursedCores * @return array */ private function aggregateBills( iterable $bills, array $source, string $orderColumn, string $timeColumn, string $amountColumn, string $expenseColumn, string $realStoreColumn, array $plantingCores, array $plantingReimbursementCores, array $plantingUnreimbursedCores, AccountLedgerRemarkMap $remarkMap, ?string $tabPlatform, ): array { /** @var array> $groups 账单侧按「店铺 + 订单号」聚合 */ $groups = []; $paymentTotal = 0; $incomeTotal = 0.0; $discountTotal = 0.0; $offsetTotal = 0.0; $defaultCompensationTotal = 0.0; $refundTotal = 0.0; /** @var array $promoRowsByKey 门店+订单 维度的优惠/冲减账单行数(用于统计未挂单行数) */ $promoRowsByKey = []; // 本月账单里真正命中「优惠收入 / 冲减收入 / 退款」的行数(重建后自检用: // 只有账单里确实有这些行,却一行都没写进结果表,才算异常)。 $moneyRemarkRows = 0; $billCount = 0; $promoOnlyGroups = []; foreach ($bills as $bill) { $billCount++; $storeName = (string) ($bill->store_name ?? ''); $billItem = (string) ($bill->bill_item ?? ''); // 收入 / 优惠 / 冲减 / 退款统一按科目映射表的当前财务备注判桶; // 只有当前科目映射表没有规则时,remark() 才回落到旧总账备注表。 $remark = $remarkMap->remark( $storeName, $tabPlatform, $billItem, $this->billBusinessRemarks($bill), $this->billGoodsName($bill), ); $isPromoRemark = in_array($remark, self::DISCOUNT_REMARKS, true) || in_array($remark, self::OFFSET_REMARKS, true) || in_array($remark, self::DEFAULT_COMPENSATION_REMARKS, true); $isRefundRemark = in_array($remark, self::REFUND_REMARKS, true); $isIncomeRemark = in_array($remark, self::LEDGER_REMARKS, true); if ($isPromoRemark || $isRefundRemark) { $moneyRemarkRows++; } $bucket = match (true) { $isIncomeRemark => 'income', $isRefundRemark => 'refund', in_array($remark, self::DISCOUNT_REMARKS, true) => 'discount', in_array($remark, self::DEFAULT_COMPENSATION_REMARKS, true) => 'default_compensation', in_array($remark, self::OFFSET_REMARKS, true) => 'offset', default => null, }; if ($bucket === null) { continue; } $rawNo = trim((string) ($bill->{$orderColumn} ?? '')); $core = self::cleanOrderNo($rawNo); $identity = self::orderIdentity($rawNo); $realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : ''; $shopKey = self::normalizeShop($realStore !== '' ? $realStore : (string) $bill->store_name); $key = $shopKey . '|' . $identity; if (! isset($groups[$key])) { $groups[$key] = [ 'platform' => (string) $source['label'], // 展示名优先用「线上真实店铺」(与匹配键同源):账户名(如「支付宝-XX公司」)只是收款壳。 'store_name' => $realStore !== '' ? $realStore : (string) ($bill->store_name ?? ''), 'shop_key' => $shopKey, 'online_order_no' => $rawNo, 'order_core_no' => $core, 'order_identity' => $identity, '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, 'default_compensation_amount' => 0.0, 'default_compensation_seen' => false, 'refund_amount' => 0.0, 'refund_seen' => false, 'bill_items' => [], ]; } // 三列统一按「行金额=收入侧 − 支出侧」取数(负数表示净扣减)。 // 得物/天猫/淘系这类平台把「卖家承担优惠 / 扣回」记在支出侧,只取收入侧会漏掉扣减。 $amount = (float) $bill->{$amountColumn} - ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0); // 同一遍读取同时累计补行,避免把整月原始账单留到第二遍。 // 首笔元数据、无单号按账单项目拆分、收入行随后出现的语义与原有补行一致。 if ($bucket !== 'income') { $promoKey = $identity === '' ? $shopKey . '||' . $billItem : $key; if (! isset($promoOnlyGroups[$promoKey])) { $promoOnlyGroups[$promoKey] = [ 'shop_key' => $shopKey, 'store_name' => $realStore !== '' ? $realStore : $storeName, 'online_order_no' => $rawNo, 'order_core_no' => $core, 'order_identity' => $identity, '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, 'default_compensation_amount' => 0.0, 'default_compensation_seen' => false, 'refund_amount' => 0.0, 'refund_seen' => false, 'bill_items' => [], ]; } $promoOnlyGroups[$promoKey][$bucket . '_seen'] = true; $promoOnlyGroups[$promoKey][$bucket . '_amount'] += $amount; } $billItemKey = \Illuminate\Support\Str::limit($billItem !== '' ? $billItem : '未命名账单项目', 191, ''); $groups[$key]['bill_items'][$bucket][$billItemKey]['amount'] = ($groups[$key]['bill_items'][$bucket][$billItemKey]['amount'] ?? 0.0) + $amount; $groups[$key]['bill_items'][$bucket][$billItemKey]['line_count'] = ($groups[$key]['bill_items'][$bucket][$billItemKey]['line_count'] ?? 0) + 1; if ($bucket !== 'income') { $promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['amount'] = ($promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['amount'] ?? 0.0) + $amount; $promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['line_count'] = ($promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['line_count'] ?? 0) + 1; } 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; } elseif ($bucket === 'refund') { $groups[$key]['refund_seen'] = true; $groups[$key]['refund_amount'] += $amount; $refundTotal += $amount; } elseif ($bucket === 'default_compensation') { $groups[$key]['default_compensation_seen'] = true; $groups[$key]['default_compensation_amount'] += $amount; $defaultCompensationTotal += $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; } if (isset($plantingReimbursementCores[$core])) { $groups[$key]['planting_reimbursement_seen'] = true; } if (isset($plantingUnreimbursedCores[$core])) { $groups[$key]['planting_unreimbursed_seen'] = true; } } // 行口径保持不变:仍然是「有收款账单(收入白名单)的店铺 + 订单号」, // 只有优惠/冲减、没有收款的订单号不单独成行。 $groups = array_filter($groups, static fn (array $group): bool => (int) $group['payment_count'] > 0); // 口径对照:账单口径合计、已挂到订单行的合计、挂不上的行数(账单没有订单号 / 订单没有收款行)。 $attachedDiscount = 0.0; $attachedOffset = 0.0; $attachedDefaultCompensation = 0.0; foreach ($groups as $group) { $attachedDiscount += (float) $group['discount_amount']; $attachedOffset += (float) $group['offset_amount']; $attachedDefaultCompensation += (float) $group['default_compensation_amount']; } $unattachedRows = 0; foreach ($promoRowsByKey as $key => $count) { if (! isset($groups[$key])) { $unattachedRows += $count; } } // A 方案:挂不到订单行的优惠/冲减也补成行(收入/商品/差额留空),金额不丢。 foreach ($promoOnlyGroups as $key => $group) { if ($group['order_identity'] !== '' && isset($groups[$group['shop_key'] . '|' . $group['order_identity']])) { unset($promoOnlyGroups[$key]); } } return compact( 'groups', 'promoOnlyGroups', 'paymentTotal', 'incomeTotal', 'discountTotal', 'offsetTotal', 'defaultCompensationTotal', 'refundTotal', 'moneyRemarkRows', 'attachedDiscount', 'attachedOffset', 'attachedDefaultCompensation', 'unattachedRows', 'billCount', ); } /** * A 方案补行的入库数据:收入/商品/差额留空,只带优惠/冲减金额与原因标注。 * * @param array $group * @return array */ private static function promoOnlyRow(string $reportMonth, string $platform, array $group, mixed $now): array { $identity = (string) ($group['order_identity'] ?? ''); $core = (string) $group['order_core_no']; $rowKey = $identity !== '' ? self::groupRowKey($reportMonth, $platform, (string) $group['shop_key'], $identity) : 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, 'default_compensation_amount' => ! empty($group['default_compensation_seen']) ? round((float) $group['default_compensation_amount'], 2) : null, 'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null, // 补行没有订单侧商品金额,种菜金额同样留空;列集合与顺序必须与主行完全一致,批量插入才不会错列。 'planting_amount' => null, 'planting_label' => null, 'product_amount' => null, 'paid_amount' => null, 'difference' => null, 'match_status' => self::STATUS_UNMATCHED, 'remark' => $core === '' ? self::caixiaoRemark((string) $group['store_name'], self::REMARK_EMPTY_NO) : self::REMARK_PROMO_ONLY, 'imported_at' => $now, ]; } /** * 重建一个平台×账期的账单项目金额明细。 * * 汇总值来自同一遍账单读取,并通过 row_key 绑定主表行;页面按主表当前筛选 * 条件关联本表,所以详情合计和指标卡使用同一批订单。 * * @param array> $groups * @param array> $promoOnlyGroups */ private function replaceMetricDetails( string $reportMonth, string $platform, array $groups, array $promoOnlyGroups, mixed $now, ): void { $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); $target = DB::connection($connection)->table(self::METRIC_DETAIL_TABLE); $target->where('report_month', $reportMonth) ->where('platform', $platform) ->delete(); $buffer = []; $append = static function (array $group, string $rowKey) use (&$buffer, $target, $reportMonth, $platform, $now): void { foreach ((array) ($group['bill_items'] ?? []) as $metric => $billItems) { foreach ($billItems as $billItem => $values) { $amount = round((float) ($values['amount'] ?? 0), 4); if (abs($amount) < 0.00005) { continue; } $billItem = trim((string) $billItem) !== '' ? trim((string) $billItem) : '未命名账单项目'; $buffer[] = [ 'report_month' => $reportMonth, 'detail_key' => md5($rowKey . '|' . $metric . '|' . $billItem), 'row_key' => $rowKey, 'platform' => $platform, 'metric' => (string) $metric, 'bill_item' => $billItem, 'amount' => $amount, 'line_count' => (int) ($values['line_count'] ?? 1), 'imported_at' => $now, ]; if (count($buffer) >= 1000) { $target->insertOrIgnore($buffer); $buffer = []; } } } }; foreach ($groups as $group) { $append($group, self::groupRowKey( $reportMonth, $platform, (string) ($group['shop_key'] ?? ''), (string) ($group['order_identity'] ?? ''), )); } foreach ($promoOnlyGroups as $group) { $identity = (string) ($group['order_identity'] ?? ''); $rowKey = $identity !== '' ? self::groupRowKey($reportMonth, $platform, (string) ($group['shop_key'] ?? ''), $identity) : md5($reportMonth . '|' . $platform . '|' . ($group['shop_key'] ?? '') . '|promo|' . ($group['bill_item'] ?? '')); $append($group, $rowKey); } if ($buffer !== []) { $target->insertOrIgnore($buffer); } } /** * 账单项目明细与主表金额列必须逐项相等。任何一分钱差异都视为同步失败, * 避免页面出现“指标总额”和“明细合计”对不上的情况。 */ private function verifyMetricDetailTotals(string $reportMonth, string $platform): void { $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); $main = DB::connection($connection)->table(self::TARGET_TABLE) ->where('report_month', $reportMonth) ->where('platform', $platform) ->selectRaw( 'SUM(COALESCE(income_amount, 0)) as income, ' . 'SUM(COALESCE(discount_amount, 0)) as discount, ' . 'SUM(COALESCE(offset_amount, 0)) as offset_amount, ' . 'SUM(COALESCE(default_compensation_amount, 0)) as default_compensation, ' . 'SUM(COALESCE(refund_amount, 0)) as refund' ) ->first(); $details = DB::connection($connection)->table(self::METRIC_DETAIL_TABLE) ->where('report_month', $reportMonth) ->where('platform', $platform) ->selectRaw( "SUM(CASE WHEN metric = 'income' THEN amount ELSE 0 END) as income, " . "SUM(CASE WHEN metric = 'discount' THEN amount ELSE 0 END) as discount, " . "SUM(CASE WHEN metric = 'offset' THEN amount ELSE 0 END) as offset_amount, " . "SUM(CASE WHEN metric = 'default_compensation' THEN amount ELSE 0 END) as default_compensation, " . "SUM(CASE WHEN metric = 'refund' THEN amount ELSE 0 END) as refund" ) ->first(); $labels = [ 'income' => '收入', 'discount' => '优惠', 'offset_amount' => '冲减', 'default_compensation' => '违约赔付', 'refund' => '退款', ]; foreach ($labels as $column => $label) { $mainTotal = round((float) ($main->{$column} ?? 0), 2); $detailTotal = round((float) ($details->{$column} ?? 0), 2); if (abs($mainTotal - $detailTotal) > 0.005) { throw new RuntimeException(sprintf( '账单项目明细校验失败:%s %s %s,主表 %s,明细 %s。', $reportMonth, $platform, $label, number_format($mainTotal, 2, '.', ''), number_format($detailTotal, 2, '.', ''), )); } } } /** @param array $group */ private static function plantingLabel(array $group): ?string { if (! empty($group['planting_reimbursement_seen'])) { return self::PLANTING_LABEL_REIMBURSED; } if (! empty($group['planting_unreimbursed_seen'])) { return self::PLANTING_LABEL_UNREIMBURSED; } return null; } /** * 按 row_key 批量更新一列字段值:只更新镜像里已有的行,不做插入。 * * @param array $values row_key => 金额或文本 */ private function updateColumnValues(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); } /** * 完整线上单号身份:保留日期前缀,只统一空白、横杠形态和大小写。 * * 拼多多的「260627-...」「260713-...」是两个不同订单,不能按去掉 * 日期前缀后的核心号合并;完整单号用于账单分组与订单侧精确匹配, * 核心号只在唯一候选时作为兼容回退。 */ private static function orderIdentity(string $value): string { $value = str_replace("\u{3000}", ' ', trim($value)); if ($value === '') { return ''; } $value = preg_replace('/[\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '-', $value) ?? $value; $value = preg_replace('/\s+/u', '', $value) ?? ''; return strtoupper($value); } private static function groupRowKey(string $reportMonth, string $platform, string $shopKey, string $identity): string { return md5($reportMonth . '|' . $platform . '|' . $shopKey . '|' . $identity); } /** * 种菜订单号集合:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号。 * * 「是否种菜订单=是」与页面同一规则:命中种菜标签映射,或订单货号含信誉订单虚拟条码(X00006)。 * 三个条件是「或」:订单号在任意一边出现过就算种菜订单(业务口径已确认)。 * 两边都按键清洗后的「核心单号」比较(去掉日期前缀、空格、横杠),与账单侧同一套口径。 * * @return array */ private function plantingOrderCores(): array { $companyId = (int) $this->option('company'); $cores = $this->plantingReimbursementCores(); $positiveLabels = []; foreach (app(JstOrderLabelPlantingRules::class)->map($companyId) as $label => $flag) { if ($flag === '是') { $positiveLabels[] = $label; } } // 聚水潭订单标签明细里「是否种菜订单=是」的订单号 // (命中种菜标签映射,或货号含信誉订单虚拟条码 X00006,与页面同一规则) foreach (app(PlantingOrderLabelLookup::class)->orderNumbers($companyId, $positiveLabels) as $orderNo) { $core = self::orderCoreNo((string) $orderNo); if ($core !== '') { $cores[$core] = true; } } return $cores; } /** * 种菜报销订单号:只取「种菜明细表」的订单号,不包含聚水潭种菜标签。 * * @return array */ private function plantingReimbursementCores(): array { $companyId = (int) $this->option('company'); $cores = []; 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; } /** * 种菜未报销订单号:聚水潭订单标签明细里「是否种菜订单=是」,且尚未出现在种菜明细表。 * * @return array */ private function plantingUnreimbursedCores(): array { $companyId = (int) $this->option('company'); $reimbursementCores = $this->plantingReimbursementCores(); $positiveLabels = []; foreach (app(JstOrderLabelPlantingRules::class)->map($companyId) as $label => $flag) { if ($flag === '是') { $positiveLabels[] = $label; } } $cores = []; foreach (app(PlantingOrderLabelLookup::class)->orderNumbers($companyId, $positiveLabels) as $orderNo) { $core = self::orderCoreNo((string) $orderNo); if ($core !== '' && ! isset($reimbursementCores[$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); } /** * 是否 1688 采销专用账户(按账单店铺名判断)。 */ private static function isCaixiaoStore(string $storeName): bool { return str_contains(trim($storeName), self::CAIXIAO_STORE_KEYWORD); } /** * 1688 采销专用账户的未匹配原因单独成档;其余店铺的标注原样返回。 */ private static function caixiaoRemark(string $storeName, string $remark): string { if (! self::isCaixiaoStore($storeName)) { return $remark; } return match ($remark) { self::REMARK_EMPTY_NO => self::REMARK_EMPTY_NO_CAIXIAO, self::REMARK_NO_ORDER => self::REMARK_NO_ORDER_CAIXIAO, default => $remark, }; } /** * 账单行上的业务备注:科目映射表的「业务备注关键词」用它匹配。 * * 与「店铺收款账户账单」页面(AccountLedgerRemarkMap::decorate)保持同一套口径, * 否则同一账单项目在不同页签能区分、同步却区分不了(例如得物的退款行)。 * 各平台账单表列名不一致,按存在的列取值。 * * @return list */ private function billBusinessRemarks(object $bill): array { $values = []; foreach (self::BILL_REMARK_COLUMNS as $column) { if (! property_exists($bill, $column)) { continue; } $text = trim((string) $bill->{$column}); if ($text !== '') { $values[] = $text; } } return array_values(array_unique($values)); } /** * 「差额 / 匹配状态 / 差额异常备注」的比对基准: * paid(默认)=订单实付金额;product=订单商品金额(旧口径,可用配置切回)。 */ private static function differenceBasis(): string { return (string) config('performance_details.difference_basis', 'paid') === 'product' ? 'product' : 'paid'; } /** * 按「店铺 + 完整线上单号」在订单侧取金额,返回 [匹配状态, 订单侧金额, 原因标注]。 * * 两个比对基准共用这一条取数链: * 同店完整单号 → 同店核心号唯一时回退 → 跨店唯一时回退 → 空店铺唯一时回退。 * 核心号一旦对应多个完整线上单号就视为歧义,不再合并金额,避免不同订单串单。 * * @param array>> $identitiesByCore 核心单号 → 店铺 → 完整线上单号 * @param array $amounts 订单侧金额集合(商品金额或订单实付) * @param array $orderSeen 订单侧出现过的「店铺|单号」 * @param array $coreSeen 订单侧出现过的单号 * @param array> $coresByCore 单号 → 店铺集合 * @return array{0:string,1:?float,2:string} */ private function matchOrderSide( string $onlineOrderNo, string $core, string $shopKey, array $amounts, array $orderSeen, array $coreSeen, array $coresByCore, array $identitiesByCore, string $noAmountRemark, ): array { if ($core === '') { return [self::STATUS_UNMATCHED, null, self::REMARK_EMPTY_NO]; } if (preg_match('/^\d{8,}$/', $core) !== 1) { return [self::STATUS_UNMATCHED, null, self::REMARK_BAD_FORMAT]; } $identity = self::orderIdentity($onlineOrderNo); $exactKey = $shopKey . '|' . $identity; if ($identity !== '' && isset($orderSeen[$exactKey])) { if (isset($amounts[$exactKey])) { $remark = count($coresByCore[$core] ?? []) > 1 ? self::REMARK_DUP_ORDER : ''; return [self::STATUS_MATCHED, round((float) $amounts[$exactKey], 2), $remark]; } // 订单侧有记录,但没有这一项金额(全是 0 元/退款行)。 return [self::STATUS_UNMATCHED, null, $noAmountRemark]; } if (isset($coreSeen[$core])) { $sameShopIdentities = array_keys($identitiesByCore[$core][$shopKey] ?? []); if (count($sameShopIdentities) > 1) { return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER]; } if (count($sameShopIdentities) === 1) { $fallbackKey = $shopKey . '|' . $sameShopIdentities[0]; if (isset($amounts[$fallbackKey])) { $remark = count($coresByCore[$core] ?? []) > 1 ? self::REMARK_DUP_ORDER : ''; return [self::STATUS_MATCHED, round((float) $amounts[$fallbackKey], 2), $remark]; } return [self::STATUS_UNMATCHED, null, $noAmountRemark]; } $shops = array_keys($identitiesByCore[$core] ?? []); if (count($shops) === 1) { $otherShop = (string) $shops[0]; $otherIdentities = array_keys($identitiesByCore[$core][$otherShop] ?? []); if (count($otherIdentities) > 1) { return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER]; } if (count($otherIdentities) === 1) { $otherKey = $otherShop . '|' . $otherIdentities[0]; if (isset($amounts[$otherKey])) { $remark = $otherShop === '' ? '' : self::REMARK_SHOP_DIFF; return [self::STATUS_MATCHED, round((float) $amounts[$otherKey], 2), $remark]; } return [self::STATUS_UNMATCHED, null, $noAmountRemark]; } } // 同一核心号跨多个店铺或同一店铺多个完整单号:禁止把金额相加。 return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER]; } return [self::STATUS_UNMATCHED, null, self::REMARK_NO_ORDER]; } /** 账单行上的商品名称:科目映射表的「商品名称关键词」用它匹配。 */ private function billGoodsName(object $bill): string { foreach (self::BILL_GOODS_COLUMNS as $column) { if (property_exists($bill, $column)) { $text = trim((string) $bill->{$column}); if ($text !== '') { return $text; } } } return ''; } /** * 科目映射表里「财务备注命中给定备注」的账单项目(限本页签涉及的平台)。 * * @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); } }