店铺维度报表销量改为收入+优惠+冲减+退款合计 (#389)

This commit is contained in:
DoDo 2026-10-19 10:47:29 +08:00
parent 9d8133cc73
commit a3b36b1fb6
8 changed files with 64 additions and 32 deletions

View File

@ -7,7 +7,8 @@ use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
use Throwable;
/**
* 店铺维度报表的「销量」汇总:业绩明细「订单实付/实收金额」按「店铺名称 + 发生日期」预汇总。
* 店铺维度报表的「销量」汇总:业绩明细「收入金额 + 优惠金额 + 冲减金额 + 退款金额」
* 按「店铺名称 + 发生日期」预汇总。
*
* 业绩明细同步(store-receipt:order-match:sync)结束后会自动刷新一次;
* 这个命令用于单独重跑(例如补数据或按最新代码强制重建)。
@ -17,7 +18,7 @@ class SyncStoreReportPaidSales extends Command
protected $signature = 'business-mirror:sync-store-report-paid-sales
{--force : 忽略来源指纹,强制重建销量汇总}';
protected $description = '把业绩明细的订单实付/实收金额按店铺和发生日期汇总,供店铺维度报表销量列读取';
protected $description = '把业绩明细的收入/优惠/冲减/退款金额按店铺和发生日期汇总,供店铺维度报表销量列读取';
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
{

View File

@ -180,7 +180,7 @@ return [
'store_report_paid_sales_rows' => [
'role' => 'aggregate',
'target_store' => 'mariadb',
'consumer' => '店铺维度报表销量(业绩明细订单实付/实收金额)',
'consumer' => '店铺维度报表销量(业绩明细收入+优惠+冲减+退款金额合计)',
],
],
];

View File

@ -252,7 +252,7 @@ $navigation = [
'directory' => [
'icon' => 'query_stats',
'status' => '共享镜像',
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「订单实付/实收金额」,按店铺和发生时间汇总后落到主体期间。',
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按店铺和发生日期逐日汇总后落到主体期间。',
'empty' => '店铺维度报表尚未完成共享数据导入。',
'fields' => [
['name' => 'erp_store_name', 'label' => 'ERP系统店铺名称'],

View File

@ -16,8 +16,9 @@ use RuntimeException;
* 失效时间为变更日前一天、新主体从变更日当天生效,收入按实际日期归集,不重复统计。
*
* 两列金额口径不同,页面分别展示:
* 销量(period_sales / sales_NN):业绩明细(店铺收款匹配订单明细)的「订单实付/实收金额」,
* 按「店铺名称 + 发生时间」预汇总到 store_report_paid_sales_rows,再按发生日期落到主体期间;
* 销量(period_sales / sales_NN):业绩明细(店铺收款匹配订单明细)的
* 「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按「店铺名称 + 发生日期」
* 预汇总到 store_report_paid_sales_rows,再按发生日期落到主体期间;
* 收入(rolling_12m_income / income_NN):平台账单日汇总 store_report_daily_rows 的收入,
* 供主体维度经营报表按主体汇总使用。
*/
@ -28,7 +29,7 @@ class StoreReportRepository
public const LIFECYCLES_DATASET = 'store-report.store-lifecycles';
/** 平台账单日汇总:主体维度经营报表的「收入」列仍按这里取数。 */
public const DAILY_DATASET = 'store-report.daily-sales';
/** 销量数据集:业绩明细「订单实付/实收金额」按「店铺名称 + 发生日期」预汇总。 */
/** 销量数据集:业绩明细四项金额合计按「店铺名称 + 发生日期」预汇总。 */
public const PAID_SALES_DATASET = 'store-report.paid-sales';
/** 销量汇总表;由业绩明细表汇总而来,页面按店铺名与发生日期关联主体期间。 */
public const PAID_SALES_TABLE = 'store_report_paid_sales_rows';
@ -36,7 +37,8 @@ class StoreReportRepository
public const PAID_SALES_SOURCE_TABLE = 'store_receipt_order_match_rows';
/**
* 销量来源:业绩明细的「订单实付/实收金额」(按店铺 + 发生日期预汇总)。
* 销量来源:收入+优惠+冲减+退款四项合计(按店铺 + 发生日期预汇总)。
* 列名沿用历史命名 paid_amount,避免共享镜像表结构变更影响线上旧版本。
*
* @var array{table:string,amount:string}
*/

View File

@ -80,7 +80,7 @@ class StoreReportLogic
[
'code' => 'R9',
'object' => '销量口径',
'logic' => '销量=业绩明细(店铺收款匹配订单明细)的「订单实付/实收金额」,按「店铺名称 + 发生时间」汇总;订单实付为空的未匹配行不计入。收入列仍按平台账单日汇总,两个口径分开统计、不互相覆盖。',
'logic' => '销量=业绩明细(店铺收款匹配订单明细)的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按「店铺名称 + 发生日期」逐日汇总;退款、冲减按账单净额(负数)计入,不再使用订单实付/实收金额。收入列仍按平台账单日汇总,两个口径分开统计、不互相覆盖。',
'source' => 'store_receipt_order_match_rows → store_report_paid_sales_rows',
'priority' => '与业绩明细同口径',
],

View File

@ -447,7 +447,8 @@ class StoreReportMirrorSynchronizer
}
/**
* 店铺维度报表的「销量」:把业绩明细的「订单实付/实收金额」按「店铺名称 + 发生日期」预汇总。
* 店铺维度报表的「销量」:把业绩明细的
* 「收入金额 + 优惠金额 + 冲减金额 + 退款金额」按「店铺名称 + 发生日期」预汇总。
*
* 业绩明细由 store-receipt:order-match:sync 按「账期 + 平台」整体重写,这里用
* 「行数 + 最近写入时间 + 金额合计」的指纹判断是否变化:没变化就复用当前版本,
@ -481,7 +482,9 @@ class StoreReportMirrorSynchronizer
$this->connection->statement(
'INSERT INTO ' . StoreReportRepository::PAID_SALES_TABLE
. ' (generation, store_name, occurred_on, paid_amount, order_count, updated_at)'
. ' SELECT ?, store_name, occurred_date, SUM(COALESCE(paid_amount, 0)), COUNT(paid_amount), ?'
. ' SELECT ?, store_name, occurred_date,'
. ' SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), COUNT(*), ?'
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE
. " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IS NOT NULL"
. ' GROUP BY store_name, occurred_date',
@ -529,18 +532,20 @@ class StoreReportMirrorSynchronizer
}
}
/** 业绩明细是否变化的指纹:行数 + 最近写入时间 + 订单实付合计。 */
/** 业绩明细是否变化的指纹:行数 + 最近写入时间 + 销量口径四项金额合计。 */
private function paidSalesFingerprint(): string
{
$row = $this->connection->selectOne(
'SELECT COUNT(*) AS rows_total, MAX(imported_at) AS last_imported_at,'
. ' COALESCE(SUM(paid_amount), 0) AS paid_total FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE,
. ' COALESCE(SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), 0) AS sales_total'
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE,
);
return hash('sha256', implode('|', [
(string) ($row->rows_total ?? 0),
(string) ($row->last_imported_at ?? ''),
(string) ($row->paid_total ?? '0'),
(string) ($row->sales_total ?? '0'),
]));
}

View File

@ -228,9 +228,11 @@ class StoreReportMirrorSyncTest extends TestCase
$mirror->table('store_receipt_order_match_rows')->insert([
$this->perfRow('k1', '2026-08-02', '店铺A', 100.5),
$this->perfRow('k2', '2026-08-02', '店铺A', 9.5),
// 未匹配行没有订单实付,落到 0,不计入销量金额。
// 未匹配行没有订单实付、也没有收入,四项金额合计为 0。
$this->perfRow('k3', '2026-08-03', '店铺A', null),
$this->perfRow('k4', '2026-08-03', '店铺B', 20),
// 销量=收入 + 优惠 + 冲减 + 退款:100 + 5 + (−3) + (−10) = 92。
$this->perfRow('k6', '2026-08-04', '店铺C', 100, discount: 5, offset: -3, refund: -10),
]);
$upstream = $this->createMock(StoreReportUpstreamRepository::class);
$upstream->method('lifecycleBatch')->willReturn([]);
@ -250,9 +252,13 @@ class StoreReportMirrorSyncTest extends TestCase
'generation' => $generation, 'store_name' => '店铺B', 'occurred_on' => '2026-08-03',
'paid_amount' => 20, 'order_count' => 1,
], 'business_mirror');
$this->assertSame(3, $mirror->table('store_report_paid_sales_rows')->count());
$this->assertDatabaseHas('store_report_paid_sales_rows', [
'generation' => $generation, 'store_name' => '店铺C', 'occurred_on' => '2026-08-04',
'paid_amount' => 92, 'order_count' => 1,
], 'business_mirror');
$this->assertSame(4, $mirror->table('store_report_paid_sales_rows')->count());
$this->assertDatabaseHas('store_report_import_states', [
'dataset_key' => StoreReportRepository::PAID_SALES_DATASET, 'status' => 'succeeded', 'row_count' => 3,
'dataset_key' => StoreReportRepository::PAID_SALES_DATASET, 'status' => 'succeeded', 'row_count' => 4,
], 'business_mirror');
// 业绩明细没有变化:沿用同一版本,不重复全量重建。
@ -262,22 +268,36 @@ class StoreReportMirrorSyncTest extends TestCase
$this->assertSame($generation, $mirror->table('store_report_paid_sales_rows')->value('generation'));
// 账期重写后指纹变化:重建并只保留新版本,页面不会读到两个版本。
$mirror->table('store_receipt_order_match_rows')->insert($this->perfRow('k5', '2026-08-04', '店铺B', 5, '2026-10-16 12:00:00'));
$mirror->table('store_receipt_order_match_rows')->insert($this->perfRow('k5', '2026-08-05', '店铺B', 5, '2026-10-16 12:00:00'));
$third = $synchronizer->sync(100, 2);
$newGeneration = $mirror->table('store_report_paid_sales_rows')->where('occurred_on', '2026-08-04')->value('generation');
$newGeneration = $mirror->table('store_report_paid_sales_rows')->where('occurred_on', '2026-08-05')->value('generation');
$this->assertTrue($third['paid_sales']['changed']);
$this->assertNotSame($generation, $newGeneration);
$this->assertSame(4, $mirror->table('store_report_paid_sales_rows')->count());
$this->assertSame(5, $mirror->table('store_report_paid_sales_rows')->count());
$this->assertSame(0, $mirror->table('store_report_paid_sales_rows')->where('generation', $generation)->count());
}
/** @return array<string,mixed> */
private function perfRow(string $key, string $occurredOn, string $store, ?float $paid, string $importedAt = '2026-10-16 10:00:00'): array
private function perfRow(
string $key,
string $occurredOn,
string $store,
?float $income,
string $importedAt = '2026-10-16 10:00:00',
float $discount = 0,
float $offset = 0,
float $refund = 0,
): array
{
return [
'report_month' => '2026-08-01', 'platform' => '淘系支付宝', 'row_key' => $key,
'occurred_date' => $occurredOn, 'store_name' => $store,
'paid_amount' => $paid, 'imported_at' => $importedAt,
'income_amount' => $income,
'discount_amount' => $discount,
'offset_amount' => $offset,
'refund_amount' => $refund,
'paid_amount' => $income,
'imported_at' => $importedAt,
];
}
@ -371,6 +391,10 @@ class StoreReportMirrorSyncTest extends TestCase
$table->string('row_key');
$table->date('occurred_date')->nullable();
$table->string('store_name')->nullable();
$table->decimal('income_amount', 20, 4)->nullable();
$table->decimal('discount_amount', 20, 4)->nullable();
$table->decimal('offset_amount', 20, 4)->nullable();
$table->decimal('refund_amount', 20, 4)->nullable();
$table->decimal('paid_amount', 20, 4)->nullable();
$table->dateTime('imported_at');
$table->primary(['report_month', 'row_key']);

View File

@ -57,7 +57,7 @@ class StoreReportRepositoryTest extends TestCase
$table->decimal('income', 20, 4)->default(0);
$table->decimal('expense', 20, 4)->default(0);
});
// 销量来源:业绩明细「订单实付/实收金额」按店铺 + 发生日期的预汇总。
// 销量来源:业绩明细「收入+优惠+冲减+退款金额」按店铺 + 发生日期的预汇总。
$schema->create('store_report_paid_sales_rows', function (Blueprint $table): void {
$table->increments('id');
$table->string('generation');
@ -224,13 +224,13 @@ class StoreReportRepositoryTest extends TestCase
$this->repository->rows([['erp_store_name' => '店铺A']], 2026);
}
public function test_sales_use_performance_details_paid_amount_and_income_keeps_bill_summary(): void
public function test_sales_use_performance_details_amount_components_and_income_keeps_bill_summary(): void
{
Carbon::setTestNow('2026-09-15 10:00:00');
// 同一店铺同一天:账单收入 100,业绩明细订单实付 88 —— 销量取实付、收入取账单。
// 同一店铺同一天:账单收入 100,业绩明细四项金额合计 88 —— 销量取合计、收入取账单。
$this->insertIncome('店铺A', '2026-07-05', 100);
$this->insertPaidSales('店铺A', '2026-07-05', 88);
$this->insertPaidSales('店铺A', '2026-08-03', 5);
$this->insertSales('店铺A', '2026-07-05', 88);
$this->insertSales('店铺A', '2026-08-03', 5);
$rows = $this->repository->rows([
['erp_store_name' => '店铺A', 'company_name' => '主体甲', 'store_status' => '0'],
@ -351,8 +351,8 @@ class StoreReportRepositoryTest extends TestCase
private function insertBill(string $store, string $occurredOn, float $income): void
{
$this->insertIncome($store, $occurredOn, $income);
// 销量与收入同源不同表:默认按同额写入业绩明细的订单实付,需要区分口径的用例单独调用。
$this->insertPaidSales($store, $occurredOn, $income);
// 销量与收入同源不同表:默认按同额写入业绩明细的四项金额合计,需要区分口径的用例单独调用。
$this->insertSales($store, $occurredOn, $income);
}
/** 平台账单日汇总的收入行(主体维度经营报表的「收入」列)。 */
@ -367,14 +367,14 @@ class StoreReportRepositoryTest extends TestCase
]);
}
/** 业绩明细「订单实付/实收金额」按店铺 + 发生日期的预汇总行。 */
private function insertPaidSales(string $store, string $occurredOn, float $paid): void
/** 业绩明细「收入+优惠+冲减+退款金额」按店铺 + 发生日期的预汇总行。 */
private function insertSales(string $store, string $occurredOn, float $amount): void
{
DB::connection('business_mirror')->table('store_report_paid_sales_rows')->insert([
'generation' => $this->paidGeneration,
'store_name' => $store,
'occurred_on' => $occurredOn,
'paid_amount' => $paid,
'paid_amount' => $amount,
'order_count' => 1,
]);
}