feat: 按出纳节点重做流程付款状态 #685

Merged
yueyang merged 1 commits from feature/yueyang-cashier-payment-status into main 2026-10-27 14:01:24 +08:00
11 changed files with 480 additions and 79 deletions

View File

@ -0,0 +1,51 @@
<?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', 'cashier_status')) {
$schema->table('ding_process_detail_mirror', function (Blueprint $table): void {
$table->string('cashier_status', 32)->nullable()->after('payment_source_state');
$table->dateTime('cashier_checked_at')->nullable()->after('cashier_status');
$table->index(
['source_scope', 'status', 'cashier_checked_at'],
'ding_process_cashier_refresh_index',
);
});
}
}
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', 'cashier_status')) {
$schema->table('ding_process_detail_mirror', function (Blueprint $table): void {
$table->dropIndex('ding_process_cashier_refresh_index');
$table->dropColumn(['cashier_status', 'cashier_checked_at']);
});
}
}
};

View File

@ -35,9 +35,9 @@
| 待有效凭据后首次同步 | 普通商品当前成本价、商品类型和更新时间继续读取 `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 分钟。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。页面按审批单费用合计和独立付款事实展示“付款明细待同步 / 未付款 / 部分付款 / 已付款 / 不适用”:只有源表单明确存在付款表或付款合计字段但金额为零时才标“未付款”,付款表单缺失时必须显示“付款明细待同步”,不能把上游尚未返回的数据误报为未付款。审批状态不能代替付款状态。
流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布后由工作站执行 `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` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。
`operations_replica` 缺失付款组件时,同步器复用现有钉钉应用凭据调用 `https://api.dingtalk.com/v1.0/workflow/processInstances` 补取官方最新表单。付款来源标记为 `api`,付款事实仍进入 `ding_process_payment_mirror`;不会用官方接口覆盖副本已有的明细,只补缺失或空的付款字段。自动增量每 5 分钟按 `process_instance_id` 游标轮转最多 50 个缺失流程,避免单个失败记录阻塞队列或瞬时打满接口;人工核查可执行 `business-mirror:sync-ding-process-details --business-id=审批编号1,审批编号2 --dry-run`,确认后再去掉 `--dry-run`。单次最多 50 个审批编号。
`operations_replica` 缺失付款组件或进行中流程需要刷新出纳节点时,同步器复用现有钉钉应用凭据调用 `https://api.dingtalk.com/v1.0/workflow/processInstances`。官方接口同时返回表单、`tasks.taskGroupName` 和 `operationRecords.showName`,用于识别“出纳打款”节点及退回/待处理/已通过状态。付款来源标记为 `api`,付款事实仍进入 `ding_process_payment_mirror`;不会用官方接口覆盖副本已有的业务明细,只补缺失或空的付款字段,并独立保存“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”,`cashier_checked_at` 用于记录近期已检查时间。自动增量每 5 分钟按 `process_instance_id` 游标轮转最多 50 个缺失或需要刷新出纳状态的流程,避免单个失败记录阻塞队列或瞬时打满接口;人工核查可执行 `business-mirror:sync-ding-process-details --business-id=审批编号1,审批编号2 --dry-run`,确认后再去掉 `--dry-run`。单次最多 50 个审批编号。
抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。

View File

@ -95,7 +95,7 @@ class ProcessDetails extends Controller
return response()->streamDownload(function () use ($filters): void {
$stream = fopen('php://output', 'wb');
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, [...DingProcessDetailMirrorImporter::HEADERS, '付款状态']);
fputcsv($stream, [...DingProcessDetailMirrorImporter::HEADERS, '付款状态', '付款差额']);
$number = 0;
foreach ($this->repository->exportRows($filters) as $row) {
@ -117,6 +117,9 @@ class ProcessDetails extends Controller
(string) ($row['payment_method'] ?? ''),
(string) ($row['paid_at'] ?? ''),
(string) ($row['payment_state'] ?? ''),
isset($row['payment_difference'])
? number_format((float) $row['payment_difference'], 2, '.', '')
: '',
]);
}

View File

@ -15,7 +15,15 @@ class DingProcessDetailMirrorRepository
public const DATASET_PREFIX = 'ding.process_details.';
public const PAYMENT_STATES = ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'];
public const PAYMENT_STATES = [
'出纳状态待同步',
'出纳未开始',
'出纳待打款',
'出纳退回',
'出纳已打款',
'流程终止',
'不涉及付款',
];
public const LEGACY_PAYMENT_STATE = '未确认';
@ -23,6 +31,8 @@ class DingProcessDetailMirrorRepository
private ?bool $paymentSourceStateColumn = null;
private ?bool $cashierStatusColumn = null;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
@ -233,12 +243,14 @@ class DingProcessDetailMirrorRepository
})
->selectRaw(
'COALESCE(payment_totals.payment_amount, 0) as payment_fact_amount, '
. 'COALESCE(detail_totals.approval_amount, 0) '
. '- COALESCE(payment_totals.payment_amount, 0) as payment_difference, '
. $this->paymentStateExpression() . ' as payment_state',
);
} else {
$query
->selectRaw(
'NULL as payment_fact_amount, ? as payment_state',
'NULL as payment_fact_amount, NULL as payment_difference, ? as payment_state',
[self::LEGACY_PAYMENT_STATE],
);
}
@ -300,15 +312,18 @@ class DingProcessDetailMirrorRepository
$sourceState = $this->paymentSourceStateColumnAvailable()
? 'ding_process_detail_mirror.payment_source_state'
: "'missing'";
$cashierState = $this->cashierStatusColumnAvailable()
? 'ding_process_detail_mirror.cashier_status'
: "'pending_sync'";
return "CASE
WHEN COALESCE(payment_totals.payment_amount, 0) > 0.005
AND payment_totals.payment_amount + 0.01
< COALESCE(detail_totals.approval_amount, 0) THEN '部分付款'
WHEN COALESCE(payment_totals.payment_amount, 0) > 0.005 THEN '已付款'
WHEN {$sourceState} = 'not_applicable' THEN '不适用'
WHEN {$sourceState} IN ('present', 'api') THEN '未付款'
ELSE '付款明细待同步'
WHEN {$sourceState} = 'not_applicable' THEN '不涉及付款'
WHEN {$cashierState} = 'completed' THEN '出纳已打款'
WHEN {$cashierState} = 'pending' THEN '出纳待打款'
WHEN {$cashierState} = 'returned' THEN '出纳退回'
WHEN {$cashierState} = 'not_started' THEN '出纳未开始'
WHEN {$cashierState} = 'terminated' THEN '流程终止'
ELSE '出纳状态待同步'
END";
}
@ -319,6 +334,13 @@ class DingProcessDetailMirrorRepository
)->hasColumn('ding_process_detail_mirror', 'payment_source_state');
}
private function cashierStatusColumnAvailable(): bool
{
return $this->cashierStatusColumn ??= Schema::connection(
$this->connection->getName(),
)->hasColumn('ding_process_detail_mirror', 'cashier_status');
}
/** @param array<string,mixed>|null $state */
private function stateIsStale(?array $state, int $staleAfterMinutes): bool
{

View File

@ -40,6 +40,9 @@ class DingProcessDetailMirrorSynchronizer
private bool $approvalApiAvailable = true;
/** @var array<string,array<string,mixed>> */
private array $approvalSnapshots = [];
public function __construct(
DatabaseManager $database,
private readonly UpstreamReadOnlyQueryFactory $queries,
@ -65,6 +68,7 @@ class DingProcessDetailMirrorSynchronizer
{
$this->approvalApiAttempts = 0;
$this->approvalApiAvailable = true;
$this->approvalSnapshots = [];
$scope = $this->scope();
$state = $this->state();
$structuredState = $this->structuredState($state);
@ -173,6 +177,7 @@ class DingProcessDetailMirrorSynchronizer
$this->approvalApiAttempts = 0;
$this->approvalApiAvailable = true;
$this->approvalSnapshots = [];
$scope = $this->scope();
$state = $this->state();
if (! $this->structuredState($state)) {
@ -444,6 +449,10 @@ class DingProcessDetailMirrorSynchronizer
)) {
return [];
}
$cashierRefreshEnabled = Schema::connection($this->connection->getName())->hasColumn(
'ding_process_detail_mirror',
'cashier_checked_at',
);
$remainingAttempts = self::APPROVAL_API_FALLBACK_LIMIT - $this->approvalApiAttempts;
if (! $this->approvalApiAvailable || $remainingAttempts <= 0) {
return [];
@ -452,7 +461,18 @@ class DingProcessDetailMirrorSynchronizer
$base = $this->connection->table('ding_process_detail_mirror')
->where('source_scope', $this->scope())
->whereIn('status', ['审批中', '已完成'])
->where('payment_source_state', 'missing')
->where(function ($query) use ($cashierRefreshEnabled): void {
$query->where('payment_source_state', 'missing');
if ($cashierRefreshEnabled) {
$query->orWhere(function ($cashier): void {
$cashier->where('status', '审批中')
->where(function ($checked): void {
$checked->whereNull('cashier_checked_at')
->orWhere('cashier_checked_at', '<', now()->subMinutes(15));
});
});
}
})
->whereNotNull('process_instance_id')
->where('process_instance_id', '!=', '');
$excluded = array_fill_keys($excludedProcessInstanceIds, true);
@ -655,7 +675,7 @@ class DingProcessDetailMirrorSynchronizer
}
$workflows = $query->select(
'SELECT w.processInstanceId, w.processCode, w.businessId, w.title, w.status, '
'SELECT w.processInstanceId, w.processCode, w.businessId, w.title, w.status, w.result, '
. 'w.originatorUserId, w.originatorDeptName, w.joinAt, w.updateAt, w.finishTime, '
. "{$cursorExpression} AS sync_cursor "
. 'FROM rpt.ding_Workflow w '
@ -849,8 +869,18 @@ class DingProcessDetailMirrorSynchronizer
$status = $this->status((string) ($workflow['status'] ?? ''));
$businessMonth = $this->businessMonth($businessId, (string) ($workflow['joinAt'] ?? ''));
$paymentSourceState = $this->paymentSourceState($process, $fields);
if ($this->needsApprovalApiFallback($process, $fields, $paymentSourceState)) {
$apiFields = $this->approvalApiFields($workflow);
$needsPaymentApi = $this->needsApprovalApiFallback(
$process,
$fields,
$paymentSourceState,
);
$needsCashierApi = $this->needsCashierApi($process, $workflow);
$approvalSnapshot = null;
if ($needsPaymentApi || $needsCashierApi) {
$approvalSnapshot = $this->approvalApiSnapshot($workflow);
}
if ($needsPaymentApi && $approvalSnapshot !== null) {
$apiFields = (array) ($approvalSnapshot['form_values'] ?? []);
if ($apiFields !== []) {
foreach ($apiFields as $fieldName => $fieldValue) {
if (! array_key_exists($fieldName, $fields)
@ -863,6 +893,8 @@ class DingProcessDetailMirrorSynchronizer
: $this->paymentSourceState($process, $fields);
}
}
$cashierStatus = $this->cashierStatus($process, $workflow, $approvalSnapshot);
$cashierCheckedAt = $cashierStatus === 'pending_sync' ? null : $now;
[$workflowDetails, $workflowAmountCents, $detailTableName] = $this->detailRows(
$workflow,
@ -876,6 +908,8 @@ class DingProcessDetailMirrorSynchronizer
$status,
$businessMonth,
$paymentSourceState,
$cashierStatus,
$cashierCheckedAt,
);
$this->validateAmountTotal(
$businessId,
@ -911,6 +945,8 @@ class DingProcessDetailMirrorSynchronizer
$status,
$businessMonth,
$paymentSourceState,
$cashierStatus,
$cashierCheckedAt,
);
}
@ -954,6 +990,8 @@ class DingProcessDetailMirrorSynchronizer
string $status,
string $businessMonth,
string $paymentSourceState,
string $cashierStatus,
?Carbon $cashierCheckedAt,
): array {
$tableName = trim((string) ($process['detail_table'] ?? ''));
$rows = $tableName !== '' ? $this->tableRows($fields[$tableName] ?? null, $workflow, $tableName, $issues) : [];
@ -986,6 +1024,8 @@ class DingProcessDetailMirrorSynchronizer
$status,
$businessMonth,
$paymentSourceState,
$cashierStatus,
$cashierCheckedAt,
);
}
@ -1170,33 +1210,122 @@ class DingProcessDetailMirrorSynchronizer
* @param array<string,mixed> $workflow
* @return array<string,mixed>
*/
private function approvalApiFields(array $workflow): array
private function approvalApiSnapshot(array $workflow): ?array
{
$processInstanceId = trim((string) ($workflow['processInstanceId'] ?? ''));
if ($processInstanceId === '') {
return null;
}
if (isset($this->approvalSnapshots[$processInstanceId])) {
return $this->approvalSnapshots[$processInstanceId];
}
if (! $this->approvalClient->isConfigured()
|| ! $this->approvalApiAvailable
|| $this->approvalApiAttempts >= self::APPROVAL_API_FALLBACK_LIMIT) {
return [];
}
$processInstanceId = trim((string) ($workflow['processInstanceId'] ?? ''));
if ($processInstanceId === '') {
return [];
return null;
}
$this->approvalApiAttempts++;
try {
return $this->approvalClient->formValues($processInstanceId);
return $this->approvalSnapshots[$processInstanceId] =
$this->approvalClient->processSnapshot($processInstanceId);
} catch (Throwable $exception) {
$this->approvalApiAvailable = false;
Log::warning('钉钉官方审批接口补取流程表单失败。', [
Log::warning('钉钉官方审批接口读取流程状态失败。', [
'process_instance_id' => $processInstanceId,
'exception' => $exception::class,
]);
return [];
return null;
}
}
/**
* @param array<string,mixed> $process
* @param array<string,mixed> $workflow
*/
private function needsCashierApi(array $process, array $workflow): bool
{
return $this->paymentSourceFieldNames($process) !== []
&& strtoupper(trim((string) ($workflow['status'] ?? ''))) === 'RUNNING';
}
/**
* @param array<string,mixed> $process
* @param array<string,mixed> $workflow
* @param array<string,mixed>|null $snapshot
*/
private function cashierStatus(array $process, array $workflow, ?array $snapshot): string
{
if ($this->paymentSourceFieldNames($process) === []) {
return 'not_applicable';
}
$workflowStatus = strtoupper(trim((string) ($workflow['status'] ?? '')));
$workflowResult = strtoupper(trim((string) ($workflow['result'] ?? '')));
if (in_array($workflowStatus, ['TERMINATED', 'CANCELED', 'CANCELLED', 'REVOKED'], true)
|| in_array($workflowResult, ['REFUSE', 'REJECT', 'REJECTED'], true)) {
return 'terminated';
}
if ($workflowStatus === 'COMPLETED') {
return 'completed';
}
if ($workflowStatus !== 'RUNNING' || $snapshot === null) {
return 'pending_sync';
}
$events = [];
foreach ((array) ($snapshot['tasks'] ?? []) as $task) {
if (! is_array($task)
|| ! $this->isCashierLabel((string) ($task['taskGroupName'] ?? ''))) {
continue;
}
$status = strtoupper(trim((string) ($task['status'] ?? '')));
$result = strtoupper(trim((string) ($task['result'] ?? '')));
$time = trim((string) ($task['finishTime'] ?? $task['createTime'] ?? ''));
if ($status === 'COMPLETED' && $result === 'AGREE') {
$events[] = ['time' => $time, 'state' => 'completed', 'priority' => 2];
} elseif ($status === 'RUNNING') {
$events[] = ['time' => $time, 'state' => 'pending', 'priority' => 1];
} elseif (in_array($result, ['REDIRECTED', 'REFUSE', 'REJECT'], true)
|| $status === 'CANCELED') {
$events[] = ['time' => $time, 'state' => 'returned', 'priority' => 3];
}
}
foreach ((array) ($snapshot['operation_records'] ?? []) as $operation) {
if (! is_array($operation)
|| ! $this->isCashierLabel((string) ($operation['showName'] ?? ''))) {
continue;
}
$type = strtoupper(trim((string) ($operation['type'] ?? '')));
$result = strtoupper(trim((string) ($operation['result'] ?? '')));
$time = trim((string) ($operation['date'] ?? ''));
if (str_starts_with($type, 'REDIRECT')
|| in_array($result, ['REDIRECTED', 'REFUSE', 'REJECT'], true)) {
$events[] = ['time' => $time, 'state' => 'returned', 'priority' => 3];
} elseif ($type === 'EXECUTE_TASK_NORMAL' && $result === 'AGREE') {
$events[] = ['time' => $time, 'state' => 'completed', 'priority' => 2];
}
}
if ($events === []) {
return 'not_started';
}
usort($events, static function (array $left, array $right): int {
return [$left['time'], $left['priority']] <=> [$right['time'], $right['priority']];
});
return (string) ($events[array_key_last($events)]['state'] ?? 'not_started');
}
private function isCashierLabel(string $label): bool
{
return str_contains($label, '出纳') && (
str_contains($label, '打款') || str_contains($label, '付款')
);
}
/**
* @param array<string,mixed> $process
* @param array<string,mixed> $apiFields
@ -1321,6 +1450,8 @@ class DingProcessDetailMirrorSynchronizer
string $status,
string $businessMonth,
string $paymentSourceState,
string $cashierStatus,
?Carbon $cashierCheckedAt,
): array {
return [
'business_id' => mb_substr(trim((string) ($workflow['businessId'] ?? '')), 0, 32),
@ -1334,6 +1465,8 @@ class DingProcessDetailMirrorSynchronizer
'originator_name' => mb_substr($originator, 0, 100),
'status' => mb_substr($status, 0, 20),
'payment_source_state' => mb_substr($paymentSourceState, 0, 24),
'cashier_status' => mb_substr($cashierStatus, 0, 32),
'cashier_checked_at' => $cashierCheckedAt,
'document_type' => mb_substr($documentType, 0, 100),
'purpose' => $purpose,
'account_code' => mb_substr($accountCode, 0, 64),

View File

@ -16,6 +16,9 @@ class DingTalkProcessInstanceClient
private int $accessTokenExpiresAt = 0;
/** @var array<string,array<string,mixed>> */
private array $snapshots = [];
public function isConfigured(): bool
{
return trim((string) config('dingtalk.client_id', '')) !== ''
@ -28,11 +31,28 @@ class DingTalkProcessInstanceClient
* @return array<string,mixed>
*/
public function formValues(string $processInstanceId): array
{
return $this->processSnapshot($processInstanceId)['form_values'];
}
/**
* @return array{
* form_values:array<string,mixed>,
* tasks:list<array<string,mixed>>,
* operation_records:list<array<string,mixed>>,
* status:?string,
* result:?string
* }
*/
public function processSnapshot(string $processInstanceId): array
{
$processInstanceId = trim($processInstanceId);
if ($processInstanceId === '') {
throw new RuntimeException('DingTalk process instance ID is required.');
}
if (isset($this->snapshots[$processInstanceId])) {
return $this->snapshots[$processInstanceId];
}
try {
$response = Http::acceptJson()
@ -73,7 +93,19 @@ class DingTalkProcessInstanceClient
}
}
return $values;
return $this->snapshots[$processInstanceId] = [
'form_values' => $values,
'tasks' => array_values(array_filter(
(array) ($result['tasks'] ?? []),
'is_array',
)),
'operation_records' => array_values(array_filter(
(array) ($result['operationRecords'] ?? []),
'is_array',
)),
'status' => isset($result['status']) ? (string) $result['status'] : null,
'result' => isset($result['result']) ? (string) $result['result'] : null,
];
}
private function appAccessToken(): string

View File

@ -3,10 +3,10 @@
$mirrorStale = $mirrorStale ?? ($pageData['mirrorStale'] ?? false);
$mirrorStaleAfterMinutes = $mirrorStaleAfterMinutes ?? ($pageData['mirrorStaleAfterMinutes'] ?? 15);
$paymentStateClass = static fn (string $state): string => match ($state) {
'已付款' => 'is-success',
'部分付款' => 'is-warning',
'付款明细待同步', '未确认' => 'is-warning',
'未付款', '不适用' => 'is-neutral',
'出纳已打款' => 'is-success',
'出纳待打款', '出纳退回' => 'is-warning',
'出纳状态待同步', '未确认' => 'is-warning',
'出纳未开始', '流程终止', '不涉及付款' => 'is-neutral',
default => 'is-neutral',
};
@endphp
@ -273,6 +273,11 @@
font-variant-numeric: tabular-nums;
}
.process-details-table .is-difference {
color: var(--vip-danger, #B80000);
font-weight: 600;
}
.process-details-table .center {
text-align: center;
}
@ -612,8 +617,8 @@
<p class="process-details-note">
金额合计按审批费用明细汇总,付款合计按结构化付款明细汇总,同一笔付款不会因关联多条费用而重复计算。
付款状态按每张审批单的费用合计与独立付款事实判断,不能仅用审批状态推断是否已付款。
“付款明细待同步”表示上游表单尚未返回付款明细,不能据此认定未付款。
付款状态按钉钉“出纳打款”节点判断;付款差额按“费用合计 − 已付款合计”单独计算,不参与付款状态判断。
“出纳状态待同步”表示尚未取得出纳节点任务,不能据此推断已付款或未付款。
当前展示的是当前账号权限范围内可读取的钉钉审批流程明细;审批中的金额和行项目仍可能随流程变化。
企业级全量数据待领导开通 OA 权限后切换。
</p>
@ -637,6 +642,7 @@
<th>银行科目编码</th>
<th>银行科目名称</th>
<th class="num">付款金额</th>
<th class="num">付款差额</th>
<th>付款方式</th>
<th>打款时间</th>
</tr>
@ -662,12 +668,13 @@
<td>{{ $text($row->bank_account_code) }}</td>
<td class="wide" title="{{ $row->bank_account_name }}">{{ $text($row->bank_account_name) }}</td>
<td class="num">{{ $money($row->bank_amount) }}</td>
<td class="num {{ abs((float) ($row->payment_difference ?? 0)) > 0.01 ? 'is-difference' : '' }}">{{ $money($row->payment_difference) }}</td>
<td>{{ $text($row->payment_method) }}</td>
<td>{{ $text($row->paid_at) }}</td>
</tr>
@empty
<tr>
<td colspan="16" class="process-details-empty">
<td colspan="17" class="process-details-empty">
当前筛选条件没有流程明细。可以调整月份、单据类型、状态或关键词后重试。
</td>
</tr>

View File

@ -22,8 +22,9 @@ class ProcessDetailsPageTest extends FeatureTestCase
'line_no' => 1,
'originator_name' => '张三',
'status' => '已完成',
'payment_state' => '未付款',
'payment_state' => '出纳已打款',
'payment_fact_amount' => 0,
'payment_difference' => 0.20,
'document_type' => '公摊日常报销',
'purpose' => '办公用品采购',
'account_code' => '5602.04',
@ -52,7 +53,15 @@ class ProcessDetailsPageTest extends FeatureTestCase
$repository->shouldReceive('filterOptions')->once()->andReturn([
'document_types' => ['公摊日常报销'],
'statuses' => ['已完成'],
'payment_states' => ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'],
'payment_states' => [
'出纳状态待同步',
'出纳未开始',
'出纳待打款',
'出纳退回',
'出纳已打款',
'流程终止',
'不涉及付款',
],
]);
$this->app->instance(DingProcessDetailMirrorRepository::class, $repository);
@ -76,9 +85,11 @@ class ProcessDetailsPageTest extends FeatureTestCase
->assertSee('name="payment_state"', false)
->assertSee('审批状态')
->assertSee('付款状态')
->assertSee('未付款')
->assertSee('付款明细待同步')
->assertSee('出纳已打款')
->assertSee('出纳状态待同步')
->assertSee('付款金额')
->assertSee('付款差额')
->assertSee('0.20')
->assertSee('付款合计')
->assertDontSee('银行付款合计')
->assertSee('流程数据已超过 15 分钟未成功同步')
@ -119,7 +130,8 @@ class ProcessDetailsPageTest extends FeatureTestCase
'business_id' => '202601011200000000001',
'originator_name' => '张三',
'status' => '已完成',
'payment_state' => '未付款',
'payment_state' => '出纳已打款',
'payment_difference' => 0.20,
'document_type' => '公摊日常报销',
'purpose' => '办公用品采购',
'line_no' => 1,
@ -137,7 +149,7 @@ class ProcessDetailsPageTest extends FeatureTestCase
$response = $this->loginAs()->get(route('business.process-details.export', [
'status' => '已完成',
'payment_state' => '未付款',
'payment_state' => '出纳已打款',
]));
$response->assertOk();
@ -147,6 +159,8 @@ class ProcessDetailsPageTest extends FeatureTestCase
$this->assertStringContainsString('张三', $content);
$this->assertStringContainsString('管理费用_办公费', $content);
$this->assertStringContainsString('付款状态', $content);
$this->assertStringContainsString('未付款', $content);
$this->assertStringContainsString('出纳已打款', $content);
$this->assertStringContainsString('付款差额', $content);
$this->assertStringContainsString('0.20', $content);
}
}

View File

@ -44,10 +44,9 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
$this->assertEqualsWithDelta(650.5, $result['summary']['amount_total'], 0.001);
$this->assertEqualsWithDelta(350.5, $result['summary']['bank_amount_total'], 0.001);
$this->assertSame('personal_permission', $result['scope']);
$this->assertSame(
['已付款', '部分付款'],
collect($result['data'])->pluck('payment_state')->unique()->sort()->values()->all(),
);
$paymentStates = collect($result['data'])->pluck('payment_state')->unique()->all();
$this->assertContains('出纳已打款', $paymentStates);
$this->assertContains('出纳待打款', $paymentStates);
$options = $this->repository->filterOptions();
$this->assertArrayNotHasKey('months', $options);
@ -55,10 +54,15 @@ 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
@ -96,52 +100,50 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
);
$this->assertSame(
2,
$this->repository->paginate(['payment_state' => '已付款'], 1, 50)['total'],
$this->repository->paginate(['payment_state' => '出纳已打款'], 1, 50)['total'],
);
$this->assertSame(
2,
$this->repository->paginate(['payment_state' => '部分付款'], 1, 50)['total'],
$this->repository->paginate(['payment_state' => '出纳待打款'], 1, 50)['total'],
);
}
public function test_missing_payment_source_is_not_reported_as_unpaid(): void
public function test_pending_cashier_status_is_not_inferred_from_payment_amount(): 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']);
->update(['cashier_status' => 'pending_sync', 'cashier_checked_at' => null]);
$result = $this->repository->paginate(
['payment_state' => '付款明细待同步'],
['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
public function test_payment_difference_is_separate_from_cashier_status(): void
{
DB::connection('business_mirror')
->table('ding_process_payment_mirror')
->where('business_id', '202602031200000000002')
->delete();
$result = $this->repository->paginate(['payment_state' => '未付款'], 1, 50);
$result = $this->repository->paginate(
['business_id' => '202602031200000000002'],
1,
50,
);
$this->assertSame(2, $result['total']);
$this->assertSame(
['未付款'],
collect($result['data'])->pluck('payment_state')->unique()->values()->all(),
);
$this->assertEqualsWithDelta(500.0, (float) $result['data'][0]->payment_difference, 0.001);
$this->assertSame('出纳待打款', $result['data'][0]->payment_state);
}
public function test_process_without_payment_fields_is_not_applicable(): void
@ -153,13 +155,16 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->where('business_id', '202601011200000000001')
->update(['payment_source_state' => 'not_applicable']);
->update([
'payment_source_state' => 'not_applicable',
'cashier_status' => 'not_applicable',
]);
$result = $this->repository->paginate(['payment_state' => '不适用'], 1, 50);
$result = $this->repository->paginate(['payment_state' => '不涉及付款'], 1, 50);
$this->assertSame(2, $result['total']);
$this->assertSame(
['不适用'],
['不涉及付款'],
collect($result['data'])->pluck('payment_state')->unique()->values()->all(),
);
}
@ -173,11 +178,11 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->where('business_id', '202602031200000000002')
->update(['payment_source_state' => 'api']);
->update(['payment_source_state' => 'api', 'cashier_status' => 'completed']);
$result = $this->repository->paginate(['payment_state' => '未付款'], 1, 50);
$result = $this->repository->paginate(['payment_state' => '出纳已打款'], 1, 50);
$this->assertSame(2, $result['total']);
$this->assertSame(4, $result['total']);
$this->assertSame('api', (string) $result['data'][0]->payment_source_state);
}
@ -274,6 +279,8 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
'originator_name' => $originator,
'status' => $status,
'payment_source_state' => 'present',
'cashier_status' => $status === '已完成' ? 'completed' : 'pending',
'cashier_checked_at' => $now,
'document_type' => $documentType,
'purpose' => $purpose,
'account_code' => '5602.04',
@ -328,6 +335,8 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
$table->string('originator_name', 100)->nullable()->index();
$table->string('status', 20)->nullable()->index();
$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()->index();
$table->text('purpose')->nullable();
$table->string('account_code', 64)->nullable();

View File

@ -109,6 +109,76 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
$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_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 = [[
@ -178,8 +248,9 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
$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' => '付款明细待同步'],
['payment_state' => '出纳未开始'],
1,
50,
);
@ -508,6 +579,8 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
$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();
@ -635,6 +708,9 @@ class FakeDingTalkProcessInstanceClient extends DingTalkProcessInstanceClient
/** @var array<string,array<string,mixed>> */
public array $valuesByInstance = [];
/** @var array<string,string> */
public array $cashierStatusByInstance = [];
/** @var list<string> */
public array $calls = [];
@ -645,8 +721,46 @@ class FakeDingTalkProcessInstanceClient extends DingTalkProcessInstanceClient
public function formValues(string $processInstanceId): array
{
$this->calls[] = $processInstanceId;
return $this->processSnapshot($processInstanceId)['form_values'];
}
return $this->valuesByInstance[$processInstanceId] ?? [];
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,
];
}
}

View File

@ -46,7 +46,7 @@ class ProcessDetailsPageDataTest extends TestCase
->method('paginate')
->with($this->callback(static fn (array $filters): bool =>
$filters['paid_date'] === '2026-10-24'
&& $filters['payment_state'] === '未付款'
&& $filters['payment_state'] === '出纳已打款'
), 2, 50)
->willReturn([
'data' => [$row],
@ -68,13 +68,21 @@ class ProcessDetailsPageDataTest extends TestCase
->willReturn([
'document_types' => ['种菜核销/种菜报销'],
'statuses' => ['审批中'],
'payment_states' => ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'],
'payment_states' => [
'出纳状态待同步',
'出纳未开始',
'出纳待打款',
'出纳退回',
'出纳已打款',
'流程终止',
'不涉及付款',
],
]);
$data = (new ProcessDetailsPageData($repository))->page(Request::create(
'/1/workspace/financial-operations/process-details',
'GET',
['paid_date' => '2026-10-24', 'payment_state' => '未付款', 'page' => 2],
['paid_date' => '2026-10-24', 'payment_state' => '出纳已打款', 'page' => 2],
));
$this->assertInstanceOf(LengthAwarePaginator::class, $data['processRows']);
@ -85,9 +93,17 @@ class ProcessDetailsPageDataTest extends TestCase
$this->assertSame('succeeded', $data['mirrorStatus']);
$this->assertSame(11331, $data['summary']['rows']);
$this->assertSame('2026-10-24', $data['filters']['paid_date']);
$this->assertSame('未付款', $data['filters']['payment_state']);
$this->assertSame('出纳已打款', $data['filters']['payment_state']);
$this->assertSame(
['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'],
[
'出纳状态待同步',
'出纳未开始',
'出纳待打款',
'出纳退回',
'出纳已打款',
'流程终止',
'不涉及付款',
],
$data['paymentStates'],
);
$this->assertTrue($data['mirrorStale']);