fix: 避免缺失付款明细被误判为未付款
This commit is contained in:
parent
79a09022ca
commit
3be3f59855
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。
|
||||
|
||||
|
||||
@ -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 = '';
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = '';
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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('银行付款合计')
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user