CaiWuXiTong/app/Console/Commands/SyncStoreReceiptOrderMatches.php

1606 lines
75 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use App\Business\JstOrderLabelPlantingRules;
use App\Business\PlantingOrderLabelLookup;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
use Throwable;
/**
* 业绩明细 → 店铺收款匹配订单明细。
*
* 以「店铺收款账户账单」为基数(LEFT JOIN 语义,账单每笔收款都保留),
* 按「店铺 + 订单号」关联订单侧商品金额,输出严格遵循「一个店铺、一个订单号、一个商品金额」。
*
* 口径(已与业务确认):
* - 账单侧:按「总账备注」筛选(与店铺收款账户账单页同一口径),
* 再按「店铺 + 清洗后订单号」汇总收入金额与收款笔数
* - 订单侧:销售全量按「店铺 + 线上订单号」先聚合求和(一单多品/多子订单在此合并),金额取 goods_count > 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 : 只回填「种菜金额」一列(不读账单,直接按该月已有明细行的订单号刷,一次覆盖全平台)}
{--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 REFUND_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_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();
} finally {
$lock->release();
}
if ($result === self::SUCCESS && ! (bool) $this->option('planting-only')) $this->refreshStoreReportPaidSales();
return $result;
}
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();
$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);
$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::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 === [] && $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, $refundGroups, $promoItems): void {
LedgerRemarkFilter::applyGroups(
$group,
array_merge($baseGroups, $discountGroups, $offsetGroups, $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();
$remarkMap = app(AccountLedgerRemarkMap::class);
$tabPlatform = $remarkMap->defaultPlatform($platform);
[
'groups' => $groups, 'promoOnlyGroups' => $promoOnlyGroups,
'paymentTotal' => $paymentTotal, 'incomeTotal' => $incomeTotal,
'discountTotal' => $discountTotal, 'offsetTotal' => $offsetTotal, 'refundTotal' => $refundTotal,
'moneyRemarkRows' => $moneyRemarkRows, 'attachedDiscount' => $attachedDiscount,
'attachedOffset' => $attachedOffset, 'unattachedRows' => $unattachedRows, 'billCount' => $billCount,
] = $this->aggregateBills(
$this->streamBillRows($billQuery->orderBy($timeColumn)), $source,
$orderColumn, $timeColumn, $amountColumn, $expenseColumn, $realStoreColumn,
$plantingCores, $remarkMap, $tabPlatform,
);
if ($billCount === 0) {
$this->warn('该账期没有符合条件的账单记录,未写入任何数据。');
return self::SUCCESS;
}
$plantingTotal = 0.0;
$plantingRows = 0;
$this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行');
$this->line(' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 2) . ',退款金额合计:' . number_format($refundTotal, 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),
));
unset($plantingCores);
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<string,float> $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(商品金额求和) */
$orderAmount = [];
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */
$orderSeen = [];
/** @var array<string,array<string,true>> $coresByCore 核心单号 → 出现过的店铺集合 */
$coresByCore = [];
/** @var array<string,true> $coreSeen 订单侧出现过的核心单号(含店铺名为空的行) */
$coreSeen = [];
/** @var array<string,float> $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<string,float> $orderPaid 订单侧实付金额(店铺 + 核心单号) */
$orderPaid = [];
/** @var array<string,float> $orderPaidAlt 订单侧「卖家实收」金额(双口径兜底用) */
$orderPaidAlt = [];
/** @var array<string,float> $orderPaidPay 订单侧「应付」金额(实收缺失时的退化口径) */
$orderPaidPay = [];
foreach ($paidRows as $row) {
$core = self::cleanOrderNo((string) $row['so_id']);
if ($core === '') {
continue;
}
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
$key = $shopKey . '|' . $core;
$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;
if ($shopKey !== '') {
$coresByCore[$core][$shopKey] = true;
}
}
unset($paidRows, $row);
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'];
// 虚拟条码行的实付:从订单实付里扣除,和商品金额保持同一口径。
$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();
}
$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(
$core,
(string) $group['shop_key'],
$orderAmount,
$orderSeen,
$coreSeen,
$coresByCore,
self::REMARK_NO_AMOUNT,
);
[$paidStatus, $paidAmount, $paidRemark] = $this->matchOrderSide(
$core,
(string) $group['shop_key'],
$orderPaidNet,
$orderSeen,
$coreSeen,
$coresByCore,
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), 2);
$difference = $basisAmount === null ? null : round($income + $promoAmount - $basisAmount, 2);
// 双口径兜底:账单(收入+优惠+冲减)能对上「卖家实收」时,按实收核对——
// 差额归零、订单实付列显示实收金额。用于京东这类「账单收款=卖家实收」的平台,
// 避免把口径差当成对不上的差额。
if ($basis === 'paid' && $difference !== null && abs($difference) > 0.005) {
// 原因优先:这里只登记「口径能对上」的标识,不动差额(差额与原因由页面按
// 「先备注规则、后口径兜底」的顺序判定),避免把有明确原因的行悄悄抹平。
{
[$altStatus, $altAmount] = $this->matchOrderSide(
$core,
(string) $group['shop_key'],
$orderPaidAlt,
$orderSeen,
$coreSeen,
$coresByCore,
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(
$core,
(string) $group['shop_key'],
$orderPaidPay,
$orderSeen,
$coreSeen,
$coresByCore,
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' => 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,
'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null,
'planting_amount' => ! empty($group['planting_seen']) ? $income : null,
'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);
}
$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('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();
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<string,mixed> $source
* @param array<string,array<string,mixed>> $groups
* @param array<string,array<string,mixed>> $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, 'refund_amount' => null, 'planting_amount' => null]);
$discounts = [];
$offsets = [];
$refunds = [];
$plantings = [];
foreach ($groups as $group) {
if (empty($group['discount_seen']) && empty($group['offset_seen']) && empty($group['refund_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['refund_seen'])) {
$refunds[$rowKey] = round((float) $group['refund_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, 'refund_amount', $refunds);
$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) {
$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) - ' . $basisColumn . ', 2)'),
]);
$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;
}
/**
* 独立只读游标只保留当前行;普通镜像连接仍可在备注匹配中查询。
* 不更改全局 PDO 状态,结束或异常时关闭专用连接。
*
* @return \Generator<int,object>
*/
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<int,object> $bills
* @param array<string,mixed> $source
* @param array<string,bool> $plantingCores
* @return array<string,mixed>
*/
private function aggregateBills(
iterable $bills,
array $source,
string $orderColumn,
string $timeColumn,
string $amountColumn,
string $expenseColumn,
string $realStoreColumn,
array $plantingCores,
AccountLedgerRemarkMap $remarkMap,
?string $tabPlatform,
): array {
/** @var array<string,array<string,mixed>> $groups 账单侧按「店铺 + 订单号」聚合 */
$groups = [];
$paymentTotal = 0;
$incomeTotal = 0.0;
$discountTotal = 0.0;
$offsetTotal = 0.0;
$refundTotal = 0.0;
/** @var array<string,int> $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);
$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::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'],
// 展示名优先用「线上真实店铺」(与匹配键同源):账户名(如「支付宝-XX公司」)只是收款壳。
'store_name' => $realStore !== '' ? $realStore : (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,
'refund_amount' => 0.0,
'refund_seen' => false,
];
}
// 三列统一按「行金额=收入侧 − 支出侧」取数(负数表示净扣减)。
// 得物/天猫/淘系这类平台把「卖家承担优惠 / 扣回」记在支出侧,只取收入侧会漏掉扣减。
$amount = (float) $bill->{$amountColumn}
- ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0);
// 同一遍读取同时累计补行,避免把整月原始账单留到第二遍。
// 首笔元数据、无单号按账单项目拆分、收入行随后出现的语义与原有补行一致。
if ($bucket !== 'income') {
$promoKey = $core === '' ? $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,
'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,
'refund_amount' => 0.0,
'refund_seen' => false,
];
}
$promoOnlyGroups[$promoKey][$bucket . '_seen'] = true;
$promoOnlyGroups[$promoKey][$bucket . '_amount'] += $amount;
}
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;
} 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 方案:挂不到订单行的优惠/冲减也补成行(收入/商品/差额留空),金额不丢。
foreach ($promoOnlyGroups as $key => $group) {
if ($group['order_core_no'] !== '' && isset($groups[$group['shop_key'] . '|' . $group['order_core_no']])) {
unset($promoOnlyGroups[$key]);
}
}
return compact(
'groups', 'promoOnlyGroups', 'paymentTotal', 'incomeTotal', 'discountTotal',
'offsetTotal', 'refundTotal', 'moneyRemarkRows', 'attachedDiscount', 'attachedOffset', 'unattachedRows', 'billCount',
);
}
/**
* A 方案补行的入库数据:收入/商品/差额留空,只带优惠/冲减金额与原因标注。
*
* @param array<string,mixed> $group
* @return array<string,mixed>
*/
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,
'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null,
// 补行没有订单侧商品金额,种菜金额同样留空;列集合与顺序必须与主行完全一致,批量插入才不会错列。
'planting_amount' => 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<string,float> $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);
}
/**
* 种菜订单号集合:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号。
*
* 「是否种菜订单=是」与页面同一规则:命中种菜标签映射,或订单货号含信誉订单虚拟条码(X00006)。
* 三个条件是「或」:订单号在任意一边出现过就算种菜订单(业务口径已确认)。
* 两边都按键清洗后的「核心单号」比较(去掉日期前缀、空格、横杠),与账单侧同一套口径。
*
* @return array<string,true>
*/
private function plantingOrderCores(): array
{
$companyId = (int) $this->option('company');
$positiveLabels = [];
foreach (app(JstOrderLabelPlantingRules::class)->map($companyId) as $label => $flag) {
if ($flag === '是') {
$positiveLabels[] = $label;
}
}
$cores = [];
// 并集的第一部分:聚水潭订单标签明细里「是否种菜订单=是」的订单号
// (命中种菜标签映射,或货号含信誉订单虚拟条码 X00006,与页面同一规则)
foreach (app(PlantingOrderLabelLookup::class)->orderNumbers($companyId, $positiveLabels) 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);
}
/**
* 是否 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<string>
*/
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<string,float> $amounts 订单侧金额集合(商品金额或订单实付)
* @param array<string,bool> $orderSeen 订单侧出现过的「店铺|单号」
* @param array<string,bool> $coreSeen 订单侧出现过的单号
* @param array<string,array<string,bool>> $coresByCore 单号 → 店铺集合
* @return array{0:string,1:?float,2:string}
*/
private function matchOrderSide(
string $core,
string $shopKey,
array $amounts,
array $orderSeen,
array $coreSeen,
array $coresByCore,
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];
}
$exactKey = $shopKey . '|' . $core;
if (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]) && count($coresByCore[$core] ?? []) === 1) {
// 单号一致但店铺名称对不上:仍然带上金额,标注「店铺不一致」便于复核。
$otherKey = array_key_first($coresByCore[$core]) . '|' . $core;
if (isset($amounts[$otherKey])) {
return [self::STATUS_MATCHED, round((float) $amounts[$otherKey], 2), self::REMARK_SHOP_DIFF];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
if (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) > 1) {
$sum = 0.0;
$hasAmount = false;
foreach (array_keys($coresByCore[$core]) as $otherShop) {
$key2 = $otherShop . '|' . $core;
if (isset($amounts[$key2])) {
$sum += (float) $amounts[$key2];
$hasAmount = true;
}
}
if ($hasAmount) {
return [self::STATUS_MATCHED, round($sum, 2), self::REMARK_DUP_ORDER];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
if (isset($coreSeen[$core])) {
// 订单侧只有店铺名为空的记录:按订单号兜底匹配。
$blankKey = '|' . $core;
if (isset($amounts[$blankKey])) {
return [self::STATUS_MATCHED, round((float) $amounts[$blankKey], 2), ''];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
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<string> $remarks
* @return list<string>
*/
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<string> $items
* @return list<string>
*/
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<string>|null $remarks 为空时取收入白名单(LEDGER_REMARKS)
* @return list<array<string,mixed>>
*/
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);
}
}