CaiWuXiTong/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php
yueyang 71e76b0a12 fix: 保留流程定点同步水位 (#682)
避免按审批编号补取时重置增量表单水位。
2026-10-27 12:26:45 +08:00

653 lines
28 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\DingProcessDetailMirrorRepository;
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorSynchronizer;
use Modules\EcommerceFinance\Services\DingTalkProcessInstanceClient;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use RuntimeException;
use Tests\TestCase;
class DingProcessDetailMirrorSynchronizerTest extends TestCase
{
private FakeDingProcessDetailQuery $query;
private FakeDingTalkProcessInstanceClient $approvalClient;
private DingProcessDetailMirrorSynchronizer $synchronizer;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.ding_process_details' => [
'scope' => 'personal_permission',
'start_date' => '2026-01-01',
'batch_size' => 20,
'sync_interval_minutes' => 5,
'incremental_overlap_hours' => 48,
'processes' => [
'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF' => [
'document_type' => '线上代发业务报销',
'detail_table' => '收款费用明细',
'detail_amount_fields' => ['代发订单金额'],
'amount_total_fields' => ['收款合计'],
'payment_table' => '付款明细',
],
],
],
]);
DB::purge('business_mirror');
$this->createMirrorTables();
$this->query = new FakeDingProcessDetailQuery();
$factory = new class($this->query) extends UpstreamReadOnlyQueryFactory {
public function __construct(private readonly UpstreamReadOnlyQuery $query) {}
public function forSource(string $source): UpstreamReadOnlyQuery
{
return $this->query;
}
public function forSourceWithTimeout(string $source, int $timeoutSeconds): UpstreamReadOnlyQuery
{
return $this->query;
}
};
$this->app->instance(UpstreamReadOnlyQueryFactory::class, $factory);
$this->approvalClient = new FakeDingTalkProcessInstanceClient();
$this->app->instance(DingTalkProcessInstanceClient::class, $this->approvalClient);
$this->synchronizer = app(DingProcessDetailMirrorSynchronizer::class);
}
public function test_structured_sync_counts_a_multi_line_payment_once(): void
{
$this->query->workflows = [[
'processInstanceId' => 'process-daifa-1',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202605041811000260216',
'title' => '陈芝豹提交的线上代发业务报销',
'status' => 'COMPLETED',
'originatorUserId' => 'user-1',
'joinAt' => '2026-05-04 18:12:20',
'updateAt' => '2026-05-08 09:00:00',
]];
$this->query->scheme = $this->daifaScheme();
$result = $this->synchronizer->sync(true);
$this->assertSame('succeeded', $result['status']);
$this->assertSame(1, $result['workflows']);
$this->assertSame(14, $result['details']);
$this->assertSame(1, $result['payments']);
$this->assertEqualsWithDelta(1114.57, $result['amount_total'], 0.001);
$this->assertEqualsWithDelta(1114.57, $result['bank_amount_total'], 0.001);
$this->assertSame(14, DB::connection('business_mirror')
->table('ding_process_detail_mirror')->count());
$this->assertSame(1, DB::connection('business_mirror')
->table('ding_process_payment_mirror')->count());
$this->assertEqualsWithDelta(1114.57, (float) DB::connection('business_mirror')
->table('ding_process_detail_mirror')->sum('bank_amount'), 0.001);
$this->assertSame('present', DB::connection('business_mirror')
->table('ding_process_detail_mirror')->value('payment_source_state'));
$summary = app(DingProcessDetailMirrorRepository::class)
->paginate(['status' => '已完成'], 1, 50)['summary'];
$this->assertEqualsWithDelta(1114.57, $summary['amount_total'], 0.001);
$this->assertEqualsWithDelta(1114.57, $summary['bank_amount_total'], 0.001);
}
public function test_validation_failure_keeps_the_previous_published_rows(): void
{
$this->query->workflows = [[
'processInstanceId' => 'process-old',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202605041811000260216',
'title' => '陈芝豹提交的线上代发业务报销',
'status' => 'COMPLETED',
'originatorUserId' => 'user-1',
'joinAt' => '2026-05-04 18:12:20',
'updateAt' => '2026-05-08 09:00:00',
]];
$this->query->scheme = $this->daifaScheme();
$this->synchronizer->sync(true);
$this->query->scheme['收款合计'] = 999999;
$this->query->workflows = [[
'processInstanceId' => 'process-new',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202605051811000260217',
'title' => '陈芝豹提交的线上代发业务报销',
'status' => 'COMPLETED',
'originatorUserId' => 'user-1',
'joinAt' => '2026-05-05 18:12:20',
'updateAt' => '2026-05-09 09:00:00',
]];
try {
$this->synchronizer->sync(true);
$this->fail('明细合计与审批合计不一致时应停止发布。');
} catch (RuntimeException $exception) {
$this->assertStringContainsString('明细合计', $exception->getMessage());
}
$this->assertSame(14, DB::connection('business_mirror')
->table('ding_process_detail_mirror')->count());
$this->assertTrue(DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->where('business_id', '202605041811000260216')
->exists());
$this->assertSame('failed', DB::connection('business_mirror')
->table('business_mirror_table_states')
->value('status'));
}
public function test_empty_workflow_stays_visible_with_a_null_amount(): void
{
$this->query->workflows = [[
'processInstanceId' => 'process-empty',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610081714000311523',
'title' => '陈芝豹提交的线上代发业务报销',
'status' => 'RUNNING',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-08 17:15:33',
'updateAt' => '2026-10-08 17:15:33',
]];
$this->query->scheme = [];
$result = $this->synchronizer->sync(true);
$this->assertSame(1, $result['workflows']);
$this->assertSame(1, $result['details']);
$this->assertSame(0, $result['payments']);
$row = DB::connection('business_mirror')->table('ding_process_detail_mirror')->first();
$this->assertNotNull($row);
$this->assertNull($row->amount);
$this->assertSame('审批中', $row->status);
$this->assertSame('missing', $row->payment_source_state);
$page = app(DingProcessDetailMirrorRepository::class)->paginate(
['payment_state' => '付款明细待同步'],
1,
50,
);
$this->assertSame(1, $page['total']);
}
public function test_incremental_sync_detects_late_payment_rows_without_workflow_update(): void
{
$workflow = [
'processInstanceId' => 'process-late-payment',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610151743000310225',
'title' => '李斯提交的线上代发业务报销',
'status' => 'RUNNING',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-15 17:44:06',
'updateAt' => '2026-10-15 17:44:06',
];
$this->query->workflows = [$workflow];
$this->query->schemeHighWatermark = 100;
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '15535.37'],
['label' => '科目编码', 'value' => '5601.05.03'],
['label' => '科目名称', 'value' => '销售费用_营销费用_软件服务费'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 15535.37,
];
$this->synchronizer->sync(true);
$this->assertSame('missing', DB::connection('business_mirror')
->table('ding_process_detail_mirror')->value('payment_source_state'));
DB::connection('business_mirror')
->table('business_mirror_table_states')
->where('dataset_key', 'ding.process_details.personal_permission')
->update(['last_succeeded_at' => now()->subMinutes(10)]);
$this->query->workflows = [];
$this->query->schemeChangedWorkflows = [$workflow];
$this->query->schemeChangedProcessIds = ['process-late-payment'];
$this->query->schemeHighWatermark = 101;
$this->query->scheme['付款明细'] = json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1'],
['label' => '付款方式', 'value' => '银行卡'],
['label' => '付款金额', 'value' => '15535.37'],
['label' => '科目编码', 'value' => '1002.01.544'],
['label' => '科目名称', 'value' => '华夏银行-福尼达'],
['label' => '打款时间', 'value' => '2026-10-18'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$result = $this->synchronizer->sync(false);
$this->assertSame(1, $result['workflows']);
$this->assertSame(1, $result['payments']);
$this->assertSame('present', DB::connection('business_mirror')
->table('ding_process_detail_mirror')->value('payment_source_state'));
$this->assertSame(1, DB::connection('business_mirror')
->table('ding_process_payment_mirror')->count());
$state = DB::connection('business_mirror')
->table('business_mirror_table_states')
->first();
$this->assertSame(101, (int) json_decode((string) $state->cursor, true)['scheme_high_watermark']);
}
public function test_official_api_fills_payment_when_replica_omits_payment_table(): void
{
$workflow = [
'processInstanceId' => 'process-api-payment',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610151743000310225',
'title' => '李斯提交的线上代发业务报销',
'status' => 'RUNNING',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-15 17:44:06',
'updateAt' => '2026-10-15 17:44:06',
];
$this->query->workflows = [$workflow];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '15535.37'],
['label' => '科目编码', 'value' => '5601.05.03'],
['label' => '科目名称', 'value' => '销售费用_营销费用_软件服务费'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 15535.37,
];
$this->approvalClient->valuesByInstance['process-api-payment'] = [
'付款明细' => json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1'],
['label' => '付款方式', 'value' => '银行卡'],
['label' => '付款金额', 'value' => '15535.37'],
['label' => '科目编码', 'value' => '1002.01.544'],
['label' => '科目名称', 'value' => '华夏银行-福尼达'],
['label' => '打款时间', 'value' => '2026-10-18 00:00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'付款金额合计' => 15535.37,
];
$result = $this->synchronizer->sync(true);
$this->assertSame(1, $result['payments']);
$row = DB::connection('business_mirror')->table('ding_process_detail_mirror')->first();
$this->assertSame('api', $row->payment_source_state);
$this->assertSame('银行卡', $row->payment_method);
$this->assertSame('2026-10-18 00:00', $row->paid_at);
$this->assertSame(['process-api-payment'], $this->approvalClient->calls);
}
public function test_incremental_sync_rotates_missing_payments_through_official_api(): void
{
$workflow = [
'processInstanceId' => 'process-api-queue',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610151743000310226',
'title' => '李斯提交的线上代发业务报销',
'status' => 'RUNNING',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-15 17:44:06',
'updateAt' => '2026-10-15 17:44:06',
];
$this->query->workflows = [$workflow];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [['label' => '代发订单金额', 'value' => '100.00']],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 100,
];
$this->synchronizer->sync(true);
$this->assertSame('missing', DB::connection('business_mirror')
->table('ding_process_detail_mirror')->value('payment_source_state'));
DB::connection('business_mirror')
->table('business_mirror_table_states')
->where('dataset_key', 'ding.process_details.personal_permission')
->update(['last_succeeded_at' => now()->subMinutes(10)]);
$this->query->workflows = [];
$this->query->schemeChangedWorkflows = [$workflow];
$this->approvalClient->valuesByInstance['process-api-queue'] = [
'付款明细' => json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1'],
['label' => '付款方式', 'value' => '银行卡'],
['label' => '付款金额', 'value' => '100.00'],
['label' => '科目编码', 'value' => '1002.01.544'],
['label' => '打款时间', 'value' => '2026-10-18 00:00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'付款金额合计' => 100,
];
$result = $this->synchronizer->sync(false);
$this->assertSame(1, $result['payments']);
$this->assertSame('api', DB::connection('business_mirror')
->table('ding_process_detail_mirror')->value('payment_source_state'));
$state = DB::connection('business_mirror')
->table('business_mirror_table_states')
->first();
$this->assertSame(
'process-api-queue',
json_decode((string) $state->cursor, true)['approval_api_cursor'],
);
}
public function test_business_id_dry_run_uses_official_api_without_publishing(): void
{
$workflow = [
'processInstanceId' => 'process-target-api',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610151743000310227',
'title' => '李斯提交的线上代发业务报销',
'status' => 'RUNNING',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-15 17:44:06',
'updateAt' => '2026-10-15 17:44:06',
];
$this->query->workflows = [$workflow];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [['label' => '代发订单金额', 'value' => '100.00']],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 100,
];
$this->synchronizer->sync(true);
$this->approvalClient->valuesByInstance['process-target-api'] = [
'付款明细' => json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1'],
['label' => '付款金额', 'value' => '100.00'],
['label' => '付款方式', 'value' => '银行卡'],
['label' => '打款时间', 'value' => '2026-10-18 00:00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'付款金额合计' => 100,
];
$result = $this->synchronizer->syncBusinessIds(
['202610151743000310227'],
true,
);
$this->assertSame('validated', $result['status']);
$this->assertSame(1, $result['payments']);
$this->assertSame('missing', DB::connection('business_mirror')
->table('ding_process_detail_mirror')->value('payment_source_state'));
$this->assertSame(0, DB::connection('business_mirror')
->table('ding_process_payment_mirror')->count());
}
public function test_business_id_sync_preserves_incremental_watermarks(): void
{
$workflow = [
'processInstanceId' => 'process-preserve-cursors',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610151743000310228',
'title' => '李斯提交的线上代发业务报销',
'status' => 'RUNNING',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-15 17:44:06',
'updateAt' => '2026-10-15 17:44:06',
];
$this->query->workflows = [$workflow];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [['label' => '代发订单金额', 'value' => '100.00']],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 100,
'付款明细' => '[]',
];
$this->query->schemeHighWatermark = 500;
$this->synchronizer->sync(true);
$state = DB::connection('business_mirror')
->table('business_mirror_table_states')
->first();
$cursor = json_decode((string) $state->cursor, true);
$cursor['scheme_high_watermark'] = 500;
$cursor['approval_api_cursor'] = 'api-cursor-500';
DB::connection('business_mirror')
->table('business_mirror_table_states')
->update(['cursor' => json_encode($cursor, JSON_UNESCAPED_UNICODE)]);
$this->synchronizer->syncBusinessIds(['202610151743000310228']);
$state = DB::connection('business_mirror')
->table('business_mirror_table_states')
->first();
$cursor = json_decode((string) $state->cursor, true);
$this->assertSame(500, (int) $cursor['scheme_high_watermark']);
$this->assertSame('api-cursor-500', $cursor['approval_api_cursor']);
}
/** @return array<string,mixed> */
private function daifaScheme(): array
{
$amounts = [157, 153, 56.1, 63.1, 85, 90, 56.1, 63.1, 66.1, 61.67, 66.1, 65.1, 66.1, 66.1];
$details = array_map(static fn (float $amount): array => [
'rowValue' => [
['label' => '序号', 'value' => '1'],
['label' => '商品ID', 'value' => '877714260849'],
['label' => '代发订单金额', 'value' => (string) $amount],
['label' => '科目编码', 'value' => '1405.06'],
['label' => '科目名称', 'value' => '库存商品_线上代发'],
],
], $amounts);
$payment = [[
'rowValue' => [
['label' => '对应序号', 'value' => '1-2-3-4-5-6-7-8-9-10-11-12-13-14'],
['label' => '付款方式', 'value' => '银行卡'],
['label' => '付款金额', 'value' => '1114.57'],
['label' => '科目编码', 'value' => '1002.02.114'],
['label' => '科目名称', 'value' => '华夏银行-胡燕'],
['label' => '打款时间', 'value' => '2026-05-08'],
],
]];
return [
'收款费用明细' => json_encode($details, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 1114.57,
'付款明细' => json_encode($payment, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
];
}
private function createMirrorTables(): void
{
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();
},
);
Schema::connection('business_mirror')->create(
'ding_process_detail_mirror',
function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('business_id', 32);
$table->string('process_instance_id', 100)->nullable();
$table->unsignedInteger('line_no');
$table->string('business_month', 6);
$table->string('originator_name', 100)->nullable();
$table->string('status', 20)->nullable();
$table->string('payment_source_state', 24)->nullable();
$table->string('document_type', 100)->nullable();
$table->text('purpose')->nullable();
$table->string('account_code', 64)->nullable();
$table->string('account_name', 255)->nullable();
$table->decimal('amount', 18, 2)->nullable();
$table->string('bank_account_code', 255)->nullable();
$table->string('bank_account_name', 255)->nullable();
$table->decimal('bank_amount', 18, 2)->nullable();
$table->string('payment_method', 50)->nullable();
$table->string('paid_at', 50)->nullable();
$table->string('source_scope', 40);
$table->dateTime('synced_at', 6);
$table->timestamps();
$table->unique(['business_id', 'line_no']);
},
);
Schema::connection('business_mirror')->create(
'ding_process_payment_mirror',
function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('business_id', 32);
$table->string('process_instance_id', 100);
$table->unsignedInteger('payment_no');
$table->string('sequence_spec', 255)->nullable();
$table->decimal('amount', 18, 2);
$table->string('account_code', 64)->nullable();
$table->string('account_name', 255)->nullable();
$table->string('payment_method', 50)->nullable();
$table->string('paid_at', 50)->nullable();
$table->string('source_scope', 40);
$table->dateTime('synced_at', 6);
$table->timestamps();
$table->unique(['source_scope', 'business_id', 'payment_no']);
},
);
}
}
class FakeDingProcessDetailQuery extends UpstreamReadOnlyQuery
{
/** @var list<array<string,mixed>> */
public array $workflows = [];
/** @var array<string,mixed> */
public array $scheme = [];
public array $schemeChangedWorkflows = [];
/** @var list<string> */
public array $schemeChangedProcessIds = [];
public int $schemeHighWatermark = 0;
/** @var list<array{sql:string,bindings:array}> */
public array $calls = [];
public function __construct()
{
parent::__construct('operations_replica');
}
public function scalar(string $sql, array $bindings = []): mixed
{
if (str_contains($sql, 'MAX(TRY_CONVERT(bigint, id))')) {
return $this->schemeHighWatermark;
}
if (str_contains($sql, 'COUNT(*)') && str_contains($sql, 'rpt.ding_Workflow')) {
return count($this->workflows);
}
return parent::scalar($sql, $bindings);
}
public function select(string $sql, array $bindings = []): array
{
$this->calls[] = compact('sql', 'bindings');
if (str_contains($sql, 'FROM rpt.hr_employee')) {
return [['userid' => 'user-1', 'name' => '陈芝豹']];
}
if (str_contains($sql, 'SELECT DISTINCT s.processInstanceId')) {
return array_map(
static fn (string $id): array => ['processInstanceId' => $id],
$this->schemeChangedProcessIds,
);
}
if (str_contains($sql, 'JOIN rpt.ding_WorkflowScheme')
&& str_contains($sql, 'NOT IN')) {
return [];
}
if (str_contains($sql, 'FROM rpt.ding_Workflow w')
&& str_contains($sql, 'w.processInstanceId IN')) {
return $this->schemeChangedWorkflows;
}
if (str_contains($sql, 'FROM rpt.ding_Workflow w')) {
return $this->workflows;
}
if (str_contains($sql, 'FROM rpt.ding_WorkflowScheme')) {
$rows = [];
$processInstanceId = (string) (
$this->workflows[0]['processInstanceId']
?? $this->schemeChangedWorkflows[0]['processInstanceId']
?? ''
);
foreach ($this->scheme as $name => $value) {
$rows[] = [
'processInstanceId' => $processInstanceId,
'name' => $name,
'value' => $value,
];
}
return $rows;
}
return [];
}
}
class FakeDingTalkProcessInstanceClient extends DingTalkProcessInstanceClient
{
public bool $configured = true;
/** @var array<string,array<string,mixed>> */
public array $valuesByInstance = [];
/** @var list<string> */
public array $calls = [];
public function isConfigured(): bool
{
return $this->configured;
}
public function formValues(string $processInstanceId): array
{
$this->calls[] = $processInstanceId;
return $this->valuesByInstance[$processInstanceId] ?? [];
}
}