feat: 业绩明细种菜报销与未报销合并为标签

This commit is contained in:
qiuxiang 2026-10-23 17:28:18 +08:00
parent 1ad248dda6
commit 1192cb63f9
7 changed files with 159 additions and 63 deletions

View File

@ -37,8 +37,8 @@ class SyncStoreReceiptOrderMatches extends Command
{--company=1 : 公司 ID(种菜订单号取自该公司的种菜明细表与聚水潭订单标签明细)}
{--chunk=1200 : 单次向上游查询的订单号数量}
{--limit=0 : 仅处理前 N 笔账单(调试用,0 表示全量)}
{--amounts-only : 只回填「优惠金额 / 冲减金额 / 种菜金额」三列,不重跑订单侧匹配}
{--planting-only : 只回填「种菜金额」一列(不读账单,直接按该月已有明细行的订单号刷,一次覆盖全平台)}
{--amounts-only : 只回填「优惠金额 / 冲减金额 / 种菜金额 / 标签」,不重跑订单侧匹配}
{--planting-only : 只回填「种菜金额 / 标签」(不读账单,直接按该月已有明细行的订单号刷,一次覆盖全平台)}
{--dry-run : 只计算并打印统计,不写库(用于评估口径调整的影响)}';
protected $description = '按店铺收款账户账单匹配订单侧商品金额,生成「业绩明细」核对镜像';
@ -142,6 +142,10 @@ class SyncStoreReceiptOrderMatches extends Command
private const STATUS_UNMATCHED = '未匹配';
private const PLANTING_LABEL_REIMBURSED = '种菜报销';
private const PLANTING_LABEL_UNREIMBURSED = '种菜未报销';
/**
* 异常标注取值(页面「匹配状态」筛选与导出共用)。
*
@ -401,8 +405,7 @@ class SyncStoreReceiptOrderMatches extends Command
if (! empty($group['planting_reimbursement_seen'])) {
$plantingReimbursementRows++;
$plantingReimbursementTotal += (float) $group['income_amount'];
}
if (! empty($group['planting_unreimbursed_seen'])) {
} elseif (! empty($group['planting_unreimbursed_seen'])) {
$plantingUnreimbursedRows++;
$plantingUnreimbursedTotal += (float) $group['income_amount'];
}
@ -414,12 +417,12 @@ class SyncStoreReceiptOrderMatches extends Command
number_format($plantingTotal, 2),
));
$this->line(sprintf(
' 种菜报销:%d 行(按种菜明细表订单号),合计 %s',
' 标签「种菜报销」:%d 行(按种菜明细表订单号),收入合计 %s',
$plantingReimbursementRows,
number_format($plantingReimbursementTotal, 2),
));
$this->line(sprintf(
' 种菜未报销:%d 行(按聚水潭种菜标签),合计 %s',
' 标签「种菜未报销」:%d 行(按聚水潭种菜标签),收入合计 %s',
$plantingUnreimbursedRows,
number_format($plantingUnreimbursedTotal, 2),
));
@ -796,8 +799,7 @@ class SyncStoreReceiptOrderMatches extends Command
'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,
'planting_reimbursement_amount' => ! empty($group['planting_reimbursement_seen']) ? $income : null,
'planting_unreimbursed_amount' => ! empty($group['planting_unreimbursed_seen']) ? $income : null,
'planting_label' => self::plantingLabel($group),
'product_amount' => $productAmount,
'paid_amount' => $paidAmount,
'difference' => $difference,
@ -912,9 +914,7 @@ class SyncStoreReceiptOrderMatches extends Command
}
/**
* 只回填「种菜金额 / 种菜报销 / 种菜未报销」:不读账单,直接按订单号判断,是就写入收入金额。
*/
/** 只回填「种菜金额 / 标签」:按种菜明细表与聚水潭种菜标签的并集判断。 */
private function backfillPlantingAmounts(string $reportMonth): int
{
if ((bool) $this->option('dry-run')) {
@ -926,7 +926,7 @@ class SyncStoreReceiptOrderMatches extends Command
$reimbursementCores = $this->plantingReimbursementCores();
$unreimbursedCores = $this->plantingUnreimbursedCores();
if ($cores === []) {
$this->warn('没有识别到任何种菜订单号(种菜明细表与聚水潭订单标签明细的交集为空),本列将全部为空。');
$this->warn('没有识别到任何种菜订单号(种菜明细表与聚水潭订单标签明细的并集为空),本列将全部为空。');
}
$target = static fn () => DB::connection($connection)->table(self::TARGET_TABLE)
@ -935,16 +935,14 @@ class SyncStoreReceiptOrderMatches extends Command
$rows = $target()->get(['row_key', 'order_core_no', 'income_amount']);
$target()->update([
'planting_amount' => null,
'planting_reimbursement_amount' => null,
'planting_unreimbursed_amount' => null,
'planting_label' => null,
]);
$values = [];
$labels = [];
$total = 0.0;
$reimbursementValues = [];
$reimbursementTotal = 0.0;
$unreimbursedValues = [];
$unreimbursedTotal = 0.0;
$reimbursementRows = 0;
$unreimbursedRows = 0;
foreach ($rows as $row) {
$core = self::orderCoreNo((string) $row->order_core_no);
if ($core === '') {
@ -957,31 +955,27 @@ class SyncStoreReceiptOrderMatches extends Command
$total += $amount;
}
if (isset($reimbursementCores[$core])) {
$reimbursementValues[(string) $row->row_key] = $amount;
$reimbursementTotal += $amount;
}
if (isset($unreimbursedCores[$core])) {
$unreimbursedValues[(string) $row->row_key] = $amount;
$unreimbursedTotal += $amount;
$labels[(string) $row->row_key] = self::PLANTING_LABEL_REIMBURSED;
$reimbursementRows++;
} elseif (isset($unreimbursedCores[$core])) {
$labels[(string) $row->row_key] = self::PLANTING_LABEL_UNREIMBURSED;
$unreimbursedRows++;
}
}
$this->updateAmountColumn($connection, $reportMonth, 'planting_amount', $values);
$this->updateAmountColumn($connection, $reportMonth, 'planting_reimbursement_amount', $reimbursementValues);
$this->updateAmountColumn($connection, $reportMonth, 'planting_unreimbursed_amount', $unreimbursedValues);
$this->updateColumnValues($connection, $reportMonth, 'planting_amount', $values);
$this->updateColumnValues($connection, $reportMonth, 'planting_label', $labels);
$this->info(sprintf(
'已回填 %s:种菜金额 %d / %d 行,合计 %s(识别 %d 个种菜订单号);种菜报销 %d 行,合计 %s(识别 %d 个种菜明细订单号);种菜未报销 %d 行,合计 %s(识别 %d 个聚水潭种菜标签订单号)。',
'已回填 %s:种菜金额 %d / %d 行,合计 %s(识别 %d 个种菜订单号);标签「种菜报销」%d 行(识别 %d 个种菜明细订单号);标签「种菜未报销」%d 行(识别 %d 个聚水潭种菜标签订单号)。',
$reportMonth,
count($values),
count($rows),
number_format($total, 2),
count($cores),
count($reimbursementValues),
number_format($reimbursementTotal, 2),
$reimbursementRows,
count($reimbursementCores),
count($unreimbursedValues),
number_format($unreimbursedTotal, 2),
$unreimbursedRows,
count($unreimbursedCores),
));
@ -1022,16 +1016,14 @@ class SyncStoreReceiptOrderMatches extends Command
'offset_amount' => null,
'refund_amount' => null,
'planting_amount' => null,
'planting_reimbursement_amount' => null,
'planting_unreimbursed_amount' => null,
'planting_label' => null,
]);
$discounts = [];
$offsets = [];
$refunds = [];
$plantings = [];
$plantingReimbursements = [];
$plantingUnreimbursed = [];
$plantingLabels = [];
foreach ($groups as $group) {
if (empty($group['discount_seen'])
&& empty($group['offset_seen'])
@ -1065,19 +1057,17 @@ class SyncStoreReceiptOrderMatches extends Command
$plantings[$rowKey] = round((float) $group['income_amount'], 2);
}
if (! empty($group['planting_reimbursement_seen'])) {
$plantingReimbursements[$rowKey] = round((float) $group['income_amount'], 2);
}
if (! empty($group['planting_unreimbursed_seen'])) {
$plantingUnreimbursed[$rowKey] = round((float) $group['income_amount'], 2);
$plantingLabels[$rowKey] = self::PLANTING_LABEL_REIMBURSED;
} elseif (! empty($group['planting_unreimbursed_seen'])) {
$plantingLabels[$rowKey] = self::PLANTING_LABEL_UNREIMBURSED;
}
}
$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);
$this->updateAmountColumn($connection, $reportMonth, 'planting_reimbursement_amount', $plantingReimbursements);
$this->updateAmountColumn($connection, $reportMonth, 'planting_unreimbursed_amount', $plantingUnreimbursed);
$this->updateColumnValues($connection, $reportMonth, 'discount_amount', $discounts);
$this->updateColumnValues($connection, $reportMonth, 'offset_amount', $offsets);
$this->updateColumnValues($connection, $reportMonth, 'refund_amount', $refunds);
$this->updateColumnValues($connection, $reportMonth, 'planting_amount', $plantings);
$this->updateColumnValues($connection, $reportMonth, 'planting_label', $plantingLabels);
// A 方案:本月本平台的补行(payment_count=0 的行)整体重建,保证幂等。
$target()
@ -1114,7 +1104,7 @@ class SyncStoreReceiptOrderMatches extends Command
]);
$this->info(sprintf(
'已回填 %s %s:优惠 %d 行(%s)/ 冲减 %d 行(%s)/ 补「无收款账单/无单号」优惠冲减行 %d 行 / 种菜 %d 行(%s)/ 种菜报销 %d 行(%s)/ 种菜未报销 %d 行(%s),镜像原有 %d 行,未重建业绩明细。',
'已回填 %s %s:优惠 %d 行(%s)/ 冲减 %d 行(%s)/ 补「无收款账单/无单号」优惠冲减行 %d 行 / 种菜 %d 行(%s)/ 标签「种菜报销」%d 行 / 标签「种菜未报销」%d 行,镜像原有 %d 行,未重建业绩明细。',
$month,
$platform,
count($discounts),
@ -1124,10 +1114,8 @@ class SyncStoreReceiptOrderMatches extends Command
$promoRows,
count($plantings),
number_format(array_sum($plantings), 2),
count($plantingReimbursements),
number_format(array_sum($plantingReimbursements), 2),
count($plantingUnreimbursed),
number_format(array_sum($plantingUnreimbursed), 2),
count(array_filter($plantingLabels, static fn (string $label): bool => $label === self::PLANTING_LABEL_REIMBURSED)),
count(array_filter($plantingLabels, static fn (string $label): bool => $label === self::PLANTING_LABEL_UNREIMBURSED)),
count($existing),
));
@ -1377,8 +1365,7 @@ class SyncStoreReceiptOrderMatches extends Command
'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null,
// 补行没有订单侧商品金额,种菜金额同样留空;列集合与顺序必须与主行完全一致,批量插入才不会错列。
'planting_amount' => null,
'planting_reimbursement_amount' => null,
'planting_unreimbursed_amount' => null,
'planting_label' => null,
'product_amount' => null,
'paid_amount' => null,
'difference' => null,
@ -1390,12 +1377,25 @@ class SyncStoreReceiptOrderMatches extends Command
];
}
/** @param array<string,mixed> $group */
private static function plantingLabel(array $group): ?string
{
if (! empty($group['planting_reimbursement_seen'])) {
return self::PLANTING_LABEL_REIMBURSED;
}
if (! empty($group['planting_unreimbursed_seen'])) {
return self::PLANTING_LABEL_UNREIMBURSED;
}
return null;
}
/**
* 按 row_key 批量更新一列金额:只更新镜像里已有的行,不做插入。
* 按 row_key 批量更新一列字段值:只更新镜像里已有的行,不做插入。
*
* @param array<string,float> $values row_key => 金额
* @param array<string,float|string> $values row_key => 金额或文本
*/
private function updateAmountColumn(string $connection, string $reportMonth, string $column, array $values): void
private function updateColumnValues(string $connection, string $reportMonth, string $column, array $values): void
{
if ($values === []) {
return;

View File

@ -2082,8 +2082,7 @@ class Pages extends Controller
'offset_amount' => ['offset_amount', '冲减金额', 'money'],
'refund_amount' => ['refund_amount', '退款金额', 'money'],
'planting_amount' => ['planting_amount', '种菜金额', 'money'],
'planting_reimbursement_amount' => ['planting_reimbursement_amount', '种菜报销', 'money'],
'planting_unreimbursed_amount' => ['planting_unreimbursed_amount', '种菜未报销', 'money'],
'planting_label' => ['planting_label', '标签', 'text'],
'product_amount' => ['product_amount', '商品金额', 'money'],
'paid_amount' => ['paid_amount', '订单实付金额', 'money'],
'difference' => ['difference', '差额', 'money'],

View File

@ -834,8 +834,7 @@ $navigation = [
['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'],
['name' => 'refund_amount', 'label' => '退款金额', 'kind' => 'number'],
['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'],
['name' => 'planting_reimbursement_amount', 'label' => '种菜报销', 'kind' => 'number'],
['name' => 'planting_unreimbursed_amount', 'label' => '种菜未报销', 'kind' => 'number'],
['name' => 'planting_label', 'label' => '标签'],
['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'],
['name' => 'paid_amount', 'label' => '订单实付/实收金额', 'kind' => 'number'],
['name' => 'difference', 'label' => '差额', 'kind' => 'number'],

View File

@ -0,0 +1,72 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
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_rows')) {
return;
}
if (! $schema->hasColumn('store_receipt_order_match_rows', 'planting_label')) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->string('planting_label', 32)
->nullable()
->after('planting_amount')
->comment('种菜标签:种菜报销 / 种菜未报销');
});
}
$oldColumns = array_values(array_filter(
['planting_reimbursement_amount', 'planting_unreimbursed_amount'],
static fn (string $column): bool => $schema->hasColumn('store_receipt_order_match_rows', $column),
));
if ($oldColumns !== []) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table) use ($oldColumns): void {
$table->dropColumn($oldColumns);
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')) {
return;
}
$hasReimbursement = $schema->hasColumn('store_receipt_order_match_rows', 'planting_reimbursement_amount');
$hasUnreimbursed = $schema->hasColumn('store_receipt_order_match_rows', 'planting_unreimbursed_amount');
$hasLabel = $schema->hasColumn('store_receipt_order_match_rows', 'planting_label');
if (! $hasReimbursement) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->decimal('planting_reimbursement_amount', 20, 4)
->nullable()
->after('planting_amount');
});
}
if (! $hasUnreimbursed) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->decimal('planting_unreimbursed_amount', 20, 4)
->nullable()
->after('planting_reimbursement_amount');
});
}
if ($hasLabel) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('planting_label');
});
}
}
};

View File

@ -1256,6 +1256,7 @@
<li>优惠/冲减:分别取优惠收入、冲减收入,不计入收入</li>
<li>退款:单独列示,不参与收入和差额</li>
<li>种菜:种菜订单的收入子集,不参与差额</li>
<li>标签:种菜明细表命中为“种菜报销”,其余聚水潭种菜标签命中为“种菜未报销”</li>
</ul>
</div>
<div class="receipt-method-guide__section">

View File

@ -99,16 +99,20 @@ class NavigationTest extends FeatureTestCase
->assertSee('data-column-key="discount_amount"', false)
->assertSee('data-column-key="offset_amount"', false)
->assertSee('data-column-key="planting_amount"', false)
->assertSee('data-column-key="planting_label"', false)
->assertSee('data-column-key="product_amount"', false)
->assertSee('data-column-key="difference"', false)
->assertSee('data-column-key="match_status"', false)
->assertSee('data-column-key="remark"', false)
->assertSeeText('标签:种菜明细表命中为“种菜报销”,其余聚水潭种菜标签命中为“种菜未报销”')
// 子订单号已按业务要求移除。
->assertDontSeeText('子订单号')
// 展示粒度=一笔账单收款,不再展开发货明细列。
->assertDontSee('data-column-key="warehouse"', false)
->assertDontSee('data-column-key="product_code"', false)
->assertDontSee('data-column-key="quantity"', false)
->assertDontSee('data-column-key="planting_reimbursement_amount"', false)
->assertDontSee('data-column-key="planting_unreimbursed_amount"', false)
// 筛选栏:月份选择器 + 店铺名称 + 线上单号 + 匹配状态。
->assertSee('data-inventory-month-picker', false)
->assertSee('name="store_name"', false)

View File

@ -21,7 +21,13 @@ class StoreReceiptMemoryTest extends TestCase
], $extra);
}
private function aggregate(iterable $rows, array $planting = [], ?AccountLedgerRemarkMap $map = null): array
private function aggregate(
iterable $rows,
array $planting = [],
array $plantingReimbursement = [],
array $plantingUnreimbursed = [],
?AccountLedgerRemarkMap $map = null,
): array
{
$map ??= new class extends AccountLedgerRemarkMap {
public function remark(?string $storeName, ?string $tabPlatform, ?string $billItem, array $businessRemarks = [], string $goodsName = ''): ?string
@ -32,7 +38,8 @@ class StoreReceiptMemoryTest extends TestCase
return (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke(
new SyncStoreReceiptOrderMatches(), $rows, ['label' => '淘系支付宝'], 'online_order_no', 'occurred_at',
'income_amount', 'expense_amount', 'online_real_store', $planting, [], [], $map, '淘宝',
'income_amount', 'expense_amount', 'online_real_store', $planting,
$plantingReimbursement, $plantingUnreimbursed, $map, '淘宝',
);
}
@ -156,13 +163,27 @@ class StoreReceiptMemoryTest extends TestCase
)->willReturn('优惠收入');
$result = $this->aggregate([$this->bill('动态项目', '', 1.5, 0, [
'custom_remark' => '活动优惠', 'second_remark' => '活动优惠', 'goods_name' => '商品关键词',
])], [], $map);
])], [], [], [], $map);
$this->assertSame(1.5, $result['discountTotal']);
$this->assertSame([], $result['groups']);
$this->assertCount(1, $result['promoOnlyGroups']);
$this->assertSame(0, $this->aggregate([])['billCount']);
}
public function test_planting_label_prefers_reimbursed_and_uses_unreimbursed_fallback(): void
{
$method = new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'plantingLabel');
$this->assertSame('种菜报销', $method->invoke(null, [
'planting_reimbursement_seen' => true,
'planting_unreimbursed_seen' => true,
]));
$this->assertSame('种菜未报销', $method->invoke(null, [
'planting_reimbursement_seen' => false,
'planting_unreimbursed_seen' => true,
]));
$this->assertNull($method->invoke(null, []));
}
public function test_stream_uses_bound_filter_and_sql_limit_in_sqlite(): void
{
$db = DB::connection('business_mirror');