feat: 稳定流程明细自动同步并增加付款状态 #670
@ -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 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。
|
||||
|
||||
### 成本价镜像观测
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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')) {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>
|
||||
|
||||
52
scripts/install-ding-process-sync-cron.ps1
Normal file
52
scripts/install-ding-process-sync-cron.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user