fix: 每日账单先核查覆盖并补齐历史缺口 #873

Merged
qiuxiang merged 1 commits from feature/codex-erp-daily-catchup into main 2026-11-04 18:47:10 +08:00
3 changed files with 193 additions and 19 deletions

View File

@ -22,11 +22,13 @@ pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform jd -Through 2026-
# 只跑京东钱包,补历史月份
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
```
- 归期:`--through`(默认昨日)所在月份;每月 1 日自动导出**上一个整月**。
- **先补旧账再拉新账**:每次运行先核对 `2026-07-01` 至本次截止日的覆盖情况。发现历史月份缺失、截止日不足、行数或金额不一致时,按月份从旧到新重导并复核;历史补数未通过时,不继续拉取该平台的新日期。
- 每日失败自动顺延:当天任一平台导出、上传或导入失败,不写成功状态;次日覆盖核查会重新发现缺口并优先补齐。
- **无差异跳过**:该平台该月已导入且笔数/收入/支出完全一致时跳过导入(`-ForceImport` 可强制重刷);状态记在 `runtime/private/erp-exports/imported-state.json`。
- 超过胜算同步导出上限(响应 `超出同步导出允许最大数据量`)时,脚本自动轮询「异步导出下载管理」取回本次任务的全部分片,逐片转 CSV 后合并成一份再导入;仍可用 `-Start/-Through` 分段导出。
- 导入前用页面笔数(共 N 条)与导出行数核对,不一致就跳过导入(`-ForceImport` 可强制),避免只导入一部分。
@ -47,7 +49,7 @@ docker exec -u www-data caiwu_app php artisan business-mirror:verify-erp-daily-c
| 文件 | 作用 |
| --- | --- |
| `run-erp-daily.ps1` | 统一入口:导出 → 转 CSV → 差异判断 → 上传 → 受限批处理容器导入 → 页面回验 → 归档 → 日志 |
| `run-erp-daily.ps1` | 统一入口:导出 → 转 CSV → 差异判断 → 上传 → 受控导入 → 页面回验 → 归档 → 日志 |
| `export-daily.mjs` | 浏览器自动化导出(登录、切页签、设日期、查询、导出、取下载直链) |
| `erp-session.mjs` | 胜算登录与会话工具(凭据读 `runtime/private/敏感信息.md`,可用 `ERP_SECRET_FILE` 覆盖) |
| `cdp.mjs` | 无依赖的 Edge DevTools Protocol 驱动 |
@ -55,6 +57,6 @@ docker exec -u www-data caiwu_app php artisan business-mirror:verify-erp-daily-c
## 说明
- 导入走财务系统既有受控导入命令,并通过生产 `run-finance-batch.sh` 进入 1 CPU / 1 GiB 的独立批处理容器;命令带笔数/收支核对、原子切换并保留旧版本可回滚,不改上游数据。
- 导入走财务系统既有受控导入命令(带笔数/收支核对、原子切换、保留旧版本可回滚),不改上游数据。
- 凭据、导出文件、日志都写 `runtime/private/`(`.gitignore` 忽略),不进仓库。
- 定时:Windows 计划任务每天 09:30 调 `run-erp-daily.ps1`(京东取昨日;头条取月初→昨日)。

View File

@ -13,6 +13,25 @@ function New-ErpRemoteResult {
return [pscustomobject]@{ Ok = $Ok; Output = $Output; Attempts = $Attempts }
}
function Get-ErpCoverageBackfillJobs {
param(
[object[]]$CoverageReport,
[Parameter(Mandatory = $true)][string]$Dataset,
[Parameter(Mandatory = $true)][string]$CurrentMonth
)
# 只返回当前拉取月份之前的缺口;当前月由本轮整月重导自然覆盖。
return @(
$CoverageReport |
Where-Object {
$_.dataset -eq $Dataset -and
$_.month -lt $CurrentMonth -and
$_.status -ne 'ok'
} |
Sort-Object month
)
}
function Invoke-ErpRemoteUpload {
param(
[Parameter(Mandatory = $true)][string]$PwshPath,

View File

@ -5,7 +5,8 @@
jd = 京东商城→京东日账单(一次导入按店铺名拆 京东账单 + 京喜账单)
jd-wallet = 京东商城→京东钱包(落 京东网银钱包账户)
流程:导出 → xlsx 转 CSV → 与已导入期间比对(无差异则跳过)→ 上传 → 受限批处理容器导入 → 页面回验 → 归档 → 写日志
流程:覆盖核查 → 补齐历史缺口 → 导出 → xlsx 转 CSV → 与已导入期间比对(无差异则跳过)
→ 上传 → 受限批处理容器导入 → 页面回验 → 归档 → 写日志
用法:
pwsh -File run-erp-daily.ps1 # 三个平台,区间「月初 → 昨日」
@ -18,8 +19,10 @@ param(
[ValidateSet('toutiao', 'jd', 'jd-wallet', 'both')][string]$Platform = 'both',
[string]$Through,
[string]$Start,
[string]$CoverageFrom = '2026-07-01',
[switch]$SkipImport,
[switch]$ForceImport
[switch]$ForceImport,
[switch]$SkipCoverageCheck
)
$ErrorActionPreference = 'Stop'
@ -49,6 +52,11 @@ $specs = @{
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 }
}
$coverageKeys = @{
toutiao = 'douyin'
jd = 'jd-daily'
'jd-wallet' = 'jd-wallet'
}
$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 }
@ -79,11 +87,125 @@ function Invoke-Remote {
-Log { param($message) Write-Log $message }
}
function Get-ErpDailyCoverage {
param([Parameter(Mandatory = $true)][string]$CoverageFrom, [Parameter(Mandatory = $true)][string]$Through)
# 覆盖检查在有缺口时按设计返回 exit 1;报告本身仍是完整 JSON,不能把它当成远程执行失败重试。
$body = @"
set +e
output=`$(docker exec -u www-data caiwu_app php /var/www/html/artisan business-mirror:verify-erp-daily-coverage --from=$CoverageFrom --to=$Through --json 2>&1)
status=`$?
printf '%s\n' "`$output"
if [ "`$status" -eq 1 ]; then
exit 0
fi
exit "`$status"
"@
$result = Invoke-Remote -Body $body
if (-not $result.Ok) {
throw ('覆盖核查失败:' + ($result.Output.Trim() -replace "`r?`n", ' | '))
}
$jsonLine = (($result.Output -split "`r?`n") | Where-Object { $_.TrimStart().StartsWith('[') } | Select-Object -Last 1)
if (-not $jsonLine) {
throw ('覆盖核查没有返回 JSON:' + ($result.Output.Trim() -replace "`r?`n", ' | '))
}
return @($jsonLine | ConvertFrom-Json)
}
$failed = $false
try {
$pullThroughDate = if ($Through) {
$parsed = [datetime]::MinValue
if (-not [datetime]::TryParseExact($Through, 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$parsed)) {
throw '截止日期格式无效,应为 YYYY-MM-DD。'
}
$parsed
} else {
(Get-Date).Date.AddDays(-1)
}
$pullThrough = $pullThroughDate.ToString('yyyy-MM-dd')
$pullMonth = $pullThrough.Substring(0, 7)
$coverageFromDate = [datetime]::MinValue
if (-not [datetime]::TryParseExact($CoverageFrom, 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$coverageFromDate)) {
throw '覆盖核查起始日期格式无效,应为 YYYY-MM-DD。'
}
if ($coverageFromDate.Date -gt $pullThroughDate.Date) {
throw '覆盖核查起始日期不能晚于本次账单截止日期。'
}
$coverageReady = [bool]$SkipCoverageCheck
$coverageReport = @()
if (-not $SkipCoverageCheck) {
Write-Log ('=== 每日覆盖核查:{0} ~ {1} ===' -f $CoverageFrom, $pullThrough)
try {
$coverageReport = @(Get-ErpDailyCoverage -CoverageFrom $CoverageFrom -Through $pullThrough)
$coverageIssues = @($coverageReport | Where-Object { $_.status -ne 'ok' })
Write-Log ('覆盖核查完成:{0} 项,异常 {1} 项' -f $coverageReport.Count, $coverageIssues.Count)
foreach ($issue in $coverageIssues) {
Write-Log (' 待补:{0} {1},{2} ~ {3},状态 {4}({5})' -f $issue.label, $issue.month, $issue.required_start, $issue.required_through, $issue.status, $issue.issue)
}
$coverageReady = $true
} catch {
Write-Log ('覆盖核查失败,本次不拉取新账单:' + $_.Exception.Message)
$failed = $true
}
}
if (-not $coverageReady) {
$targets = @()
}
foreach ($target in $targets) {
$spec = $specs[$target]
Write-Log ('=== ' + $spec.label + ':开始 ===')
$coverageKey = $coverageKeys[$target]
$currentIssue = @($coverageReport | Where-Object { $_.dataset -eq $coverageKey -and $_.month -eq $pullMonth -and $_.status -ne 'ok' })
$forceThisImport = [bool]$ForceImport -or $currentIssue.Count -gt 0
# 先补此前月份的缺口;历史仍不完整时,不继续拉取本月新数据。
$historicalIssues = @(Get-ErpCoverageBackfillJobs -CoverageReport $coverageReport -Dataset $coverageKey -CurrentMonth $pullMonth)
if ($historicalIssues.Count -gt 0 -and -not $SkipImport) {
$catchUpFailed = $false
foreach ($issue in $historicalIssues) {
Write-Log ('发现历史缺口,先补数:{0} {1},{2} ~ {3}({4})' -f $issue.label, $issue.month, $issue.required_start, $issue.required_through, $issue.status)
& $self -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath `
-Platform $target `
-Start $issue.required_start `
-Through $issue.required_through `
-CoverageFrom $CoverageFrom `
-ForceImport `
-SkipCoverageCheck
if ($LASTEXITCODE -ne 0) {
Write-Log ('历史补数失败:{0} {1},已停止该平台的新数据拉取' -f $issue.label, $issue.month)
$catchUpFailed = $true
break
}
}
if (-not $catchUpFailed) {
try {
$coverageReport = @(Get-ErpDailyCoverage -CoverageFrom $CoverageFrom -Through $pullThrough)
$remaining = @(Get-ErpCoverageBackfillJobs -CoverageReport $coverageReport -Dataset $coverageKey -CurrentMonth $pullMonth)
if ($remaining.Count -gt 0) {
foreach ($issue in $remaining) {
Write-Log ('历史补数后仍未完整:{0} {1},状态 {2}' -f $issue.label, $issue.month, $issue.status)
}
$catchUpFailed = $true
}
} catch {
Write-Log ('历史补数后复核失败:' + $_.Exception.Message)
$catchUpFailed = $true
}
}
if ($catchUpFailed) {
$failed = $true
continue
}
}
# 1) 导出(胜算页面偶发日历面板/授权弹窗/胜算标签页注册慢,失败后自动重试)
$exportArgs = @((Join-Path $root 'export-daily.mjs'), ('--platform=' + $target), ('--out=' + $exportDir))
@ -149,21 +271,28 @@ try {
$key = $target + ':' + $month
$prev = $state.$key
$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)
$currentCoverage = @($coverageReport | Where-Object { $_.dataset -eq $coverageKey -and $_.month -eq $month } | Select-Object -First 1)
$serverMatchesExport = $currentCoverage.Count -eq 1 -and
$currentCoverage[0].status -eq 'ok' -and
[int]$currentCoverage[0].row_count -eq $rows -and
[math]::Abs([double]$currentCoverage[0].income - $income) -lt 0.005 -and
[math]::Abs([double]$currentCoverage[0].expense - $expense) -lt 0.005
if (($same -or $serverMatchesExport) -and -not $forceThisImport) {
if (-not $same -and $serverMatchesExport) {
# 服务端覆盖已正确、只是本机状态滞后时,回填状态,避免每天重复导入同一批账单。
$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
Write-Log ('服务端覆盖已核对且与导出无差异,已回填本机状态({0} 笔 / {1} / {2}),跳过导入' -f $rows, $income, $expense)
} else {
Write-Log ('该月已导入且无差异({0} 笔 / {1} / {2}),跳过导入' -f $rows, $income, $expense)
}
continue
}
if ($SkipImport) { Write-Log '已指定 -SkipImport,跳过导入'; continue }
# 3) 上传 + 导入 + 页面回验(远端失败/断连自动重试;整段失败则本平台失败,不写状态、不归档)
# 3) 上传 + 导入 + 页面回验。统一交给 Invoke-Remote,上传断开时会重新上传并重试,
# 整段失败则本平台失败,不写状态、不归档。
$remoteFile = '/tmp/' + $target + '_daily_' + (Get-Date -Format 'yyyyMMdd_HHmmss') + '.csv'
$upload = Invoke-ErpRemoteUpload -PwshPath $self -UploaderScript $remoteUploaderScript -LocalFile $csvFile -RemoteFile $remoteFile
Write-Log ('上传:' + ($upload.Output.Trim() -replace "`r?`n", ' | '))
if (-not $upload.Ok) {
Write-Log ($spec.label + ' 上传失败:未写入本地状态、未归档(下次运行会重新尝试)')
$failed = $true
continue
}
$verifyPhp = @"
cat > /tmp/verify_daily.php <<'PHP'
@ -173,19 +302,42 @@ require '/var/www/html/vendor/autoload.php';
`$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
`$repo = app(Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository::class);
`$tabs = ['__TABS__'];
`$total = 0;
`$incomeCents = 0;
`$expenseCents = 0;
foreach (`$tabs as `$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),
default => app(Modules\EcommerceFinance\Repositories\JdDailyBillRepository::class)
->forPlatform(`$tab === 'jingxi' ? 'jingxi' : 'jd')
->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1),
};
`$total += (int) `$r['total'];
`$incomeCents += (int) round((float) `$r['summary']['income'] * 100);
`$expenseCents += (int) round((float) `$r['summary']['expense'] * 100);
echo 'PAGE ' . `$tab . ' total=' . `$r['total'] . ' ' . json_encode(`$r['summary'], JSON_UNESCAPED_UNICODE) . PHP_EOL;
}
if (`$total !== __COUNT__
|| `$incomeCents !== (int) round(__INCOME__ * 100)
|| `$expenseCents !== (int) round(__EXPENSE__ * 100)) {
fwrite(STDERR, sprintf(
'页面回验不一致:导入 %d 笔 / %s / %s,页面 %d 笔 / %.2f / %.2f%s',
__COUNT__, __INCOME__, __EXPENSE__, `$total, `$incomeCents / 100, `$expenseCents / 100, PHP_EOL
));
exit(1);
}
PHP
docker exec -i -u www-data caiwu_app php < /tmp/verify_daily.php
rm -f /tmp/verify_daily.php
"@
$verifyPhp = $verifyPhp.Replace('__TABS__', ($spec.verifyTabs -join "','")).Replace('__START__', $summary.monthStart).Replace('__THROUGH__', $summary.through)
$invariant = [Globalization.CultureInfo]::InvariantCulture
$verifyPhp = $verifyPhp.Replace('__TABS__', ($spec.verifyTabs -join "','"))
$verifyPhp = $verifyPhp.Replace('__START__', $summary.monthStart)
$verifyPhp = $verifyPhp.Replace('__THROUGH__', $summary.through)
$verifyPhp = $verifyPhp.Replace('__COUNT__', [string]$rows)
$verifyPhp = $verifyPhp.Replace('__INCOME__', $income.ToString('0.00', $invariant))
$verifyPhp = $verifyPhp.Replace('__EXPENSE__', $expense.ToString('0.00', $invariant))
# 生产重型导入必须进入发布时安装的受限批处理容器;由远端 runner
# 持有同步锁、校验可用内存、复制只读输入并限制为 1 CPU / 1 GiB。
@ -212,9 +364,10 @@ $verifyPhp
$state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $stateFile -Encoding UTF8
$monthNumber = [int]$month.Substring(5, 2)
$shareDir = Join-Path 'Z:\高祝兰\财务系统' ('{0}月' -f $monthNumber)
if (Test-Path -LiteralPath 'Z:\高祝兰\财务系统') {
$shareRoot = 'Z:\高祝兰\财务系统'
if (Test-Path -LiteralPath $shareRoot) {
try {
$shareDir = Join-Path $shareRoot ('{0}月' -f $monthNumber)
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