CaiWuXiTong/tests/Unit/EcommerceFinance/DingPayableWorkflowMirrorSynchronizerTest.php
limeng 43390be327 feat(线上代发价格): 识别改版明细表格 + 新增报销成本核查(多报销) (#841)
1) 钉钉「线上代发业务报销」有 23 张已通过审批单的商品明细表格不叫“收款费用明细”(新版模板表格名为空、旧单叫“费用明细”),
   同步时整单只剩一行空明细。按名称找不到时改取行内含“商品ID”列的明细表格;料号兼容新版“商品编码”。上线后对该数据集全量重同步。
2) 新增报销成本核查:按聚水潭订单由本行商品编码拆出的子商品,取普通商品资料成本价×发货数量求和为系统成本,
   与代发订单金额比较,高出 0.01 元以上判“多报销”,另有“成本缺失”“无发货明细”;页面新增筛选、计数、多报销金额汇总及四列,导出同步。

涉及:金额核查展示(只读)、镜像全量重同步;不涉及迁移、权限和上游写入。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-11-03 17:13:56 +08:00

362 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\DingPayableWorkflowMirrorSynchronizer;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Tests\TestCase;
class DingPayableWorkflowMirrorSynchronizerTest extends TestCase
{
private FakeDingPayableWorkflowQuery $query;
private DingPayableWorkflowMirrorSynchronizer $synchronizer;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.table_sync.overlap_hours' => 48,
'upstream_mirrors.ding_payable_workflows' => [
'dataset_key' => 'operations_replica.rpt.ding_payable_workflows',
'source_key' => 'operations_replica',
'process_code' => 'PROC-TEST',
'target_table' => 'ding_payable_workflow_mirror',
'batch_size' => 100,
'max_batches_per_run' => 2,
],
]);
DB::purge('business_mirror');
Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('source_key');
$table->string('source_object');
$table->string('target_table');
$table->string('status')->default('pending');
$table->string('run_mode')->nullable();
$table->string('generation')->nullable();
$table->json('cursor')->nullable();
$table->json('high_watermark')->nullable();
$table->dateTime('window_started_at')->nullable();
$table->dateTime('window_ends_at')->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_full_succeeded_at')->nullable();
$table->dateTime('last_attempted_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->unsignedBigInteger('last_upserted_rows')->default(0);
$table->unsignedBigInteger('mirror_row_count')->default(0);
$table->string('last_error_code')->nullable();
$table->timestamps();
});
$this->query = new FakeDingPayableWorkflowQuery();
$factory = new class($this->query) extends UpstreamReadOnlyQueryFactory {
public function __construct(private readonly UpstreamReadOnlyQuery $query) {}
public function forSource(string $source): UpstreamReadOnlyQuery
{
return $this->query;
}
};
$this->synchronizer = new DingPayableWorkflowMirrorSynchronizer($factory);
}
public function test_full_sync_resumes_with_update_time_and_process_id_tie_breaker(): void
{
$sameUpdateAt = '2026-08-29 10:00:00.1234567';
$this->query->workflowBatches = [[
$this->workflow('A-process', $sameUpdateAt),
$this->workflow('B-process', $sameUpdateAt),
]];
$result = $this->synchronizer->sync(true, 2, 1);
$this->assertSame('running', $result['status']);
$this->assertStringContainsString('ORDER BY COALESCE([updateAt], [createAt]), [processInstanceId]', $this->query->calls[0]['sql']);
$state = DB::connection('business_mirror')->table('business_mirror_table_states')->first();
$this->assertSame([
'updateAt' => $sameUpdateAt,
'processInstanceId' => 'B-process',
], json_decode((string) $state->cursor, true));
$this->query->calls = [];
$this->query->workflowBatches = [[
$this->workflow('C-process', $sameUpdateAt),
]];
$result = $this->synchronizer->sync(true, 2, 0);
$this->assertSame('succeeded', $result['status']);
$this->assertStringContainsString(
'COALESCE([updateAt], [createAt]) > ? OR (COALESCE([updateAt], [createAt]) = ? AND [processInstanceId] > ?',
$this->query->calls[0]['sql'],
);
$this->assertSame([$sameUpdateAt, $sameUpdateAt, 'B-process'], array_slice($this->query->calls[0]['bindings'], -3));
$this->assertSame(3, DB::connection('business_mirror')->table('ding_payable_workflow_mirror')->count());
}
public function test_incremental_sync_uses_overlap_window_and_keeps_fixed_process_code(): void
{
$this->query->workflowBatches = [[
$this->workflow('existing-process', now()->subHour()->format('Y-m-d H:i:s.u')),
]];
$this->synchronizer->sync(true, 10, 0);
$this->query->calls = [];
$this->query->workflowBatches = [[
$this->workflow('new-process', now()->format('Y-m-d H:i:s.u'), 'RUNNING'),
]];
$result = $this->synchronizer->sync(false, 10, 0);
$this->assertSame('succeeded', $result['status']);
$sql = $this->query->calls[0]['sql'];
$this->assertStringContainsString('[processCode] = ?', $sql);
$this->assertStringContainsString('COALESCE([updateAt], [createAt]) >= ? AND COALESCE([updateAt], [createAt]) < ?', $sql);
$this->assertStringContainsString('ORDER BY COALESCE([updateAt], [createAt]), [processInstanceId]', $sql);
$this->assertCount(3, $this->query->calls[0]['bindings']);
$this->assertSame('PROC-TEST', $this->query->calls[0]['bindings'][0]);
$this->assertSame('RUNNING', json_decode(
(string) DB::connection('business_mirror')->table('ding_payable_workflow_mirror')->where('process_instance_id', 'new-process')->value('workflow_payload'),
true,
)['status']);
$state = DB::connection('business_mirror')->table('business_mirror_table_states')->first();
$this->assertArrayHasKey('updateAt', json_decode((string) $state->high_watermark, true));
}
public function test_legacy_process_id_only_watermark_does_not_skip_new_processes(): void
{
$this->query->workflowBatches = [[
$this->workflow('existing-process', now()->subHour()->format('Y-m-d H:i:s.u')),
]];
$this->synchronizer->sync(true, 10, 0);
DB::connection('business_mirror')->table('business_mirror_table_states')->update([
'high_watermark' => json_encode(['processInstanceId' => 'z-last-process']),
]);
$this->query->calls = [];
$this->query->workflowBatches = [[
$this->workflow('a-new-process', now()->format('Y-m-d H:i:s.u')),
]];
$this->synchronizer->sync(false, 10, 0);
$this->assertStringNotContainsString('AND [processInstanceId] > ?', $this->query->calls[0]['sql']);
$this->assertSame(2, DB::connection('business_mirror')->table('ding_payable_workflow_mirror')->count());
}
public function test_extended_workflow_update_removes_rows_that_no_longer_match(): void
{
config(['upstream_mirrors.ding_online_daifa_price' => [
'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price',
'target_table' => 'ding_online_daifa_price_mirror',
'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST',
'extend_columns' => true, 'only_status' => 'COMPLETED',
'column_map' => ['product_id' => '商品ID'],
]]);
$sync = $this->synchronizer->forConfigKey('ding_online_daifa_price');
$this->query->workflowBatches = [[$this->workflow('changed', now()->subHour()->format('Y-m-d H:i:s.u'))]];
$sync->sync(true, 10, 0);
$table = DB::connection('business_mirror')->table('ding_online_daifa_price_mirror');
$this->assertSame(1, $table->count());
$this->query->workflowBatches = [[$this->workflow('changed', now()->format('Y-m-d H:i:s.u'), 'RUNNING')]];
$sync->sync(false, 10, 0);
$this->assertSame(0, $table->count());
}
public function test_daifa_detail_table_falls_back_to_grid_with_product_id_column(): void
{
config(['upstream_mirrors.ding_online_daifa_price' => [
'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price',
'target_table' => 'ding_online_daifa_price_mirror',
'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST',
'extend_columns' => true, 'only_status' => 'COMPLETED',
'detail_table' => '收款费用明细', 'detail_table_match_label' => '商品ID',
'column_map' => ['product_id' => '商品ID', 'shop_trade_no' => '店铺交易单号', 'material_no' => ['料号', '商品编码'], 'payee_name' => '收款人全称'],
]]);
$cell = static fn (string $label, string $value): array => ['label' => $label, 'value' => $value];
$this->query->schemeRows = [
['processInstanceId' => 'blank-name', 'name' => '收款人全称', 'componentType' => 'TextField', 'value' => '宁波顾家望业家居有限公司'],
// 付款明细不含“商品ID”,不能被当成商品明细。
['processInstanceId' => 'blank-name', 'name' => '付款明细', 'componentType' => 'TableField', 'value' => json_encode([
['rowValue' => [$cell('付款金额', '1384')]],
], JSON_UNESCAPED_UNICODE)],
// 新版模板:商品明细表格名称为空,料号改叫商品编码。
['processInstanceId' => 'blank-name', 'name' => '', 'componentType' => 'TableField', 'value' => json_encode([
['rowValue' => [$cell('商品ID', '10035514372245'), $cell('店铺交易单号', '3630463003975610'), $cell('商品编码', 'DNZ-01')]],
['rowValue' => [$cell('商品ID', '10035514372246'), $cell('店铺交易单号', '3630463003975611'), $cell('商品编码', 'DNZ-02')]],
], JSON_UNESCAPED_UNICODE)],
];
$this->query->workflowBatches = [[$this->workflow('blank-name', now()->subHour()->format('Y-m-d H:i:s.u'))]];
$this->synchronizer->forConfigKey('ding_online_daifa_price')->sync(true, 10, 0);
$rows = DB::connection('business_mirror')->table('ding_online_daifa_price_mirror')->orderBy('detail_index')->get();
$this->assertCount(2, $rows);
$this->assertSame('10035514372245', $rows[0]->product_id);
$this->assertSame('3630463003975611', $rows[1]->shop_trade_no);
$this->assertSame('DNZ-01', $rows[0]->material_no);
$this->assertSame('宁波顾家望业家居有限公司', $rows[1]->payee_name);
}
public function test_daifa_repository_includes_end_date_and_counts_only_resolved_products(): void
{
config(['upstream_mirrors.ding_online_daifa_price' => [
'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price',
'target_table' => 'ding_online_daifa_price_mirror',
'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST',
'extend_columns' => true, 'column_map' => ['product_id' => '商品ID'],
]]);
$this->query->workflowBatches = [[
$this->workflow('A', '2026-08-01 00:00:00'),
$this->workflow('B', '2026-08-01 23:59:59'),
$this->workflow('C', '2026-08-02 00:00:00'),
]];
$this->synchronizer->forConfigKey('ding_online_daifa_price')->sync(true, 10, 0);
$db = DB::connection('business_mirror');
$db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'A')
->update(['product_id' => 'fixture-a', 'product_name' => '已匹配示例']);
$db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'B')
->update(['product_id' => 'fixture-b', 'product_name' => null]);
$db->enableQueryLog();
$result = app(\Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository::class)
->paginate(['created_from' => '2026-08-01', 'created_to' => '2026-08-01'], 1, 25);
$this->assertSame(2, $result['total']);
$this->assertSame(1, $result['summary']['matched_count']);
$this->assertSame(['B', 'A'], array_column($result['data'], 'process_instance_id'));
$queries = array_filter($db->getQueryLog(), fn ($q) => str_contains($q['query'], 'workflow_created_at'));
$this->assertContains('2026-08-02 00:00:00', array_values($queries)[0]['bindings']);
}
public function test_daifa_repository_filters_supplier_and_material_no_for_page_and_export(): void
{
config(['upstream_mirrors.ding_online_daifa_price' => [
'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price',
'target_table' => 'ding_online_daifa_price_mirror',
'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST',
'extend_columns' => true, 'column_map' => ['product_id' => '商品ID'],
]]);
$this->query->workflowBatches = [[
$this->workflow('A', '2026-08-01 10:00:00'),
$this->workflow('B', '2026-08-01 11:00:00'),
$this->workflow('C', '2026-08-01 12:00:00'),
]];
$this->synchronizer->forConfigKey('ding_online_daifa_price')->sync(true, 10, 0);
$db = DB::connection('business_mirror');
$db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'A')->update([
'supplier_name' => '供应商%测试', 'material_no' => 'MAT-001',
]);
$db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'B')->update([
'supplier_name' => '供应商X测试', 'material_no' => 'MAT-001',
]);
$db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'C')->update([
'supplier_name' => '供应商%测试', 'material_no' => 'MAT-002',
]);
$filters = ['supplier_name' => '供应商%测试', 'material_no' => 'MAT-001'];
$repository = app(\Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository::class);
$page = $repository->paginate($filters, 1, 25);
$this->assertSame(1, $page['total']);
$this->assertSame(['A'], array_column($page['data'], 'process_instance_id'));
$exported = iterator_to_array($repository->exportRows($filters));
$this->assertSame(['A'], array_column($exported, 'process_instance_id'));
$this->assertSame('供应商%测试', $exported[0]['supplier_name']);
$this->assertSame('MAT-001', $exported[0]['material_no']);
}
public function test_daifa_repository_pages_in_sql_and_labels_the_payee(): void
{
config(['upstream_mirrors.ding_online_daifa_price' => [
'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price',
'target_table' => 'ding_online_daifa_price_mirror',
'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST',
'extend_columns' => true, 'only_status' => 'COMPLETED',
'column_map' => ['product_id' => '商品ID'],
]]);
$this->query->workflowBatches = [[
$this->workflow('A', '2026-08-01 10:00:00'),
$this->workflow('B', '2026-08-01 11:00:00'),
$this->workflow('C', '2026-08-01 12:00:00'),
]];
$this->synchronizer->forConfigKey('ding_online_daifa_price')->sync(true, 10, 0);
$db = DB::connection('business_mirror');
$db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'B')->update([
'payee_name' => '宁波顾家望业家居有限公司',
]);
$db->flushQueryLog();
$db->enableQueryLog();
$result = app(\Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository::class)
->paginate([], 2, 1);
$this->assertSame(3, $result['total']);
$this->assertSame(3, $result['summary']['count']);
$this->assertSame(['B'], array_column($result['data'], 'process_instance_id'));
$this->assertSame('刘萧代发', $result['data'][0]['tag']);
$this->assertTrue(collect($db->getQueryLog())->contains(
static fn (array $query): bool => str_contains($query['query'], 'limit 1 offset 1')
));
}
/** @return array<string,string> */
private function workflow(string $id, string $updateAt, string $status = 'COMPLETED'): array
{
return [
'processInstanceId' => $id,
'processCode' => 'PROC-TEST',
'updateAt' => $updateAt,
'createAt' => $updateAt,
'status' => $status,
'originatorUserId' => '',
];
}
}
class FakeDingPayableWorkflowQuery extends UpstreamReadOnlyQuery
{
/** @var list<list<array<string,mixed>>> */
public array $workflowBatches = [];
/** @var list<array<string,mixed>> */
public array $schemeRows = [];
/** @var list<array{sql:string,bindings:array}> */
public array $calls = [];
public function __construct()
{
parent::__construct('operations_replica');
}
public function select(string $sql, array $bindings = []): array
{
$this->calls[] = compact('sql', 'bindings');
if (str_contains($sql, 'FROM [rpt].[ding_Workflow]')) {
return array_shift($this->workflowBatches) ?? [];
}
if (str_contains($sql, 'FROM [rpt].[ding_WorkflowScheme]')) {
return $this->schemeRows;
}
return [];
}
}