Compare commits

..

10 Commits

Author SHA1 Message Date
473dc2820c 屏蔽 2月份补传 2026-09-30 13:56:10 +08:00
bbb9321265 ygb 2026-09-30 11:57:08 +08:00
f0e728c535 消费费用补传 2026-09-30 11:51:04 +08:00
28006eae86 【临时·一次性】2026-02 消费费用补传(2086) 2026-09-30 11:44:28 +08:00
2e92ba82af LW工号劳务不发放生日券 2026-09-30 11:04:38 +08:00
0ed292e214 生日券 LW开头的工号不发 外包 2026-09-30 10:01:28 +08:00
234852704f 上传消费数据失败 加日志 2026-09-29 15:30:55 +08:00
04f08fbc6e ygb 2026-09-18 16:01:13 +08:00
4ec68ac8cf 手动处理余额 去除写死的admin权限 2026-09-18 16:01:01 +08:00
e3d736d69a Initial commit 2026-09-18 07:52:11 +00:00
9 changed files with 597 additions and 68 deletions

166
.gitignore vendored
View File

@@ -1,54 +1,130 @@
# ---> C Sharp
# Build Folders (you can keep bin if you'd like, to store dlls and pdbs)
[Bb]in/
[Oo]bj/
# mstest test results
TestResults
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
@@ -56,12 +132,37 @@ ipch/
# ReSharper is a .NET coding add-in
_ReSharper*
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
*.ncrunch*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress
# DocProject is a documentation generator add-in
DocProject/buildhelp/
@@ -74,42 +175,43 @@ DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish
# Publish Web Output
*.Publish.xml
# NuGet Packages Directory
packages
# Windows Azure Build Output
csx
*.build.csdef
# Windows Store app package directory
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
[Bb]in
[Oo]bj
sql
TestResults
[Tt]est[Rr]esult*
*.Cache
ClientBin
[Ss]tyle[Cc]op.*
~$*
*~
*.dbmdl
Generated_Code #added for RIA/Silverlight projects
# Backup & report files from converting an old project file to a newer
# Visual Studio version. Backup files are not needed, because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
.idea
.vs
log
*.db
/Src/Dlhis.Basic/App/App
/Src/Dlhis.Basic/App
/Src/Dlhis.Sys/App

View File

@@ -1,2 +1,3 @@
# jinko.yikatong
晶科人脸平台 主程序

View File

@@ -14,9 +14,13 @@
</PropertyGroup>
<ItemGroup>
<Compile Remove="App\**" />
<Compile Remove="log\**" />
<Content Remove="App\**" />
<Content Remove="log\**" />
<EmbeddedResource Remove="App\**" />
<EmbeddedResource Remove="log\**" />
<None Remove="App\**" />
<None Remove="log\**" />
<Content Update="appsettings.Development.json">
<DependentUpon>appsettings.json</DependentUpon>

View File

@@ -10,9 +10,13 @@
</PropertyGroup>
<ItemGroup>
<Compile Remove="App\**" />
<Compile Remove="log\**" />
<Content Remove="App\**" />
<Content Remove="log\**" />
<EmbeddedResource Remove="App\**" />
<EmbeddedResource Remove="log\**" />
<None Remove="App\**" />
<None Remove="log\**" />
</ItemGroup>

View File

@@ -161,7 +161,6 @@ public class BalancesController : ControllerBase
/// 手动处理余额
/// </summary>
/// <param name="requests"></param>
[Authorize(Roles = "admin")]
[HttpPost("manuallyRecalc")]
[IgnoreTenant]
[Transactional]

View File

@@ -245,6 +245,7 @@ public class BirthdayVoucherService : BaseVoucherService, IVoucherService
.Where(x => x.Birthday.HasValue)
//.Where(x => !string.IsNullOrEmpty(x.CostId))
// 正常情况:匹配当天生日
// 注意:LW 劳务派遣工号统一在下方返回前过滤,不能在此处 SQL 过滤,否则留痕事件无法写入
.WhereIF(isToday && !includeLeapDayBirthday, x => SqlFunc.DateValue(x.Birthday.Value, DateType.Month) == begin.Month && SqlFunc.DateValue(x.Birthday.Value, DateType.Day) == begin.Day)
// 特殊情况:非闰年的2月28日,同时匹配当天生日和2月29日生日
.WhereIF(isToday && includeLeapDayBirthday, x =>
@@ -272,12 +273,17 @@ public class BirthdayVoucherService : BaseVoucherService, IVoucherService
{
events.Add(new UserBusinessEventEntity { UserCode = gainfo.Code, EventType = "GenerateBirthdayVoucher", Content = "成本中心为空" });
}
else if (gainfo.Code.StartsWith("LW", StringComparison.OrdinalIgnoreCase))
{
events.Add(new UserBusinessEventEntity { UserCode = gainfo.Code, EventType = "GenerateBirthdayVoucher", Content = "LW工号劳务不发放生日券" });
}
}
return (gainfos
.Where(x => x.DepartmentNo.IsPresent())
.Where(x => !excludePositionNos.Contains(x.PositionNo))
.Where(x => x.CostId.IsPresent())
.Where(x => !x.Code.StartsWith("LW", StringComparison.OrdinalIgnoreCase))//排除劳务派遣工号(LW 开头)
.ToList(), events);
}

View File

@@ -3,6 +3,7 @@ using Dlhis.Entity.Canteen.Enums;
using Dlhis.Jip.Api;
using Dlhis.Jip.Api.Requests;
using Dlhis.ScheduledTask.Attributes;
using Newtonsoft.Json;
using SqlSugar;
namespace Dlhis.ScheduledTask.Jobs;
@@ -78,6 +79,8 @@ public class CostUploadJob : BaseJob
}).ToList()
};
try
{
var response = await jipApi.UploadCostAsync(request);
var handleData = response.Data.Where(x => x.Success == "S").ToList();
Logger.LogInformation("CostUploadJob消费上传成功人员:{count}", handleData.Count);
@@ -123,5 +126,10 @@ public class CostUploadJob : BaseJob
await DbContext.InsertAsync(newRecords);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "BirthdayCostUploadJob上传消费数据失败-{userCode}:{error}", JsonConvert.SerializeObject(request), ex.Message);
}
}
}
}

View File

@@ -0,0 +1,401 @@
using System.Diagnostics;
using Dlhis.Entity.Canteen.Entities;
using Dlhis.Entity.Canteen.Enums;
using Dlhis.Jip.Api;
using Dlhis.Jip.Api.Requests;
using Dlhis.Jip.Api.Responses;
using Dlhis.ScheduledTask.Attributes;
using Newtonsoft.Json;
using SqlSugar;
namespace Dlhis.ScheduledTask.Jobs;
/// <summary>
/// 【临时·一次性】2026-02 消费费用补传(2086)
///
/// 背景:2026-03-08 00:00 的 <see cref="CostUploadJob"/> 在 00:20 前后进程异常终止,
/// 2 月仅成功上传 7,893 人 / 应有 20,972 人,缺失 13,078 人 / 5,333,866.15 元。
/// 排查与名单见 `_运维笔记/人脸平台/问题与解决汇总/20260913 *`(已三方核对一致)。
///
/// 与 <see cref="CostUploadJob"/> 的关系与差异:
/// 相同:取数口径(Type='Out'、Status='Valid'、CostDate∈[月初,次月初)、Source 排除
/// EMealVoucher/Birthday、合计≠0)、批大小 100、SUBTY=2086、BEGDA=月末、
/// BETRG=|合计|、只按 MSGTY=='S' 回写 UserBalanceUploadRecords;
/// 不同:① 目标年月写死为 2026-02(原任务固定"运行当月-1",无法补历史月份);
/// ② 先一次 GROUP BY 聚合成"工号→金额",不再逐人查询(原任务 N+1 查询是耗时主因);
/// ③ **逐批 try/catch**,单批失败不中断后续(原任务缺此保护,是本次事故的根因);
/// ④ 回写单独 try/catch,与"JIP 请求失败"分开记,避免把"上传成功但回写失败"误报成请求异常;
/// ⑤ 响应工号按"去前导零"二次匹配,匹配不到的人进告警清单而不是被静默跳过;
/// ⑥ 数量闸门 + 时长预算 + 演练模式(见下方常量),避免误发整月数据或超 2 小时被 Hangfire 重入队;
/// ⑦ 结束输出分类汇总(成功/失败/未返回/请求异常/回写失败/未处理)。
///
/// 幂等性(可安全重复执行):
/// 1. 只在 UserBalanceUploadRecords 中**没有**(2026,2)记录的人里取数 → 已成功的 7,893 人自动跳过;
/// 2. 补传成功后写入 (UserCode,2026,2,Cost) → 再次执行时这些人自动被排除。
/// ⚠️ 因此**必须**只在 MSGTY=='S' 时回写;绝不可按"缺失集合"全量回写,
/// 否则失败者会被标记为"已上传"而永久排除、永不补传。
/// ⚠️ 写库用的工号必须是**本地的请求工号**(不是 JIP 回显的工号),否则与复核 SQL 关联不上。
///
/// 风险与约束(务必遵守):
/// 1. HCM_0015_INSERT 对同一 (ZWORKCODE,SUBTY,BEGDA) 是"覆盖"还是"累加"**尚未与 JIP 确认**,
/// 因此对请求异常/超时的批次**不做任何自动重试**(JipClientHandler 也未配置重试策略)。
/// 超时/未返回结果的批次必须先人工核对 JIP 侧是否已落库,再决定是否重跑本任务。
/// 2. 手工已补传但未回写上传记录的工号必须排除,见 <see cref="ManuallyUploadedUserCodes"/>。
/// 3. 上报金额沿用原任务写法(<c>Math.Abs(合计)</c>,未做 ROUND),与现网行为一致,勿擅自改成保留 2 位。
///
/// 当前状态(2026-09-30 更新):**2 月补传已完成,本任务已屏蔽**
/// - 执行结果:待补传 13,078 人 → 成功写入 13,076 人(7,893 + 13,076 = 20,969,与库内计数一致);
/// 未写入 2 人:`30002488`(23.20 元,JIP 未返回 S,待财务在 SAP 侧确认)、`WB-1091`(0.70 元,本地测试工号);
/// 另有 `30011368`(388.00 元) 系 2026-03 用 Postman 手工补传、未回写平台记录(JIP 侧已有,无需处理)。
/// - 屏蔽方式:<see cref="Disabled"/> = true ⇒ 任务即使被触发也只写一行警告日志、**不取数、不上传、不写库**;
/// 代码保留不删除,便于日后核对/复用。恢复方式:把 Disabled 改为 false 并重新发版。
/// - 注意:Hangfire 的 recurring job 定义存在 Redis 里,屏蔽代码**不会**删掉它;
/// 它只会在兜底时间(2027-01-01)触发一次,届时因 Disabled=true 而空转,不会误传数据。
///
/// 历史执行方式(cron = "0 0 0 1 1 ?",仅兜底 ⇒ 不会自动跑,需手动触发一次):
/// - 触发入口:**https://face.jinkosolar.com/hangfire/**(走 ingress;NodePort 31005 的 /hangfire 返回 404)
/// → `Recurring Jobs` → **勾选 `SupplementCostUploadJob` 这一行** → 列表上方出现 **`Trigger now`** 按钮 → 点它
/// (注意:不勾选时行内看不到按钮,容易误以为没有该功能);
/// - 千万不要用"每天/每小时"这类会反复命中的 cron:已成功的会被自动跳过,
/// 但"未确认"批次(超时/未返回)会被反复重试,那是唯一会造成重复提交的场景;
/// - 本地调试需要注册时,给特性加 RunOnDevelopment = true(默认 false,避免开发环境误跑真接口)。
///
/// 补传完成后:**删除本文件**并从 scheduled 重新发版(即方案中的"用完即拆")。
/// </summary>
[RecurringJob(nameof(SupplementCostUploadJob), "0 0 0 1 1 ?", Name = "【临时】2026-02消费费用补传")]
public class SupplementCostUploadJob : BaseJob
{
/// <summary>目标数据所属年月(写死:只补 2026 年 2 月)</summary>
private const int TargetYear = 2026;
private const int TargetMonth = 2;
/// <summary>单批条数(与 <see cref="CostUploadJob"/> 一致)</summary>
private const int BatchSize = 100;
/// <summary>SUBTY:2086 餐卡消费金额</summary>
private const string SubTypeMealCardCost = "2086";
/// <summary>日志中最多打印多少个工号,避免刷爆日志</summary>
private const int MaxLogUserCodes = 500;
/// <summary>
/// 【安全闸门】待补传人数上限:预期 13,078 人(该月有消费 20,972 − 已上传 7,893 − 手工补传 1)。
/// 超过此上限说明取数口径异常(例如区间写错、IgnoreSources 失效),直接中止、不发起任何上传。
/// </summary>
private const int MaxAllowedUserCount = 15000;
/// <summary>
/// 【安全闸门】总时长预算(分钟)。Hangfire 的 InvisibilityTimeout = 2 小时
/// (见 <see cref="Packs.HangfirePack"/>),超时任务会被判定"丢失"并重新入队;
/// 这里主动提前停止后续批次,避免越界,同时保证已处理部分已正确回写。
/// </summary>
private const int MaxRunMinutes = 90;
/// <summary>
/// 【演练模式】true = 只取数、只打日志,**不调用 JIP 接口、不写任何数据**。
/// 当前值 = <c>false</c>:经确认(2026-09-30)跳过演练,直接执行真实补传。
/// 若需退回演练:把本值改回 true,并重新编译 + 重新发版。
/// 注意:故意声明为 static readonly(非 const),否则编译器会把 if 之后的代码判为"不可达"而报 CS0162;
/// 改动本值后**必须重新编译并重新发版**才生效。
/// </summary>
private static readonly bool DryRun = false;
/// <summary>
/// 已通过其它方式(Postman 手工)补传过、但**未回写** UserBalanceUploadRecords 的工号,必须排除,否则会重复提交。
/// 依据:`20260913 2月费用上传缺失排查与补传方案.md` 第 6.1 节 —— 30011368 已于 2026-03 手工补传 388 元。
/// </summary>
private static readonly List<string> ManuallyUploadedUserCodes = new() { "30011368" };
/// <summary>
/// 【总开关】true = **整体屏蔽**(不取数、不调 JIP、不写库),仅写一行警告日志。
/// 2026-09-30 补传完成后按运维要求置为 true,防止再次自动上传;
/// 与 <see cref="DryRun"/> 的区别:DryRun 仍会取数并打统计日志,Disabled 连取数都不做。
/// 同样故意声明为 static readonly(非 const),否则 if 之后的代码会被判为"不可达"而报 CS0162。
/// </summary>
private static readonly bool Disabled = true;
public SupplementCostUploadJob(IServiceProvider serviceProvider) : base(serviceProvider)
{
}
protected override async Task RunAsync(IServiceProvider serviceProvider)
{
if (Disabled)
{
Logger.LogWarning(
"【补传】本任务已屏蔽(Disabled=true),未执行任何操作。2026-02 补传已于 2026-09-30 完成(13076/13078);若确需重跑,请把 Disabled 改为 false 并重新发版");
return;
}
var jipApi = serviceProvider.GetService<IJinkoJipApi>();
var stopwatch = Stopwatch.StartNew();
// 目标区间:2026-02-01 ~ 2026-03-01(必须单月,跨月会得到多个月相加)
var s = new DateTime(TargetYear, TargetMonth, 1);
var e = s.AddMonths(1);
var endDayOfMonth = e.AddDays(-1); // 2026-02-28 → BEGDA
var beginDate = endDayOfMonth.ToString("yyyy-MM-dd");
// 1) 该月已有上传记录的人(成功过一次就跳过,这是幂等的唯一依据)
var uploadedUserCodes = await DbContext.Queryable<UserBalanceUploadRecordEntity>()
.Where(x => x.Year == TargetYear && x.Month == TargetMonth)
.Select(x => x.UserCode)
.ToListAsync();
var uploadedSet = uploadedUserCodes.Where(x => !string.IsNullOrEmpty(x)).ToHashSet();
// 2) 一次聚合出"该月消费合计≠0"的工号与金额(口径与 CostUploadJob / 排查 SQL 完全一致)
var amounts = await DbContext.Queryable<BalanceRecordEntity>()
.Where(x => x.Status == BalanceRecordStatusEnumType.Valid)
.Where(x => x.Type == BalanceRecordTypeEnumType.Out)
.Where(x => x.CostDate >= s && x.CostDate < e)
.Where(x => !BalanceRecordSourceConstants.IgnoreSources.Contains(x.Source))
.GroupBy(x => x.UserCode)
.Select(x => new
{
x.UserCode,
Amount = SqlFunc.AggregateSum(x.Amount)
})
.ToListAsync();
// 3) 过滤:排除已上传、已手工补传、金额为 0 的人
var toUpload = amounts
.Where(x => !string.IsNullOrEmpty(x.UserCode))
.Where(x => x.Amount != 0)
.Where(x => !uploadedSet.Contains(x.UserCode))
.Where(x => !ManuallyUploadedUserCodes.Contains(x.UserCode))
.OrderBy(x => x.UserCode, StringComparer.Ordinal)
.ToList();
var totalAmount = toUpload.Sum(x => Math.Abs(x.Amount));
var batchTotal = (toUpload.Count + BatchSize - 1) / BatchSize;
Logger.LogInformation(
"【补传】开始(演练模式={dryRun}):区间[{s},{e}) BEGDA={begda} 该月有消费人数={all} 已上传跳过={uploaded} 本次待补传={count}人/合计{amount}元 共{batch}批",
DryRun, s.ToString("yyyy-MM-dd"), e.ToString("yyyy-MM-dd"), beginDate,
amounts.Count(x => x.Amount != 0), uploadedSet.Count, toUpload.Count, Math.Round(totalAmount, 2), batchTotal);
if (!toUpload.Any())
{
Logger.LogInformation("【补传】无待补传人员(若属预期,说明 2 月已补全或已回写完毕)");
return;
}
// 4) 数量闸门:口径异常时宁可不动,也不要误发整月数据
if (toUpload.Count > MaxAllowedUserCount)
{
Logger.LogError(
"【补传】待补传人数{count}超过安全上限{max},已中止(未上传任何数据)。请先核对取数口径与 UserBalanceUploadRecords 现状!",
toUpload.Count, MaxAllowedUserCount);
return;
}
// 5) 演练模式:只输出统计,不调 JIP、不写库
if (DryRun)
{
Logger.LogWarning(
"【补传·演练模式】未调用任何 JIP 接口、未写入任何数据!期望值:13078人/5333866.15元/131批;实际:{count}人/{amount}元/{batch}批;样例(前10):{sample}",
toUpload.Count, Math.Round(totalAmount, 2), batchTotal,
string.Join(',', toUpload.Take(10).Select(x => $"{x.UserCode}={Math.Abs(x.Amount)}")));
Logger.LogWarning("【补传·演练模式】核对无误后,请把 DryRun 改为 false 并重新发版,再触发一次执行真实补传");
return;
}
var processedCount = 0; // 已处理(进入循环)的人数
var writtenCount = 0; // 已成功上传并回写记录的人数
var writtenAmount = 0m;
var batchNo = 0;
var failedUserCodes = new List<string>(); // MSGTY != 'S'
var unreportedUserCodes = new List<string>(); // JIP 未返回结果(未确认)
var unmatchedUserCodes = new List<string>(); // 响应工号无法对应到本批请求(未确认)
var emptyResponseBatches = new List<int>(); // 响应为空(未确认)
var errorBatches = new List<string>(); // JIP 请求异常/超时(未确认,禁止盲目重发)
var writeBackFailed = new List<string>(); // JIP 成功但回写失败(重跑前必须核对)
foreach (var items in toUpload.Chunk(BatchSize))
{
// 6) 时长预算:避免单次运行超过 Hangfire 的 InvisibilityTimeout(2h) 被重新入队
if (stopwatch.Elapsed.TotalMinutes > MaxRunMinutes)
{
Logger.LogWarning(
"【补传】已运行{elapsed}分钟,超过预算{max}分钟,主动停止后续批次:剩余{rest}人未处理(已成功者已回写,重跑本任务会自动跳过)",
Math.Round(stopwatch.Elapsed.TotalMinutes), MaxRunMinutes, toUpload.Count - processedCount);
break;
}
batchNo++;
processedCount += items.Length;
var request = new UploadCostRequest
{
Data = items.Select(x => new UploadCostItemRequest
{
WorkCode = x.UserCode,
Code = SubTypeMealCardCost,
BeginDate = beginDate,
Charge = Math.Abs(x.Amount)
}).ToList()
};
UploadCostResponse response;
try
{
response = await jipApi.UploadCostAsync(request);
}
catch (Exception ex)
{
// 单批异常不中断后续批次;且不自动重试(幂等语义未确认)
errorBatches.Add($"#{batchNo}({items.First().UserCode}~{items.Last().UserCode})");
Logger.LogError(ex,
"【补传】第{batch}/{total}批 JIP请求异常,未回写(该批{count}人;若为超时,请先核对JIP侧是否已落库,再决定是否重跑):{request}",
batchNo, batchTotal, request.Data.Count, JsonConvert.SerializeObject(request));
continue;
}
var responseItems = response?.Data ?? new List<UploadCostItemResponse>();
if (responseItems.Count == 0)
{
emptyResponseBatches.Add(batchNo);
Logger.LogWarning("【补传】第{batch}/{total}批 响应为空,未回写(该批{count}人需人工确认):{request}",
batchNo, batchTotal, request.Data.Count, JsonConvert.SerializeObject(request));
continue;
}
// 7) 匹配:按"去前导零"二次匹配,容忍 JIP 回显补零(接口样例:请求 343 → 响应 00000343)
var successKeys = new HashSet<string>();
foreach (var resp in responseItems.Where(x => x.Success == "S"))
{
successKeys.Add(NormalizeUserCode(resp.WorkCode));
}
var failItems = responseItems.Where(x => x.Success != "S").ToList();
failedUserCodes.AddRange(failItems.Select(x => $"{x.WorkCode}:{x.Message}"));
var respondedKeys = responseItems.Select(x => NormalizeUserCode(x.WorkCode)).ToHashSet();
var requestKeys = items.Select(x => NormalizeUserCode(x.UserCode)).ToHashSet();
// 响应里出现的、不属于本批请求的工号 → 告警(不回写,避免张冠李戴)
unmatchedUserCodes.AddRange(responseItems
.Select(x => x.WorkCode)
.Where(x => !requestKeys.Contains(NormalizeUserCode(x)))
.Distinct());
// 本批请求了、但 JIP 既没给 S 也没给 E 的人 → 未确认
unreportedUserCodes.AddRange(items
.Where(x => !respondedKeys.Contains(NormalizeUserCode(x.UserCode)))
.Select(x => x.UserCode));
// 只回写"本批请求里被判定成功"的人,且**用本地请求工号**写库(保证与复核 SQL 可关联)
var writeItems = items.Where(x => successKeys.Contains(NormalizeUserCode(x.UserCode))).ToList();
if (unreportedUserCodes.Any() || unmatchedUserCodes.Any())
{
Logger.LogWarning("【补传】第{batch}/{total}批 结果不完整:未返回{no}人、响应异常工号{un}个(已记入告警清单,未回写)",
batchNo, batchTotal, unreportedUserCodes.Count, unmatchedUserCodes.Count);
}
if (!writeItems.Any())
{
Logger.LogWarning("【补传】第{batch}/{total}批 无成功记录,未回写;失败{count}项:{fail}",
batchNo, batchTotal, failItems.Count,
string.Join(',', failItems.Take(MaxLogUserCodes).Select(x => $"{x.WorkCode}:{x.Message}")));
continue;
}
// 8) 回写单独 try/catch:把"JIP 已成功但回写失败"与"JIP 请求失败"区分开
try
{
var workCodes = writeItems.Select(x => x.UserCode).ToList();
var existRecords = await DbContext.Queryable<UserBalanceUploadRecordEntity>()
.Where(x => workCodes.Contains(x.UserCode))
.Where(x => x.Year == TargetYear && x.Month == TargetMonth)
.ToListAsync();
var updatedRecords = new List<UserBalanceUploadRecordEntity>();
var newRecords = new List<UserBalanceUploadRecordEntity>();
foreach (var item in writeItems)
{
var cost = Math.Abs(item.Amount);
var record = existRecords.FirstOrDefault(x => x.UserCode == item.UserCode);
if (record != null)
{
record.Cost = cost;
updatedRecords.Add(record);
}
else
{
newRecords.Add(new UserBalanceUploadRecordEntity
{
UserCode = item.UserCode,
Year = TargetYear,
Month = TargetMonth,
Cost = cost
});
}
}
if (updatedRecords.Any())
{
await DbContext.UpdateAsync(updatedRecords);
}
if (newRecords.Any())
{
await DbContext.InsertAsync(newRecords);
}
writtenCount += writeItems.Count;
writtenAmount += writeItems.Sum(x => Math.Abs(x.Amount));
Logger.LogInformation("【补传】第{batch}/{total}批 成功{ok}人 失败{fail}人 累计({done}/{all})",
batchNo, batchTotal, writeItems.Count, failItems.Count, writtenCount, toUpload.Count);
}
catch (Exception ex)
{
// JIP 侧多半已成功;这里不回滚、不重试,只告警——重跑前必须先核对 JIP 侧
writeBackFailed.Add($"#{batchNo}({writeItems.Count}人)");
Logger.LogError(ex,
"【补传】第{batch}/{total}批 JIP已返回成功但**回写上传记录失败**:{codes} → 这些人在JIP侧可能已成功,重跑前必须先核对,否则会重复提交!",
batchNo, batchTotal, string.Join(',', writeItems.Select(x => x.UserCode)));
}
}
// 9) 分类汇总(便于核对"应上传/成功/各类未确认")
Logger.LogInformation(
"【补传】结束:待补传{all}人/{amount}元 → 成功回写{ok}人/{okAmount}元;MSGTY≠S {fail}人 未返回结果{no}人 响应异常工号{un}个 响应为空{empty}批 JIP请求异常{err}批 回写失败{wb}批 未处理{rest}人;耗时{elapsed}分钟",
toUpload.Count, Math.Round(totalAmount, 2), writtenCount, Math.Round(writtenAmount, 2),
failedUserCodes.Count, unreportedUserCodes.Count, unmatchedUserCodes.Count,
emptyResponseBatches.Count, errorBatches.Count, writeBackFailed.Count,
toUpload.Count - processedCount, Math.Round(stopwatch.Elapsed.TotalMinutes));
if (failedUserCodes.Any())
{
Logger.LogWarning("【补传】MSGTY≠S 工号({count}):{codes}",
failedUserCodes.Count, string.Join(',', failedUserCodes.Take(MaxLogUserCodes)));
}
if (unreportedUserCodes.Any() || unmatchedUserCodes.Any())
{
Logger.LogWarning("【补传】未确认工号 未返回结果({no}):{noCodes} | 响应异常({un}):{unCodes}",
unreportedUserCodes.Count, string.Join(',', unreportedUserCodes.Take(MaxLogUserCodes)),
unmatchedUserCodes.Count, string.Join(',', unmatchedUserCodes.Take(MaxLogUserCodes)));
}
if (emptyResponseBatches.Any() || errorBatches.Any() || writeBackFailed.Any())
{
Logger.LogWarning(
"【补传】未确认批次 响应为空[{empty}] JIP请求异常[{err}] 回写失败[{wb}] → **先核对 JIP 侧再决定是否重跑**",
string.Join(',', emptyResponseBatches), string.Join(',', errorBatches), string.Join(',', writeBackFailed));
}
Logger.LogInformation(
"【补传】请按 `20260913 每月费用上传完整性核对SQL.sql` 复核:该月记录数应达 {expect} 左右(7893+本次成功);剩余缺失应回归 0(或仅剩 {manual})",
7893 + writtenCount, string.Join(',', ManuallyUploadedUserCodes));
}
/// <summary>
/// 工号规范化:去空白 + 去前导零,用于容忍 JIP 回显工号与本地工号的格式差异
/// (依据:SI 接口样例中请求 PERNR=343 → 响应 PERNR=00000343)。
/// </summary>
private static string NormalizeUserCode(string userCode)
=> string.IsNullOrWhiteSpace(userCode) ? string.Empty : userCode.Trim().TrimStart('0');
}

View File

@@ -10,9 +10,13 @@
</PropertyGroup>
<ItemGroup>
<Compile Remove="App\**" />
<Compile Remove="log\**" />
<Content Remove="App\**" />
<Content Remove="log\**" />
<EmbeddedResource Remove="App\**" />
<EmbeddedResource Remove="log\**" />
<None Remove="App\**" />
<None Remove="log\**" />
</ItemGroup>