using System.Globalization;
namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
/// S5 IQC 检规解析结果状态。
public static class S5IqcSpecResolveStatus
{
/// 唯一命中(AUTO EXACT,且生效日期可解析并已生效)。
public const string Matched = "MATCHED";
/// 唯一命中人工绑定(MANUAL 优先于 AUTO)。
public const string MatchedManual = "MATCHED_MANUAL";
/// 唯一命中,但生效日期为空/不可解析。仍固化,因 S5 历史 sxrq 未治理。
public const string MatchedUndated = "MATCHED_UNDATED";
/// 该物料无任何检规映射。合法业务状态(如免检料),不阻断生成。
public const string NoSpec = "NO_SPEC";
/// 有候选,但全部生效日期晚于参考时点(仅未来版本)。不把未来检规当作当前检规。
public const string NoEffectiveSpec = "NO_EFFECTIVE_SPEC";
/// 多个互斥候选,无业务依据择一。不瞎选、不阻断,四列留空。
public const string Ambiguous = "AMBIGUOUS";
}
/// Resolver 候选行(来自桥表 ⋈ qms_jygf,均已按租户双重过滤)。
public class S5IqcSpecCandidate
{
public long SpecId { get; set; }
/// 检规文件编号 wjbh。
public string? SpecNo { get; set; }
/// 检规版本 bb。
public string? SpecVersion { get; set; }
/// 生效日期原始文本 sxrq(varchar,可能为 NULL / 'AUTODED' 等不可解析值)。
public string? EffectiveDateRaw { get; set; }
/// 是否人工绑定行。
public bool IsManual { get; set; }
/// 归一化后的生效日期;不可解析为 null。由 Decide 填充。
public DateTime? NormalizedEffective { get; set; }
}
/// Resolver 返回模型(最小集,YAGNI:不返回完整候选列表)。
public class S5IqcSpecResolveResult
{
public string Status { get; set; } = S5IqcSpecResolveStatus.NoSpec;
public long? SpecId { get; set; }
public string? SpecNo { get; set; }
public string? SpecVersion { get; set; }
/// 生效日期**原始文本**,原样固化进 jgsxrq(不写归一化值,避免信息损失)。
public string? EffectiveDateRaw { get; set; }
public int CandidateCount { get; set; }
public string? Warning { get; set; }
/// 是否命中可固化的检规(三个 MATCHED* 状态之一)。
public bool IsMatched =>
Status is S5IqcSpecResolveStatus.Matched
or S5IqcSpecResolveStatus.MatchedManual
or S5IqcSpecResolveStatus.MatchedUndated;
}
///
/// S5 IQC 原材料检规解析器(B-2)。
///
/// 输入 (tenantId, materialCode, referenceTime) → 唯一可固化检规 或 明确的不可解析原因。
///
/// 【硬边界】
/// ① 候选唯一入口是桥表 ado_s5_iqc_spec_material_map,**绝不在运行期解析 qms_jygf.wlbm**;
/// ② 物料等值匹配,禁 LIKE;
/// ③ 租户双重过滤:map.tenant_id 与 spec.tenant_id 同时约束,不得只信桥表 spec_id;
/// ④ 禁止无排序 FirstOrDefault、禁止用 specId 大小或版本字符串字典序决定业务结果;
/// ⑤ **Resolver 只读,且只允许在 GenerateInspBill 调用**——detail / list / flow 路径实时 resolve
/// 会把"今天的检规"显示到历史检验单上,破坏快照语义(B-2 §17)。
///
/// 【与 S6 的差异(刻意)】S6 S6ProcessSpecResolver 要求 sxrj 可解析且明细行数>0,否则
/// INVALID_SPEC。S5 两条都放宽:S5 的报检分录是 165 贴源既成事实,Ai-DOP 无权因检规配置不全
/// 而拒绝为其建检验单;且实测 qms_jygf 4712 行中 428 行(9.1%) sxrq 不可解析、UAT-A 8 条检规中
/// 7 条明细为 0。Resolver 是 enrichment,不是 hard validation gate。
///
public class S5IqcSpecResolver : ITransient
{
private readonly ISqlSugarClient _db;
public S5IqcSpecResolver(ISqlSugarClient db) => _db = db;
///
/// 解析物料对应的唯一可固化检规。
///
/// 严格可信租户(调用方已解析)。
/// 报检分录 FMATERIALCFG。
///
/// 参考时点。口径对齐 S6(S6ProcessInspectionService.cs:66「ReferenceDate 取检验时点(now)」):
/// 取**检验单生成时刻**,即"生成时点当前有效"的检规版本。
///
public async Task ResolveAsync(long tenantId, string? materialCode, DateTime referenceTime)
{
if (tenantId <= 0 || string.IsNullOrWhiteSpace(materialCode))
{
return new S5IqcSpecResolveResult
{
Status = S5IqcSpecResolveStatus.NoSpec,
Warning = "报检分录缺物料编码,无法解析来料检验规范。"
};
}
var normalized = S5IqcMaterialTokenizer.Normalize(materialCode);
// 租户双重过滤:桥表与检规主表分别显式约束,绝不仅凭 map.spec_id 反查。
var candidates = await _db.Ado.SqlQueryAsync(
"""
SELECT g.id AS SpecId, g.wjbh AS SpecNo, g.bb AS SpecVersion,
g.sxrq AS EffectiveDateRaw, m.is_manual AS IsManual
FROM ado_s5_iqc_spec_material_map m
JOIN qms_jygf g ON g.id = m.spec_id AND g.tenant_id = @TenantId
WHERE m.tenant_id = @TenantId
AND m.material_code = @MaterialCode
AND m.match_status IN ('EXACT','MANUAL')
""",
new List
{
new("@TenantId", tenantId),
new("@MaterialCode", normalized),
});
return Decide(candidates, referenceTime);
}
///
/// 纯裁决逻辑(无 DB,便于单测)。对齐 S6 S6ProcessSpecResolver.Decide 的可测形态。
///
public static S5IqcSpecResolveResult Decide(IReadOnlyList? candidates, DateTime referenceTime)
{
var all = (candidates ?? Array.Empty()).ToList();
if (all.Count == 0)
{
return new S5IqcSpecResolveResult
{
Status = S5IqcSpecResolveStatus.NoSpec,
CandidateCount = 0,
Warning = "当前物料未配置来料检验规范(可能为免检料)。"
};
}
foreach (var c in all) c.NormalizedEffective = NormalizeDate(c.EffectiveDateRaw);
// ── ① MANUAL 优先:人工绑定既然存在,AUTO 不得覆盖它 ──
var manual = all.Where(c => c.IsManual).ToList();
if (manual.Count > 0)
{
var manualSpecIds = manual.Select(c => c.SpecId).Distinct().ToList();
if (manualSpecIds.Count == 1)
{
var win = manual[0];
return Hit(win, S5IqcSpecResolveStatus.MatchedManual, manual.Count);
}
return new S5IqcSpecResolveResult
{
Status = S5IqcSpecResolveStatus.Ambiguous,
CandidateCount = manualSpecIds.Count,
Warning = $"该物料存在 {manualSpecIds.Count} 条互斥的人工绑定检规,请在 S0 订正后重试;本单未固化检规。"
};
}
// ── ② AUTO EXACT ──
var auto = all.Where(c => !c.IsManual).ToList();
var distinctSpecIds = auto.Select(c => c.SpecId).Distinct().ToList();
if (distinctSpecIds.Count == 0)
{
return new S5IqcSpecResolveResult
{
Status = S5IqcSpecResolveStatus.NoSpec,
CandidateCount = 0,
Warning = "当前物料未配置来料检验规范。"
};
}
// 单候选:即使 sxrq 为空/不可解析也允许固化(B-2 §七 Case 1)
if (distinctSpecIds.Count == 1)
{
var only = auto[0];
var status = only.NormalizedEffective == null
? S5IqcSpecResolveStatus.MatchedUndated
: (only.NormalizedEffective.Value.Date <= referenceTime.Date
? S5IqcSpecResolveStatus.Matched
// 单候选且生效日期在未来:不把未来检规当作当前检规
: S5IqcSpecResolveStatus.NoEffectiveSpec);
if (status == S5IqcSpecResolveStatus.NoEffectiveSpec)
{
return new S5IqcSpecResolveResult
{
Status = status,
CandidateCount = 1,
Warning = $"该物料唯一检规 {only.SpecNo}/{only.SpecVersion} 生效日期 {only.EffectiveDateRaw} 晚于当前时点,本单未固化检规。"
};
}
return Hit(only, status, 1);
}
// ── ③ 多候选:先看是否跨不同 wjbh(检规编号) ──
// 跨 wjbh = 两份不同的检规文件同时匹配该物料,无业务依据择一 → AMBIGUOUS(核心防误配门)
var wjbhGroups = auto
.GroupBy(c => (c.SpecNo ?? string.Empty).Trim(), StringComparer.OrdinalIgnoreCase)
.ToList();
if (wjbhGroups.Count > 1)
{
return new S5IqcSpecResolveResult
{
Status = S5IqcSpecResolveStatus.Ambiguous,
CandidateCount = distinctSpecIds.Count,
Warning = $"该物料同时匹配 {wjbhGroups.Count} 个不同检规编号({string.Join(" / ", wjbhGroups.Select(g => g.Key))}),"
+ "无法自动判定,请在 S0 订正检规物料范围或人工绑定唯一检规;本单未固化检规。"
};
}
// ── ④ 同一 wjbh 的多版本:按生效日期裁决(A > B > C) ──
var dated = auto.Where(c => c.NormalizedEffective != null).ToList();
var effective = dated.Where(c => c.NormalizedEffective!.Value.Date <= referenceTime.Date).ToList();
var undated = auto.Where(c => c.NormalizedEffective == null).ToList();
// A. 已生效且日期可解析 → 取最大生效日期
if (effective.Count > 0)
{
var maxDate = effective.Max(c => c.NormalizedEffective!.Value.Date);
var top = effective.Where(c => c.NormalizedEffective!.Value.Date == maxDate).ToList();
var topSpecIds = top.Select(c => c.SpecId).Distinct().ToList();
if (topSpecIds.Count == 1) return Hit(top[0], S5IqcSpecResolveStatus.Matched, distinctSpecIds.Count);
// 同一最大生效日期仍对应多个 specId → 不再靠 id 排序决定业务结果
return new S5IqcSpecResolveResult
{
Status = S5IqcSpecResolveStatus.Ambiguous,
CandidateCount = topSpecIds.Count,
Warning = $"检规 {top[0].SpecNo} 存在 {topSpecIds.Count} 个生效日期相同({maxDate:yyyy-MM-dd})的版本,无法自动判定;本单未固化检规。"
};
}
// B. 无已生效版本,但有恰好 1 条未标注日期 → 允许
if (undated.Count > 0)
{
var undatedSpecIds = undated.Select(c => c.SpecId).Distinct().ToList();
if (undatedSpecIds.Count == 1) return Hit(undated[0], S5IqcSpecResolveStatus.MatchedUndated, distinctSpecIds.Count);
return new S5IqcSpecResolveResult
{
Status = S5IqcSpecResolveStatus.Ambiguous,
CandidateCount = undatedSpecIds.Count,
Warning = $"检规 {undated[0].SpecNo} 有 {undatedSpecIds.Count} 个版本均未标注生效日期,无业务依据判断哪个有效;本单未固化检规。"
};
}
// C. 只剩未来版本
return new S5IqcSpecResolveResult
{
Status = S5IqcSpecResolveStatus.NoEffectiveSpec,
CandidateCount = distinctSpecIds.Count,
Warning = $"检规 {auto[0].SpecNo} 的全部版本生效日期均晚于当前时点,本单未固化检规。"
};
}
private static S5IqcSpecResolveResult Hit(S5IqcSpecCandidate c, string status, int candidateCount) => new()
{
Status = status,
SpecId = c.SpecId,
SpecNo = c.SpecNo,
SpecVersion = c.SpecVersion,
EffectiveDateRaw = c.EffectiveDateRaw,
CandidateCount = candidateCount,
};
///
/// 归一化 qms_jygf.sxrq 自由文本为日期。支持 2021.07.13 / 2021-07-13 / 2021/07/13 /
/// 带时间后缀等;'AUTODED' 等非日期与 NULL 返回 null。
///
/// 口径与 S6ProcessSpecResolver.NormalizeDate 一致,此处**刻意复制而非跨模块引用**:
/// CLAUDE.md 第九节·三要求模块间不直接依赖对方目录的服务类,S5 不应 using S6 的 Manufacturing 命名空间。
///
public static DateTime? NormalizeDate(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var s = raw.Trim().Replace('.', '-').Replace('/', '-');
var sp = s.IndexOf(' ');
if (sp > 0) s = s[..sp];
if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d)) return d;
if (DateTime.TryParseExact(s, new[] { "yyyy-M-d", "yyyy-MM-dd" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out d)) return d;
return null;
}
}