1548 lines
71 KiB
PHP
1548 lines
71 KiB
PHP
<?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 : 只回填「种菜金额」一列(不读账单,直接按该月已有明细行的订单号刷,一次覆盖全平台)}
|
||
{--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
|
||
{
|
||
// 全量重建时账单与订单侧映射都在内存里:拼多多 / 淘系支付宝这类大平台需要 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 = [];
|
||
// 第 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):聚水潭的拆单子单会把主单的商品行再列一遍,
|
||
// 子单的平台子订单号与主单不同时,按「子订单号 + 条码」的去重键拦不住,会把同一批
|
||
// 商品重复累加。整单没有任何主单行时退化为取全部行,避免把真实金额抹掉——与实付
|
||
// 「主单优先,无主单退化」保持同一套规则。
|
||
$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
|
||
, COALESCE(
|
||
MAX(CASE WHEN link_o_id IS NULL OR link_o_id = 0 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(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.{$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;
|
||
}
|
||
}
|
||
$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;
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// 只重建「本账期 + 本平台」的数据,避免把同月其它平台的结果一起清掉。
|
||
$dryRun = (bool) $this->option('dry-run');
|
||
if (! $dryRun) {
|
||
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);
|
||
}
|
||
|
||
// 比对基准(差额 / 匹配状态 / 差额异常备注):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) {
|
||
$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) {
|
||
$target->insertOrIgnore($buffer);
|
||
}
|
||
$imported += count($buffer);
|
||
$buffer = [];
|
||
}
|
||
}
|
||
|
||
if ($buffer !== []) {
|
||
if (! $dryRun) {
|
||
$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 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);
|
||
}
|
||
|
||
// 只回填金额列时也要把「差额」跟着刷新,否则页面上差额列与优惠/冲减列对不上。
|
||
$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;
|
||
}
|
||
|
||
/**
|
||
* 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::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 (DB::table('jst_order_labels')
|
||
->where('company_id', $companyId)
|
||
->where(function ($query) use ($positiveLabels): void {
|
||
if ($positiveLabels !== []) {
|
||
$query->whereIn('label', $positiveLabels);
|
||
}
|
||
$query->orWhereRaw('FIND_IN_SET(?, item_code) > 0', [JstOrderLabelPlantingRules::PLANTING_ITEM_CODE]);
|
||
})
|
||
->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);
|
||
}
|
||
|
||
/**
|
||
* 是否 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);
|
||
}
|
||
}
|