CaiWuXiTong/app/Console/Commands/SyncStoreReceiptOrderMatches.php
yueyang 107c883eb2 业绩明细:新增「退款金额」列,按总账备注=退款取数
- 新增列「退款金额」:店铺收款账户账单里总账备注=退款的金额(收入侧 − 支出侧),
  与优惠金额/冲减金额同一口径,单独列示、不参与差额计算;统计卡、导出字段、
  字段结构说明同步补充
- 同步命令支持账单表的 expense 列(京东账单/京喜账单/京东网银钱包/抖音用 expense,
  淘系/拼多多/得物用 expense_amount),此前只认 expense_amount,
  导致这些平台支出侧的退款、扣回金额被当成 0
- 重建后自检改为「本月账单确有优惠/冲减/退款行、但三列一行都没写」才报错,
  并把退款金额纳入判定,避免无数据月份误报
- 新增迁移:store_receipt_order_match_rows.refund_amount
2026-10-10 13:41:03 +08:00

1347 lines
62 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 Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
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 : 只回填「种菜金额」一列(不读账单,直接按该月已有明细行的订单号刷,一次覆盖全平台)}';
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_SHOP_DIFF = '店铺名不一致';
private const REMARK_DUP_ORDER = '订单侧同号多条';
private const REMARK_MULTI_PAY = '同单多笔收款';
/** 优惠/冲减账单所在的订单没有收款行时,补行用这个标注。 */
private const REMARK_PROMO_ONLY = '无收款账单';
public function handle(): 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);
}
});
$bills = $billQuery->orderBy($timeColumn)->get();
if ($limit > 0) {
$bills = $bills->take($limit);
}
if ($bills->isEmpty()) {
$this->warn('该账期没有符合条件的账单记录,未写入任何数据。');
return self::SUCCESS;
}
/** @var array<string,array<string,mixed>> $groups 账单侧按「店铺 + 订单号」聚合 */
$groups = [];
$paymentTotal = 0;
$incomeTotal = 0.0;
$discountTotal = 0.0;
$offsetTotal = 0.0;
$refundTotal = 0.0;
// 种菜订单号:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号(或的关系)。
$plantingCores = $this->plantingOrderCores();
$plantingTotal = 0.0;
$plantingRows = 0;
$remarkMap = app(AccountLedgerRemarkMap::class);
$tabPlatform = $remarkMap->defaultPlatform($platform);
$ledgerMap = $remarkMap->map();
$incomeOverlapRows = 0;
$incomeOverlapTotal = 0.0;
/** @var array<string,int> $promoRowsByKey 门店+订单 维度的优惠/冲减账单行数(用于统计未挂单行数) */
$promoRowsByKey = [];
// 本月账单里真正命中「优惠收入 / 冲减收入 / 退款」的行数(重建后自检用:
// 只有账单里确实有这些行,却一行都没写进结果表,才算异常)。
$moneyRemarkRows = 0;
foreach ($bills as $bill) {
$storeName = (string) ($bill->store_name ?? '');
$billItem = (string) ($bill->bill_item ?? '');
// 收入金额沿用「总账备注映射」白名单口径,保证既有数字不变;
// 优惠 / 冲减两列按新口径(科目映射表的财务备注优先)取数。
$legacyRemark = $ledgerMap[$remarkMap->platform($storeName, $tabPlatform) ?? ''][$billItem] ?? null;
$remark = $remarkMap->remark(
$storeName,
$tabPlatform,
$billItem,
$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($legacyRemark, self::LEDGER_REMARKS, true);
if ($isPromoRemark || $isRefundRemark) {
$moneyRemarkRows++;
}
if ($isIncomeRemark && ($isPromoRemark || $isRefundRemark)) {
// 已经在收入金额里的行:不重复计入优惠/冲减列,单独提示金额。
$incomeOverlapRows++;
$incomeOverlapTotal += (float) $bill->{$amountColumn};
}
$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'],
'store_name' => (string) ($bill->store_name ?? ''),
'shop_key' => $shopKey,
'online_order_no' => $rawNo,
'order_core_no' => $core,
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
'payment_count' => 0,
'income_amount' => 0.0,
'discount_amount' => 0.0,
'discount_seen' => false,
'offset_amount' => 0.0,
'offset_seen' => false,
'refund_amount' => 0.0,
'refund_seen' => false,
];
}
// 三列统一按「行金额=收入侧 − 支出侧」取数(负数表示净扣减)。
// 得物/天猫/淘系这类平台把「卖家承担优惠 / 扣回」记在支出侧,只取收入侧会漏掉扣减。
$amount = (float) $bill->{$amountColumn}
- ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0);
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 方案:挂不到订单行的优惠/冲减也补成行(收入/商品/差额留空),金额不丢。
$promoOnlyGroups = $this->promoOnlyGroups(
$bills,
$groups,
$remarkMap,
$ledgerMap,
$tabPlatform,
$orderColumn,
$timeColumn,
$amountColumn,
$expenseColumn,
$realStoreColumn,
);
$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),
));
if ($incomeOverlapRows > 0) {
$this->line(sprintf(
' 提示:%d 笔(%s 元)按旧口径已计入收入金额,未重复计入优惠/冲减列。',
$incomeOverlapRows,
number_format($incomeOverlapTotal, 2),
));
}
if ((bool) $this->option('amounts-only')) {
return $this->backfillPromoAmounts($month, $source, $groups, $promoOnlyGroups, $reportMonth);
}
// 订单侧的单号在不同平台形态不同:淘系/京东是无前缀纯数字,
// 拼多多等平台带日期前缀(260630-xxx)。这里同时带上原始号与清洗后的
// 核心号去查订单侧,再统一按核心号聚合,避免漏匹配。
$cores = [];
foreach ($groups as $group) {
foreach ([(string) $group['online_order_no'], (string) $group['order_core_no']] as $candidate) {
$candidate = trim($candidate);
if ($candidate !== '') {
$cores[$candidate] = true;
}
}
}
/** @var array<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);
foreach (array_chunk($candidates, 1000) as $batch) {
$mirror->table('tmp_receipt_order_nos')->insertOrIgnore(
array_map(static fn (string $no): array => ['no' => $no], $batch)
);
}
$this->info('订单侧候选单号:' . count($candidates));
$orderRows = [];
$paidRows = [];
foreach ([
['ops_jst_qmcust_orders', 'ops_jst_qmcust_order_items', 'pay_amount', 'ops_jst_qmcust'],
['ops_jst_orders', 'ops_jst_order_item', 'buyer_paid_amount', 'ops_jst'],
] as [$ordersTable, $itemsTable, $paidColumn, $tag]) {
try {
// 商品金额只取主单(link_o_id 为空/0):聚水潭的拆单子单会把主单的商品行再列一遍,
// 子单的平台子订单号与主单不同时,按「子订单号 + 条码」的去重键拦不住,会把同一批
// 商品重复累加。整单没有任何主单行时退化为取全部行,避免把真实金额抹掉——与实付
// 「主单优先,无主单退化」保持同一套规则。
$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"
);
// 同一线上订单号在聚水潭可能对应多个 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
WHERE (o.link_o_id IS NULL OR o.link_o_id = 0)
OR m.no IS NULL
GROUP BY o.so_id, o.shop_name, dedup_key
) d
GROUP BY so_id, shop_name"
);
// 订单实付:取订单级字段(明细行上的实付会重复);拆单取主单(link_o_id 为空),
// 没有主单时退化为取该单号下的最大值。
$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(paid)
) as paid
FROM (
SELECT o.so_id as so_id, o.shop_name as shop_name, o.link_o_id as link_o_id, o.{$paidColumn} as paid
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;
}
}
$this->info('订单侧命中(店铺 × 订单)行数:' . count($orderRows) . ',实付行数:' . count($paidRows));
/** @var array<string,float> $orderPaid 订单侧实付金额(店铺 + 核心单号) */
$orderPaid = [];
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'];
}
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);
}
// 只重建「本账期 + 本平台」的数据,避免把同月其它平台的结果一起清掉。
DB::connection($connection)->table(self::TARGET_TABLE)
->where('report_month', $reportMonth)
->where('platform', (string) $source['label'])
->delete();
$target = DB::connection($connection)->table(self::TARGET_TABLE);
$now = now();
$buffer = [];
$imported = 0;
$stats = ['已匹配' => 0, '未匹配' => 0, self::REMARK_MULTI_PAY => 0];
$remarked = [];
$goodsTotal = 0.0;
$differenceTotal = 0.0;
foreach ($groups as $group) {
$core = (string) $group['order_core_no'];
$income = round((float) $group['income_amount'], 2);
$remark = '';
$productAmount = null;
$status = self::STATUS_UNMATCHED;
// 订单实付=订单级实付 − 虚拟条码(X0000x)明细行实付,下限 0;
// 与商品金额保持一致的口径:纯信誉/虚拟订单的实付归零。
$paidKey = $group['shop_key'] . '|' . $core;
$paidAmount = isset($orderPaid[$paidKey])
? round(max((float) $orderPaid[$paidKey] - (float) ($orderVirtualPaid[$paidKey] ?? 0.0), 0.0), 2)
: null;
if ($core === '') {
$remark = self::REMARK_EMPTY_NO;
} elseif (preg_match('/^\d{8,}$/', $core) !== 1) {
$remark = self::REMARK_BAD_FORMAT;
} else {
$exactKey = $group['shop_key'] . '|' . $core;
if (isset($orderSeen[$exactKey])) {
if (isset($orderAmount[$exactKey])) {
$status = self::STATUS_MATCHED;
$productAmount = round((float) $orderAmount[$exactKey], 2);
if (count($coresByCore[$core] ?? []) > 1) {
$remark = self::REMARK_DUP_ORDER;
}
} else {
// 订单侧有记录,但全是 0 元/退款行:没有商品金额可比对。
$remark = self::REMARK_NO_AMOUNT;
}
} elseif (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) === 1) {
// 单号一致但店铺名称对不上:仍然带上金额,标注「店铺不一致」便于复核。
$onlyShop = array_key_first($coresByCore[$core]);
$otherKey = $onlyShop . '|' . $core;
if (isset($orderAmount[$otherKey])) {
$status = self::STATUS_MATCHED;
$productAmount = round((float) $orderAmount[$otherKey], 2);
$remark = self::REMARK_SHOP_DIFF;
} else {
$remark = self::REMARK_NO_AMOUNT;
}
} elseif (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) > 1) {
$sum = 0.0;
$hasAmount = false;
foreach (array_keys($coresByCore[$core]) as $shopKey) {
$key2 = $shopKey . '|' . $core;
if (isset($orderAmount[$key2])) {
$sum += (float) $orderAmount[$key2];
$hasAmount = true;
}
}
if ($hasAmount) {
$status = self::STATUS_MATCHED;
$productAmount = round($sum, 2);
$remark = self::REMARK_DUP_ORDER;
} else {
$remark = self::REMARK_NO_AMOUNT;
}
} elseif (isset($coreSeen[$core])) {
// 订单侧只有店铺名为空的记录:按订单号兜底匹配。
$blankKey = '|' . $core;
if (isset($orderAmount[$blankKey])) {
$status = self::STATUS_MATCHED;
$productAmount = round((float) $orderAmount[$blankKey], 2);
} else {
$remark = self::REMARK_NO_AMOUNT;
}
} else {
$remark = self::REMARK_NO_ORDER;
}
}
if ($status === self::STATUS_MATCHED && (int) $group['payment_count'] > 1 && $remark === '') {
$remark = self::REMARK_MULTI_PAY;
}
// 差额口径:(收入金额 + 优惠金额 + 冲减金额)− 订单侧商品金额。
// 优惠/冲减属于同一订单的账单,把它们加回来,差额才只反映真正对不上的部分。
$promoAmount = round((float) ($group['discount_amount'] ?? 0.0) + (float) ($group['offset_amount'] ?? 0.0), 2);
$difference = $productAmount === null ? null : round($income + $promoAmount - $productAmount, 2);
if ($productAmount !== null) {
$goodsTotal += $productAmount;
$differenceTotal += (float) $difference;
}
$stats[$status] = ($stats[$status] ?? 0) + 1;
if ((int) $group['payment_count'] > 1) {
$stats[self::REMARK_MULTI_PAY]++;
}
if ($remark !== '') {
$remarked[$remark] = ($remarked[$remark] ?? 0) + 1;
}
$buffer[] = [
'report_month' => $reportMonth,
'platform' => $group['platform'],
'row_key' => md5($reportMonth . '|' . $group['platform'] . '|' . $group['shop_key'] . '|' . $core),
'occurred_date' => $group['occurred_date'],
'store_name' => $group['store_name'],
'online_order_no' => $group['online_order_no'] !== '' ? $group['online_order_no'] : null,
'order_core_no' => $core !== '' ? $core : null,
'payment_count' => (int) $group['payment_count'],
'income_amount' => $income,
'discount_amount' => ! empty($group['discount_seen']) ? round((float) $group['discount_amount'], 2) : null,
'offset_amount' => ! empty($group['offset_seen']) ? round((float) $group['offset_amount'], 2) : null,
'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) {
$target->insertOrIgnore($buffer);
$imported += count($buffer);
$buffer = [];
}
}
// A 方案补行:挂不到订单行的优惠/冲减也写进业绩明细(收入/商品/差额留空)。
foreach ($promoOnlyGroups as $promoGroup) {
$buffer[] = self::promoOnlyRow($reportMonth, (string) $source['label'], $promoGroup, $now);
if (count($buffer) >= 500) {
$target->insertOrIgnore($buffer);
$imported += count($buffer);
$buffer = [];
}
}
if ($buffer !== []) {
$target->insertOrIgnore($buffer);
$imported += count($buffer);
}
$this->info('写入完成,共 ' . $imported . ' 行(账单收款 ' . $paymentTotal . ' 笔)。');
foreach ($stats as $status => $count) {
$this->line(' ' . $status . ':' . $count);
}
foreach ($remarked as $remark => $count) {
$this->line(' 标注 ' . $remark . ':' . $count);
}
$this->line(' 账单收入合计:' . number_format($incomeTotal, 2));
$this->line(' 订单侧商品金额合计:' . number_format($goodsTotal, 2));
$this->line(' 差额合计:' . number_format($differenceTotal, 2));
// 重建后自检:本月账单里确实存在「优惠收入 / 冲减收入 / 退款」的账单行,但重建出来的行
// 三列一行都没写,说明这次重建用的代码或参数不对(历史上出现过用旧代码重建、把优惠金额
// 整列写成空),这里显式报错,避免静默丢列后没人发现。
$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 backfillPlantingAmounts(string $reportMonth): int
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$cores = $this->plantingOrderCores();
if ($cores === []) {
$this->warn('没有识别到任何种菜订单号(种菜明细表与聚水潭订单标签明细的交集为空),本列将全部为空。');
}
$target = static fn () => DB::connection($connection)->table(self::TARGET_TABLE)
->where('report_month', $reportMonth);
$rows = $target()->get(['row_key', 'order_core_no', 'income_amount']);
$target()->update(['planting_amount' => null]);
$values = [];
$total = 0.0;
foreach ($rows as $row) {
$core = self::orderCoreNo((string) $row->order_core_no);
if ($core === '' || ! isset($cores[$core])) {
continue;
}
$amount = round((float) $row->income_amount, 2);
$values[(string) $row->row_key] = $amount;
$total += $amount;
}
$this->updateAmountColumn($connection, $reportMonth, 'planting_amount', $values);
$this->info(sprintf(
'已回填 %s 种菜金额:%d / %d 行,合计 %s(共识别 %d 个种菜订单号)。',
$reportMonth,
count($values),
count($rows),
number_format($total, 2),
count($cores),
));
return self::SUCCESS;
}
/**
* 只回填「优惠金额 / 冲减金额 / 种菜金额」三列:不重建业绩明细、不查订单侧,
* 先清空该账期该平台的三列,再按 row_key 更新镜像里已存在的行。
*
* @param array<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
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$platform = (string) $source['label'];
$target = static fn () => DB::connection($connection)->table(self::TARGET_TABLE);
$existing = $target()
->where('report_month', $reportMonth)
->where('platform', $platform)
->pluck('row_key')
->flip()
->all();
$target()
->where('report_month', $reportMonth)
->where('platform', $platform)
->update(['discount_amount' => null, 'offset_amount' => null, '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) {
$target()->insertOrIgnore($buffer);
$promoRows += count($buffer);
$buffer = [];
}
}
if ($buffer !== []) {
$target()->insertOrIgnore($buffer);
$promoRows += count($buffer);
}
// 只回填金额列时也要把「差额」跟着刷新,否则页面上差额列与优惠/冲减列对不上。
$target()
->where('report_month', $reportMonth)
->where('platform', $platform)
->whereNotNull('product_amount')
->update([
'difference' => DB::raw('ROUND(income_amount + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0) - product_amount, 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;
}
/**
* A 方案:把挂不到订单行的优惠/冲减整理成「补行」。
*
* - 有订单号、但该订单没有收款账单(成不了行)→ 一行 = 店铺 + 订单号
* - 账单本身没有订单号 → 一行 = 店铺 + 账单项目(按账期汇总)
*
* @param iterable<int,mixed> $bills
* @param array<string,array<string,mixed>> $groups 已有收款行(已按 payment_count 过滤)
* @param array<string,string> $ledgerMap
* @return array<string,array<string,mixed>>
*/
private function promoOnlyGroups(
iterable $bills,
array $groups,
AccountLedgerRemarkMap $remarkMap,
array $ledgerMap,
?string $tabPlatform,
string $orderColumn,
string $timeColumn,
string $amountColumn,
string $expenseColumn,
string $realStoreColumn,
): array {
$result = [];
foreach ($bills as $bill) {
$storeName = (string) ($bill->store_name ?? '');
$billItem = (string) ($bill->bill_item ?? '');
$remark = $remarkMap->remark(
$storeName,
$tabPlatform,
$billItem,
$this->billBusinessRemarks($bill),
$this->billGoodsName($bill),
);
$type = in_array($remark, self::DISCOUNT_REMARKS, true) ? 'discount'
: (in_array($remark, self::OFFSET_REMARKS, true) ? 'offset'
: (in_array($remark, self::REFUND_REMARKS, true) ? 'refund' : null));
if ($type === null) {
continue;
}
// 已经算进「收入金额」白名单的行不重复补。
$legacy = $ledgerMap[$remarkMap->platform($storeName, $tabPlatform) ?? ''][$billItem] ?? null;
if (in_array($legacy, self::LEDGER_REMARKS, true)) {
continue;
}
$rawNo = trim((string) ($bill->{$orderColumn} ?? ''));
$core = self::cleanOrderNo($rawNo);
$realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : '';
$shopKey = self::normalizeShop($realStore !== '' ? $realStore : $storeName);
if ($core !== '' && isset($groups[$shopKey . '|' . $core])) {
// 该订单已有收款行,优惠/冲减已经挂在那一行上。
continue;
}
$key = $core === '' ? $shopKey . '||' . $billItem : $shopKey . '|' . $core;
if (! isset($result[$key])) {
$result[$key] = [
'shop_key' => $shopKey,
'store_name' => $storeName,
'online_order_no' => $rawNo,
'order_core_no' => $core,
'bill_item' => $billItem,
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
'discount_amount' => 0.0,
'discount_seen' => false,
'offset_amount' => 0.0,
'offset_seen' => false,
'refund_amount' => 0.0,
'refund_seen' => false,
];
}
// 与主流程一致:优惠/冲减都按「收入侧 − 支出侧」取数。
$amount = (float) $bill->{$amountColumn}
- ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0);
if ($type === 'discount') {
$result[$key]['discount_seen'] = true;
$result[$key]['discount_amount'] += $amount;
} elseif ($type === 'refund') {
$result[$key]['refund_seen'] = true;
$result[$key]['refund_amount'] += $amount;
} else {
$result[$key]['offset_seen'] = true;
$result[$key]['offset_amount'] += $amount;
}
}
return $result;
}
/**
* 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::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);
}
/**
* 种菜订单号集合:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号。
*
* 两个条件是「或」:订单号在任意一边出现过就算种菜订单(业务口径已确认)。
* 两边都按键清洗后的「核心单号」比较(去掉日期前缀、空格、横杠),与账单侧同一套口径。
*
* @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;
}
}
if ($positiveLabels === []) {
return [];
}
// 并集的第一部分:聚水潭订单标签明细里命中种菜映射的订单号
$cores = [];
foreach (DB::table('jst_order_labels')
->where('company_id', $companyId)
->whereIn('label', $positiveLabels)
->distinct()
->pluck('order_no') as $orderNo) {
$core = self::orderCoreNo((string) $orderNo);
if ($core !== '') {
$cores[$core] = true;
}
}
// 并集的第二部分:种菜明细表里的订单号
foreach (DB::table('planting_details')->where('company_id', $companyId)->distinct()->pluck('order_no') as $orderNo) {
$core = self::orderCoreNo((string) $orderNo);
if ($core !== '') {
$cores[$core] = true;
}
}
return $cores;
}
private static function orderCoreNo(string $value): string
{
$value = str_replace("\u{3000}", ' ', trim($value));
if ($value === '') {
return '';
}
// 日期前缀形如「260630-」:只有明确带 6 位数字 + 横杠时才去除,
// 避免误伤本身就是纯数字的订单号(如 5126322747312081812)。
if (preg_match('/^\d{6}\s*[-\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', $value) === 1) {
$value = preg_replace('/^\d{6}\s*[-\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '', $value) ?? $value;
}
$value = preg_replace('/[\s\-\x{FF0D}\x{2014}\x{2013}\x{2010}]+/u', '', $value) ?? '';
return strtoupper($value);
}
/**
* 账单行上的业务备注:科目映射表的「业务备注关键词」用它匹配。
*
* 与「店铺收款账户账单」页面(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));
}
/** 账单行上的商品名称:科目映射表的「商品名称关键词」用它匹配。 */
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);
}
}