Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 473dc2820c | |||
| bbb9321265 | |||
| f0e728c535 | |||
| 28006eae86 | |||
| 2e92ba82af | |||
| 0ed292e214 | |||
| 234852704f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -212,3 +212,6 @@ ServiceFabricBackup/
|
|||||||
*.ldf
|
*.ldf
|
||||||
*.ndf
|
*.ndf
|
||||||
|
|
||||||
|
/Src/Dlhis.Basic/App/App
|
||||||
|
/Src/Dlhis.Basic/App
|
||||||
|
/Src/Dlhis.Sys/App
|
||||||
|
|||||||
@@ -14,9 +14,13 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Compile Remove="App\**" />
|
||||||
<Compile Remove="log\**" />
|
<Compile Remove="log\**" />
|
||||||
|
<Content Remove="App\**" />
|
||||||
<Content Remove="log\**" />
|
<Content Remove="log\**" />
|
||||||
|
<EmbeddedResource Remove="App\**" />
|
||||||
<EmbeddedResource Remove="log\**" />
|
<EmbeddedResource Remove="log\**" />
|
||||||
|
<None Remove="App\**" />
|
||||||
<None Remove="log\**" />
|
<None Remove="log\**" />
|
||||||
<Content Update="appsettings.Development.json">
|
<Content Update="appsettings.Development.json">
|
||||||
<DependentUpon>appsettings.json</DependentUpon>
|
<DependentUpon>appsettings.json</DependentUpon>
|
||||||
|
|||||||
@@ -10,9 +10,13 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Compile Remove="App\**" />
|
||||||
<Compile Remove="log\**" />
|
<Compile Remove="log\**" />
|
||||||
|
<Content Remove="App\**" />
|
||||||
<Content Remove="log\**" />
|
<Content Remove="log\**" />
|
||||||
|
<EmbeddedResource Remove="App\**" />
|
||||||
<EmbeddedResource Remove="log\**" />
|
<EmbeddedResource Remove="log\**" />
|
||||||
|
<None Remove="App\**" />
|
||||||
<None Remove="log\**" />
|
<None Remove="log\**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ public class BirthdayVoucherService : BaseVoucherService, IVoucherService
|
|||||||
.Where(x => x.Birthday.HasValue)
|
.Where(x => x.Birthday.HasValue)
|
||||||
//.Where(x => !string.IsNullOrEmpty(x.CostId))
|
//.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)
|
.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日生日
|
// 特殊情况:非闰年的2月28日,同时匹配当天生日和2月29日生日
|
||||||
.WhereIF(isToday && includeLeapDayBirthday, x =>
|
.WhereIF(isToday && includeLeapDayBirthday, x =>
|
||||||
@@ -272,12 +273,17 @@ public class BirthdayVoucherService : BaseVoucherService, IVoucherService
|
|||||||
{
|
{
|
||||||
events.Add(new UserBusinessEventEntity { UserCode = gainfo.Code, EventType = "GenerateBirthdayVoucher", Content = "成本中心为空" });
|
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
|
return (gainfos
|
||||||
.Where(x => x.DepartmentNo.IsPresent())
|
.Where(x => x.DepartmentNo.IsPresent())
|
||||||
.Where(x => !excludePositionNos.Contains(x.PositionNo))
|
.Where(x => !excludePositionNos.Contains(x.PositionNo))
|
||||||
.Where(x => x.CostId.IsPresent())
|
.Where(x => x.CostId.IsPresent())
|
||||||
|
.Where(x => !x.Code.StartsWith("LW", StringComparison.OrdinalIgnoreCase))//排除劳务派遣工号(LW 开头)
|
||||||
.ToList(), events);
|
.ToList(), events);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Dlhis.Entity.Canteen.Enums;
|
|||||||
using Dlhis.Jip.Api;
|
using Dlhis.Jip.Api;
|
||||||
using Dlhis.Jip.Api.Requests;
|
using Dlhis.Jip.Api.Requests;
|
||||||
using Dlhis.ScheduledTask.Attributes;
|
using Dlhis.ScheduledTask.Attributes;
|
||||||
|
using Newtonsoft.Json;
|
||||||
using SqlSugar;
|
using SqlSugar;
|
||||||
|
|
||||||
namespace Dlhis.ScheduledTask.Jobs;
|
namespace Dlhis.ScheduledTask.Jobs;
|
||||||
@@ -78,6 +79,8 @@ public class CostUploadJob : BaseJob
|
|||||||
|
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
var response = await jipApi.UploadCostAsync(request);
|
var response = await jipApi.UploadCostAsync(request);
|
||||||
var handleData = response.Data.Where(x => x.Success == "S").ToList();
|
var handleData = response.Data.Where(x => x.Success == "S").ToList();
|
||||||
Logger.LogInformation("CostUploadJob消费上传成功人员:{count}", handleData.Count);
|
Logger.LogInformation("CostUploadJob消费上传成功人员:{count}", handleData.Count);
|
||||||
@@ -123,5 +126,10 @@ public class CostUploadJob : BaseJob
|
|||||||
await DbContext.InsertAsync(newRecords);
|
await DbContext.InsertAsync(newRecords);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "BirthdayCostUploadJob上传消费数据失败-{userCode}:{error}", JsonConvert.SerializeObject(request), ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
401
Src/Dlhis.ScheduledTask/Jobs/SupplementCostUploadJob.cs
Normal file
401
Src/Dlhis.ScheduledTask/Jobs/SupplementCostUploadJob.cs
Normal 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');
|
||||||
|
}
|
||||||
@@ -10,9 +10,13 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Compile Remove="App\**" />
|
||||||
<Compile Remove="log\**" />
|
<Compile Remove="log\**" />
|
||||||
|
<Content Remove="App\**" />
|
||||||
<Content Remove="log\**" />
|
<Content Remove="log\**" />
|
||||||
|
<EmbeddedResource Remove="App\**" />
|
||||||
<EmbeddedResource Remove="log\**" />
|
<EmbeddedResource Remove="log\**" />
|
||||||
|
<None Remove="App\**" />
|
||||||
<None Remove="log\**" />
|
<None Remove="log\**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user