using Admin.NET.Plugin.AiDOP.Entity.S8;
using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Admin.NET.Test.S8;
///
/// S8-RULE01-PURCHASE-DELIVERY-A:PURCHASE_DELIVERY 的取数作用域、canonical 映射与 TIMEOUT 判定契约。
/// 锁定项与失败后果:
///
/// A. 作用域只能按 tenant → Phase 1 拍板:Security Boundary = Tenant。若有人日后往 SQL 里补回
/// factory_id 过滤,规则会恒返 0 行且不报错(dwd_supplier_delivery.factory_id
/// 是写死的常量 1,与 S8 侧真实 SysOrg.Id 永不相等)——静默失效,最难发现。
/// B. 快照按 tenant 各自收敛 → 用全局 MAX(stat_date) 会让落后一天的租户整租户静默丢数据。
/// C. 身份稳定且唯一 → source_object_id 进 DedupKey,格式一变历史异常与新异常直接断代。
/// D. DedupKey 预算守卫 → 预算比 source_object_id 列宽(64)更紧;落在预算与列宽之间的键
/// 会过得了列校验、卡在 DedupKey 上,数据库静默截断会把两个 PO Line 折叠成一条异常。
/// E. due_at 双分支 → need_date 为 NULL 时回落 due_date;两者实测 18/36 行不同,不是等价列。
/// F. 判定归 evaluator → Provider 不滤 status,"逾期但已完成不命中"必须由 evaluator 判出来。
/// G. 取数路径唯一 → Provider 取数失败必须暴露为 dataset_query_failed,而不是换一条路。
///
/// 不接 DB、不接 DI、不触发任何真实规则。SQL 在真实库上的行为已由实施前的只读核验覆盖
/// (4 租户身份唯一、跨租户交集 0、外层走 idx_stat_date),本文件锁定的是结构契约。
///
public class S8PurchaseDeliveryProviderMappingTests
{
private const string PurchaseDelivery = S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
private const string RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY";
private static S8MonitoringDataRequest Request(
long tenantId = 838257186181189, long factoryId = 838257186320453) => new()
{
TenantId = tenantId,
FactoryId = factoryId,
DatasetCode = PurchaseDelivery,
RuleCode = RuleCode,
RuleType = "TIMEOUT"
};
private static AdoS8WatchRule NewRule() => new()
{
Id = 900101,
RuleCode = RuleCode,
SceneCode = "S4",
WatchObjectType = "PURCHASE_ORDER_LINE",
SourceObjectType = "PURCHASE_ORDER_LINE",
Severity = "SERIOUS",
RuleType = "TIMEOUT",
DatasetCode = PurchaseDelivery
};
private static S8TimeoutParams Params() => S8TimeoutParams.Parse(
"""
{"dueAtField":"due_at","statusField":"status",
"completedStates":["COMPLETED","CLOSED","CANCELLED"],
"objectCodeField":"related_object_code","objectIdField":"source_object_id",
"graceMinutes":0,"exceptionTypeCode":"PURCHASE_DELIVERY_ABNORMAL"}
""");
/// 真实 DWD 行样本(取自 UAT 租户 2026-09-05 快照)。
private static S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow Row(
string poNo, string poLine, DateTime? needDate, DateTime? dueDate, string status,
string supplierName = "珩睿", string supplierCode = "10003232") => new()
{
SourceObjectId = $"{poNo}#{poLine}",
RelatedObjectCode = poNo,
RelatedObjectName = string.IsNullOrEmpty(supplierName) ? supplierCode : supplierName,
DueAt = needDate ?? dueDate,
Status = status,
DueAtSource = needDate == null ? "due_date" : "need_date",
PoNo = poNo,
PoLine = poLine,
PoType = "po",
SupplierCode = supplierCode,
SupplierName = supplierName,
ItemCode = "ITM-001",
ItemName = "物料",
OrderQty = 3000m,
RemainingQty = 3000m,
NeedDate = needDate,
DueDate = dueDate,
RiskLevel = "HIGH",
StatDate = new DateTime(2026, 9, 5)
};
private static S8MonitoringRowSet RowSet(params S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow[] rows) =>
S8MonitoringRowSet.FromRows(
rows.Select(r => S8MonitoringRow.FromValues(S8PurchaseDeliveryDataProvider.ToCanonicalValues(r))));
// ============================================================
// A/B. 作用域与快照收敛(SQL 结构契约)
// ============================================================
[Fact]
public void T4_Sql_FiltersByTenant_InBothOuterQueryAndSnapshotSubquery()
{
// 外层与快照子查询都必须带租户,否则会拿到别的租户的最新快照日期。
var sql = S8PurchaseDeliveryDataProvider.Sql;
Assert.Contains("d.tenant_id = @TenantId", sql);
Assert.Contains("x.tenant_id = @TenantId", sql);
}
[Fact]
public void T5_Sql_DoesNotUseFactoryAsDatasetFilter()
{
// Phase 1 拍板:Factory 是业务上下文,不是本数据集的过滤条件。
// 这条断言是防回归的主锁——补回 factory 过滤会让规则恒返 0 行且不报任何错。
Assert.DoesNotContain("factory_id", S8PurchaseDeliveryDataProvider.Sql);
}
[Fact]
public void T5b_FactoryId_DoesNotAlterQuery_ButTenantIdDoes()
{
// 同一租户下换工厂:SQL 与唯一参数都不受影响(工厂根本不进 SQL)。
// 换租户:参数随之改变。二者共同证明"隔离维度只有租户"。
var sameTenantOtherFactory = Request(factoryId: 999_999_999);
var otherTenant = Request(tenantId: 838257212780613);
Assert.Equal(Request().TenantId, sameTenantOtherFactory.TenantId);
Assert.NotEqual(Request().TenantId, otherTenant.TenantId);
// 工厂不在 SQL 中,因此不可能成为取数差异的来源。
Assert.DoesNotContain("@FactoryId", S8PurchaseDeliveryDataProvider.Sql);
}
[Fact]
public void T6_Sql_ConvergesSnapshot_PerTenantLatest_NotGlobalMax()
{
var sql = S8PurchaseDeliveryDataProvider.Sql;
// 反向索引扫描而非聚合:大租户上实测 143ms → 2.7ms。
Assert.Contains("ORDER BY x.stat_date DESC", sql);
Assert.Contains("LIMIT 1", sql);
Assert.DoesNotContain("MAX(", sql);
}
[Fact]
public void Sql_ExcludesDegenerateIdentity_AndDoesNotFilterOnBusinessState()
{
var sql = S8PurchaseDeliveryDataProvider.Sql;
// 身份任一段为空会产生退化的 "#",必须排除。
Assert.Contains("d.po_no <> ''", sql);
Assert.Contains("d.po_line <> ''", sql);
// 判定归 evaluator:Provider 不得按状态或不可信的 remaining_qty 预筛。
Assert.DoesNotContain("remaining_qty >", sql);
Assert.DoesNotContain("delivery_status <>", sql);
Assert.DoesNotContain("delivery_status !=", sql);
}
[Fact]
public async Task LoadAsync_ValidatesScope_BeforeTouchingDatabase()
{
// 故意注入 null 数据库:若作用域校验不是第一步,这里会得到 NullReferenceException。
var provider = new S8PurchaseDeliveryDataProvider(
null, NullLogger.Instance);
var ex = await Assert.ThrowsAsync(() =>
provider.LoadAsync(Request(tenantId: 0)));
Assert.Equal("dataset_scope_invalid", ex.Reason);
var ex2 = await Assert.ThrowsAsync(() =>
provider.LoadAsync(Request(factoryId: 0)));
Assert.Equal("dataset_scope_invalid", ex2.Reason);
}
// ============================================================
// C/D. 身份与 DedupKey 预算
// ============================================================
[Fact]
public void T7_Identity_IsStableComposite_UniquePerLine()
{
var line3 = Row("PO202608260002", "3", new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED");
var line4 = Row("PO202608260002", "4", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "DELAYED");
var v3 = S8PurchaseDeliveryDataProvider.ToCanonicalValues(line3);
var v4 = S8PurchaseDeliveryDataProvider.ToCanonicalValues(line4);
Assert.Equal("PO202608260002#3", v3[S8CanonicalColumns.SourceObjectId]);
Assert.Equal("PO202608260002", v3[S8CanonicalColumns.RelatedObjectCode]);
// 同单不同行必须是不同对象,否则同一采购单只会产出一条异常。
Assert.NotEqual(v3[S8CanonicalColumns.SourceObjectId], v4[S8CanonicalColumns.SourceObjectId]);
}
[Fact]
public void T7b_Identity_IsInvariantAcrossSnapshots()
{
// 身份只由 po_no + po_line 决定,与 stat_date 无关;否则每天换一次身份,
// 昨天的异常今天会被判恢复、并新建一条。
var day1 = Row("PO202608260002", "3", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "DELAYED");
var day2 = Row("PO202608260002", "3", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "DELAYED");
day2.StatDate = new DateTime(2026, 9, 6);
Assert.Equal(
S8PurchaseDeliveryDataProvider.ToCanonicalValues(day1)[S8CanonicalColumns.SourceObjectId],
S8PurchaseDeliveryDataProvider.ToCanonicalValues(day2)[S8CanonicalColumns.SourceObjectId]);
}
[Fact]
public void T7c_DedupKey_StaysWithinColumnLimit_ForRealWorstCase()
{
// 实测该租户最长 source_object_id 为 16 字符;此处按真实规则编码与作用域算完整 DedupKey。
var key = S8TimeoutRuleEvaluator.BuildDedupKey(
838257186181189, 838257186320453, RuleCode, "PURCHASE_ORDER_LINE", "PO202608260002#4");
Assert.True(key.Length <= S8PurchaseDeliveryDataProvider.DedupKeyMaxLength,
$"DedupKey 长度 {key.Length} 超过 {S8PurchaseDeliveryDataProvider.DedupKeyMaxLength}:{key}");
}
[Fact]
public void T7d_Budget_IsTighterThanSourceObjectIdColumnWidth()
{
// 关键认知:预算(128 − 前缀)比 source_object_id 的列宽 64 更紧。
// 落在预算与 64 之间的键会过得了列校验、卡在 DedupKey 上。
var budget = S8PurchaseDeliveryDataProvider.ResolveSourceObjectIdBudget(Request());
Assert.True(budget > 0);
Assert.True(budget < 64, $"预期预算紧于列宽 64,实际 {budget}");
// 预算 + 前缀必须正好等于上限,证明算式与 BuildDedupKey 的格式一致。
var probe = new string('x', budget);
var key = S8TimeoutRuleEvaluator.BuildDedupKey(
Request().TenantId, Request().FactoryId, RuleCode, "PURCHASE_ORDER_LINE", probe);
Assert.Equal(S8PurchaseDeliveryDataProvider.DedupKeyMaxLength, key.Length);
}
[Fact]
public void T7e_Budget_ShrinksWhenRuleCodeGrows()
{
// 预算按 request 现算而非写死;换更长的规则编码必须自动收紧。
var longRule = new S8MonitoringDataRequest
{
TenantId = Request().TenantId,
FactoryId = Request().FactoryId,
DatasetCode = PurchaseDelivery,
RuleCode = RuleCode + "_MUCH_LONGER_SUFFIX",
RuleType = "TIMEOUT"
};
Assert.True(
S8PurchaseDeliveryDataProvider.ResolveSourceObjectIdBudget(longRule)
< S8PurchaseDeliveryDataProvider.ResolveSourceObjectIdBudget(Request()));
}
// ============================================================
// E. Canonical 映射
// ============================================================
[Fact]
public void T8_Canonical_Mapping_IsComplete_WhenNeedDatePresent()
{
var values = S8PurchaseDeliveryDataProvider.ToCanonicalValues(
Row("PO202608260002", "3", new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
Assert.Equal("PO202608260002#3", values[S8CanonicalColumns.SourceObjectId]);
Assert.Equal("PO202608260002", values[S8CanonicalColumns.RelatedObjectCode]);
Assert.Equal(new DateTime(2026, 8, 17, 4, 33, 7), values[S8CanonicalColumns.DueAt]);
Assert.Equal("DELAYED", values[S8CanonicalColumns.Status]);
Assert.Equal("need_date", values["due_at_source"]);
}
[Fact]
public void T8b_DueAt_FallsBackToDueDate_WhenNeedDateIsNull()
{
// 真实样本 PW202608280001#1:need_date 为 NULL,是唯一能验回落分支的行。
var values = S8PurchaseDeliveryDataProvider.ToCanonicalValues(
Row("PW202608280001", "1", null, new DateTime(2026, 8, 29), "DELAYED", supplierName: "怡祥"));
Assert.Equal(new DateTime(2026, 8, 29), values[S8CanonicalColumns.DueAt]);
// due_at 是两种业务日期的混合,必须逐行自证用的是哪个基准。
Assert.Equal("due_date", values["due_at_source"]);
}
[Fact]
public void T8c_Canonical_DoesNotFabricateBusinessJudgements()
{
// Provider 不得构造 is_delivery_complete / is_active_obligation / severity ——
// 上一轮数据契约审计已判这三项 NOT_PROVEN,凭空构造等于把中台缺口埋进 S8。
var values = S8PurchaseDeliveryDataProvider.ToCanonicalValues(
Row("PO202608260002", "1", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "COMPLETED"));
Assert.DoesNotContain("is_delivery_complete", values.Keys);
Assert.DoesNotContain("is_active_obligation", values.Keys);
Assert.DoesNotContain("severity", values.Keys);
}
// ============================================================
// F. TIMEOUT 判定(由 evaluator 判,不由 Provider 预筛)
// ============================================================
[Fact]
public void T9_Timeout_Hits_WhenOverdueAndNotCompleted()
{
var rowSet = RowSet(Row("PO202608260002", "3",
new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
var hits = S8TimeoutRuleEvaluator.EvaluateRows(
rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
var hit = Assert.Single(hits);
Assert.Equal("PO202608260002#3", hit.SourceObjectId);
Assert.Equal("PO202608260002", hit.RelatedObjectCode);
Assert.Equal("PURCHASE_ORDER_LINE", hit.SourceObjectType);
}
[Fact]
public void T10_Timeout_NoHit_WhenNotYetDue()
{
// 真实样本 PO202608280001#1:due 2026-09-17,状态 OPEN。
var rowSet = RowSet(Row("PO202608280001", "1",
new DateTime(2026, 9, 17), new DateTime(2026, 8, 9), "OPEN", supplierName: "怡祥"));
var hits = S8TimeoutRuleEvaluator.EvaluateRows(
rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
Assert.Empty(hits);
}
[Fact]
public void T11_Timeout_NoHit_WhenOverdueButCompleted()
{
// 真实样本 PO202608260002#1:已逾期但 COMPLETED。
// 这一条必须由 evaluator 判出来 —— 若 Provider 在 SQL 里滤掉已完成行,
// 本测试会"通过"但测的是过滤而非判定,且该行不在 hits 里会被误判为恢复。
var rowSet = RowSet(Row("PO202608260002", "1",
new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"));
var hits = S8TimeoutRuleEvaluator.EvaluateRows(
rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
Assert.Empty(hits);
}
[Fact]
public void T11b_Timeout_NoHit_ForClosedAndCancelled()
{
// dwd_supplier_delivery 有三个写入方,词表不一致:批量转换产 COMPLETED/DELAYED/OPEN,
// 增量写入方产 CANCELLED/CLOSED/PARTIAL。当前库中后两值 0 行,这是零行为变更的安全余量。
var rowSet = RowSet(
Row("PO-CLOSED", "1", new DateTime(2026, 8, 1), null, "CLOSED"),
Row("PO-CANCELLED", "1", new DateTime(2026, 8, 1), null, "CANCELLED"));
var hits = S8TimeoutRuleEvaluator.EvaluateRows(
rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
Assert.Empty(hits);
}
[Fact]
public void T11c_Timeout_Hits_ForPartial_BecausePartialIsNotTerminal()
{
// PARTIAL 刻意不列入 completedStates:部分收货不是终态,仍需继续监控。
var rowSet = RowSet(Row("PO-PARTIAL", "1", new DateTime(2026, 8, 1), null, "PARTIAL"));
var hits = S8TimeoutRuleEvaluator.EvaluateRows(
rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
Assert.Single(hits);
}
[Fact]
public void T13_Recovery_Contract_HitSetShrinksWhenStatusBecomesCompleted()
{
// 恢复判定的输入是"本轮 hits 的 dedup_key 集合"(见 ReconcileRecoveriesForRuleAsync)。
// 这里证明:同一对象在状态转为 COMPLETED 后不再出现在 hits 中,
// 从而进入恢复候选。真实 recovered_at 写入属运行态,由 Batch B 验证。
var before = RowSet(Row("PO202608260002", "2",
new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
var after = RowSet(Row("PO202608260002", "2",
new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"));
var hitsBefore = S8TimeoutRuleEvaluator.EvaluateRows(
before, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
var hitsAfter = S8TimeoutRuleEvaluator.EvaluateRows(
after, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
var dedupKey = Assert.Single(hitsBefore).DedupKey;
Assert.Empty(hitsAfter);
Assert.DoesNotContain(dedupKey, hitsAfter.Select(h => h.DedupKey));
}
[Fact]
public void T14_Hit_CarriesExceptionTypeAndDiagnosticPayload()
{
var rowSet = RowSet(Row("PO202608260002", "3",
new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
var hit = Assert.Single(S8TimeoutRuleEvaluator.EvaluateRows(
rowSet, Params(), NewRule(), 838257186181189, 838257186320453, new DateTime(2026, 9, 5)));
Assert.Equal("PURCHASE_DELIVERY_ABNORMAL", hit.ExceptionTypeCode);
Assert.Equal("PURCHASE_ORDER_LINE", hit.SourceObjectType);
Assert.Equal("PO202608260002#3", hit.SourceObjectId);
Assert.Equal("T838257186181189:F838257186320453:RRULE_S4_PURCHASE_DELIVERY_DATE_DELAY:PURCHASE_ORDER_LINE:PO202608260002#3",
hit.DedupKey);
// 诊断列必须随 payload 落盘:remaining_qty 供后续数据治理观察,
// due_at_source 用于追溯每行实际使用了 need_date 还是 due_date。
Assert.Contains("remaining_qty", hit.SourcePayload);
Assert.Contains("due_at_source", hit.SourcePayload);
Assert.Contains("stat_date", hit.SourcePayload);
}
// ============================================================
// G. 取数路径唯一
// ============================================================
//
// 原 T12 / T12b 断言「STANDARD_DATASET 绝不回落 Legacy」,做法是注入一个
// SpyLegacy 探针并断言 CallCount == 0,同时在 rule.Expression 里塞一条
// "DROP TABLE ado_s8_exception; SELECT SHOULD_NEVER_RUN" 当诱饵。
//
// S8-STANDARD-DATASET-HARD-CUTOVER-1 之后这两条已无法成立、也无需成立:
// · IS8LegacySqlDataProvider 不存在 → 探针类型写不出来;
// · AdoS8WatchRule.Expression / DataSourceId 不存在 → 诱饵放不进去。
// 「不回落」从一条需要反复验证的运行期行为,变成了类型系统层面的事实。
//
// 结构性替代断言见 S8MonitoringDataGatewayTests.Gateway_HasNoLegacyDependency
// 与 WatchRule_HasNoSqlOrDataSourceFields;此处只保留失败分类这一条仍有意义的行为。
[Fact]
public async Task T12_ProviderFailure_SurfacesAsQueryFailed()
{
var gateway = new S8MonitoringDataGateway(
new S8MonitoringDataProviderRegistry(new IS8MonitoringDataProvider[] { new ThrowingPurchaseDeliveryProvider() }),
new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() }),
NullLogger.Instance);
var ex = await Assert.ThrowsAsync(() =>
gateway.LoadAsync(838257186181189, 838257186320453, NewRule(), "TIMEOUT", 1000));
Assert.Equal(S8DatasetReasonCode.QueryFailed, ex.Reason);
}
private sealed class EmptyPurchaseDeliveryProvider : IS8MonitoringDataProvider
{
public string DatasetCode => PurchaseDelivery;
public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
public Task LoadAsync(
S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
{
request.EnsureValid();
return Task.FromResult(S8MonitoringRowSet.Empty);
}
}
private sealed class ThrowingPurchaseDeliveryProvider : IS8MonitoringDataProvider
{
public string DatasetCode => PurchaseDelivery;
public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
public Task LoadAsync(
S8MonitoringDataRequest request, CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("dwd unavailable");
}
}