CaiWuXiTong/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php

1104 lines
47 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_completed_workflow_uses_cashier_status_and_keeps_difference_separate(): void
{
$this->query->workflows = [[
'processInstanceId' => 'process-cashier-completed',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202606031318000506330',
'title' => '苏木提交的线上代发业务报销',
'status' => 'COMPLETED',
'result' => 'AGREE',
'originatorUserId' => 'user-1',
'joinAt' => '2026-06-03 13:18:00',
]];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '100.00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 100,
'付款明细' => json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1'],
['label' => '付款金额', 'value' => '99.80'],
['label' => '付款方式', 'value' => '银行卡'],
['label' => '打款时间', 'value' => '2026-06-03 00:00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
];
$this->synchronizer->sync(true);
$row = app(DingProcessDetailMirrorRepository::class)
->paginate(['business_id' => '202606031318000506330'], 1, 50)['data'][0];
$this->assertSame('completed', $row->cashier_status);
$this->assertSame('已付款', $row->payment_state);
$this->assertEqualsWithDelta(0.20, (float) $row->payment_difference, 0.001);
}
public function test_completed_but_refused_workflow_is_not_labelled_completed(): void
{
$this->query->workflows = [[
'processInstanceId' => 'process-cashier-refused',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610261048000410425',
'title' => '勇武提交的店铺日常报销',
'status' => 'COMPLETED',
'result' => 'REFUSE',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-26 10:49:07',
]];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '1.00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 1,
'付款明细' => '[]',
];
$this->synchronizer->sync(true);
$row = app(DingProcessDetailMirrorRepository::class)
->paginate(['business_id' => '202610261048000410425'], 1, 50)['data'][0];
$this->assertSame('已拒绝', $row->status);
$this->assertSame('terminated', $row->cashier_status);
$this->assertSame('不涉及付款', $row->payment_state);
}
public function test_terminated_workflow_is_labelled_revoked(): void
{
$this->query->workflows = [[
'processInstanceId' => 'process-cashier-terminated',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610191640000251028',
'title' => '雁兰提交的应付申请流程',
'status' => 'TERMINATED',
'result' => 'AGREE',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-19 16:40:00',
]];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '1.00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 1,
'付款明细' => '[]',
];
$this->synchronizer->sync(true);
$row = app(DingProcessDetailMirrorRepository::class)
->paginate(['business_id' => '202610191640000251028'], 1, 50)['data'][0];
$this->assertSame('已撤销', $row->status);
$this->assertSame('不涉及付款', $row->payment_state);
}
public function test_running_workflow_detects_cashier_returned_from_official_tasks(): void
{
$this->query->workflows = [[
'processInstanceId' => 'process-cashier-returned',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610221534000124202',
'title' => '武松提交的线上代发业务报销',
'status' => 'RUNNING',
'result' => 'NONE',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-22 15:34:00',
]];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '750.00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 750,
'付款明细' => '[]',
];
$this->approvalClient->cashierStatusByInstance['process-cashier-returned'] = 'returned';
$this->synchronizer->sync(true);
$row = app(DingProcessDetailMirrorRepository::class)
->paginate(['business_id' => '202610221534000124202'], 1, 50)['data'][0];
$this->assertSame('returned', $row->cashier_status);
$this->assertSame('待付款', $row->payment_state);
$this->assertEqualsWithDelta(750.0, (float) $row->payment_difference, 0.001);
}
public function test_running_workflow_resolves_current_approver_and_keeps_cashier_task_state(): void
{
$this->query->workflows = [[
'processInstanceId' => 'process-task-state',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610291045000346211',
'title' => '苏木提交的种菜核销种菜报销流程',
'status' => 'RUNNING',
'result' => 'NONE',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-29 10:45:00',
]];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '32622.59'],
['label' => '科目编码', 'value' => '1221.01'],
['label' => '科目名称', 'value' => '其他应收款_运营中心-种菜刷单'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 32622.59,
'付款明细' => '[]',
];
$this->approvalClient->cashierStatusByInstance['process-task-state'] = 'pending';
$this->approvalClient->tasksByInstance['process-task-state'] = [[
'taskGroupName' => '财务审批',
'status' => 'RUNNING',
'result' => 'NONE',
'userId' => 'user-2',
'createTime' => '2026-06-03T15:00Z',
'finishTime' => '',
]];
$this->synchronizer->sync(true);
$row = DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->where('business_id', '202610291045000346211')
->first();
$this->assertSame('财务审批 / 出纳打款', $row->current_task_name);
$this->assertSame('王五', $row->current_approver_name);
$this->assertSame('2026-06-03 15:00:00', (string) $row->current_task_started_at);
$this->assertSame('出纳打款', $row->cashier_task_name);
$this->assertSame('2026-06-03 15:34:00', (string) $row->cashier_task_started_at);
$this->assertSame('pending', $row->cashier_status);
$this->assertSame(
'待付款',
app(DingProcessDetailMirrorRepository::class)
->paginate(['business_id' => '202610291045000346211'], 1, 50)['data'][0]
->payment_state,
);
}
public function test_running_cashier_task_resolves_specific_approver(): void
{
$this->query->workflows = [[
'processInstanceId' => 'process-running-cashier',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610291850000052465',
'title' => '苏木提交的种菜核销种菜报销流程',
'status' => 'RUNNING',
'result' => 'NONE',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-29 18:48:00',
]];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '100.00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 100,
'付款明细' => '[]',
];
$this->approvalClient->tasksByInstance['process-running-cashier'] = [[
'taskGroupName' => '出纳打款',
'status' => 'RUNNING',
'result' => 'NONE',
'userId' => 'user-2',
'createTime' => '2026-10-29T18:50Z',
'finishTime' => '',
]];
$this->synchronizer->sync(true);
$row = DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->where('business_id', '202610291850000052465')
->first();
$this->assertSame('出纳打款', $row->current_task_name);
$this->assertSame('王五', $row->current_approver_name);
$this->assertSame('2026-10-29 18:50:00', (string) $row->current_task_started_at);
}
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);
$this->assertSame('not_started', $row->cashier_status);
$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']);
$this->assertFalse(collect($this->query->calls)->contains(
static fn (array $call): bool => str_contains(
(string) ($call['sql'] ?? ''),
'ORDER BY w.processInstanceId, w.processInstanceId',
),
));
}
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_incremental_sync_drains_cashier_backlog_before_changed_workflows(): void
{
$connection = DB::connection('business_mirror');
$now = now();
$backlog = [];
$bulk = [];
for ($index = 0; $index < 30; $index++) {
$processInstanceId = sprintf('backlog-%03d', $index);
$businessId = sprintf('20261022%09d', 100000000 + $index);
$backlog[] = $this->runningWorkflow($processInstanceId, $businessId);
$this->approvalClient->cashierStatusByInstance[$processInstanceId] = 'completed';
$connection->table('ding_process_detail_mirror')->insert([
'business_id' => $businessId,
'process_instance_id' => $processInstanceId,
'line_no' => 1,
'business_month' => '202610',
'originator_name' => '李斯',
'status' => '审批中',
'payment_source_state' => 'present',
'cashier_status' => 'pending_sync',
'cashier_checked_at' => $now,
'document_type' => '公摊借支-销账',
'purpose' => 'AI TOKEN费用',
'account_code' => '1221.04',
'account_name' => '其他应收款_借支销账',
'amount' => 100,
'source_scope' => 'personal_permission',
'synced_at' => $now->copy()->subHour(),
'created_at' => $now->copy()->subDay(),
'updated_at' => $now->copy()->subHour(),
]);
}
for ($index = 0; $index < 60; $index++) {
$processInstanceId = sprintf('bulk-%03d', $index);
$businessId = sprintf('202611%015d', $index);
$bulk[] = $this->runningWorkflow($processInstanceId, $businessId);
}
$this->query->workflows = $bulk;
$this->query->workflowsById = array_column(
[...$backlog, ...$bulk],
null,
'processInstanceId',
);
$this->query->schemeHighWatermark = 100;
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '100.00'],
['label' => '科目编码', 'value' => '1221.04'],
['label' => '科目名称', 'value' => '其他应收款_借支销账'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 100,
'付款明细' => json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1'],
['label' => '付款金额', 'value' => '100.00'],
['label' => '付款方式', 'value' => '银行卡'],
['label' => '打款时间', 'value' => '2026-10-23'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
];
$connection->table('business_mirror_table_states')->insert([
'dataset_key' => 'ding.process_details.personal_permission',
'source_key' => 'operations_replica',
'source_object' => 'test',
'target_table' => 'ding_process_detail_mirror',
'status' => 'succeeded',
'run_mode' => 'incremental',
'generation' => 'test',
'cursor' => json_encode([
'source' => 'structured',
'scheme_high_watermark' => 100,
'approval_api_cursor' => '',
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'initial_completed_at' => $now->copy()->subDay(),
'last_full_succeeded_at' => $now->copy()->subDay(),
'last_attempted_at' => $now->copy()->subMinutes(10),
'last_succeeded_at' => $now->copy()->subMinutes(10),
'last_upserted_rows' => 0,
'mirror_row_count' => 30,
'created_at' => $now->copy()->subDay(),
'updated_at' => $now->copy()->subMinutes(10),
]);
$this->synchronizer->sync(false);
$expectedBacklog = array_map(
static fn (array $workflow): string => (string) $workflow['processInstanceId'],
$backlog,
);
$this->assertCount(90, $this->approvalClient->calls);
$this->assertSame($expectedBacklog, array_slice($this->approvalClient->calls, 0, 30));
$this->assertSame(
'backlog-029',
json_decode((string) $connection->table('business_mirror_table_states')
->value('cursor'), true)['approval_api_cursor'],
);
$this->assertSame('completed', $connection->table('ding_process_detail_mirror')
->where('process_instance_id', 'backlog-029')
->value('cashier_status'));
$this->assertNotNull($connection->table('ding_process_detail_mirror')
->where('process_instance_id', 'backlog-029')
->value('cashier_checked_at'));
}
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),
];
}
/** @return array<string,mixed> */
private function runningWorkflow(string $processInstanceId, string $businessId): array
{
return [
'processInstanceId' => $processInstanceId,
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => $businessId,
'title' => '李斯提交的公摊借支-销账',
'status' => 'RUNNING',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-22 17:33:00',
'updateAt' => '2026-10-28 17:00:00',
];
}
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('cashier_status', 32)->nullable();
$table->dateTime('cashier_checked_at')->nullable();
$table->string('current_approver_name', 255)->nullable();
$table->string('current_task_name', 255)->nullable();
$table->dateTime('current_task_started_at')->nullable();
$table->string('cashier_task_name', 100)->nullable();
$table->dateTime('cashier_task_started_at')->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 array<string,array<string,mixed>> */
public array $workflowsById = [];
/** @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' => '陈芝豹'],
['userid' => 'user-2', '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')) {
$wanted = array_fill_keys(array_map('strval', $bindings), true);
$rows = [];
$candidates = [
...$this->workflows,
...$this->schemeChangedWorkflows,
...array_values($this->workflowsById),
];
foreach ($candidates as $workflow) {
$processInstanceId = (string) ($workflow['processInstanceId'] ?? '');
if ($processInstanceId !== '' && isset($wanted[$processInstanceId])) {
$rows[$processInstanceId] = $workflow;
}
}
return array_values($rows);
}
if (str_contains($sql, 'FROM rpt.ding_Workflow w')) {
return $this->workflows;
}
if (str_contains($sql, 'FROM rpt.ding_WorkflowScheme')) {
$rows = [];
$requested = array_fill_keys(array_map('strval', $bindings), true);
$known = [];
$candidates = [
...$this->workflows,
...$this->schemeChangedWorkflows,
...array_values($this->workflowsById),
];
foreach ($candidates as $workflow) {
$processInstanceId = (string) ($workflow['processInstanceId'] ?? '');
if ($processInstanceId !== '' && isset($requested[$processInstanceId])) {
$known[$processInstanceId] = true;
}
}
if ($known === []) {
$fallback = '';
if ($this->workflows !== []) {
$fallback = (string) ($this->workflows[0]['processInstanceId'] ?? '');
} elseif ($this->schemeChangedWorkflows !== []) {
$fallback = (string) ($this->schemeChangedWorkflows[0]['processInstanceId'] ?? '');
} elseif ($this->workflowsById !== []) {
$fallbackWorkflow = $this->workflowsById[array_key_first($this->workflowsById)];
$fallback = (string) ($fallbackWorkflow['processInstanceId'] ?? '');
}
if ($fallback !== '') {
$known[$fallback] = true;
}
}
foreach (array_keys($known) as $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 array<string,string> */
public array $cashierStatusByInstance = [];
/** @var array<string,list<array<string,mixed>>> */
public array $tasksByInstance = [];
/** @var list<string> */
public array $calls = [];
public function isConfigured(): bool
{
return $this->configured;
}
public function formValues(string $processInstanceId): array
{
return $this->processSnapshot($processInstanceId)['form_values'];
}
public function processSnapshot(string $processInstanceId): array
{
$this->calls[] = $processInstanceId;
$cashierStatus = $this->cashierStatusByInstance[$processInstanceId] ?? 'not_started';
$tasks = $this->tasksByInstance[$processInstanceId] ?? [];
$operations = [];
if ($cashierStatus === 'completed') {
$tasks[] = [
'taskGroupName' => '出纳打款',
'status' => 'COMPLETED',
'result' => 'AGREE',
'createTime' => '2026-06-03T15:34Z',
'finishTime' => '2026-06-03T16:42Z',
];
} elseif ($cashierStatus === 'pending') {
$tasks[] = [
'taskGroupName' => '出纳打款',
'status' => 'RUNNING',
'result' => 'NONE',
'createTime' => '2026-06-03T15:34Z',
'finishTime' => '',
];
} elseif ($cashierStatus === 'returned') {
$operations[] = [
'showName' => '出纳打款',
'type' => 'REDIRECT_PROCESS',
'result' => 'NONE',
'date' => '2026-06-03T16:55Z',
];
}
return [
'form_values' => $this->valuesByInstance[$processInstanceId] ?? [],
'tasks' => $tasks,
'operation_records' => $operations,
'status' => null,
'result' => null,
];
}
}