999 lines
43 KiB
PHP
999 lines
43 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_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_reserves_api_quota_for_cashier_backlog(): 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' => null,
|
|
'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'],
|
|
array_slice($backlog, 0, 25),
|
|
);
|
|
$this->assertCount(50, $this->approvalClient->calls);
|
|
$this->assertSame($expectedBacklog, array_slice($this->approvalClient->calls, 0, 25));
|
|
$this->assertSame(
|
|
'backlog-024',
|
|
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-024')
|
|
->value('cashier_status'));
|
|
$this->assertNotNull($connection->table('ding_process_detail_mirror')
|
|
->where('process_instance_id', 'backlog-024')
|
|
->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('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' => '陈芝豹']];
|
|
}
|
|
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 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 = [];
|
|
$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,
|
|
];
|
|
}
|
|
}
|