From 144937bfa3fc9a8ab8522d7d7a1ecd346316b675 Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Tue, 20 Oct 2026 12:20:13 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=BA=AC=E4=B8=9C=E9=92=B1?= =?UTF-8?q?=E5=8C=85=E6=AF=8F=E6=97=A5=E8=B4=A6=E5=8D=95=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=B9=B6=E5=A2=9E=E5=8A=A0=E8=A6=86=E7=9B=96=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/VerifyErpDailyBillCoverage.php | 172 ++++++++++++++++++ scripts/erp-daily-bills/README.md | 18 +- scripts/erp-daily-bills/export-daily.mjs | 4 +- scripts/erp-daily-bills/run-erp-daily.ps1 | 29 +-- 4 files changed, 209 insertions(+), 14 deletions(-) create mode 100644 app/Console/Commands/VerifyErpDailyBillCoverage.php diff --git a/app/Console/Commands/VerifyErpDailyBillCoverage.php b/app/Console/Commands/VerifyErpDailyBillCoverage.php new file mode 100644 index 00000000..caab18ce --- /dev/null +++ b/app/Console/Commands/VerifyErpDailyBillCoverage.php @@ -0,0 +1,172 @@ + [ + 'label' => '京东日账单', + 'periods' => 'jd_daily_bill_periods', + 'rows' => 'jd_daily_bill_rows', + ], + 'jd-wallet' => [ + 'label' => '京东钱包', + 'periods' => 'jd_wallet_bill_periods', + 'rows' => 'jd_wallet_bill_rows', + ], + 'douyin' => [ + 'label' => '头条放心购', + 'periods' => 'douyin_authoritative_periods', + 'rows' => 'douyin_authoritative_bills', + ], + ]; + + public function handle(): int + { + $fromText = (string) $this->option('from'); + $toText = (string) ($this->option('to') ?: now('Asia/Shanghai')->subDay()->toDateString()); + $from = $this->date($fromText); + $to = $this->date($toText); + + if ($from === null || $to === null || $from->gt($to)) { + $this->error('日期范围无效,应为 from <= to,格式 YYYY-MM-DD。'); + return self::FAILURE; + } + + $db = DB::connection(config('upstream_mirrors.connection', 'business_mirror')); + $schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror')); + $results = []; + $failed = false; + + foreach (self::TARGETS as $key => $target) { + if (! $schema->hasTable($target['periods']) || ! $schema->hasTable($target['rows'])) { + $results[] = [ + 'dataset' => $key, + 'label' => $target['label'], + 'month' => null, + 'required_start' => $from->toDateString(), + 'required_through' => $to->toDateString(), + 'imported_start' => null, + 'imported_end' => null, + 'row_count' => 0, + 'registered_rows' => null, + 'income' => null, + 'expense' => null, + 'status' => 'missing_table', + 'issue' => '镜像表不存在', + ]; + $failed = true; + continue; + } + + $month = $from->copy()->startOfMonth(); + while ($month->lte($to)) { + $monthEnd = $month->copy()->endOfMonth(); + $requiredStart = $month->lt($from) ? $from->copy() : $month->copy(); + $requiredThrough = $monthEnd->gt($to) ? $to->copy() : $monthEnd->copy(); + $requiredEndExclusive = $requiredThrough->copy()->addDay(); + $period = $db->table($target['periods']) + ->where('period_start', $month->toDateString()) + ->first(); + + $status = 'ok'; + $rowCount = 0; + $issue = null; + $summary = null; + + if ($period === null) { + $status = 'missing_month'; + $issue = '缺少该月份导入记录'; + } elseif (Carbon::parse((string) $period->period_end)->lt($requiredEndExclusive)) { + $status = 'partial'; + $issue = '导入截止日早于要求日期'; + } else { + $summary = $db->table($target['rows']) + ->where('generation', $period->active_generation) + ->selectRaw('COUNT(*) AS rows_count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense') + ->first(); + $rowCount = (int) ($summary->rows_count ?? 0); + if ($rowCount === 0) { + $status = 'empty'; + $issue = '有效版本没有明细'; + } elseif ($rowCount !== (int) $period->row_count) { + $status = 'row_count_mismatch'; + $issue = '有效版本实际行数与期间登记不一致'; + } elseif (abs(round((float) $summary->income, 2) - (float) $period->income) > 0.005 + || abs(round((float) $summary->expense, 2) - (float) $period->expense) > 0.005) { + $status = 'amount_mismatch'; + $issue = '有效版本收支合计与期间登记不一致'; + } + } + + if ($status !== 'ok') { + $failed = true; + } + + $results[] = [ + 'dataset' => $key, + 'label' => $target['label'], + 'month' => $month->format('Y-m'), + 'required_start' => $requiredStart->toDateString(), + 'required_through' => $requiredThrough->toDateString(), + 'imported_start' => $period?->period_start, + 'imported_end' => $period?->period_end, + 'row_count' => $rowCount, + 'registered_rows' => $period === null ? null : (int) $period->row_count, + 'income' => isset($summary) ? round((float) $summary->income, 2) : null, + 'expense' => isset($summary) ? round((float) $summary->expense, 2) : null, + 'status' => $status, + 'issue' => $issue, + ]; + + unset($summary); + $month->addMonthNoOverflow()->startOfMonth(); + } + } + + if ($this->option('json')) { + $this->line(json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } else { + $this->table( + ['数据集', '月份', '要求覆盖', '导入覆盖', '实际行数', '登记行数', '收入', '支出', '状态', '说明'], + array_map(static fn (array $row): array => [ + $row['label'], + $row['month'] ?? '—', + ($row['required_start'] ?? '—') . ' ~ ' . ($row['required_through'] ?? '—'), + ($row['imported_start'] ?? '—') . ' ~ ' . ($row['imported_end'] ?? '—'), + number_format((int) $row['row_count']), + $row['registered_rows'] === null ? '—' : number_format((int) $row['registered_rows']), + $row['income'] === null ? '—' : number_format((float) $row['income'], 2), + $row['expense'] === null ? '—' : number_format((float) $row['expense'], 2), + $row['status'], + $row['issue'] ?? '', + ], $results), + ); + } + + return $failed ? self::FAILURE : self::SUCCESS; + } + + private function date(string $value): ?Carbon + { + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) { + return null; + } + + $date = Carbon::createFromFormat('!Y-m-d', $value, 'Asia/Shanghai'); + return $date !== false && $date->format('Y-m-d') === $value ? $date : null; + } +} diff --git a/scripts/erp-daily-bills/README.md b/scripts/erp-daily-bills/README.md index 9c5c2336..27852764 100644 --- a/scripts/erp-daily-bills/README.md +++ b/scripts/erp-daily-bills/README.md @@ -8,16 +8,20 @@ | --- | --- | --- | --- | | `toutiao` | 头条放心购 → 资金流水 | `business-mirror:import-douyin-authoritative` | 账单流水数据 › 店铺收款账户账单 › **抖音账户** | | `jd` | 京东商城 → 京东日账单 | `business-mirror:import-jd-daily` | **京东账单 + 京喜账单**(一次导入按店铺名拆两边,两边合计恒等于导出行数) | +| `jd-wallet` | 京东商城 → 京东钱包 | `business-mirror:import-jd-wallet` | **京东网银钱包账户** | ## 用法 ```powershell -# 两个平台都跑:区间「月初 → 昨日」 +# 三个平台都跑:区间「月初 → 昨日」 pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 # 只跑京东,补历史月份 pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform jd -Through 2026-07-31 +# 只跑京东钱包,补历史月份 +pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform jd-wallet -Through 2026-07-31 + # 只导出不导入(排查) pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform toutiao -Through 2026-10-05 -SkipImport ``` @@ -26,9 +30,19 @@ pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform toutiao -Through - **无差异跳过**:该平台该月已导入且笔数/收入/支出完全一致时跳过导入(`-ForceImport` 可强制重刷);状态记在 `runtime/private/erp-exports/imported-state.json`。 - 超过胜算同步导出上限(响应 `超出同步导出允许最大数据量`)时,脚本自动轮询「异步导出下载管理」取回本次任务的全部分片,逐片转 CSV 后合并成一份再导入;仍可用 `-Start/-Through` 分段导出。 - 导入前用页面笔数(共 N 条)与导出行数核对,不一致就跳过导入(`-ForceImport` 可强制),避免只导入一部分。 -- 稳定性:导出失败自动重试一次(胜算页面偶发日历面板/授权弹窗异常);上传与"导入+回验"失败自动重试 3 次(含重新上传 CSV),用于吸收服务器 SSH 偶发断连。 +- 稳定性:导出最多重试 3 次(胜算页面偶发日历面板/授权弹窗异常);上传与"导入+回验"失败自动重试 3 次(含重新上传 CSV),用于吸收服务器 SSH 偶发断连。 - 失败即失败:导出/转 CSV/笔数核对/上传/导入回验任一步失败,都会**不写本地状态、不归档**,并在结束时以退出码 1 结束(下次运行会重新导入),不会把失败当成功记入 `imported-state.json`。 +## 覆盖校验 + +导入后可核对三个数据集在指定区间内是否逐月覆盖到截止日: + +```powershell +docker exec -u www-data caiwu_app php artisan business-mirror:verify-erp-daily-coverage --from=2026-07-01 --to=2026-10-19 +``` + +命令会逐个月份检查期间起止范围、有效版本行数和登记行数;缺月、只导到月中或行数不一致都会返回失败。 + ## 组成 | 文件 | 作用 | diff --git a/scripts/erp-daily-bills/export-daily.mjs b/scripts/erp-daily-bills/export-daily.mjs index f5100d60..4829e3e5 100644 --- a/scripts/erp-daily-bills/export-daily.mjs +++ b/scripts/erp-daily-bills/export-daily.mjs @@ -3,6 +3,7 @@ * * --platform=toutiao 头条放心购 → 资金流水 (导入 import-douyin-authoritative → 抖音账户) * --platform=jd 京东商城 → 京东日账单 (导入 import-jd-daily → 京东账单 + 京喜账单,按店铺名拆分) + * --platform=jd-wallet 京东商城 → 京东钱包 (导入 import-jd-wallet → 京东网银钱包账户) * * 用法: * node export-daily.mjs --platform=toutiao # 本月月初 → 昨日 @@ -24,6 +25,7 @@ const BASE = path.dirname(fileURLToPath(import.meta.url)); const PLATFORMS = { toutiao: { label: '头条放心购', subTab: '资金流水', filePrefix: '头条放心购_资金流水', exportMainName: '头条放心购头条资金流水' }, jd: { label: '京东商城', subTab: '京东日账单', filePrefix: '京东日账单', exportMainName: '京东商城京东日账单' }, + 'jd-wallet': { label: '京东商城', subTab: '京东钱包', filePrefix: '京东钱包', exportMainName: '京东商城京东钱包' }, }; const arg = (name, fallback = null) => { const hit = process.argv.find((a) => a.startsWith(`--${name}=`)); @@ -41,7 +43,7 @@ const BILL_URL = 'https://ss.erp321.com/process/platform-bill-new'; const platformKey = arg('platform', 'toutiao'); const platform = PLATFORMS[platformKey]; if (!platform) { - throw new Error('未知平台:' + platformKey + '(可选 toutiao / jd)'); + throw new Error('未知平台:' + platformKey + '(可选 toutiao / jd / jd-wallet)'); } fs.mkdirSync(OUT_DIR, { recursive: true }); diff --git a/scripts/erp-daily-bills/run-erp-daily.ps1 b/scripts/erp-daily-bills/run-erp-daily.ps1 index aaac4af5..235f31f2 100644 --- a/scripts/erp-daily-bills/run-erp-daily.ps1 +++ b/scripts/erp-daily-bills/run-erp-daily.ps1 @@ -1,19 +1,21 @@ <# 聚胜算每日账单 → 财务系统(统一入口) - 平台:toutiao = 头条放心购→资金流水(落 抖音账户) - jd = 京东商城→京东日账单(一次导入按店铺名拆 京东账单 + 京喜账单) + 平台:toutiao = 头条放心购→资金流水(落 抖音账户) + jd = 京东商城→京东日账单(一次导入按店铺名拆 京东账单 + 京喜账单) + jd-wallet = 京东商城→京东钱包(落 京东网银钱包账户) 流程:导出 → xlsx 转 CSV → 与已导入期间比对(无差异则跳过)→ 上传 → 受控导入 → 页面回验 → 归档 → 写日志 用法: - pwsh -File run-erp-daily.ps1 # 两个平台,区间「月初 → 昨日」 + pwsh -File run-erp-daily.ps1 # 三个平台,区间「月初 → 昨日」 pwsh -File run-erp-daily.ps1 -Platform jd -Through 2026-07-31 + pwsh -File run-erp-daily.ps1 -Platform jd-wallet -Through 2026-07-31 pwsh -File run-erp-daily.ps1 -Platform toutiao -SkipImport #> [CmdletBinding()] param( - [ValidateSet('toutiao', 'jd', 'both')][string]$Platform = 'both', + [ValidateSet('toutiao', 'jd', 'jd-wallet', 'both')][string]$Platform = 'both', [string]$Through, [string]$Start, [switch]$SkipImport, @@ -45,9 +47,10 @@ function Write-Log { $specs = @{ toutiao = @{ label = '头条放心购'; artifact = 'douyin'; importCmd = 'business-mirror:import-douyin-authoritative'; verifyTabs = @('douyin'); split = $false } jd = @{ label = '京东日账单'; artifact = 'jd'; importCmd = 'business-mirror:import-jd-daily'; verifyTabs = @('jd-transactions', 'jingxi'); split = $true } + 'jd-wallet' = @{ label = '京东钱包'; artifact = 'jd-wallet'; importCmd = 'business-mirror:import-jd-wallet'; verifyTabs = @('jd-wallet'); split = $false } } -$targets = if ($Platform -eq 'both') { @('toutiao', 'jd') } else { @($Platform) } +$targets = if ($Platform -eq 'both') { @('toutiao', 'jd', 'jd-wallet') } else { @($Platform) } $state = if (Test-Path -LiteralPath $stateFile) { (Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json) } else { New-Object psobject } $auth = Start-CaiwuDeploymentAuth -Server '218.93.190.75' -SshUser 'root' @@ -144,7 +147,7 @@ try { # 2) 无差异跳过 $key = $target + ':' + $month $prev = $state.$key - $same = $prev -and ([int]$prev.rows -eq $rows) -and ([math]::Abs([double]$prev.income - $income) -lt 0.005) -and ([math]::Abs([double]$prev.expense - $expense) -lt 0.005) + $same = $prev -and ([string]$prev.through -eq [string]$summary.through) -and ([int]$prev.rows -eq $rows) -and ([math]::Abs([double]$prev.income - $income) -lt 0.005) -and ([math]::Abs([double]$prev.expense - $expense) -lt 0.005) if ($same -and -not $ForceImport) { Write-Log ('该月已导入且无差异({0} 笔 / {1} / {2}),跳过导入' -f $rows, $income, $expense) continue @@ -170,9 +173,11 @@ require '/var/www/html/vendor/autoload.php'; `$repo = app(Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository::class); `$tabs = ['__TABS__']; foreach (`$tabs as `$tab) { - `$r = `$tab === 'douyin' - ? `$repo->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1) - : app(Modules\EcommerceFinance\Repositories\JdDailyBillRepository::class)->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1, `$tab); + `$r = match (`$tab) { + 'douyin' => `$repo->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1), + 'jd-wallet' => app(Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository::class)->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1), + default => app(Modules\EcommerceFinance\Repositories\JdDailyBillRepository::class)->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1, `$tab), + }; echo 'PAGE ' . `$tab . ' total=' . `$r['total'] . ' ' . json_encode(`$r['summary'], JSON_UNESCAPED_UNICODE) . PHP_EOL; } PHP @@ -197,12 +202,14 @@ rm -f /tmp/daily_bills.csv $remoteFile } # 4) 记录状态 + 归档 - $state | Add-Member -NotePropertyName $key -NotePropertyValue ([pscustomobject]@{ rows = $rows; income = $income; expense = $expense; file = $csvFile; at = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') }) -Force + $state | Add-Member -NotePropertyName $key -NotePropertyValue ([pscustomobject]@{ rows = $rows; income = $income; expense = $expense; through = $summary.through; file = $csvFile; at = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') }) -Force $state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $stateFile -Encoding UTF8 - $shareDir = 'Z:\高祝兰\财务系统\9月' + $monthNumber = [int]$month.Substring(5, 2) + $shareDir = Join-Path 'Z:\高祝兰\财务系统' ('{0}月' -f $monthNumber) if (Test-Path -LiteralPath 'Z:\高祝兰\财务系统') { try { + New-Item -ItemType Directory -Path $shareDir -Force | Out-Null foreach ($f in $rawFiles) { Copy-Item -LiteralPath $f -Destination (Join-Path $shareDir ([IO.Path]::GetFileName($f))) -Force }