diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 641fb365..814a28a2 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -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 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。 ### 成本价镜像观测 diff --git a/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php b/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php index 57c49d24..5ef1ae8b 100644 --- a/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php +++ b/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php @@ -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), diff --git a/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php index 0b366f47..720d6350 100644 --- a/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php @@ -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,statuses:list} */ + /** @return array{document_types:list,statuses:list,payment_states:list} */ 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 $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 $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|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')) { diff --git a/modules/EcommerceFinance/View/ProcessDetailsPageData.php b/modules/EcommerceFinance/View/ProcessDetailsPageData.php index 2b6a4231..eba03f7d 100644 --- a/modules/EcommerceFinance/View/ProcessDetailsPageData.php +++ b/modules/EcommerceFinance/View/ProcessDetailsPageData.php @@ -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), diff --git a/resources/views/business/pages/financial-operations/process-details-content.blade.php b/resources/views/business/pages/financial-operations/process-details-content.blade.php index f195fdc2..7ae34cae 100644 --- a/resources/views/business/pages/financial-operations/process-details-content.blade.php +++ b/resources/views/business/pages/financial-operations/process-details-content.blade.php @@ -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 +