diff --git a/app/Console/Commands/SyncStoreReceiptOrderMatches.php b/app/Console/Commands/SyncStoreReceiptOrderMatches.php index ae2e7673..bc94c95b 100644 --- a/app/Console/Commands/SyncStoreReceiptOrderMatches.php +++ b/app/Console/Commands/SyncStoreReceiptOrderMatches.php @@ -7,6 +7,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use InvalidArgumentException; use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap; +use Modules\EcommerceFinance\Services\AccountMappingStore; use Modules\EcommerceFinance\Services\LedgerRemarkFilter; use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory; use Throwable; @@ -31,7 +32,8 @@ class SyncStoreReceiptOrderMatches extends Command {month : 账单月份,格式 YYYY-MM} {--platform=taobao-alipay : 收款账户,目前支持 taobao-alipay} {--chunk=1200 : 单次向上游查询的订单号数量} - {--limit=0 : 仅处理前 N 笔账单(调试用,0 表示全量)}'; + {--limit=0 : 仅处理前 N 笔账单(调试用,0 表示全量)} + {--amounts-only : 只回填「优惠金额 / 冲减金额」两列,不重跑订单侧匹配}'; protected $description = '按店铺收款账户账单匹配订单侧商品金额,生成「业绩明细」核对镜像'; @@ -95,6 +97,21 @@ class SyncStoreReceiptOrderMatches extends Command '订单收入', ]; + /** + * 「优惠金额」列口径:账单的总账备注命中以下取值。 + * + * 总账备注优先取「财务规则 › 科目映射表」的财务备注(与店铺收款账户账单页一致), + * 取不到时回落到 ec_account_ledger_remarks。金额不计入收入金额与差额。 + */ + private const DISCOUNT_REMARKS = [ + '优惠收入', + ]; + + /** 「冲减金额」列口径:账单的总账备注命中以下取值,不参与收入金额与差额计算。 */ + private const OFFSET_REMARKS = [ + '冲减收入', + ]; + private const TARGET_TABLE = 'store_receipt_order_match_rows'; private const STATUS_MATCHED = '已匹配'; @@ -152,13 +169,28 @@ class SyncStoreReceiptOrderMatches extends Command // 「总账备注」→ 按平台分组的账单项目集合(淘系页签同页混有淘宝/天猫/阿里巴巴店铺, // 必须连店铺名前缀一起过滤,才能与页面按行取到的总账备注一致)。 - $groups = $this->ledgerRemarkGroups($platform); - if ($groups === []) { + // 收入金额只认 LEDGER_REMARKS;优惠/冲减金额各自单独成列,不参与收入与差额。 + $baseGroups = $this->ledgerRemarkGroups($platform); + $discountGroups = $this->ledgerRemarkGroups($platform, self::DISCOUNT_REMARKS); + $offsetGroups = $this->ledgerRemarkGroups($platform, self::OFFSET_REMARKS); + // 总账备注现在优先取科目映射表的财务备注,因此这两列的候选账单项目 + // 也要按科目映射表补一遍,否则「优惠收入 / 冲减收入」永远取不到项目。 + $promoItems = array_values(array_unique(array_merge( + $this->accountMappingItems($platform, self::DISCOUNT_REMARKS), + $this->accountMappingItems($platform, self::OFFSET_REMARKS), + ))); + // 微信账户页签的账单项目带「wx」前缀(wx限时红包代商家垫付扣回 等), + // 科目映射表用的是不带前缀的名字,这里两种写法都带上。 + $promoItems = $this->withWxVariants($promoItems); + if ($baseGroups === []) { $this->error('「总账备注」映射表没有匹配到任何账单项目,请先导入 ec_account_ledger_remarks。'); return self::FAILURE; } - $this->info('总账备注命中平台分组:' . count($groups) . ' 组'); + $this->info('总账备注命中平台分组:' . count($baseGroups) . ' 组'); + if ($discountGroups === [] && $offsetGroups === [] && $promoItems === []) { + $this->warn('「优惠收入」「冲减收入」在科目映射表与总账备注映射里都没有对应账单项目,两列将全部为空。'); + } $select = ['store_name', 'bill_item', $orderColumn, $timeColumn, $amountColumn]; if ($realStoreColumn !== '') { @@ -193,13 +225,18 @@ class SyncStoreReceiptOrderMatches extends Command $billQuery->whereIn('generation', $generations); $this->info('活动代:' . count($generations) . ' 个'); } - LedgerRemarkFilter::applyGroups( - $billQuery, - $groups, - '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 . '%'), - ); + $billQuery->where(function ($group) use ($baseGroups, $discountGroups, $offsetGroups, $promoItems): void { + LedgerRemarkFilter::applyGroups( + $group, + array_merge($baseGroups, $discountGroups, $offsetGroups), + 'bill_item', + static fn ($sub, string $prefix) => $sub->where('store_name', 'like', $prefix . '%'), + static fn ($sub, string $prefix) => $sub->where('store_name', 'not like', $prefix . '%'), + ); + if ($promoItems !== []) { + $group->orWhereIn('bill_item', $promoItems); + } + }); $bills = $billQuery->orderBy($timeColumn)->get(); if ($limit > 0) { @@ -216,7 +253,38 @@ class SyncStoreReceiptOrderMatches extends Command $groups = []; $paymentTotal = 0; $incomeTotal = 0.0; + $discountTotal = 0.0; + $offsetTotal = 0.0; + $remarkMap = app(AccountLedgerRemarkMap::class); + $tabPlatform = $remarkMap->defaultPlatform($platform); + $ledgerMap = $remarkMap->map(); + $incomeOverlapRows = 0; + $incomeOverlapTotal = 0.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); + $isPromoRemark = in_array($remark, self::DISCOUNT_REMARKS, true) + || in_array($remark, self::OFFSET_REMARKS, true); + $isIncomeRemark = in_array($legacyRemark, self::LEDGER_REMARKS, true); + if ($isIncomeRemark && $isPromoRemark) { + // 已经在收入金额里的行:不重复计入优惠/冲减列,单独提示金额。 + $incomeOverlapRows++; + $incomeOverlapTotal += (float) $bill->{$amountColumn}; + } + $bucket = match (true) { + $isIncomeRemark => 'income', + in_array($remark, self::DISCOUNT_REMARKS, true) => 'discount', + in_array($remark, self::OFFSET_REMARKS, true) => 'offset', + default => null, + }; + if ($bucket === null) { + continue; + } + $rawNo = trim((string) ($bill->{$orderColumn} ?? '')); $core = self::cleanOrderNo($rawNo); $realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : ''; @@ -233,16 +301,47 @@ class SyncStoreReceiptOrderMatches extends Command '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, ]; } - $groups[$key]['payment_count']++; - $groups[$key]['income_amount'] += (float) $bill->{$amountColumn}; - $paymentTotal++; - $incomeTotal += (float) $bill->{$amountColumn}; + $amount = (float) $bill->{$amountColumn}; + if ($bucket === 'income') { + $groups[$key]['payment_count']++; + $groups[$key]['income_amount'] += $amount; + $paymentTotal++; + $incomeTotal += $amount; + } elseif ($bucket === 'discount') { + $groups[$key]['discount_seen'] = true; + $groups[$key]['discount_amount'] += $amount; + $discountTotal += $amount; + } else { + $groups[$key]['offset_seen'] = true; + $groups[$key]['offset_amount'] += $amount; + $offsetTotal += $amount; + } } + // 行口径保持不变:仍然是「有收款账单(收入白名单)的店铺 + 订单号」, + // 只有优惠/冲减、没有收款的订单号不单独成行。 + $groups = array_filter($groups, static fn (array $group): bool => (int) $group['payment_count'] > 0); + $this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行'); + $this->line(' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 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, $reportMonth); + } // 订单侧的单号在不同平台形态不同:淘系/京东是无前缀纯数字, // 拼多多等平台带日期前缀(260630-xxx)。这里同时带上原始号与清洗后的 @@ -419,6 +518,8 @@ class SyncStoreReceiptOrderMatches extends Command '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, 'product_amount' => $productAmount, 'difference' => $difference, 'match_status' => $status, @@ -452,6 +553,105 @@ class SyncStoreReceiptOrderMatches extends Command return self::SUCCESS; } + /** + * 只回填「优惠金额 / 冲减金额」两列:不重建业绩明细、不查订单侧, + * 先清空该账期该平台的两列,再按 row_key 更新镜像里已存在的行。 + * + * @param array $source + * @param array> $groups + */ + private function backfillPromoAmounts(string $month, array $source, array $groups, 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]); + + $discounts = []; + $offsets = []; + foreach ($groups as $group) { + if (empty($group['discount_seen']) && empty($group['offset_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); + } + } + + $this->updateAmountColumn($connection, $reportMonth, 'discount_amount', $discounts); + $this->updateAmountColumn($connection, $reportMonth, 'offset_amount', $offsets); + + $this->info(sprintf( + '已回填 %s %s:优惠 %d 行(%s)/ 冲减 %d 行(%s),镜像原有 %d 行,未重建业绩明细。', + $month, + $platform, + count($discounts), + number_format(array_sum($discounts), 2), + count($offsets), + number_format(array_sum($offsets), 2), + count($existing), + )); + + return self::SUCCESS; + } + + /** + * 按 row_key 批量更新一列金额:只更新镜像里已有的行,不做插入。 + * + * @param array $values row_key => 金额 + */ + private function updateAmountColumn(string $connection, string $reportMonth, string $column, array $values): void + { + if ($values === []) { + return; + } + + foreach (array_chunk($values, 500, true) as $chunk) { + $cases = ''; + $bindings = []; + foreach ($chunk as $rowKey => $amount) { + $cases .= ' WHEN ? THEN ?'; + $bindings[] = (string) $rowKey; + $bindings[] = $amount; + } + + $bindings[] = $reportMonth; + $bindings = array_merge($bindings, array_map('strval', array_keys($chunk))); + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + + DB::connection($connection)->update( + sprintf( + 'update %s set %s = case row_key%s end where report_month = ? and row_key in (%s)', + self::TARGET_TABLE, + $column, + $cases, + $placeholders, + ), + $bindings, + ); + } + } + /** 单号清洗:去掉日期前缀(如 260630-)、空格、横杠,得到核心号。 */ public static function cleanOrderNo(string $value): string { @@ -471,18 +671,85 @@ class SyncStoreReceiptOrderMatches extends Command return strtoupper($value); } + /** + * 科目映射表里「财务备注命中给定备注」的账单项目(限本页签涉及的平台)。 + * + * @param list $remarks + * @return list + */ + private function accountMappingItems(string $tab, array $remarks): array + { + $map = app(AccountLedgerRemarkMap::class); + $platforms = $map->platformsForTab($tab); + $items = []; + + foreach (app(AccountMappingStore::class)->all() as $rule) { + if (! is_array($rule)) { + continue; + } + $platform = trim((string) ($rule['platform'] ?? '')); + $item = trim((string) ($rule['item'] ?? '')); + $memo = trim((string) ($rule['memo'] ?? '')); + if ($platform === '' || $item === '' || ! in_array($platform, $platforms, true)) { + continue; + } + if (! in_array($memo, $remarks, true)) { + continue; + } + $items[$item] = true; + } + + $list = array_keys($items); + sort($list); + + return $list; + } + + /** + * 账单项目名的「wx」前缀变体:科目映射表用不带前缀的名字, + * 微信账户页签的账单带前缀(反之亦然),两种写法都纳入候选。 + * + * @param list $items + * @return list + */ + private function withWxVariants(array $items): array + { + $candidates = []; + foreach ($items as $item) { + $item = trim((string) $item); + if ($item === '') { + continue; + } + $candidates[$item] = true; + if (stripos($item, 'wx') === 0) { + $stripped = ltrim(mb_substr($item, 2)); + if ($stripped !== '') { + $candidates[$stripped] = true; + } + } else { + $candidates['wx' . $item] = true; + } + } + + $list = array_keys($candidates); + sort($list); + + return $list; + } + /** * 「总账备注」集合 → 平台分组条件(复用账单页同一份映射与翻译)。 * + * @param list|null $remarks 为空时取收入白名单(LEDGER_REMARKS) * @return list> */ - private function ledgerRemarkGroups(string $tab): array + private function ledgerRemarkGroups(string $tab, ?array $remarks = null): array { $map = app(AccountLedgerRemarkMap::class); $groups = []; $seen = []; - foreach (self::LEDGER_REMARKS as $remark) { + 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])) { diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index c855c6ed..4f0a56c3 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -634,6 +634,8 @@ class Pages extends Controller $agg = $aggQuery->selectRaw(" SUM(payment_count) as payments, SUM(income_amount) as income_total, + SUM(discount_amount) as discount_total, + SUM(offset_amount) as offset_total, SUM(product_amount) as goods_total, SUM(difference) as difference_total, SUM(CASE WHEN payment_count > 1 THEN 1 ELSE 0 END) as multi_pay, @@ -653,6 +655,8 @@ class Pages extends Controller 'orders_multi_pay' => (int) ($agg->multi_pay ?? 0), 'orders_zero_income' => (int) ($agg->zero_income ?? 0), 'income_total' => round((float) ($agg->income_total ?? 0), 2), + 'discount_total' => round((float) ($agg->discount_total ?? 0), 2), + 'offset_total' => round((float) ($agg->offset_total ?? 0), 2), 'goods_total' => round((float) ($agg->goods_total ?? 0), 2), 'difference_total' => round((float) ($agg->difference_total ?? 0), 2), ]; @@ -689,6 +693,8 @@ class Pages extends Controller 'store_name' => ['store_name', '店铺名称', 'text'], 'online_order_no' => ['online_order_no', '线上单号', 'text'], 'income_amount' => ['income_amount', '收入金额', 'money'], + 'discount_amount' => ['discount_amount', '优惠金额', 'money'], + 'offset_amount' => ['offset_amount', '冲减金额', 'money'], 'product_amount' => ['product_amount', '商品金额', 'money'], 'difference' => ['difference', '差额', 'money'], 'match_status' => ['match_status', '匹配状态', 'text'], diff --git a/config/business_navigation.php b/config/business_navigation.php index d2fcf544..3d86c442 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -787,7 +787,7 @@ $navigation = [ 'directory' => [ 'icon' => 'receipt_long', 'status' => '共享镜像', - 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额;一行 = 一个店铺的一个订单,商品金额只挂一次,差额保留供后续分类。', + 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额;一行 = 一个店铺的一个订单,商品金额只挂一次,差额保留供后续分类。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额与差额。', 'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], @@ -795,6 +795,8 @@ $navigation = [ ['name' => 'store_name', 'label' => '店铺名称'], ['name' => 'online_order_no', 'label' => '线上单号'], ['name' => 'income_amount', 'label' => '收入金额', 'kind' => 'number'], + ['name' => 'discount_amount', 'label' => '优惠金额', 'kind' => 'number'], + ['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'], ['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'], ['name' => 'difference', 'label' => '差额', 'kind' => 'number'], ['name' => 'match_status', 'label' => '匹配状态'], diff --git a/database/migrations/2026_10_07_140000_add_promo_amounts_to_store_receipt_order_match_rows.php b/database/migrations/2026_10_07_140000_add_promo_amounts_to_store_receipt_order_match_rows.php new file mode 100644 index 00000000..4d1182dd --- /dev/null +++ b/database/migrations/2026_10_07_140000_add_promo_amounts_to_store_receipt_order_match_rows.php @@ -0,0 +1,55 @@ +connection()); + if (! $schema->hasTable('store_receipt_order_match_rows')) { + return; + } + + $schema->table('store_receipt_order_match_rows', function (Blueprint $table): void { + if (! Schema::connection($this->connection())->hasColumn('store_receipt_order_match_rows', 'discount_amount')) { + $table->decimal('discount_amount', 20, 4)->nullable()->after('income_amount'); + } + if (! Schema::connection($this->connection())->hasColumn('store_receipt_order_match_rows', 'offset_amount')) { + $table->decimal('offset_amount', 20, 4)->nullable()->after('discount_amount'); + } + }); + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable('store_receipt_order_match_rows')) { + return; + } + + $columns = array_values(array_filter( + ['discount_amount', 'offset_amount'], + static fn (string $column): bool => Schema::connection($this->connection()) + ->hasColumn('store_receipt_order_match_rows', $column), + )); + + if ($columns !== []) { + $schema->table('store_receipt_order_match_rows', function (Blueprint $table) use ($columns): void { + $table->dropColumn($columns); + }); + } + } +}; diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index d5f66569..29d0f76a 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -1039,6 +1039,8 @@ 一单多笔{{ number_format($receiptStats['orders_multi_pay']) }} 收入为 0 的账单行{{ number_format($receiptStats['orders_zero_income']) }} 账单收入合计{{ number_format((float) $receiptStats['income_total'], 2) }} + 优惠金额合计{{ number_format((float) ($receiptStats['discount_total'] ?? 0), 2) }} + 冲减金额合计{{ number_format((float) ($receiptStats['offset_total'] ?? 0), 2) }} 订单商品金额合计{{ number_format((float) $receiptStats['goods_total'], 2) }} 差额合计(已匹配){{ number_format((float) $receiptStats['difference_total'], 2) }}