CaiWuXiTong/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php

1794 lines
78 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\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Exceptions\DingTalkProcessInstanceException;
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',
'originatorDeptName' => '线上代发部',
'joinAt' => '2026-05-04 18:12:20',
'updateAt' => '2026-05-08 09:00:00',
'finishTime' => '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->assertSame(14, DB::connection('business_mirror')
->table('ding_process_payment_line_mirror')->count());
$this->assertEqualsWithDelta(1114.57, (float) DB::connection('business_mirror')
->table('ding_process_payment_line_mirror')->sum('amount'), 0.001);
$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'));
$this->assertSame('线上代发部', DB::connection('business_mirror')
->table('ding_process_detail_mirror')->value('originator_department'));
$this->assertSame('2026-05-08 09:00:00', DB::connection('business_mirror')
->table('ding_process_detail_mirror')->value('approval_finished_at'));
$this->assertSame('天猫-测试店铺', DB::connection('business_mirror')
->table('ding_process_detail_mirror')->value('store_name'));
$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_unnamed_detail_table_is_used_when_configured_name_is_missing(): void
{
$this->query->workflows = [[
'processInstanceId' => 'process-unnamed-detail',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610091326000505189',
'title' => '钰岚提交的线上代发业务报销',
'status' => 'COMPLETED',
'result' => 'AGREE',
'originatorUserId' => 'user-1',
'originatorDeptName' => '运营中心G-运营一部',
'joinAt' => '2026-10-09 13:26:00',
'finishTime' => '2026-10-15 10:32:00',
]];
$this->query->scheme = [
'付款明细' => json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1'],
['label' => '付款方式', 'value' => '银行卡'],
['label' => '付款金额', 'value' => '341.00'],
['label' => '科目编码', 'value' => '1002.01.584'],
['label' => '科目名称', 'value' => '农业银行-庆之图'],
['label' => '打款时间', 'value' => '2026-10-12'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
];
$this->query->unnamedSchemeValues = [
"流程使用范围:\n线上代发业务费用报销",
json_encode([[
'rowValue' => [
['label' => '序号', 'value' => '1'],
['label' => '代发订单金额', 'value' => '341'],
['label' => '科目编码', 'value' => '1405.06'],
['label' => '科目名称', 'value' => '库存商品_线上代发'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
];
$this->synchronizer->sync(true);
$row = DB::connection('business_mirror')->table('ding_process_detail_mirror')
->where('business_id', '202610091326000505189')
->first();
$this->assertSame('1405.06', $row->account_code);
$this->assertEqualsWithDelta(341.0, (float) $row->amount, 0.001);
$this->assertEqualsWithDelta(341.0, (float) $row->bank_amount, 0.001);
$this->assertSame('2026-10-15 10:32:00', $row->approval_finished_at);
}
public function test_payment_with_acceptance_bill_account_and_paid_time_is_included(): void
{
$workflow = [
'processInstanceId' => 'process-acceptance-payment',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202602081023000284562',
'title' => '欧伯娜项目,康达电梯款15万承兑',
'status' => 'COMPLETED',
'result' => 'AGREE',
'originatorUserId' => 'user-1',
'joinAt' => '2026-02-08 10:23:00',
'finishTime' => '2026-02-22 13:43:00',
];
$this->query->workflows = [$workflow];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '序号', 'value' => '1'],
['label' => '代发订单金额', 'value' => '150000'],
['label' => '科目编码', 'value' => '1221.04'],
['label' => '科目名称', 'value' => '其他应收款_借支销账'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 150000,
'付款明细' => json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1'],
['label' => '付款方式', 'value' => '承兑汇票'],
['label' => '付款金额', 'value' => '150000'],
['label' => '科目编码', 'value' => '2201.01'],
['label' => '科目名称', 'value' => '应付票据_江南农村商业银行'],
['label' => '打款时间', 'value' => '2026-02-11 00:00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'付款合计' => 150000,
];
$result = $this->synchronizer->sync(true);
$this->assertSame(1, $result['payments']);
$payment = DB::connection('business_mirror')
->table('ding_process_payment_mirror')
->where('business_id', $workflow['businessId'])
->first();
$this->assertSame('2201.01', $payment->account_code);
$this->assertSame('2026-02-11 00:00', $payment->paid_at);
$this->assertSame('2026-02', DB::connection('business_mirror')
->table('ding_process_payment_line_mirror')
->where('business_id', $workflow['businessId'])
->value('paid_month'));
}
public function test_top_level_account_fields_fill_subject_missing_from_detail_rows(): void
{
$this->query->workflows = [[
'processInstanceId' => 'process-top-level-account',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202609291009000054611',
'title' => '半夏提交的兼职主播工资流程',
'status' => 'COMPLETED',
'result' => 'AGREE',
'originatorUserId' => 'user-1',
'originatorDeptName' => '运营中心A-事业四部',
'joinAt' => '2026-09-29 10:09:00',
'finishTime' => '2026-10-09 09:09:00',
]];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '5500'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'科目编码' => '2211.01',
'科目名称' => '应付职工薪酬_工资',
'付款明细' => json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1'],
['label' => '付款金额', 'value' => '5500'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
];
$this->synchronizer->sync(true);
$row = DB::connection('business_mirror')->table('ding_process_detail_mirror')
->where('business_id', '202609291009000054611')
->first();
$this->assertSame('2211.01', $row->account_code);
$this->assertSame('应付职工薪酬_工资', $row->account_name);
$this->assertEqualsWithDelta(5500.0, (float) $row->amount, 0.001);
$this->assertEqualsWithDelta(5500.0, (float) $row->bank_amount, 0.001);
}
public function test_official_completed_status_overrides_stale_running_source(): void
{
$this->query->workflows = [[
'processInstanceId' => 'process-official-completed',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610301116000560927',
'title' => '周生辰提交的种菜核销种菜报销流程',
'status' => 'RUNNING',
'result' => '',
'originatorUserId' => 'user-1',
'originatorDeptName' => '财务中心',
'joinAt' => '2026-10-30 11:17:49',
'updateAt' => '2026-10-30 11:17:49',
'finishTime' => '',
]];
$this->query->scheme = $this->daifaScheme();
$this->approvalClient->cashierStatusByInstance['process-official-completed'] = 'completed';
$this->approvalClient->statusByInstance['process-official-completed'] = 'COMPLETED';
$this->approvalClient->resultByInstance['process-official-completed'] = 'agree';
$this->approvalClient->tasksByInstance['process-official-completed'] = [
[
'taskGroupName' => '财务助理',
'status' => 'COMPLETED',
'result' => 'AGREE',
'createTime' => '2026-10-30T19:51Z',
'finishTime' => '2026-11-02T11:05Z',
],
[
'taskGroupName' => '董事长',
'status' => 'COMPLETED',
'result' => 'AGREE',
'createTime' => '2026-11-02T13:03Z',
'finishTime' => '2026-11-02T13:03Z',
],
];
$this->synchronizer->sync(true);
$row = DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->where('business_id', '202610301116000560927')
->first();
$this->assertSame('已完成', $row->status);
$this->assertSame('2026-11-02 13:03:00', $row->approval_finished_at);
$this->assertNull($row->current_task_name);
$this->assertNull($row->current_approver_name);
$this->assertNull($row->current_task_started_at);
$this->assertSame('completed', $row->cashier_status);
}
public function test_official_detail_columns_fill_missing_accounts_from_replica_rows(): void
{
config([
'upstream_mirrors.ding_process_details.processes.PROC-C5159B8D-F412-4DF8-981D-64EBB8D03EB6' => [
'document_type' => '种菜核销/种菜报销',
'detail_table' => '收款费用明细',
'detail_amount_fields' => ['收款金额'],
'amount_total_fields' => ['收款合计'],
'payment_table' => '付款明细',
'payment_total_fields' => ['付款合计'],
],
]);
$this->query->workflows = [[
'processInstanceId' => 'process-account-columns',
'processCode' => 'PROC-C5159B8D-F412-4DF8-981D-64EBB8D03EB6',
'businessId' => '202610301116000560927',
'title' => '周生辰提交的种菜核销种菜报销流程',
'status' => 'COMPLETED',
'result' => 'AGREE',
'originatorUserId' => 'user-1',
'originatorDeptName' => '财务中心',
'joinAt' => '2026-10-30 11:17:49',
'finishTime' => '2026-11-02 13:03:00',
]];
$this->query->scheme = [
'收款费用明细' => json_encode([
[
'rowValue' => [
['label' => '序号', 'value' => '1'],
['label' => '用途', 'value' => '10.29种菜报销'],
['label' => '收款金额', 'value' => '100.00'],
],
],
[
'rowValue' => [
['label' => '序号', 'value' => '2'],
['label' => '用途', 'value' => '10.29种菜报销'],
['label' => '收款金额', 'value' => '50.00'],
],
],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 150,
'付款明细' => json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1-2'],
['label' => '付款金额', 'value' => '150.00'],
['label' => '付款方式', 'value' => '银行卡'],
['label' => '打款时间', 'value' => '2026-10-30'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'付款合计' => 150,
];
$this->approvalClient->valuesByInstance['process-account-columns'] = [
'收款费用明细' => json_encode([
[
'rowValue' => [
['label' => '序号', 'value' => '1'],
['label' => '收款金额', 'value' => '100.00'],
['label' => '科目编码', 'value' => '1221.01'],
['label' => '科目名称', 'value' => '其他应收款_运营中心-种菜刷单'],
],
],
[
'rowValue' => [
['label' => '序号', 'value' => '2'],
['label' => '收款金额', 'value' => '50.00'],
['label' => '科目编码', 'value' => '1221.01'],
['label' => '科目名称', 'value' => '其他应收款_运营中心-种菜刷单'],
],
],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
];
$this->synchronizer->sync(true);
$rows = DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->where('process_instance_id', 'process-account-columns')
->orderBy('line_no')
->get();
$this->assertCount(2, $rows);
$this->assertSame(['1221.01', '1221.01'], $rows->pluck('account_code')->all());
$this->assertSame(
['其他应收款_运营中心-种菜刷单', '其他应收款_运营中心-种菜刷单'],
$rows->pluck('account_name')->all(),
);
$this->assertContains('process-account-columns', $this->approvalClient->calls);
}
public function test_process_fallback_account_keeps_legacy_rows_in_expense_summary(): void
{
config([
'upstream_mirrors.ding_process_details.processes.PROC-39DD70DE-DAB2-4A5F-B8F1-0B0B68BD0C88' => [
'document_type' => '应付申请流程',
'detail_table' => '应付明细',
'detail_amount_fields' => ['付款金额'],
'fallback_account_code' => '2202.01',
'fallback_account_name' => '应付账款_供货商',
'payment_table' => '付款明细',
'payment_total_fields' => ['付款金额合计'],
],
]);
$this->query->workflows = [[
'processInstanceId' => 'process-payable-no-account',
'processCode' => 'PROC-39DD70DE-DAB2-4A5F-B8F1-0B0B68BD0C88',
'businessId' => '202603090926000222573',
'title' => '三洋木业合同月结预付款',
'status' => 'COMPLETED',
'result' => 'AGREE',
'originatorUserId' => 'user-1',
'joinAt' => '2026-03-09 09:26:00',
'finishTime' => '2026-03-10 10:00:00',
]];
$this->query->scheme = [
'应付明细' => json_encode([[
'rowValue' => [
['label' => '序号', 'value' => '1'],
['label' => '类型', 'value' => '供应商货款-月结'],
['label' => '用途', 'value' => '三洋木业合同月结预付款30%申请'],
['label' => '付款金额', 'value' => '46800.00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'付款明细' => json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1'],
['label' => '付款金额', 'value' => '46800.00'],
['label' => '打款时间', 'value' => '2026-03-10'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'付款金额合计' => 46800,
];
$this->approvalClient->valuesByInstance['process-payable-no-account'] = [
'应付明细' => $this->query->scheme['应付明细'],
];
$this->synchronizer->sync(true);
$row = DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->where('process_instance_id', 'process-payable-no-account')
->first();
$this->assertSame('2202.01', $row->account_code);
$this->assertSame('应付账款_供货商', $row->account_name);
$this->assertEqualsWithDelta(46800.0, (float) $row->amount, 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_running_workflow_without_payment_fields_resolves_current_task_and_approver(): void
{
config([
'upstream_mirrors.ding_process_details.processes.PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF' => [
'document_type' => '种菜核销',
'detail_table' => '收款费用明细',
'detail_amount_fields' => ['代发订单金额'],
'amount_total_fields' => ['收款合计'],
],
]);
$this->query->workflows = [[
'processInstanceId' => 'process-running-without-payment',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610291915000052466',
'title' => '苏木提交的种菜核销种菜报销流程',
'status' => 'RUNNING',
'result' => 'NONE',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-29 19:15:00',
]];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '100.00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 100,
];
$this->approvalClient->tasksByInstance['process-running-without-payment'] = [[
'taskGroupName' => '行政核价',
'status' => 'RUNNING',
'result' => 'NONE',
'userId' => 'user-3',
'createTime' => '2026-10-29T19:20Z',
'finishTime' => '',
]];
$this->synchronizer->sync(true);
$row = DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->where('business_id', '202610291915000052466')
->first();
$this->assertSame('行政核价', $row->current_task_name);
$this->assertSame('赵六', $row->current_approver_name);
$this->assertSame('2026-10-29 19:20:00', (string) $row->current_task_started_at);
$this->assertSame('not_applicable', $row->payment_source_state);
}
public function test_one_failed_approval_snapshot_does_not_block_later_workflows(): void
{
config([
'upstream_mirrors.ding_process_details.processes.PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF' => [
'document_type' => '种菜核销',
'detail_table' => '收款费用明细',
'detail_amount_fields' => ['代发订单金额'],
'amount_total_fields' => ['收款合计'],
],
]);
$this->query->workflows = [[
'processInstanceId' => 'process-snapshot-fails',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610291930000052467',
'title' => '苏木提交的种菜核销种菜报销流程',
'status' => 'RUNNING',
'result' => 'NONE',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-29 19:30:00',
], [
'processInstanceId' => 'process-snapshot-succeeds',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610291931000052468',
'title' => '苏木提交的种菜核销种菜报销流程',
'status' => 'RUNNING',
'result' => 'NONE',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-29 19:31:00',
]];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '100.00'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 100,
];
$this->approvalClient->failingInstances = ['process-snapshot-fails'];
$this->approvalClient->tasksByInstance['process-snapshot-succeeds'] = [[
'taskGroupName' => '行政核价',
'status' => 'RUNNING',
'result' => 'NONE',
'userId' => 'user-2',
'createTime' => '2026-10-29T19:32Z',
'finishTime' => '',
]];
$this->synchronizer->sync(true);
$rows = DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->whereIn('business_id', ['202610291930000052467', '202610291931000052468'])
->pluck('current_approver_name', 'business_id');
$this->assertNull($rows['202610291930000052467']);
$this->assertSame('王五', $rows['202610291931000052468']);
}
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_backfills_completed_rows_with_missing_account_code(): void
{
$connection = DB::connection('business_mirror');
$now = now();
$workflow = [
'processInstanceId' => 'process-account-backfill',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610301116000560927',
'title' => '周生辰提交的种菜核销种菜报销流程',
'status' => 'COMPLETED',
'result' => 'AGREE',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-30 11:17:49',
'finishTime' => '2026-11-02 13:03:00',
];
$this->query->workflowsById = [$workflow['processInstanceId'] => $workflow];
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '序号', 'value' => '1'],
['label' => '代发订单金额', 'value' => '100.00'],
['label' => '用途', 'value' => '10.29种菜报销'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 100,
'付款明细' => json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1'],
['label' => '付款金额', 'value' => '100.00'],
['label' => '打款时间', 'value' => '2026-10-30'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
];
$this->approvalClient->valuesByInstance[$workflow['processInstanceId']] = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '序号', 'value' => '1'],
['label' => '代发订单金额', 'value' => '100.00'],
['label' => '科目编码', 'value' => '1221.01'],
['label' => '科目名称', 'value' => '其他应收款_运营中心-种菜刷单'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
];
$connection->table('ding_process_detail_mirror')->insert([
'business_id' => $workflow['businessId'],
'process_instance_id' => $workflow['processInstanceId'],
'line_no' => 1,
'business_month' => '202610',
'originator_name' => '周生辰',
'status' => '已完成',
'approval_finished_at' => '2026-11-02 13:03:00',
'payment_source_state' => 'present',
'cashier_status' => 'completed',
'cashier_checked_at' => $now,
'current_approver_name' => null,
'document_type' => '线上代发业务报销',
'purpose' => '10.29种菜报销',
'account_code' => '',
'account_name' => '',
'amount' => 100,
'paid_at' => '2026-10-30',
'source_scope' => 'personal_permission',
'synced_at' => $now->copy()->subHour(),
'created_at' => $now->copy()->subDay(),
'updated_at' => $now->copy()->subHour(),
]);
$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' => 0,
'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' => 1,
'created_at' => $now->copy()->subDay(),
'updated_at' => $now->copy()->subMinutes(10),
]);
$this->synchronizer->sync(false);
$row = $connection->table('ding_process_detail_mirror')
->where('process_instance_id', $workflow['processInstanceId'])
->first();
$this->assertSame('1221.01', $row->account_code);
$this->assertSame('其他应收款_运营中心-种菜刷单', $row->account_name);
$this->assertContains($workflow['processInstanceId'], $this->approvalClient->calls);
$this->assertSame(
$workflow['processInstanceId'],
json_decode((string) $connection->table('business_mirror_table_states')
->value('cursor'), true)['approval_api_cursor'],
);
}
public function test_completed_payment_row_with_blank_paid_at_is_queued_for_refresh(): void
{
$now = now();
DB::connection('business_mirror')->table('ding_process_detail_mirror')->insert([
'business_id' => '202610301116000560927',
'process_instance_id' => 'process-payment-date-refresh',
'line_no' => 1,
'business_month' => '202610',
'originator_name' => '周生辰',
'status' => '已完成',
'approval_finished_at' => '2026-11-02 13:03:00',
'payment_source_state' => 'present',
'cashier_status' => 'completed',
'cashier_checked_at' => $now,
'current_approver_name' => '已审批',
'document_type' => '线上代发业务报销',
'purpose' => '10.29种菜报销',
'account_code' => '1221.01',
'account_name' => '其他应收款_运营中心-种菜刷单',
'amount' => 100,
'paid_at' => '',
'source_scope' => 'personal_permission',
'synced_at' => $now->copy()->subHour(),
'created_at' => $now->copy()->subDay(),
'updated_at' => $now->copy()->subHour(),
]);
$method = new \ReflectionMethod(
DingProcessDetailMirrorSynchronizer::class,
'approvalApiFallbackCandidates',
);
$method->setAccessible(true);
$this->assertSame(
['process-payment-date-refresh'],
$method->invoke($this->synchronizer, '', [], 10),
);
}
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->assertNotEmpty($this->approvalClient->bulkCalls);
$this->assertContains(
'backlog-000',
array_merge(...$this->approvalClient->bulkCalls),
);
$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' => '库存商品_线上代发'],
['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('originator_department', 255)->nullable();
$table->string('status', 20)->nullable();
$table->dateTime('approval_finished_at')->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->string('store_name', 500)->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']);
},
);
Schema::connection('business_mirror')->create(
'ding_process_payment_line_mirror',
function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('source_scope', 40);
$table->string('business_id', 32);
$table->string('process_instance_id', 100)->nullable();
$table->unsignedInteger('payment_no');
$table->unsignedInteger('line_no');
$table->string('paid_month', 7)->default('');
$table->string('paid_at', 50)->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->dateTime('synced_at', 6);
$table->timestamps();
$table->unique(['source_scope', 'business_id', 'payment_no', 'line_no']);
},
);
}
}
class FakeDingProcessDetailQuery extends UpstreamReadOnlyQuery
{
/** @var list<array<string,mixed>> */
public array $workflows = [];
/** @var array<string,mixed> */
public array $scheme = [];
/** @var list<mixed> */
public array $unnamedSchemeValues = [];
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_dimission')) {
return [
['userid' => 'user-3', 'name' => '赵六', 'realName' => '赵六'],
];
}
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 = [];
$unnamedQuery = str_contains($sql, 'AND name = ?');
$bindingValues = $unnamedQuery ? array_slice($bindings, 0, -1) : $bindings;
$requested = array_fill_keys(array_map('strval', $bindingValues), 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) {
if ($unnamedQuery) {
foreach ($this->unnamedSchemeValues as $value) {
$rows[] = [
'processInstanceId' => $processInstanceId,
'name' => '',
'value' => $value,
];
}
continue;
}
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 array<string,string> */
public array $statusByInstance = [];
/** @var array<string,string> */
public array $resultByInstance = [];
/** @var list<string> */
public array $calls = [];
/** @var list<list<string>> */
public array $bulkCalls = [];
/** @var list<string> */
public array $failingInstances = [];
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;
if (in_array($processInstanceId, $this->failingInstances, true)) {
throw new DingTalkProcessInstanceException('审批接口模拟失败');
}
$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' => $this->statusByInstance[$processInstanceId] ?? null,
'result' => $this->resultByInstance[$processInstanceId] ?? null,
];
}
public function processSnapshots(array $processInstanceIds): array
{
$this->bulkCalls[] = array_values($processInstanceIds);
$snapshots = [];
$errors = [];
$fatal = false;
foreach ($processInstanceIds as $processInstanceId) {
try {
$snapshots[$processInstanceId] = $this->processSnapshot($processInstanceId);
} catch (DingTalkProcessInstanceException $exception) {
$errors[$processInstanceId] = $exception->getMessage();
$fatal = $fatal || $exception->stopsRun();
if ($fatal) {
break;
}
}
}
return [
'snapshots' => $snapshots,
'errors' => $errors,
'fatal' => $fatal,
'attempted' => count($processInstanceIds),
];
}
}