feat: 稳定流程明细自动同步并增加付款状态 #670

Merged
yueyang merged 1 commits from feature/yueyang-process-detail-payment-stability into main 2026-10-27 10:43:47 +08:00
9 changed files with 311 additions and 33 deletions

View File

@ -35,6 +35,8 @@
| 待有效凭据后首次同步 | 普通商品当前成本价、商品类型和更新时间继续读取 `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 分钟。页面同时按审批单费用合计和独立付款事实展示“未付款 / 部分付款 / 已付款”;审批状态不能代替付款状态。
抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。
### 成本价镜像观测

View File

@ -41,8 +41,11 @@ class ProcessDetails extends Controller
'summary' => $result['summary'],
'documentTypes' => $options['document_types'],
'statuses' => $options['statuses'],
'paymentStates' => $options['payment_states'],
'mirrorStatus' => $result['status'],
'mirrorSyncedAt' => $result['last_synced_at'],
'mirrorStale' => $result['sync_stale'] ?? false,
'mirrorStaleAfterMinutes' => $result['sync_stale_after_minutes'] ?? 15,
'scope' => $result['scope'],
'error' => null,
]);
@ -65,8 +68,11 @@ class ProcessDetails extends Controller
],
'documentTypes' => [],
'statuses' => [],
'paymentStates' => [],
'mirrorStatus' => 'unavailable',
'mirrorSyncedAt' => null,
'mirrorStale' => false,
'mirrorStaleAfterMinutes' => 15,
'scope' => $this->repository->scope(),
'error' => '流程明细镜像尚未完成导入,请稍后重试。',
]);
@ -89,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) {
@ -110,6 +116,7 @@ class ProcessDetails extends Controller
$row['bank_amount'] ?? '',
(string) ($row['payment_method'] ?? ''),
(string) ($row['paid_at'] ?? ''),
(string) ($row['payment_state'] ?? ''),
]);
}
@ -126,11 +133,17 @@ class ProcessDetails extends Controller
$status = trim((string) $request->query('status', ''));
$paidDateValid = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $paidDate, $dateParts) === 1
&& 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 = '';
}
return [
'paid_date' => $paidDateValid ? $paidDate : '',
'document_type' => mb_substr(trim((string) $request->query('document_type', '')), 0, 100),
'status' => mb_substr($status, 0, 20),
'payment_state' => $paymentState,
'business_id' => mb_substr(trim((string) $request->query('business_id', '')), 0, 32),
'originator_name' => mb_substr(trim((string) $request->query('originator_name', '')), 0, 100),
'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100),

View File

@ -5,6 +5,7 @@ namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
@ -14,6 +15,8 @@ class DingProcessDetailMirrorRepository
public const DATASET_PREFIX = 'ding.process_details.';
public const PAYMENT_STATES = ['未付款', '部分付款', '已付款'];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
@ -74,6 +77,8 @@ class DingProcessDetailMirrorRepository
* summary:array{rows:int,approvals:int,amount_total:float,bank_amount_total:float},
* status:string,
* last_synced_at:?string,
* sync_stale:bool,
* sync_stale_after_minutes:int,
* scope:string
* }
*/
@ -91,28 +96,30 @@ class DingProcessDetailMirrorRepository
$bankAmountTotal = $this->structuredPaymentsEnabled()
? (float) ($this->connection->table('ding_process_payment_mirror')
->where('source_scope', $this->scope())
->whereIn('business_id', function ($bankQuery) use ($filters): void {
$this->applyFilters(
$bankQuery->select('business_id')->from('ding_process_detail_mirror'),
$filters,
);
})
->whereIn(
'business_id',
(clone $query)->select('ding_process_detail_mirror.business_id'),
)
->sum('amount') ?? 0)
: (float) ((clone $query)->sum('bank_amount') ?? 0);
: (float) ((clone $query)->sum('ding_process_detail_mirror.bank_amount') ?? 0);
$summary = [
'rows' => $total,
'approvals' => (clone $query)->distinct()->count('business_id'),
'amount_total' => (float) ((clone $query)->sum('amount') ?? 0),
'approvals' => (clone $query)->distinct()->count('ding_process_detail_mirror.business_id'),
'amount_total' => (float) ((clone $query)->sum('ding_process_detail_mirror.amount') ?? 0),
'bank_amount_total' => $bankAmountTotal,
];
$data = $query
->orderByDesc('business_month')
->orderByDesc('business_id')
->orderBy('line_no')
->orderByDesc('ding_process_detail_mirror.business_month')
->orderByDesc('ding_process_detail_mirror.business_id')
->orderBy('ding_process_detail_mirror.line_no')
->forPage($page, $perPage)
->get()
->all();
$state = $this->state();
$staleAfterMinutes = max(
15,
(int) config('upstream_mirrors.ding_process_details.sync_interval_minutes', 5) * 3,
);
return [
'data' => $data,
@ -122,15 +129,17 @@ class DingProcessDetailMirrorRepository
'last_synced_at' => isset($state['last_succeeded_at'])
? (string) $state['last_succeeded_at']
: null,
'sync_stale' => $this->stateIsStale($state, $staleAfterMinutes),
'sync_stale_after_minutes' => $staleAfterMinutes,
'scope' => $this->scope(),
];
}
/** @return array{document_types:list<string>,statuses:list<string>} */
/** @return array{document_types:list<string>,statuses:list<string>,payment_states:list<string>} */
public function filterOptions(): array
{
if (! Schema::connection($this->connection->getName())->hasTable('ding_process_detail_mirror')) {
return ['document_types' => [], 'statuses' => []];
return ['document_types' => [], 'statuses' => [], 'payment_states' => []];
}
$base = $this->connection->table('ding_process_detail_mirror')
@ -153,6 +162,9 @@ class DingProcessDetailMirrorRepository
->pluck('status')
->map(static fn (mixed $value): string => (string) $value)
->all(),
'payment_states' => $this->structuredPaymentsEnabled()
? self::PAYMENT_STATES
: ['未确认'],
];
}
@ -185,20 +197,56 @@ class DingProcessDetailMirrorRepository
/** @param array<string,string> $filters */
private function filteredQuery(array $filters): Builder
{
return $this->applyFilters(
$this->connection->table('ding_process_detail_mirror'),
$filters,
);
$query = $this->connection->table('ding_process_detail_mirror')
->select('ding_process_detail_mirror.*');
if ($this->structuredPaymentsEnabled()) {
$detailTotals = $this->connection->table('ding_process_detail_mirror')
->select('business_id')
->selectRaw('SUM(COALESCE(amount, 0)) as approval_amount')
->where('source_scope', $this->scope())
->groupBy('business_id');
$paymentTotals = $this->connection->table('ding_process_payment_mirror')
->select('business_id')
->selectRaw('SUM(COALESCE(amount, 0)) as payment_amount')
->where('source_scope', $this->scope())
->groupBy('business_id');
$query
->leftJoinSub($detailTotals, 'detail_totals', function ($join): void {
$join->on(
'detail_totals.business_id',
'=',
'ding_process_detail_mirror.business_id',
);
})
->leftJoinSub($paymentTotals, 'payment_totals', function ($join): void {
$join->on(
'payment_totals.business_id',
'=',
'ding_process_detail_mirror.business_id',
);
})
->selectRaw(
'COALESCE(payment_totals.payment_amount, 0) as payment_fact_amount, '
. $this->paymentStateExpression() . ' as payment_state',
);
} else {
$query
->selectRaw('NULL as payment_fact_amount, ? as payment_state', ['未确认']);
}
return $this->applyFilters($query, $filters);
}
/** @param array<string,string> $filters */
private function applyFilters(Builder $query, array $filters): Builder
{
$query->where('source_scope', $this->scope());
$query->where('ding_process_detail_mirror.source_scope', $this->scope());
$paidDate = trim((string) ($filters['paid_date'] ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $paidDate) === 1) {
$query->where('paid_at', 'like', $paidDate . '%');
$query->where('ding_process_detail_mirror.paid_at', 'like', $paidDate . '%');
}
foreach ([
@ -207,7 +255,7 @@ class DingProcessDetailMirrorRepository
] as $filter => $column) {
$value = trim((string) ($filters[$filter] ?? ''));
if ($value !== '') {
$query->where($column, $value);
$query->where('ding_process_detail_mirror.' . $column, $value);
}
}
@ -218,13 +266,49 @@ class DingProcessDetailMirrorRepository
] as $filter => $column) {
$value = mb_substr(trim((string) ($filters[$filter] ?? '')), 0, 100);
if ($value !== '') {
$query->where($column, 'like', '%' . $value . '%');
$query->where('ding_process_detail_mirror.' . $column, 'like', '%' . $value . '%');
}
}
$paymentState = trim((string) ($filters['payment_state'] ?? ''));
if ($paymentState !== '') {
$allowed = $this->structuredPaymentsEnabled()
? self::PAYMENT_STATES
: ['未确认'];
if (in_array($paymentState, $allowed, true)) {
if ($paymentState !== '未确认') {
$query->whereRaw(
'(' . $this->paymentStateExpression() . ') = ?',
[$paymentState],
);
}
}
}
return $query;
}
private function paymentStateExpression(): string
{
return "CASE
WHEN COALESCE(payment_totals.payment_amount, 0) <= 0.005 THEN '未付款'
WHEN payment_totals.payment_amount + 0.01
< COALESCE(detail_totals.approval_amount, 0) THEN '部分付款'
ELSE '已付款'
END";
}
/** @param array<string,mixed>|null $state */
private function stateIsStale(?array $state, int $staleAfterMinutes): bool
{
if (($state['status'] ?? '') !== 'succeeded' || empty($state['last_succeeded_at'])) {
return true;
}
return Carbon::parse((string) $state['last_succeeded_at'])
->lt(now()->subMinutes($staleAfterMinutes));
}
private function structuredPaymentsEnabled(): bool
{
if (! Schema::connection($this->connection->getName())->hasTable('ding_process_payment_mirror')) {

View File

@ -36,8 +36,11 @@ class ProcessDetailsPageData
'summary' => $result['summary'],
'documentTypes' => $options['document_types'],
'statuses' => $options['statuses'],
'paymentStates' => $options['payment_states'],
'mirrorStatus' => $result['status'],
'mirrorSyncedAt' => $result['last_synced_at'],
'mirrorStale' => $result['sync_stale'] ?? false,
'mirrorStaleAfterMinutes' => $result['sync_stale_after_minutes'] ?? 15,
'scope' => $result['scope'],
'error' => null,
];
@ -58,8 +61,11 @@ class ProcessDetailsPageData
],
'documentTypes' => [],
'statuses' => [],
'paymentStates' => [],
'mirrorStatus' => 'unavailable',
'mirrorSyncedAt' => null,
'mirrorStale' => false,
'mirrorStaleAfterMinutes' => 15,
'scope' => $this->repository->scope(),
'error' => '流程明细镜像尚未完成导入,请稍后重试。',
];
@ -73,11 +79,17 @@ class ProcessDetailsPageData
$status = trim((string) $request->query('status', ''));
$paidDateValid = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $paidDate, $dateParts) === 1
&& 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 = '';
}
return [
'paid_date' => $paidDateValid ? $paidDate : '',
'document_type' => mb_substr(trim((string) $request->query('document_type', '')), 0, 100),
'status' => mb_substr($status, 0, 20),
'payment_state' => $paymentState,
'business_id' => mb_substr(trim((string) $request->query('business_id', '')), 0, 32),
'originator_name' => mb_substr(trim((string) $request->query('originator_name', '')), 0, 100),
'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100),

View File

@ -1,3 +1,15 @@
@php
$paymentStates = $paymentStates ?? ($pageData['paymentStates'] ?? []);
$mirrorStale = $mirrorStale ?? ($pageData['mirrorStale'] ?? false);
$mirrorStaleAfterMinutes = $mirrorStaleAfterMinutes ?? ($pageData['mirrorStaleAfterMinutes'] ?? 15);
$paymentStateClass = static fn (string $state): string => match ($state) {
'已付款' => 'is-success',
'部分付款' => 'is-warning',
'未付款', '未确认' => 'is-neutral',
default => 'is-neutral',
};
@endphp
<style>
.process-details-page {
display: flex;
@ -128,6 +140,9 @@
}
.process-details-alert {
display: flex;
align-items: flex-start;
gap: .55rem;
padding: .75rem 1rem;
margin-bottom: 1rem;
border: 1px solid var(--vip-danger, #B80000);
@ -136,6 +151,18 @@
color: var(--vip-danger, #B80000);
}
.process-details-alert.is-warning {
border-color: var(--vip-warning, #935F07);
background: #fffaf0;
color: var(--vip-warning, #935F07);
}
.process-details-alert .material-icons-outlined {
flex: 0 0 auto;
margin-top: .05rem;
font-size: 1.05rem;
}
.process-details-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@ -508,6 +535,16 @@
<div class="process-details-alert" role="alert">{{ $error }}</div>
@endif
@if ($mirrorStale)
<div class="process-details-alert is-warning" role="status">
<span class="material-icons-outlined" aria-hidden="true">schedule</span>
<span>
流程数据已超过 {{ number_format((int) $mirrorStaleAfterMinutes) }} 分钟未成功同步,
当前展示的是上次成功镜像。付款判断前请先核对最近同步时间。
</span>
</div>
@endif
<form method="GET" action="{{ $pageUrl }}" class="process-details-toolbar" aria-label="流程明细筛选">
<div class="process-details-field">
<label for="process-details-paid-date">打款日期</label>
@ -523,7 +560,7 @@
</select>
</div>
<div class="process-details-field">
<label for="process-details-status">状态</label>
<label for="process-details-status">审批状态</label>
<select id="process-details-status" name="status">
<option value="">全部状态</option>
@foreach ($statuses as $option)
@ -531,6 +568,15 @@
@endforeach
</select>
</div>
<div class="process-details-field">
<label for="process-details-payment-state">付款状态</label>
<select id="process-details-payment-state" name="payment_state">
<option value="">全部状态</option>
@foreach ($paymentStates as $option)
<option value="{{ $option }}" @selected(($filters['payment_state'] ?? '') === $option)>{{ $option }}</option>
@endforeach
</select>
</div>
<div class="process-details-field is-wide">
<label for="process-details-business-id">审批编号</label>
<input id="process-details-business-id" type="search" name="business_id" value="{{ $filters['business_id'] ?? '' }}" placeholder="支持包含查询">
@ -560,11 +606,12 @@
<div class="item"><span>明细行数</span><b>{{ number_format((int) ($summary['rows'] ?? 0)) }}</b></div>
<div class="item"><span>审批单数</span><b>{{ number_format((int) ($summary['approvals'] ?? 0)) }}</b></div>
<div class="item"><span>金额合计</span><b>{{ $money($summary['amount_total'] ?? null) }}</b></div>
<div class="item"><span>银行付款合计</span><b>{{ $money($summary['bank_amount_total'] ?? null) }}</b></div>
<div class="item"><span>付款合计</span><b>{{ $money($summary['bank_amount_total'] ?? null) }}</b></div>
</div>
<p class="process-details-note">
金额合计按审批费用明细汇总,银行付款合计按结构化付款明细汇总,同一笔付款不会因关联多条费用而重复计算。
金额合计按审批费用明细汇总,付款合计按结构化付款明细汇总,同一笔付款不会因关联多条费用而重复计算。
付款状态按每张审批单的费用合计与独立付款事实判断,不能仅用审批状态推断是否已付款。
当前展示的是当前账号权限范围内可读取的钉钉审批流程明细;审批中的金额和行项目仍可能随流程变化。
企业级全量数据待领导开通 OA 权限后切换。
</p>
@ -577,7 +624,8 @@
<th>序号</th>
<th>审批编号</th>
<th>发起人</th>
<th>状态</th>
<th>审批状态</th>
<th>付款状态</th>
<th>单据类型</th>
<th>用途/摘要</th>
<th class="center">单据内行号</th>
@ -586,7 +634,7 @@
<th class="num">金额(元)</th>
<th>银行科目编码</th>
<th>银行科目名称</th>
<th class="num">银行付款金额</th>
<th class="num">付款金额</th>
<th>付款方式</th>
<th>打款时间</th>
</tr>
@ -600,6 +648,9 @@
<td class="center">
<span class="process-details-status {{ $statusClass((string) $row->status) }}">{{ $text($row->status) }}</span>
</td>
<td class="center">
<span class="process-details-status {{ $paymentStateClass((string) ($row->payment_state ?? '')) }}">{{ $text($row->payment_state ?? '') }}</span>
</td>
<td>{{ $text($row->document_type) }}</td>
<td class="wide" title="{{ $row->purpose }}">{{ $text($row->purpose) }}</td>
<td class="center">{{ $row->line_no }}</td>
@ -614,7 +665,7 @@
</tr>
@empty
<tr>
<td colspan="15" class="process-details-empty">
<td colspan="16" class="process-details-empty">
当前筛选条件没有流程明细。可以调整月份、单据类型、状态或关键词后重试。
</td>
</tr>

View File

@ -0,0 +1,52 @@
[CmdletBinding()]
param(
[string]$Server = '218.93.190.75',
[string]$SshUser = 'root',
[string]$RemotePath = '/opt/caiwuxitong',
[string]$KeyPath,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
if ($RemotePath -notmatch '^/[A-Za-z0-9._/-]+$') {
throw 'Invalid remote project path.'
}
$cron = @(
'CRON_TZ=Asia/Shanghai'
'# Process details use a dedicated schedule and wait for the shared upstream lock.'
"2-59/5 * * * * root CAIWU_BATCH_LOCK_WAIT=900 /usr/bin/flock -n /run/caiwu-ding-process-details.lock /bin/sh $RemotePath/run-finance-batch.sh business-mirror:sync-ding-process-details --quiet >/dev/null 2>&1"
) -join "`n"
if ($DryRun) {
Write-Output $cron
return
}
$auth = $null
try {
. (Join-Path $PSScriptRoot 'deploy-auth.ps1')
$auth = Start-CaiwuDeploymentAuth -Server $Server -SshUser $SshUser -KeyPath $KeyPath
$remoteScript = @(
'set -eu'
"test -f '$RemotePath/run-finance-batch.sh'"
'umask 022'
"cat > /etc/cron.d/caiwu-ding-process-details <<'CAIWU_CRON'"
$cron
'CAIWU_CRON'
'chmod 644 /etc/cron.d/caiwu-ding-process-details'
"printf '\nInstalled %s\n' /etc/cron.d/caiwu-ding-process-details"
'cat /etc/cron.d/caiwu-ding-process-details'
) -join "`n"
& ssh @($auth.Options) "${SshUser}@${Server}" $remoteScript
if ($LASTEXITCODE -ne 0) {
throw 'Failed to install the process-details sync cron.'
}
} finally {
if ($auth) {
Stop-CaiwuDeploymentAuth $auth
}
}

View File

@ -22,6 +22,8 @@ class ProcessDetailsPageTest extends FeatureTestCase
'line_no' => 1,
'originator_name' => '张三',
'status' => '已完成',
'payment_state' => '未付款',
'payment_fact_amount' => 0,
'document_type' => '公摊日常报销',
'purpose' => '办公用品采购',
'account_code' => '5602.04',
@ -43,11 +45,14 @@ class ProcessDetailsPageTest extends FeatureTestCase
],
'status' => 'succeeded',
'last_synced_at' => '2026-10-26 15:00:00',
'sync_stale' => true,
'sync_stale_after_minutes' => 15,
'scope' => 'personal_permission',
]);
$repository->shouldReceive('filterOptions')->once()->andReturn([
'document_types' => ['公摊日常报销'],
'statuses' => ['已完成'],
'payment_states' => ['未付款', '部分付款', '已付款'],
]);
$this->app->instance(DingProcessDetailMirrorRepository::class, $repository);
@ -68,7 +73,14 @@ class ProcessDetailsPageTest extends FeatureTestCase
->assertSee('单据类型')
->assertSee('打款日期')
->assertSee('name="paid_date"', false)
->assertSee('银行付款金额')
->assertSee('name="payment_state"', false)
->assertSee('审批状态')
->assertSee('付款状态')
->assertSee('未付款')
->assertSee('付款金额')
->assertSee('付款合计')
->assertDontSee('银行付款合计')
->assertSee('流程数据已超过 15 分钟未成功同步')
->assertSee('202601011200000000001')
->assertSee('张三')
->assertSee('管理费用_办公费')
@ -106,6 +118,7 @@ class ProcessDetailsPageTest extends FeatureTestCase
'business_id' => '202601011200000000001',
'originator_name' => '张三',
'status' => '已完成',
'payment_state' => '未付款',
'document_type' => '公摊日常报销',
'purpose' => '办公用品采购',
'line_no' => 1,
@ -123,6 +136,7 @@ class ProcessDetailsPageTest extends FeatureTestCase
$response = $this->loginAs()->get(route('business.process-details.export', [
'status' => '已完成',
'payment_state' => '未付款',
]));
$response->assertOk();
@ -131,5 +145,7 @@ class ProcessDetailsPageTest extends FeatureTestCase
$this->assertStringContainsString('202601011200000000001', $content);
$this->assertStringContainsString('张三', $content);
$this->assertStringContainsString('管理费用_办公费', $content);
$this->assertStringContainsString('付款状态', $content);
$this->assertStringContainsString('未付款', $content);
}
}

View File

@ -42,8 +42,12 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
$this->assertSame(4, $result['summary']['rows']);
$this->assertSame(2, $result['summary']['approvals']);
$this->assertEqualsWithDelta(650.5, $result['summary']['amount_total'], 0.001);
$this->assertEqualsWithDelta(650.5, $result['summary']['bank_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(),
);
$options = $this->repository->filterOptions();
$this->assertArrayNotHasKey('months', $options);
@ -51,6 +55,7 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
$this->assertContains('应付申请流程', $options['document_types']);
$this->assertContains('已完成', $options['statuses']);
$this->assertContains('审批中', $options['statuses']);
$this->assertSame(['未付款', '部分付款', '已付款'], $options['payment_states']);
}
public function test_paginate_applies_paid_date_status_and_keyword_filters(): void
@ -86,6 +91,14 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
2,
$this->repository->paginate(['business_id' => '0002'], 1, 50)['total'],
);
$this->assertSame(
2,
$this->repository->paginate(['payment_state' => '已付款'], 1, 50)['total'],
);
$this->assertSame(
2,
$this->repository->paginate(['payment_state' => '部分付款'], 1, 50)['total'],
);
}
public function test_export_rows_returns_every_filtered_row(): void
@ -123,6 +136,20 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->insert($rows);
DB::connection('business_mirror')
->table('ding_process_payment_mirror')
->insert([
[
'business_id' => '202601011200000000001',
'amount' => 150.50,
'source_scope' => 'personal_permission',
],
[
'business_id' => '202602031200000000002',
'amount' => 200.00,
'source_scope' => 'personal_permission',
],
]);
DB::connection('business_mirror')
->table('business_mirror_table_states')
@ -134,6 +161,7 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
'status' => 'succeeded',
'run_mode' => 'full',
'generation' => '00000000-0000-0000-0000-000000000001',
'cursor' => json_encode(['source' => 'structured']),
'initial_completed_at' => $now,
'last_full_succeeded_at' => $now,
'last_attempted_at' => $now,
@ -234,5 +262,15 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
$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)->index();
$table->decimal('amount', 18, 2)->nullable();
$table->string('source_scope', 40)->default('personal_permission')->index();
},
);
}
}

View File

@ -44,7 +44,10 @@ class ProcessDetailsPageDataTest extends TestCase
$repository = $this->createMock(DingProcessDetailMirrorRepository::class);
$repository->expects($this->once())
->method('paginate')
->with($this->callback(static fn (array $filters): bool => $filters['paid_date'] === '2026-10-24'), 2, 50)
->with($this->callback(static fn (array $filters): bool =>
$filters['paid_date'] === '2026-10-24'
&& $filters['payment_state'] === '未付款'
), 2, 50)
->willReturn([
'data' => [$row],
'total' => 11331,
@ -56,6 +59,8 @@ class ProcessDetailsPageDataTest extends TestCase
],
'status' => 'succeeded',
'last_synced_at' => '2026-10-26 16:55:11',
'sync_stale' => true,
'sync_stale_after_minutes' => 15,
'scope' => 'personal_permission',
]);
$repository->expects($this->once())
@ -63,12 +68,13 @@ class ProcessDetailsPageDataTest extends TestCase
->willReturn([
'document_types' => ['种菜核销/种菜报销'],
'statuses' => ['审批中'],
'payment_states' => ['未付款', '部分付款', '已付款'],
]);
$data = (new ProcessDetailsPageData($repository))->page(Request::create(
'/1/workspace/financial-operations/process-details',
'GET',
['paid_date' => '2026-10-24', 'page' => 2],
['paid_date' => '2026-10-24', 'payment_state' => '未付款', 'page' => 2],
));
$this->assertInstanceOf(LengthAwarePaginator::class, $data['processRows']);
@ -79,6 +85,10 @@ 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['paymentStates']);
$this->assertTrue($data['mirrorStale']);
$this->assertSame(15, $data['mirrorStaleAfterMinutes']);
$this->assertArrayNotHasKey('months', $data);
$this->assertNull($data['error']);
}