fix: 避免缺失付款明细被误判为未付款

This commit is contained in:
yueyang 2026-10-27 11:15:00 +08:00
parent 79a09022ca
commit 3be3f59855
11 changed files with 429 additions and 18 deletions

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
if ($schema->hasTable('ding_process_detail_mirror')
&& ! $schema->hasColumn('ding_process_detail_mirror', 'payment_source_state')) {
$schema->table('ding_process_detail_mirror', function (Blueprint $table): void {
$table->string('payment_source_state', 24)
->nullable()
->after('status');
});
}
}
public function down(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
if ($schema->hasTable('ding_process_detail_mirror')
&& $schema->hasColumn('ding_process_detail_mirror', 'payment_source_state')) {
$schema->table('ding_process_detail_mirror', function (Blueprint $table): void {
$table->dropColumn('payment_source_state');
});
}
}
};

View File

@ -35,7 +35,7 @@
| 待有效凭据后首次同步 | 普通商品当前成本价、商品类型和更新时间继续读取 `operations_replica.rpt.jst_sku`;`rpt.fys_skuCostPriceUpdate` 是一 SKU 一行的旧快照,不能充当变价历史。完整成本价变更仍读取聚水潭经典开放平台“商品操作日志查询”接口 `worklog.query`,只镜像操作内容含“采购价”的记录到 `jushuitan_cost_price_history_rows`;经典接口必须在服务器私有配置中使用同一应用的 `JUSHUITAN_PARTNER_ID / JUSHUITAN_PARTNER_KEY / JUSHUITAN_TOKEN`,不得把新开放平台的 App Key / App Secret / Access Token 混用。页面在首次同步完成前明确显示待同步或失败,不把 0 条解释为真实无历史。 |
| 本次接入 | 流程明细读取 `operations_replica/rpt.ding_Workflow + rpt.ding_WorkflowScheme` 的结构化审批主表、费用明细和付款明细,写入 `ding_process_detail_mirror` 与 `ding_process_payment_mirror`。禁止先把明细表和付款表铺平成 CSV 后逐行求和;付款明细必须按“对应序号”关联到代表明细行,但合计始终读取独立付款事实。每个纳入范围的源审批至少保留一条页面记录,没有费用金额的审批以空金额显示,不静默隐藏。首次全量先运行 `business-mirror:sync-ding-process-details --full --dry-run` 校验,再使用 `business-mirror:sync-ding-process-details --full` 发布;日常通过 `updateAt/createAt` 48 小时重叠增量同步。当前纳入日常报销、应付、线上代发、种菜、快递面单、固定资产、日常物品、样品、一次性采买、板材、工资、外贸费用和种菜充值等 14 个财务流程;发现新的明细流程编码时全量同步报错并保留上一版。 |
流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布后由工作站执行 `scripts/install-ding-process-sync-cron.ps1` 安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,使用独立调度锁并在共用上游锁上最多等待 15 分钟。页面同时按审批单费用合计和独立付款事实展示“未付款 / 部分付款 / 已付款”;审批状态不能代替付款状态。
流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布后由工作站执行 `scripts/install-ding-process-sync-cron.ps1` 安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,使用独立调度锁并在共用上游锁上最多等待 15 分钟。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。页面按审批单费用合计和独立付款事实展示“付款明细待同步 / 未付款 / 部分付款 / 已付款 / 不适用”:只有源表单明确存在付款表或付款合计字段但金额为零时才标“未付款”,付款表单缺失时必须显示“付款明细待同步”,不能把上游尚未返回的数据误报为未付款。审批状态不能代替付款状态。
抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。

View File

@ -135,7 +135,7 @@ class ProcessDetails extends Controller
&& checkdate((int) $dateParts[2], (int) $dateParts[3], (int) $dateParts[1]);
$paymentState = trim((string) $request->query('payment_state', ''));
if (! in_array($paymentState, DingProcessDetailMirrorRepository::PAYMENT_STATES, true)
&& $paymentState !== '未确认') {
&& $paymentState !== DingProcessDetailMirrorRepository::LEGACY_PAYMENT_STATE) {
$paymentState = '';
}

View File

@ -15,10 +15,14 @@ class DingProcessDetailMirrorRepository
public const DATASET_PREFIX = 'ding.process_details.';
public const PAYMENT_STATES = ['未付款', '部分付款', '已付款'];
public const PAYMENT_STATES = ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'];
public const LEGACY_PAYMENT_STATE = '未确认';
private readonly ConnectionInterface $connection;
private ?bool $paymentSourceStateColumn = null;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
@ -164,7 +168,7 @@ class DingProcessDetailMirrorRepository
->all(),
'payment_states' => $this->structuredPaymentsEnabled()
? self::PAYMENT_STATES
: ['未确认'],
: [self::LEGACY_PAYMENT_STATE],
];
}
@ -233,7 +237,10 @@ class DingProcessDetailMirrorRepository
);
} else {
$query
->selectRaw('NULL as payment_fact_amount, ? as payment_state', ['未确认']);
->selectRaw(
'NULL as payment_fact_amount, ? as payment_state',
[self::LEGACY_PAYMENT_STATE],
);
}
return $this->applyFilters($query, $filters);
@ -274,9 +281,9 @@ class DingProcessDetailMirrorRepository
if ($paymentState !== '') {
$allowed = $this->structuredPaymentsEnabled()
? self::PAYMENT_STATES
: ['未确认'];
: [self::LEGACY_PAYMENT_STATE];
if (in_array($paymentState, $allowed, true)) {
if ($paymentState !== '未确认') {
if ($paymentState !== self::LEGACY_PAYMENT_STATE) {
$query->whereRaw(
'(' . $this->paymentStateExpression() . ') = ?',
[$paymentState],
@ -290,14 +297,28 @@ class DingProcessDetailMirrorRepository
private function paymentStateExpression(): string
{
$sourceState = $this->paymentSourceStateColumnAvailable()
? 'ding_process_detail_mirror.payment_source_state'
: "'missing'";
return "CASE
WHEN COALESCE(payment_totals.payment_amount, 0) <= 0.005 THEN '未付款'
WHEN payment_totals.payment_amount + 0.01
WHEN COALESCE(payment_totals.payment_amount, 0) > 0.005
AND payment_totals.payment_amount + 0.01
< COALESCE(detail_totals.approval_amount, 0) THEN '部分付款'
ELSE '已付款'
WHEN COALESCE(payment_totals.payment_amount, 0) > 0.005 THEN '已付款'
WHEN {$sourceState} = 'not_applicable' THEN '不适用'
WHEN {$sourceState} = 'present' THEN '未付款'
ELSE '付款明细待同步'
END";
}
private function paymentSourceStateColumnAvailable(): bool
{
return $this->paymentSourceStateColumn ??= Schema::connection(
$this->connection->getName(),
)->hasColumn('ding_process_detail_mirror', 'payment_source_state');
}
/** @param array<string,mixed>|null $state */
private function stateIsStale(?array $state, int $staleAfterMinutes): bool
{

View File

@ -142,6 +142,7 @@ class DingProcessDetailMirrorSynchronizer
*/
private function extractFullWindow(Carbon $now): array
{
$schemeHighWatermark = $this->schemeHighWatermark();
$start = trim((string) config('upstream_mirrors.ding_process_details.start_date', '2026-01-01'));
$end = $now->copy()->addDay()->toDateString();
$where = 'w.joinAt >= ? AND w.joinAt < ?';
@ -163,6 +164,7 @@ class DingProcessDetailMirrorSynchronizer
$result['metrics']['workflows'],
));
}
$result['metrics']['scheme_high_watermark'] = $schemeHighWatermark;
return $result;
}
@ -239,6 +241,7 @@ class DingProcessDetailMirrorSynchronizer
*/
private function extractIncremental(?array $state, Carbon $now): array
{
$schemeHighWatermark = $this->schemeHighWatermark();
$overlapHours = max(
1,
(int) config('upstream_mirrors.ding_process_details.incremental_overlap_hours', 48),
@ -250,13 +253,145 @@ class DingProcessDetailMirrorSynchronizer
$where = $cursorExpression . ' >= ? AND ' . $cursorExpression . ' < ?';
$bindings = [$since, $until];
return $this->extractWorkflows(
$cursor = $this->stateCursor($state);
$lastSchemeId = max(0, (int) ($cursor['scheme_high_watermark'] ?? 0));
$result = $this->extractWorkflows(
$where,
$bindings,
$cursorExpression,
'sync_cursor',
$now,
);
if ($lastSchemeId > 0) {
$changedProcessIds = $this->schemeChangedProcessIds(
$lastSchemeId,
$schemeHighWatermark,
);
if ($changedProcessIds !== []) {
foreach (array_chunk($changedProcessIds, 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$schemeResult = $this->extractWorkflows(
"w.processInstanceId IN ({$placeholders})",
$chunk,
'w.processInstanceId',
'processInstanceId',
$now,
);
$result = $this->mergeExtracted($result, $schemeResult);
}
}
}
$result['metrics']['scheme_high_watermark'] = $schemeHighWatermark;
return $result;
}
/**
* New scheme rows can be appended after the workflow itself stopped
* changing. Track their numeric ID so late payment/details are discovered
* without rescanning every workflow.
*
* @return list<string>
*/
private function schemeChangedProcessIds(int $afterId, int $throughId): array
{
if ($throughId <= $afterId) {
return [];
}
$processCodes = array_keys((array) config(
'upstream_mirrors.ding_process_details.processes',
[],
));
$placeholders = implode(',', array_fill(0, count($processCodes), '?'));
$query = $this->queries->forSourceWithTimeout(
self::SOURCE,
(int) config('finance_ledger.query_timeout', 120),
);
$rows = $query->select(
'SELECT DISTINCT s.processInstanceId '
. 'FROM rpt.ding_WorkflowScheme s '
. 'JOIN rpt.ding_Workflow w ON w.processInstanceId = s.processInstanceId '
. 'WHERE TRY_CONVERT(bigint, s.id) > ? '
. 'AND TRY_CONVERT(bigint, s.id) <= ? '
. "AND w.processCode IN ({$placeholders}) "
. 'ORDER BY s.processInstanceId',
[$afterId, $throughId, ...$processCodes],
);
return array_values(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['processInstanceId'] ?? '')),
$rows,
)));
}
private function schemeHighWatermark(): int
{
$query = $this->queries->forSourceWithTimeout(
self::SOURCE,
(int) config('finance_ledger.query_timeout', 120),
);
return max(0, (int) $query->scalar(
'SELECT MAX(TRY_CONVERT(bigint, id)) FROM rpt.ding_WorkflowScheme',
));
}
/** @param array<string,mixed>|null $state @return array<string,mixed> */
private function stateCursor(?array $state): array
{
$cursor = $state['cursor'] ?? null;
if (is_string($cursor)) {
$cursor = json_decode($cursor, true);
}
return is_array($cursor) ? $cursor : [];
}
/**
* @param array{details:list<array<string,mixed>>,payments:list<array<string,mixed>>,process_instance_ids:list<string>,metrics:array<string,mixed>} $first
* @param array{details:list<array<string,mixed>>,payments:list<array<string,mixed>>,process_instance_ids:list<string>,metrics:array<string,mixed>} $second
* @return array{details:list<array<string,mixed>>,payments:list<array<string,mixed>>,process_instance_ids:list<string>,metrics:array<string,mixed>}
*/
private function mergeExtracted(array $first, array $second): array
{
if ($second['process_instance_ids'] === []) {
return $first;
}
$details = [];
foreach ([...$first['details'], ...$second['details']] as $row) {
$key = (string) ($row['business_id'] ?? '') . '|' . (int) ($row['line_no'] ?? 0);
$details[$key] = $row;
}
$payments = [];
foreach ([...$first['payments'], ...$second['payments']] as $row) {
$key = (string) ($row['business_id'] ?? '') . '|' . (int) ($row['payment_no'] ?? 0);
$payments[$key] = $row;
}
$details = array_values($details);
$payments = array_values($payments);
$processInstanceIds = array_values(array_unique([
...$first['process_instance_ids'],
...$second['process_instance_ids'],
]));
return [
'details' => $details,
'payments' => $payments,
'process_instance_ids' => $processInstanceIds,
'metrics' => [
'workflows' => count($processInstanceIds),
'details' => count($details),
'payments' => count($payments),
'amount_total' => array_sum(array_column($details, 'amount_cents')) / 100,
'bank_amount_total' => array_sum(array_column($payments, 'amount_cents')) / 100,
],
];
}
/**
@ -507,6 +642,7 @@ class DingProcessDetailMirrorSynchronizer
$documentType = $this->documentType($process, $title);
$status = $this->status((string) ($workflow['status'] ?? ''));
$businessMonth = $this->businessMonth($businessId, (string) ($workflow['joinAt'] ?? ''));
$paymentSourceState = $this->paymentSourceState($process, $fields);
[$workflowDetails, $workflowAmountCents, $detailTableName] = $this->detailRows(
$workflow,
@ -519,6 +655,7 @@ class DingProcessDetailMirrorSynchronizer
$documentType,
$status,
$businessMonth,
$paymentSourceState,
);
$this->validateAmountTotal(
$businessId,
@ -553,6 +690,7 @@ class DingProcessDetailMirrorSynchronizer
$documentType,
$status,
$businessMonth,
$paymentSourceState,
);
}
@ -595,6 +733,7 @@ class DingProcessDetailMirrorSynchronizer
string $documentType,
string $status,
string $businessMonth,
string $paymentSourceState,
): array {
$tableName = trim((string) ($process['detail_table'] ?? ''));
$rows = $tableName !== '' ? $this->tableRows($fields[$tableName] ?? null, $workflow, $tableName, $issues) : [];
@ -626,6 +765,7 @@ class DingProcessDetailMirrorSynchronizer
$documentType,
$status,
$businessMonth,
$paymentSourceState,
);
}
@ -748,6 +888,39 @@ class DingProcessDetailMirrorSynchronizer
return $payments;
}
/**
* Distinguish an empty payment table from a payment form section that the
* upstream replica has not delivered yet. Missing sections must never be
* presented as "未付款".
*
* @param array<string,mixed> $process
* @param array<string,mixed> $fields
*/
private function paymentSourceState(array $process, array $fields): string
{
$paymentTable = trim((string) ($process['payment_table'] ?? ''));
$paymentTotalFields = array_values(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
(array) ($process['payment_total_fields'] ?? []),
)));
if ($paymentTable === '' && $paymentTotalFields === []) {
return 'not_applicable';
}
if ($paymentTable !== '' && array_key_exists($paymentTable, $fields)) {
return 'present';
}
foreach ($paymentTotalFields as $field) {
if (array_key_exists($field, $fields)) {
return 'present';
}
}
return 'missing';
}
/**
* @param string $businessId
* @param array<string,mixed> $process
@ -840,6 +1013,7 @@ class DingProcessDetailMirrorSynchronizer
string $documentType,
string $status,
string $businessMonth,
string $paymentSourceState,
): array {
return [
'business_id' => mb_substr(trim((string) ($workflow['businessId'] ?? '')), 0, 32),
@ -852,6 +1026,7 @@ class DingProcessDetailMirrorSynchronizer
'business_month' => $businessMonth,
'originator_name' => mb_substr($originator, 0, 100),
'status' => mb_substr($status, 0, 20),
'payment_source_state' => mb_substr($paymentSourceState, 0, 24),
'document_type' => mb_substr($documentType, 0, 100),
'purpose' => $purpose,
'account_code' => mb_substr($accountCode, 0, 64),
@ -1182,6 +1357,7 @@ class DingProcessDetailMirrorSynchronizer
'mirror_payments' => $paymentCount,
'amount_total' => round($amountTotal, 2),
'bank_amount_total' => round($bankAmountTotal, 2),
'scheme_high_watermark' => (int) ($metrics['scheme_high_watermark'] ?? 0),
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'high_watermark' => null,
'window_started_at' => null,

View File

@ -81,7 +81,7 @@ class ProcessDetailsPageData
&& checkdate((int) $dateParts[2], (int) $dateParts[3], (int) $dateParts[1]);
$paymentState = trim((string) $request->query('payment_state', ''));
if (! in_array($paymentState, DingProcessDetailMirrorRepository::PAYMENT_STATES, true)
&& $paymentState !== '未确认') {
&& $paymentState !== DingProcessDetailMirrorRepository::LEGACY_PAYMENT_STATE) {
$paymentState = '';
}

View File

@ -5,7 +5,8 @@
$paymentStateClass = static fn (string $state): string => match ($state) {
'已付款' => 'is-success',
'部分付款' => 'is-warning',
'未付款', '未确认' => 'is-neutral',
'付款明细待同步', '未确认' => 'is-warning',
'未付款', '不适用' => 'is-neutral',
default => 'is-neutral',
};
@endphp
@ -612,6 +613,7 @@
<p class="process-details-note">
金额合计按审批费用明细汇总,付款合计按结构化付款明细汇总,同一笔付款不会因关联多条费用而重复计算。
付款状态按每张审批单的费用合计与独立付款事实判断,不能仅用审批状态推断是否已付款。
“付款明细待同步”表示上游表单尚未返回付款明细,不能据此认定未付款。
当前展示的是当前账号权限范围内可读取的钉钉审批流程明细;审批中的金额和行项目仍可能随流程变化。
企业级全量数据待领导开通 OA 权限后切换。
</p>

View File

@ -52,7 +52,7 @@ class ProcessDetailsPageTest extends FeatureTestCase
$repository->shouldReceive('filterOptions')->once()->andReturn([
'document_types' => ['公摊日常报销'],
'statuses' => ['已完成'],
'payment_states' => ['未付款', '部分付款', '已付款'],
'payment_states' => ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'],
]);
$this->app->instance(DingProcessDetailMirrorRepository::class, $repository);
@ -77,6 +77,7 @@ class ProcessDetailsPageTest extends FeatureTestCase
->assertSee('审批状态')
->assertSee('付款状态')
->assertSee('未付款')
->assertSee('付款明细待同步')
->assertSee('付款金额')
->assertSee('付款合计')
->assertDontSee('银行付款合计')

View File

@ -55,7 +55,10 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
$this->assertContains('应付申请流程', $options['document_types']);
$this->assertContains('已完成', $options['statuses']);
$this->assertContains('审批中', $options['statuses']);
$this->assertSame(['未付款', '部分付款', '已付款'], $options['payment_states']);
$this->assertSame(
['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'],
$options['payment_states'],
);
}
public function test_paginate_applies_paid_date_status_and_keyword_filters(): void
@ -101,6 +104,66 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
);
}
public function test_missing_payment_source_is_not_reported_as_unpaid(): void
{
DB::connection('business_mirror')
->table('ding_process_payment_mirror')
->where('business_id', '202601011200000000001')
->delete();
DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->where('business_id', '202601011200000000001')
->update(['payment_source_state' => 'missing']);
$result = $this->repository->paginate(
['payment_state' => '付款明细待同步'],
1,
50,
);
$this->assertSame(2, $result['total']);
$this->assertSame(
['付款明细待同步'],
collect($result['data'])->pluck('payment_state')->unique()->values()->all(),
);
}
public function test_present_empty_payment_table_is_reported_as_unpaid(): void
{
DB::connection('business_mirror')
->table('ding_process_payment_mirror')
->where('business_id', '202602031200000000002')
->delete();
$result = $this->repository->paginate(['payment_state' => '未付款'], 1, 50);
$this->assertSame(2, $result['total']);
$this->assertSame(
['未付款'],
collect($result['data'])->pluck('payment_state')->unique()->values()->all(),
);
}
public function test_process_without_payment_fields_is_not_applicable(): void
{
DB::connection('business_mirror')
->table('ding_process_payment_mirror')
->where('business_id', '202601011200000000001')
->delete();
DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->where('business_id', '202601011200000000001')
->update(['payment_source_state' => 'not_applicable']);
$result = $this->repository->paginate(['payment_state' => '不适用'], 1, 50);
$this->assertSame(2, $result['total']);
$this->assertSame(
['不适用'],
collect($result['data'])->pluck('payment_state')->unique()->values()->all(),
);
}
public function test_export_rows_returns_every_filtered_row(): void
{
$rows = iterator_to_array($this->repository->exportRows(['status' => '已完成']));
@ -193,6 +256,7 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
'business_month' => $month,
'originator_name' => $originator,
'status' => $status,
'payment_source_state' => 'present',
'document_type' => $documentType,
'purpose' => $purpose,
'account_code' => '5602.04',
@ -246,6 +310,7 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
$table->string('business_month', 6)->index();
$table->string('originator_name', 100)->nullable()->index();
$table->string('status', 20)->nullable()->index();
$table->string('payment_source_state', 24)->nullable();
$table->string('document_type', 100)->nullable()->index();
$table->text('purpose')->nullable();
$table->string('account_code', 64)->nullable();

View File

@ -95,6 +95,8 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
->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'];
@ -170,6 +172,74 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
$this->assertNotNull($row);
$this->assertNull($row->amount);
$this->assertSame('审批中', $row->status);
$this->assertSame('missing', $row->payment_source_state);
$page = app(DingProcessDetailMirrorRepository::class)->paginate(
['payment_state' => '付款明细待同步'],
1,
50,
);
$this->assertSame(1, $page['total']);
}
public function test_incremental_sync_detects_late_payment_rows_without_workflow_update(): void
{
$workflow = [
'processInstanceId' => 'process-late-payment',
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'businessId' => '202610151743000310225',
'title' => '李斯提交的线上代发业务报销',
'status' => 'RUNNING',
'originatorUserId' => 'user-1',
'joinAt' => '2026-10-15 17:44:06',
'updateAt' => '2026-10-15 17:44:06',
];
$this->query->workflows = [$workflow];
$this->query->schemeHighWatermark = 100;
$this->query->scheme = [
'收款费用明细' => json_encode([[
'rowValue' => [
['label' => '代发订单金额', 'value' => '15535.37'],
['label' => '科目编码', 'value' => '5601.05.03'],
['label' => '科目名称', 'value' => '销售费用_营销费用_软件服务费'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'收款合计' => 15535.37,
];
$this->synchronizer->sync(true);
$this->assertSame('missing', DB::connection('business_mirror')
->table('ding_process_detail_mirror')->value('payment_source_state'));
DB::connection('business_mirror')
->table('business_mirror_table_states')
->where('dataset_key', 'ding.process_details.personal_permission')
->update(['last_succeeded_at' => now()->subMinutes(10)]);
$this->query->workflows = [];
$this->query->schemeChangedWorkflows = [$workflow];
$this->query->schemeChangedProcessIds = ['process-late-payment'];
$this->query->schemeHighWatermark = 101;
$this->query->scheme['付款明细'] = json_encode([[
'rowValue' => [
['label' => '对应序号', 'value' => '1'],
['label' => '付款方式', 'value' => '银行卡'],
['label' => '付款金额', 'value' => '15535.37'],
['label' => '科目编码', 'value' => '1002.01.544'],
['label' => '科目名称', 'value' => '华夏银行-福尼达'],
['label' => '打款时间', 'value' => '2026-10-18'],
],
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$result = $this->synchronizer->sync(false);
$this->assertSame(1, $result['workflows']);
$this->assertSame(1, $result['payments']);
$this->assertSame('present', DB::connection('business_mirror')
->table('ding_process_detail_mirror')->value('payment_source_state'));
$this->assertSame(1, DB::connection('business_mirror')
->table('ding_process_payment_mirror')->count());
$state = DB::connection('business_mirror')
->table('business_mirror_table_states')
->first();
$this->assertSame(101, (int) json_decode((string) $state->cursor, true)['scheme_high_watermark']);
}
/** @return array<string,mixed> */
@ -240,6 +310,7 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
$table->string('business_month', 6);
$table->string('originator_name', 100)->nullable();
$table->string('status', 20)->nullable();
$table->string('payment_source_state', 24)->nullable();
$table->string('document_type', 100)->nullable();
$table->text('purpose')->nullable();
$table->string('account_code', 64)->nullable();
@ -287,6 +358,13 @@ class FakeDingProcessDetailQuery extends UpstreamReadOnlyQuery
/** @var array<string,mixed> */
public array $scheme = [];
public array $schemeChangedWorkflows = [];
/** @var list<string> */
public array $schemeChangedProcessIds = [];
public int $schemeHighWatermark = 0;
/** @var list<array{sql:string,bindings:array}> */
public array $calls = [];
@ -297,6 +375,9 @@ class FakeDingProcessDetailQuery extends UpstreamReadOnlyQuery
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);
}
@ -311,18 +392,33 @@ class FakeDingProcessDetailQuery extends UpstreamReadOnlyQuery
if (str_contains($sql, 'FROM rpt.hr_employee')) {
return [['userid' => 'user-1', 'name' => '陈芝豹']];
}
if (str_contains($sql, 'SELECT DISTINCT s.processInstanceId')) {
return array_map(
static fn (string $id): array => ['processInstanceId' => $id],
$this->schemeChangedProcessIds,
);
}
if (str_contains($sql, 'JOIN rpt.ding_WorkflowScheme')
&& str_contains($sql, 'NOT IN')) {
return [];
}
if (str_contains($sql, 'FROM rpt.ding_Workflow w')
&& str_contains($sql, 'w.processInstanceId IN')) {
return $this->schemeChangedWorkflows;
}
if (str_contains($sql, 'FROM rpt.ding_Workflow w')) {
return $this->workflows;
}
if (str_contains($sql, 'FROM rpt.ding_WorkflowScheme')) {
$rows = [];
$processInstanceId = (string) (
$this->workflows[0]['processInstanceId']
?? $this->schemeChangedWorkflows[0]['processInstanceId']
?? ''
);
foreach ($this->scheme as $name => $value) {
$rows[] = [
'processInstanceId' => (string) ($this->workflows[0]['processInstanceId'] ?? ''),
'processInstanceId' => $processInstanceId,
'name' => $name,
'value' => $value,
];

View File

@ -68,7 +68,7 @@ class ProcessDetailsPageDataTest extends TestCase
->willReturn([
'document_types' => ['种菜核销/种菜报销'],
'statuses' => ['审批中'],
'payment_states' => ['未付款', '部分付款', '已付款'],
'payment_states' => ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'],
]);
$data = (new ProcessDetailsPageData($repository))->page(Request::create(
@ -86,7 +86,10 @@ class ProcessDetailsPageDataTest extends TestCase
$this->assertSame(11331, $data['summary']['rows']);
$this->assertSame('2026-10-24', $data['filters']['paid_date']);
$this->assertSame('未付款', $data['filters']['payment_state']);
$this->assertSame(['未付款', '部分付款', '已付款'], $data['paymentStates']);
$this->assertSame(
['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'],
$data['paymentStates'],
);
$this->assertTrue($data['mirrorStale']);
$this->assertSame(15, $data['mirrorStaleAfterMinutes']);
$this->assertArrayNotHasKey('months', $data);