feat: 增加业绩明细金额指标与账单明细汇总 #498

Merged
yueyang merged 1 commits from feature/yueyang-performance-metric-details into main 2026-10-24 16:27:20 +08:00
8 changed files with 1498 additions and 235 deletions

View File

@ -13,6 +13,7 @@ use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
use RuntimeException;
use Throwable;
/**
@ -143,6 +144,8 @@ class SyncStoreReceiptOrderMatches extends Command
private const TARGET_TABLE = 'store_receipt_order_match_rows';
private const METRIC_DETAIL_TABLE = 'store_receipt_order_match_metric_details';
private const STATUS_MATCHED = '已匹配';
private const STATUS_UNMATCHED = '未匹配';
@ -253,6 +256,10 @@ class SyncStoreReceiptOrderMatches extends Command
->where('report_month', $reportMonth)
->where('platform', (string) $source['label'])
->delete();
DB::connection($connection)->table(self::METRIC_DETAIL_TABLE)
->where('report_month', $reportMonth)
->where('platform', (string) $source['label'])
->delete();
}
$this->warn(sprintf(
'%s 已从业绩明细排除,%s 删除镜像行 %d 行(收、支均不取)。',
@ -457,7 +464,13 @@ class SyncStoreReceiptOrderMatches extends Command
));
unset($plantingCores);
if ((bool) $this->option('amounts-only')) {
return $this->backfillPromoAmounts($month, $source, $groups, $promoOnlyGroups, $reportMonth);
$result = $this->backfillPromoAmounts($month, $source, $groups, $promoOnlyGroups, $reportMonth);
if ($result === self::SUCCESS && ! (bool) $this->option('dry-run')) {
$this->replaceMetricDetails($reportMonth, (string) $source['label'], $groups, $promoOnlyGroups, now());
$this->verifyMetricDetailTotals($reportMonth, (string) $source['label']);
}
return $result;
}
// 订单侧的单号在不同平台形态不同:淘系/京东是无前缀纯数字,
@ -663,6 +676,10 @@ class SyncStoreReceiptOrderMatches extends Command
->where('report_month', $reportMonth)
->where('platform', (string) $source['label'])
->delete();
DB::connection($connection)->table(self::METRIC_DETAIL_TABLE)
->where('report_month', $reportMonth)
->where('platform', (string) $source['label'])
->delete();
}
$target = DB::connection($connection)->table(self::TARGET_TABLE);
@ -869,6 +886,17 @@ class SyncStoreReceiptOrderMatches extends Command
$imported += count($buffer);
}
if (! $dryRun) {
$this->replaceMetricDetails(
$reportMonth,
(string) $source['label'],
$groups,
$promoOnlyGroups,
$now,
);
$this->verifyMetricDetailTotals($reportMonth, (string) $source['label']);
}
$this->info('写入完成,共 ' . $imported . ' 行(账单收款 ' . $paymentTotal . ' 笔)。');
foreach ($stats as $status => $count) {
$this->line(' ' . $status . ':' . $count);
@ -1290,6 +1318,7 @@ class SyncStoreReceiptOrderMatches extends Command
'default_compensation_seen' => false,
'refund_amount' => 0.0,
'refund_seen' => false,
'bill_items' => [],
];
}
@ -1318,11 +1347,23 @@ class SyncStoreReceiptOrderMatches extends Command
'default_compensation_seen' => false,
'refund_amount' => 0.0,
'refund_seen' => false,
'bill_items' => [],
];
}
$promoOnlyGroups[$promoKey][$bucket . '_seen'] = true;
$promoOnlyGroups[$promoKey][$bucket . '_amount'] += $amount;
}
$billItemKey = \Illuminate\Support\Str::limit($billItem !== '' ? $billItem : '未命名账单项目', 191, '');
$groups[$key]['bill_items'][$bucket][$billItemKey]['amount'] =
($groups[$key]['bill_items'][$bucket][$billItemKey]['amount'] ?? 0.0) + $amount;
$groups[$key]['bill_items'][$bucket][$billItemKey]['line_count'] =
($groups[$key]['bill_items'][$bucket][$billItemKey]['line_count'] ?? 0) + 1;
if ($bucket !== 'income') {
$promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['amount'] =
($promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['amount'] ?? 0.0) + $amount;
$promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['line_count'] =
($promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['line_count'] ?? 0) + 1;
}
if ($bucket === 'income') {
$groups[$key]['payment_count']++;
$groups[$key]['income_amount'] += $amount;
@ -1440,6 +1481,129 @@ class SyncStoreReceiptOrderMatches extends Command
];
}
/**
* 重建一个平台×账期的账单项目金额明细。
*
* 汇总值来自同一遍账单读取,并通过 row_key 绑定主表行;页面按主表当前筛选
* 条件关联本表,所以详情合计和指标卡使用同一批订单。
*
* @param array<string,array<string,mixed>> $groups
* @param array<string,array<string,mixed>> $promoOnlyGroups
*/
private function replaceMetricDetails(
string $reportMonth,
string $platform,
array $groups,
array $promoOnlyGroups,
mixed $now,
): void {
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$target = DB::connection($connection)->table(self::METRIC_DETAIL_TABLE);
$target->where('report_month', $reportMonth)
->where('platform', $platform)
->delete();
$buffer = [];
$append = static function (array $group, string $rowKey) use (&$buffer, $target, $reportMonth, $platform, $now): void {
foreach ((array) ($group['bill_items'] ?? []) as $metric => $billItems) {
foreach ($billItems as $billItem => $values) {
$amount = round((float) ($values['amount'] ?? 0), 4);
if (abs($amount) < 0.00005) {
continue;
}
$billItem = trim((string) $billItem) !== '' ? trim((string) $billItem) : '未命名账单项目';
$buffer[] = [
'report_month' => $reportMonth,
'detail_key' => md5($rowKey . '|' . $metric . '|' . $billItem),
'row_key' => $rowKey,
'platform' => $platform,
'metric' => (string) $metric,
'bill_item' => $billItem,
'amount' => $amount,
'line_count' => (int) ($values['line_count'] ?? 1),
'imported_at' => $now,
];
if (count($buffer) >= 1000) {
$target->insertOrIgnore($buffer);
$buffer = [];
}
}
}
};
foreach ($groups as $group) {
$append($group, self::groupRowKey(
$reportMonth,
$platform,
(string) ($group['shop_key'] ?? ''),
(string) ($group['order_identity'] ?? ''),
));
}
foreach ($promoOnlyGroups as $group) {
$identity = (string) ($group['order_identity'] ?? '');
$rowKey = $identity !== ''
? self::groupRowKey($reportMonth, $platform, (string) ($group['shop_key'] ?? ''), $identity)
: md5($reportMonth . '|' . $platform . '|' . ($group['shop_key'] ?? '') . '|promo|' . ($group['bill_item'] ?? ''));
$append($group, $rowKey);
}
if ($buffer !== []) {
$target->insertOrIgnore($buffer);
}
}
/**
* 账单项目明细与主表金额列必须逐项相等。任何一分钱差异都视为同步失败,
* 避免页面出现“指标总额”和“明细合计”对不上的情况。
*/
private function verifyMetricDetailTotals(string $reportMonth, string $platform): void
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$main = DB::connection($connection)->table(self::TARGET_TABLE)
->where('report_month', $reportMonth)
->where('platform', $platform)
->selectRaw(
'SUM(COALESCE(income_amount, 0)) as income, '
. 'SUM(COALESCE(discount_amount, 0)) as discount, '
. 'SUM(COALESCE(offset_amount, 0)) as offset_amount, '
. 'SUM(COALESCE(default_compensation_amount, 0)) as default_compensation, '
. 'SUM(COALESCE(refund_amount, 0)) as refund'
)
->first();
$details = DB::connection($connection)->table(self::METRIC_DETAIL_TABLE)
->where('report_month', $reportMonth)
->where('platform', $platform)
->selectRaw(
"SUM(CASE WHEN metric = 'income' THEN amount ELSE 0 END) as income, "
. "SUM(CASE WHEN metric = 'discount' THEN amount ELSE 0 END) as discount, "
. "SUM(CASE WHEN metric = 'offset' THEN amount ELSE 0 END) as offset_amount, "
. "SUM(CASE WHEN metric = 'default_compensation' THEN amount ELSE 0 END) as default_compensation, "
. "SUM(CASE WHEN metric = 'refund' THEN amount ELSE 0 END) as refund"
)
->first();
$labels = [
'income' => '收入',
'discount' => '优惠',
'offset_amount' => '冲减',
'default_compensation' => '违约赔付',
'refund' => '退款',
];
foreach ($labels as $column => $label) {
$mainTotal = round((float) ($main->{$column} ?? 0), 2);
$detailTotal = round((float) ($details->{$column} ?? 0), 2);
if (abs($mainTotal - $detailTotal) > 0.005) {
throw new RuntimeException(sprintf(
'账单项目明细校验失败:%s %s %s,主表 %s,明细 %s。',
$reportMonth,
$platform,
$label,
number_format($mainTotal, 2, '.', ''),
number_format($detailTotal, 2, '.', ''),
));
}
}
}
/** @param array<string,mixed> $group */
private static function plantingLabel(array $group): ?string
{

View File

@ -111,6 +111,18 @@ class Pages extends Controller
'得物',
];
/** 业绩明细可点击指标 → 主表金额列、账单项目明细桶、页面名称。 */
private const RECEIPT_METRIC_MAP = [
'income_amount' => ['label' => '账单收入', 'metric' => 'income'],
'paid_amount' => ['label' => '订单实付', 'metric' => null],
'discount_amount' => ['label' => '优惠收入', 'metric' => 'discount'],
'offset_amount' => ['label' => '冲减收入', 'metric' => 'offset'],
'default_compensation_amount' => ['label' => '违约赔付', 'metric' => 'default_compensation'],
'refund_amount' => ['label' => '退款金额', 'metric' => 'refund'],
'planting_amount' => ['label' => '种菜金额', 'metric' => null],
'difference' => ['label' => '差额合计', 'metric' => null],
];
/** 业绩明细不纳入的收款账户:京东网银钱包的收、支均不取。 */
private const RECEIPT_MATCH_EXCLUDED_PLATFORMS = [
'京东网银钱包',
@ -1537,6 +1549,235 @@ class Pages extends Controller
]), $stats];
}
/**
* 业绩明细指标卡点击后的明细汇总。
*
* 收入、优惠、冲减、违约赔付、退款从账单项目明细表按「平台 + 店铺 + 账单项目」
* 汇总;订单实付、种菜金额、差额没有账单项目维度,直接按主表行汇总。
*/
public function receiptOrderMatchMetricDetails(Request $request)
{
$metric = trim((string) $request->query('metric', ''));
$definition = self::RECEIPT_METRIC_MAP[$metric] ?? null;
if ($definition === null) {
return response()->json(['message' => '不支持的业绩明细指标。'], 422);
}
[, $month] = $this->receiptOrderMatchMonths();
if ($month === null) {
return response()->json([
'metric' => $metric,
'label' => $definition['label'],
'month' => null,
'total' => 0,
'detail_total' => 0,
'balance' => 0,
'balanced' => true,
'rows' => [],
]);
}
$query = $this->receiptOrderMatchFilteredQuery($month);
$total = $this->receiptOrderMatchMetricTotal($query, $metric);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$detailMetric = $definition['metric'];
if ($detailMetric !== null) {
$filteredRows = (clone $query)->select([
'report_month',
'row_key',
'platform',
'store_name',
]);
$rows = DB::connection($connection)
->query()
->fromSub($filteredRows, 'filtered_receipt_rows')
->join('store_receipt_order_match_metric_details as metric_details', function ($join): void {
$join->on('metric_details.report_month', '=', 'filtered_receipt_rows.report_month')
->on('metric_details.row_key', '=', 'filtered_receipt_rows.row_key');
})
->where('metric_details.metric', $detailMetric)
->select([
'filtered_receipt_rows.platform',
'filtered_receipt_rows.store_name',
'metric_details.bill_item',
])
->selectRaw('SUM(metric_details.amount) as amount')
->selectRaw('SUM(metric_details.line_count) as line_count')
->groupBy([
'filtered_receipt_rows.platform',
'filtered_receipt_rows.store_name',
'metric_details.bill_item',
])
->havingRaw('ABS(SUM(metric_details.amount)) > 0.004')
->orderBy('filtered_receipt_rows.platform')
->orderBy('filtered_receipt_rows.store_name')
->orderBy('metric_details.bill_item')
->get();
} elseif ($metric === 'difference') {
// 与汇总使用同一套“默认差额 + 口径兜底修正”,避免对整月每一行都执行
// 复杂的差额原因子查询;复杂规则只落在实收/应付口径兜底行上。
$baseRows = (clone $query)
->select(['platform', 'store_name'])
->selectRaw('SUM(' . self::receiptDefaultDifferenceSql() . ') as amount')
->selectRaw('COUNT(*) as line_count')
->groupBy(['platform', 'store_name'])
->get();
$adjustments = (clone $query)
->whereIn('remark', self::RECEIPT_BASIS_FALLBACK_REMARKS)
->select(['platform', 'store_name'])
->selectRaw('SUM(' . self::receiptFallbackDifferenceAdjustmentSql() . ') as adjustment')
->groupBy(['platform', 'store_name'])
->get();
$grouped = [];
foreach ($baseRows as $row) {
$key = (string) $row->platform . '|' . (string) $row->store_name;
$grouped[$key] = [
'platform' => (string) $row->platform,
'store_name' => (string) $row->store_name,
'amount' => (float) $row->amount,
'line_count' => (int) $row->line_count,
];
}
foreach ($adjustments as $row) {
$key = (string) $row->platform . '|' . (string) $row->store_name;
if (! isset($grouped[$key])) {
$grouped[$key] = [
'platform' => (string) $row->platform,
'store_name' => (string) $row->store_name,
'amount' => 0.0,
'line_count' => 0,
];
}
$grouped[$key]['amount'] += (float) $row->adjustment;
}
ksort($grouped, SORT_NATURAL | SORT_FLAG_CASE);
$rows = collect($grouped)
->filter(static fn (array $row): bool => abs((float) $row['amount']) > 0.004)
->map(static function (array $row) use ($definition): object {
return (object) [
'platform' => $row['platform'],
'store_name' => $row['store_name'],
'bill_item' => $definition['label'],
'amount' => $row['amount'],
'line_count' => $row['line_count'],
];
})
->values();
} else {
$amountSql = 'COALESCE(' . $metric . ', 0)';
$rows = (clone $query)
->select(['platform', 'store_name'])
->selectRaw('SUM(' . $amountSql . ') as amount')
->selectRaw('COUNT(*) as line_count')
->groupBy(['platform', 'store_name'])
->havingRaw('ABS(SUM(' . $amountSql . ')) > 0.004')
->orderBy('platform')
->orderBy('store_name')
->get()
->map(static function (object $row) use ($definition): object {
$row->bill_item = $definition['label'];
return $row;
});
}
$detailTotal = round((float) $rows->sum(static fn (object $row): float => (float) ($row->amount ?? 0)), 2);
$balance = round($detailTotal - $total, 2);
$rows = $rows->map(static function (object $row): array {
return [
'platform' => (string) ($row->platform ?? ''),
'store_name' => (string) ($row->store_name ?? ''),
'bill_item' => (string) ($row->bill_item ?? ''),
'amount' => round((float) ($row->amount ?? 0), 2),
'line_count' => (int) ($row->line_count ?? 0),
];
})->values();
return response()->json([
'metric' => $metric,
'label' => $definition['label'],
'month' => substr($month, 0, 7),
'total' => round($total, 2),
'detail_total' => $detailTotal,
'balance' => $balance,
'balanced' => abs($balance) <= 0.005,
'rows' => $rows,
]);
}
/**
* 业绩明细当前筛选条件。指标详情与页面统计共用同一组请求参数。
*
* @return \Illuminate\Database\Query\Builder
*/
private function receiptOrderMatchFilteredQuery(string $month)
{
$platformFilter = $this->receiptOrderMatchFilterValues(request('platform'));
$storeFilter = $this->receiptOrderMatchFilterValues(request('store_name'));
[$statusFilter, $remarkFilter] = $this->receiptOrderMatchStatusFilters(
request('match_status'),
request('remark'),
);
$unmatchedReasonFilter = $this->receiptOrderMatchFilterValues(request('unmatched_reason'));
$differenceState = $this->receiptOrderMatchFilterValues(request('difference_state'));
$orderNo = trim((string) request('online_order_no', ''));
$query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('store_receipt_order_match_rows')
->where('report_month', $month)
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
if ($platformFilter !== []) {
$query->whereIn('platform', $platformFilter);
}
if ($storeFilter !== []) {
$query->whereIn('store_name', $storeFilter);
}
if ($orderNo !== '') {
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
if ($statusFilter !== []) {
$query->whereIn('match_status', $statusFilter);
}
if ($remarkFilter !== []) {
$this->applyReceiptRemarkFilter($query, $remarkFilter);
}
if ($unmatchedReasonFilter !== []) {
$this->applyReceiptUnmatchedReasonFilter($query, $unmatchedReasonFilter);
}
if ($differenceState !== []) {
$this->applyReceiptDifferenceStateFilter($query, $differenceState);
}
return $query;
}
private function receiptOrderMatchMetricTotal($query, string $metric): float
{
if ($metric !== 'difference') {
return round((float) (clone $query)->sum($metric), 2);
}
$fallbackSql = self::receiptBasisFallbackSqlList();
$row = (clone $query)->selectRaw(
'SUM(' . self::receiptDefaultDifferenceSql() . ') as difference_base, '
. "SUM(CASE WHEN remark IN ({$fallbackSql}) THEN 1 ELSE 0 END) as basis_fallback_rows"
)->first();
$total = (float) ($row->difference_base ?? 0);
if ((int) ($row->basis_fallback_rows ?? 0) > 0) {
$adjustment = (clone $query)
->whereIn('remark', self::RECEIPT_BASIS_FALLBACK_REMARKS)
->selectRaw('SUM(' . self::receiptFallbackDifferenceAdjustmentSql() . ') as adjustment')
->first();
$total += (float) ($adjustment->adjustment ?? 0);
}
return round($total, 2);
}
/**
* 「差额异常备注」筛选:按差额原因的条件判定,多选内部是「或」。
*

View File

@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 业绩明细金额指标的账单项目拆分。
*
* 主表仍按「店铺 + 订单号」保留一行,不能直接拆成账单项目行,否则匹配状态、
* 差额和分页口径都会变化。本表以 row_key 关联主表,只保存收入、优惠、冲减、
* 违约赔付、退款各自的账单项目金额,供页面点击指标后查看可核对的明细汇总。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if ($schema->hasTable('store_receipt_order_match_metric_details')) {
return;
}
$schema->create('store_receipt_order_match_metric_details', function (Blueprint $table): void {
$table->date('report_month');
$table->char('detail_key', 64);
$table->char('row_key', 64);
$table->string('platform', 64);
$table->string('metric', 32);
$table->string('bill_item', 191)->default('');
$table->decimal('amount', 20, 4)->default(0);
$table->unsignedInteger('line_count')->default(1);
$table->dateTime('imported_at');
$table->primary(['report_month', 'detail_key'], 'receipt_metric_details_primary');
$table->index(['report_month', 'row_key', 'metric'], 'receipt_metric_details_row_metric_index');
$table->index(['report_month', 'platform', 'metric'], 'receipt_metric_details_metric_index');
$table->index(['report_month', 'bill_item'], 'receipt_metric_details_item_index');
});
}
public function down(): void
{
Schema::connection($this->connection())
->dropIfExists('store_receipt_order_match_metric_details');
}
};

View File

@ -263,6 +263,10 @@ Route::get('workspace/product-inventory/monthly-weighted-average-price/export',
->middleware('business.page:financial-operations,performance-details')
->name('business.financial-operations.performance-details.filter-options');
Route::get('workspace/financial-operations/performance-details/metric-details', 'Business\Pages@receiptOrderMatchMetricDetails')
->middleware('business.page:financial-operations,performance-details')
->name('business.financial-operations.performance-details.metric-details');
Route::get('workspace/financial-operations/product-costs/export', 'Business\Pages@exportProductCosts')
->middleware('business.page:financial-operations,product-costs')
->name('business.financial-operations.product-costs.export');

View File

@ -0,0 +1,152 @@
<?php
namespace Tests\Feature\Business;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\Feature\FeatureTestCase;
class PerformanceDetailsMetricDetailsTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
]);
DB::purge('business_mirror');
$this->createMirrorTables();
}
public function test_offset_metric_details_group_by_store_and_bill_item_and_reconcile_to_total(): void
{
$mirror = DB::connection('business_mirror');
$mirror->table('store_receipt_order_match_rows')->insert([
$this->row('row-a1', '店铺A', -100),
$this->row('row-a2', '店铺A', -200),
$this->row('row-b1', '店铺B', -50),
]);
$mirror->table('store_receipt_order_match_metric_details')->insert([
$this->detail('detail-a1', 'row-a1', '账单扣款', -60),
$this->detail('detail-a2', 'row-a1', '售后扣减', -40),
$this->detail('detail-a3', 'row-a2', '账单扣款', -200),
$this->detail('detail-b1', 'row-b1', '账单扣款', -50),
]);
$response = $this->loginAs()->getJson(route(
'business.financial-operations.performance-details.metric-details',
[
'metric' => 'offset_amount',
'month' => '2026-09',
'store_name' => ['店铺A'],
],
));
$response
->assertOk()
->assertJsonPath('label', '冲减收入')
->assertJsonPath('total', -300)
->assertJsonPath('detail_total', -300)
->assertJsonPath('balance', 0)
->assertJsonPath('balanced', true);
$rows = $response->json('rows');
$this->assertCount(2, $rows);
$this->assertContains([
'platform' => '京东账单',
'store_name' => '店铺A',
'bill_item' => '账单扣款',
'amount' => -260,
'line_count' => 2,
], $rows);
$this->assertContains([
'platform' => '京东账单',
'store_name' => '店铺A',
'bill_item' => '售后扣减',
'amount' => -40,
'line_count' => 1,
], $rows);
}
private function row(string $rowKey, string $storeName, float $offset): array
{
return [
'report_month' => '2026-09-01',
'platform' => '京东账单',
'row_key' => $rowKey,
'occurred_date' => '2026-09-01',
'store_name' => $storeName,
'online_order_no' => 'ORDER-' . $rowKey,
'payment_count' => 1,
'income_amount' => 0,
'discount_amount' => 0,
'offset_amount' => $offset,
'default_compensation_amount' => 0,
'refund_amount' => 0,
'planting_amount' => null,
'paid_amount' => 0,
'difference' => 0,
'match_status' => '已匹配',
'remark' => null,
'imported_at' => now(),
];
}
private function detail(string $detailKey, string $rowKey, string $billItem, float $amount): array
{
return [
'report_month' => '2026-09-01',
'detail_key' => $detailKey,
'row_key' => $rowKey,
'platform' => '京东账单',
'metric' => 'offset',
'bill_item' => $billItem,
'amount' => $amount,
'line_count' => 1,
'imported_at' => now(),
];
}
private function createMirrorTables(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->date('report_month');
$table->string('platform');
$table->string('row_key');
$table->date('occurred_date')->nullable();
$table->string('store_name')->nullable();
$table->string('online_order_no')->nullable();
$table->unsignedInteger('payment_count')->default(0);
$table->decimal('income_amount', 20, 4)->default(0);
$table->decimal('discount_amount', 20, 4)->nullable();
$table->decimal('offset_amount', 20, 4)->nullable();
$table->decimal('default_compensation_amount', 20, 4)->nullable();
$table->decimal('refund_amount', 20, 4)->nullable();
$table->decimal('planting_amount', 20, 4)->nullable();
$table->decimal('paid_amount', 20, 4)->nullable();
$table->decimal('difference', 20, 4)->nullable();
$table->string('match_status', 32)->nullable();
$table->string('remark', 191)->nullable();
$table->dateTime('imported_at');
});
$schema->create('store_receipt_order_match_metric_details', function (Blueprint $table): void {
$table->date('report_month');
$table->string('detail_key');
$table->string('row_key');
$table->string('platform');
$table->string('metric');
$table->string('bill_item');
$table->decimal('amount', 20, 4)->default(0);
$table->unsignedInteger('line_count')->default(1);
$table->dateTime('imported_at');
});
}
}

View File

@ -108,7 +108,9 @@ class NavigationTest extends FeatureTestCase
'data-column-key="unmatched_reason"',
'data-column-key="planting_label"',
], false)
->assertSeeText('标签:种菜明细表命中为“种菜报销”,其余聚水潭种菜标签命中为“种菜未报销”')
->assertDontSeeText('查看完整字段与差额判定规则')
->assertDontSee('receipt-method-guide', false)
->assertDontSee('receipt-match-summary', false)
->assertDontSee('data-column-key="product_amount"', false)
->assertDontSeeText('订单商品金额合计')
// 子订单号已按业务要求移除。

View File

@ -5,7 +5,9 @@ namespace Tests\Unit\EcommerceFinance;
use App\Console\Commands\SyncStoreReceiptOrderMatches;
use Illuminate\Database\Connectors\ConnectionFactory;
use Illuminate\Database\MySqlConnection;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use ReflectionMethod;
use Tests\TestCase;
@ -71,6 +73,12 @@ class StoreReceiptMemoryTest extends TestCase
$this->assertSame(-2.0, $paid['offset_amount']);
$this->assertSame(-5.0, $paid['default_compensation_amount']);
$this->assertSame(-7.0, $paid['refund_amount']);
$this->assertSame(125.0, $paid['bill_items']['income']['订单收入']['amount']);
$this->assertSame(2, $paid['bill_items']['income']['订单收入']['line_count']);
$this->assertSame(-3.0, $paid['bill_items']['discount']['优惠收入']['amount']);
$this->assertSame(-2.0, $paid['bill_items']['offset']['冲减收入']['amount']);
$this->assertSame(-5.0, $paid['bill_items']['default_compensation']['违约赔付(冲减收入)']['amount']);
$this->assertSame(-7.0, $paid['bill_items']['refund']['退款']['amount']);
$this->assertTrue($paid['planting_seen']);
$this->assertSame('260630-10001', $paid['online_order_no']);
$this->assertSame('2026-08-01', $paid['occurred_date']);
@ -93,6 +101,14 @@ class StoreReceiptMemoryTest extends TestCase
$this->assertSame(-1.0, $result['promoOnlyGroups'][$key . '|20002']['refund_amount']);
$this->assertSame(-5.0, $result['promoOnlyGroups'][$key . '||冲减收入']['offset_amount']);
$this->assertSame(-2.0, $result['promoOnlyGroups'][$key . '||退款']['refund_amount']);
$this->assertSame(
-5.0,
$result['promoOnlyGroups'][$key . '||冲减收入']['bill_items']['offset']['冲减收入']['amount'],
);
$this->assertSame(
2,
$result['promoOnlyGroups'][$key . '||冲减收入']['bill_items']['offset']['冲减收入']['line_count'],
);
}
public function test_same_core_with_different_date_prefixes_stays_as_two_orders(): void
@ -204,6 +220,65 @@ class StoreReceiptMemoryTest extends TestCase
$this->assertSame(0, $this->aggregate([])['billCount']);
}
public function test_metric_details_are_rebuilt_from_collected_bill_items(): void
{
$schema = Schema::connection('business_mirror');
if (! $schema->hasTable('store_receipt_order_match_metric_details')) {
$schema->create('store_receipt_order_match_metric_details', function (Blueprint $table): void {
$table->date('report_month');
$table->string('detail_key');
$table->string('row_key');
$table->string('platform');
$table->string('metric');
$table->string('bill_item');
$table->decimal('amount', 20, 4)->default(0);
$table->unsignedInteger('line_count')->default(1);
$table->dateTime('imported_at');
});
}
DB::connection('business_mirror')->table('store_receipt_order_match_metric_details')->delete();
(new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'replaceMetricDetails'))->invoke(
new SyncStoreReceiptOrderMatches(),
'2026-09-01',
'京东账单',
[
[
'shop_key' => '京东-店铺A',
'order_identity' => 'ORDER-A',
'bill_items' => [
'offset' => [
'价款扣减' => ['amount' => -60.0, 'line_count' => 1],
'售后扣减' => ['amount' => -40.0, 'line_count' => 1],
],
],
],
],
[
[
'shop_key' => '京东-店铺B',
'order_identity' => '',
'bill_item' => '平台扣款',
'bill_items' => [
'offset' => [
'平台扣款' => ['amount' => -25.0, 'line_count' => 2],
],
],
],
],
now(),
);
$rows = DB::connection('business_mirror')
->table('store_receipt_order_match_metric_details')
->orderBy('bill_item')
->get();
$this->assertCount(3, $rows);
$this->assertEqualsWithDelta(-125.0, (float) $rows->sum('amount'), 0.001);
$this->assertSame('京东账单', $rows->first()->platform);
$this->assertSame('offset', $rows->first()->metric);
}
public function test_planting_label_prefers_reimbursed_and_uses_unreimbursed_fallback(): void
{
$method = new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'plantingLabel');