M2 实战:询价与采购订单聚合落地——跨聚合一致性、领域事件与 Outbox

目录

  1. [事故开篇:三张采购单、超量 30% 和"报价中"的报价](#事故开篇:三张采购单、超量 30% 和"报价中"的报价)
  2. [结论先行:M2 的聚合划分与一致性全景](#结论先行:M2 的聚合划分与一致性全景)
  3. [聚合边界:Inquiry、Quote 与 PurchaseOrder 怎么切](#聚合边界:Inquiry、Quote 与 PurchaseOrder 怎么切)
  4. [充血模型(上):共享内核、Money 与领域事件基座](#充血模型(上):共享内核、Money 与领域事件基座)
  5. [充血模型(中):Inquiry 询价聚合完整实现](#充血模型(中):Inquiry 询价聚合完整实现)
  6. [充血模型(下):PurchaseOrder 聚合与跨聚合不变量](#充血模型(下):PurchaseOrder 聚合与跨聚合不变量)
  7. [跨聚合一致性:领域服务、最终一致与 Saga 的决策表](#跨聚合一致性:领域服务、最终一致与 Saga 的决策表)
  8. 领域事件机制:两种分发时机与失败窗口
  9. [Outbox 精简实现:同事务落库 + BackgroundService 轮询](#Outbox 精简实现:同事务落库 + BackgroundService 轮询)
  10. [应用服务编排:幂等转单与 Minimal API](#应用服务编排:幂等转单与 Minimal API)
  11. [EF Core 持久化:表结构、映射配置与双 Provider 差异](#EF Core 持久化:表结构、映射配置与双 Provider 差异)
  12. [测试:状态机、事件负载与 SQLite 跨聚合集成测试](#测试:状态机、事件负载与 SQLite 跨聚合集成测试)
  13. [踩坑清单、Checklist 与下一篇引子](#踩坑清单、Checklist 与下一篇引子)
  14. 官方参考资料

1. 事故开篇:三张采购单、超量 30% 和"报价中"的报价

M2 启动会上,项目经理王五先翻出了老系统采购模块的三个工单。三个事故,恰好对应三个建模难题。

事故一:同一个按钮,点出两张采购单。

老系统"询价转采购单"的接口长这样(已脱敏、简化):

csharp 复制代码
// 老系统:查询-判断-写入,三步之间没有任何并发保护,也没有幂等键
public async Task<string> GenOrder(string inqId)
{
    var inq = await db.MrpInquiries.FirstAsync(x => x.InqID == inqId);
    if (inq.GenOrder == "1")                       // ① 查标志
        return inq.OrderID;                        //    已转过就返回旧单
    var order = BuildOrder(inq);
    db.MrpOrders.Add(order);
    await db.SaveChangesAsync();                   // ② 插入新采购单
    inq.GenOrder = "1";                            // ③ 回写标志
    inq.OrderID = order.OrderID;
    await db.SaveChangesAsync();
    return order.OrderID;
}

船上网络慢,采购员李四双击了"生成采购单"按钮。两个 HTTP 请求几乎同时进来,都在 ① 处读到 GenOrder == "0",于是各自插入一张采购单------同一批物料对某供应商 A 下了两张 PO 。③ 的回写互相覆盖,最后标志位是"1",看起来一切正常,直到供应商拿着两张单号不同的订单确认函回传,船上仓库才发现货到了两份。mrp_inquiry 表上没有 (inq_id, vendor) 的唯一约束,数据库一声没吭。

事故二:采购数量比批准数量多 30%,没有一个接口拦住它。

物料申请 (2026)HKMW-TEC-MRP-0001 三级审批批准的是 100 个滤器。实际下单 130 个。复盘发现:超量校验只写在 Web 端的 JS 里(if (qty > approvedQty) alert(...)),而这批单是采购通过Excel 导入接口批量下的,导入服务里没有这段校验。规则活在按钮的事件处理函数里,绕过按钮(导入、定时任务、直接写库)就等于绕过规则。

事故三:报价单还"报价中",就被拿来下单了。

某供应商 B 的报价记录状态还是"报价中"(QuoteDate 为空,价格是业务员口头问来先填进系统的草价),采购员为了赶船期直接拿这行草价转了 PO。一周后供应商正式报价回传,单价上涨 12%,订单已确认(ComfirmNum 已回填),只能走变更索赔。

本篇结论先行:

  1. 聚合边界 :M2 切三个聚合------Inquiry(询价单,内含询价行与多家供应商的报价实体 SupplierQuote )、PurchaseOrder(独立聚合)、Supplier(本篇只建只读模型/防腐接口)。报价必须留在询价聚合内(离开询价单它没有独立身份,"同物料多家报价可比价"是聚合内不变量);采购订单必须独立(独立生命周期、独立状态机、收货与付款都要引用它)。
  2. 跨聚合约束 :"下单数量 ≤ 申请批准数量 − 已订数量"横跨 PurchaseOrderMaterialApply 两个聚合,任何单聚合在内存里都无法天然保证它 。船端单机同库用领域服务 + 单事务强一致 ;岸基多服务用领域事件 + Outbox 最终一致;跨服务长流程才上 Saga。不要为了"纯"在单机 SQLite 场景硬上消息队列。
  3. 状态不靠按钮防呆 :状态迁移只能走聚合方法(截止后不可报价、不足两家不可比价选定、报价未确认不可转单),幂等用 RequestId + 数据库唯一索引兜底,让重复点击在数据库层就不可能产生第二张单。

2. 结论先行:M2 的聚合划分与一致性全景

先上全景图,后文每一节都在填充这张图的某个局部:

text 复制代码
MaterialApply (M1 聚合)            Inquiry (M2 聚合)
┌────────────────────┐    持 Id     ┌──────────────────────────────────┐
│ MaterialApplyId    │◄─────────────│ ApplyId            (只持 Id)      │
│ ApprovedQty 批准数量│             │ InquiryItems      询价行          │
│ OrderedQty  已订数量│             │ SupplierQuotes[]  多家供应商报价   │
│ Status = Approved  │   事件回写    │   └ QuoteLines[] 逐行报价/交期    │
└────────────────────┘             │ Status: Draft→Quoting→Quoted      │
        ▲                           │        →Closed/Cancelled          │
        │ PurchaseOrderCreated      └───────────────┬──────────────────┘
        │ 事件 / 领域服务校验                        │ PickBestQuote 选定
        │                                           ▼
        │                                PurchaseOrder (M2 独立聚合)
        │                                ┌──────────────────────────────┐
        └────────────────────────────────│ ApplyId / InquiryId (只持 Id)│
             OrderedQty 回写             │ SupplierId                   │
                                         │ PurchaseOrderItems 数量/单价  │
                                         │ Money 合计                    │
                                         │ Draft→Submitted→Confirmed    │
                                         │   →PartialReceived→Received  │
                                         │   →Closed / Cancelled        │
                                         └──────────────────────────────┘
Supplier(供应商主数据):本篇只读,ISupplierLookup 防腐接口,不进采购聚合的一致性边界

三个贯穿全篇的设计决定:

决定 选择 理由一句话
报价 SupplierQuote 放哪 Inquiry 聚合内实体 报价的身份是"(询价单,供应商)",比价规则要求一次加载全部报价
采购订单放哪 独立聚合 它要活很久:收货、拒收、发票、付款都引用它,状态机与询价完全不同
跨聚合数量约束 船端领域服务强一致;岸基事件最终一致 一致性策略由部署拓扑决定,而不是由"DDD 纯度"决定

3. 聚合边界:Inquiry、Quote 与 PurchaseOrder 怎么切

3.1 老系统的模型:一家供应商一张询价单

老系统(表 mrp_inquiry / mrp_inquirymrp)的设计是一张询价单只面向一个供应商 (详设文档中的不变量 I2:一个询价单只能面向一个 Vendor)。于是"三家比价"在老系统里意味着三张询价单,比价逻辑只能写成一个领域服务,把同一申请下的多张询价单捞到内存里横向比对------详设文档里的 InquiryComparisonService 正是这么干的,它计算每家的 landed cost:

text 复制代码
总到港成本 = Σ(报价数量 QuoteNum × 单价 Price)
           + 运输费 TransportPrice + 包装费 PackingPrice + 清关费 ClearancePrice
           − 折扣 Discount

这个模型的问题:"同物料、多家报价可比对"这条规则没有任何一个聚合能守护。三张询价单各自合法,合在一起是否可比(币种是否一致、报价是否都在有效期、数量口径是否一致)要靠查询服务临时拼,拼的逻辑漏一个调用方,比价就拿脏数据比------事故三的草价下单就是这么来的。

3.2 M2 的切法:一张询价单,邀请多家,报价是聚合内实体

M2 把"同一批次向多家询价"收敛进一个 Inquiry 聚合:

  • Inquiry(聚合根,表 mrp_inquiry):关联一张 已批准的物料申请(持 MaterialApplyId)、报价截止时间、状态机;
  • InquiryItem(实体,表 mrp_inquiryitem):按物料行记录询价数量(对齐老字段 InqNum)与申请批准数量快照(AppNum);
  • SupplierQuote(实体,表 mrp_inquiryquote):一家供应商在此询价单下的一份报价 ,含逐行报价 QuoteLine(报价数量 QuoteNum、单价 Price、品牌 Brands、交货周期)、报价币种与附加费;
  • PurchaseOrder(独立聚合根,表 mrp_po)+ PurchaseOrderItem(表 mrp_poitem)。

为什么 Quote 是实体而不是独立聚合? 两个判定标准:

  1. 离开聚合根,它有没有独立身份和独立生命周期? 供应商的一份报价脱离询价单没有业务意义------没人会直接"打开一份报价",打开的永远是"某询价单下某供应商的报价"。它的身份是 (InquiryId, SupplierId) 复合的,是询价单的内部细节。
  2. 有没有必须原子满足的跨实体规则? 有,而且是核心规则:"同物料多家报价可比对"(币种一致、口径一致)、"至少两家有效报价才能比价选定"、"截止后任何一家都不能再改价"。这些规则要求报价们被同一个聚合根一次性加载、在一个事务内一致地修改。若 Quote 独立成聚合,这些规则就会退化成跨聚合约束,重蹈老系统比价服务的覆辙。

那采购订单为什么必须独立,不能做成询价的一部分? 四条理由,每一条都是硬边界:

  1. 独立生命周期 :询价在"选定供应商"那一刻就关闭(Closed)了,而采购订单要继续活几个月------确认、分批发货(老表 mrp_ordertrace 支持多批 TraceID)、到货验收、拒收(RejectQuantity)、发票(InvNum)、付款(PaidNumber),询价不可能陪着它加载、保存、并发控制;
  2. 独立状态机Draft→Submitted→Confirmed→PartialReceived→Received→Closed 与询价的 Draft→Quoting→Quoted→Closed 没有状态同步关系;
  3. 被其他聚合引用 :M3 的库存交易(mrp_storetransOrderID)、费用单(mrp_fare)都要引用采购订单,被引用者必须是独立聚合根;
  4. 加载/并发性能边界:一张询价单可能邀请 5 家、每家 30 行;一张 PO 后续还会挂收货流水。塞成一个聚合,每次收货都要把 5 家报价全部加载并置于并发令牌保护下,毫无必要。

聚合之间只持 Id,不持对象引用,也不互相持有仓储 ------PurchaseOrder 上有 InquiryId / SupplierId / ApplyId 三个强类型 Id,但没有 Inquiry Inquiry { get; set; } 这种导航属性。对象引用一旦存在,懒加载 N+1、跨聚合随便改对方状态、聚合边界事实上瓦解,都是迟早的事。


4. 充血模型(上):共享内核、Money 与领域事件基座

Domain 层零 EF Core 引用(这条纪律 M1 篇讲过,M2 继续遵守)。先放两个聚合共用的共享内核(SharedKernel)。

4.1 强类型 Id

csharp 复制代码
// Domain/SharedKernel/StronglyTypedIds.cs
namespace Pms.Domain.SharedKernel;

// readonly record struct:值语义 + 零开销,编译器替我们盯住"哪个 Id 是哪个"
public readonly record struct MaterialApplyId(Guid Value)
{
    public static MaterialApplyId New() => new(Guid.CreateVersion7()); // .NET 9+ 顺序 Guid
    public override string ToString() => Value.ToString("N");
}

public readonly record struct InquiryId(Guid Value)
{
    public static InquiryId New() => new(Guid.CreateVersion7());
}

public readonly record struct SupplierQuoteId(Guid Value)
{
    public static SupplierQuoteId New() => new(Guid.CreateVersion7());
}

public readonly record struct PurchaseOrderId(Guid Value)
{
    public static PurchaseOrderId New() => new(Guid.CreateVersion7());
}

public readonly record struct SupplierId(Guid Value);

public readonly record struct MaterialId(Guid Value);

Guid.CreateVersion7() 是 .NET 9 起 BCL 内置的 v7 顺序 GUID(时间有序),对 SQLite/SQL Server 的聚簇索引都比 v4 友好。强类型 Id 的完整讨论(路由绑定、JSON 转换器、批量值转换器)见值对象篇,本篇第 11 节只给 EF 映射。

4.2 Money:异币种不能相加、不能比较

csharp 复制代码
// Domain/SharedKernel/Money.cs
namespace Pms.Domain.SharedKernel;

using Pms.Domain.Exceptions;

/// <summary>金额 = 数值 + 币种。不可变 record,所有运算在构造期保证不变量。</summary>
public sealed record Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new(0m, currency);

    public static Money operator +(Money left, Money right)
    {
        EnsureSameCurrency(left, right);
        return left with { Amount = left.Amount + right.Amount };
    }

    public static Money operator -(Money left, Money right)
    {
        EnsureSameCurrency(left, right);
        return left with { Amount = left.Amount - right.Amount };
    }

    /// <summary>金额 × 数量(行金额用),银行家四舍五入到分。</summary>
    public Money Multiply(decimal quantity) =>
        this with { Amount = Math.Round(Amount * quantity, 2, MidpointRounding.ToEven) };

    public bool IsLessThan(Money other)
    {
        EnsureSameCurrency(this, other);
        return Amount < other.Amount;
    }

    private static void EnsureSameCurrency(Money a, Money b)
    {
        if (!string.Equals(a.Currency, b.Currency, StringComparison.OrdinalIgnoreCase))
            throw new DomainException($"币种不一致:{a.Currency} 与 {b.Currency} 不能直接运算,请先按汇率折算");
    }
}

事故里"USDusd$ 拆成三行"的教训:币种一律 ISO 4217 大写码(CNY/USD),在值对象工厂入口归一化。比价时异币种直接拒绝并提示人工按汇率折算------汇率是随时变动的外部数据,静默按某个汇率选"最低价"会在汇率波动时选错供应商,这个决策必须显式化。

4.3 实体/聚合根基座与未提交事件列表

csharp 复制代码
// Domain/SharedKernel/IDomainEvent.cs
namespace Pms.Domain.SharedKernel;

public interface IDomainEvent
{
    Guid EventId { get; }            // 幂等去重靠它,绝不能用 new Guid() 在发布时临时生成
    DateTimeOffset OccurredOn { get; }
}

// Domain/SharedKernel/Entity.cs
namespace Pms.Domain.SharedKernel;

public abstract class Entity<TId> where TId : struct
{
    public TId Id { get; protected set; }

    // 并发令牌:M1 篇讲过,SQLite/SQL Server 都会映射为 x_Token 列做乐观并发
    public uint RowVersion { get; private set; }
    public void BumpVersion() => RowVersion++;
}

public abstract class AggregateRoot<TId> : Entity<TId> where TId : struct
{
    // 未提交领域事件:聚合行为发生时挂账,由工作单元在保存时统一处置(第 8 节)
    private readonly List<IDomainEvent> _domainEvents = [];

    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    protected void AddDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);

    public void ClearDomainEvents() => _domainEvents.Clear();
}

把事件列表挂在聚合根上而不是静态总线,有三个好处:测试时可以直接断言 aggregate.DomainEvents;事件与状态在同一个方法调用里产生,不会"状态改了忘了发事件";保存与分发的时机可以由工作单元统一控制(这正是第 8 节的核心)。


5. 充血模型(中):Inquiry 询价聚合完整实现

5.1 状态机

text 复制代码
            AddItem/InviteSupplier        发布(≥1行 & ≥2家 & 截止时间在未来)
  Draft ───────────────────────► Draft ───────────────────────────────► Quoting
    │                                                                      │
    │ Cancel                                                               │ 供应商报价(可改价,留修订痕)
    ▼                                                                      │ 人工截止 / 到期截止
 Cancelled                                                        now > Deadline
                                                                           ▼
                                                                        Quoted
                                                                           │
                                              PickBestQuote/SelectQuote 选定赢家
                                                                           ▼
                                                GeneratePurchaseOrder → Closed
                                                                           │
                                                                     Cancelled(仅 Quoting 前)

状态迁移全部收敛在聚合方法内,任何 Status = ... 的外部赋值在编译期就不可能(private set)。

5.2 询价行与报价实体

csharp 复制代码
// Domain/Purchasing/InquiryItem.cs
namespace Pms.Domain.Purchasing;

using Pms.Domain.Exceptions;
using Pms.Domain.SharedKernel;

/// <summary>询价行:按物料行记录"申请批准了多少、本次询价多少"。</summary>
public class InquiryItem : Entity<Guid>
{
    public MaterialId MaterialId { get; private set; }
    public string ImpaCode { get; private set; } = default!;   // IMPA 标准编码,可空(非 IMPA 物料)
    public string MaterialName { get; private set; } = default!;
    public string Unit { get; private set; } = default!;
    public decimal ApprovedQty { get; private set; }           // 申请批准数量快照(老字段 AppNum)
    public decimal InquireQty { get; private set; }            // 本次询价数量(老字段 InqNum)
    public int LineNo { get; private set; }

    internal InquiryItem(int lineNo, MaterialId materialId, string? impaCode,
        string materialName, string unit, decimal approvedQty, decimal inquireQty)
    {
        if (inquireQty <= 0) throw new DomainException("询价数量必须大于 0");
        if (inquireQty > approvedQty)
            throw new DomainException($"物料 {materialName} 询价数量 {inquireQty} 超过申请批准数量 {approvedQty}");
        Id = Guid.CreateVersion7();
        LineNo = lineNo;
        MaterialId = materialId;
        ImpaCode = impaCode ?? string.Empty;
        MaterialName = materialName;
        Unit = unit;
        ApprovedQty = approvedQty;
        InquireQty = inquireQty;
    }

    private InquiryItem() { }  // EF 物化通道
}
csharp 复制代码
// Domain/Purchasing/SupplierQuote.cs
namespace Pms.Domain.Purchasing;

using Pms.Domain.Exceptions;
using Pms.Domain.SharedKernel;

public enum QuoteStatus { Draft, Submitted, Superseded }

/// <summary>报价行:某供应商对某询价行的报价。</summary>
public class QuoteLine : Entity<Guid>
{
    public int InquiryLineNo { get; private set; }
    public MaterialId MaterialId { get; private set; }
    public decimal Qty { get; private set; }            // 可供数量,可小于询价数量(部分可供)
    public Money UnitPrice { get; private set; } = default!;
    public int LeadTimeDays { get; private set; }       // 交货周期(天)
    public string? Brand { get; private set; }          // 品牌(老字段 Brands)

    internal QuoteLine(int lineNo, MaterialId materialId, decimal qty,
        Money unitPrice, int leadTimeDays, string? brand)
    {
        if (qty <= 0) throw new DomainException("报价数量必须大于 0");
        if (unitPrice.Amount < 0) throw new DomainException("报价单价不可为负");
        if (leadTimeDays < 0) throw new DomainException("交货周期不可为负");
        Id = Guid.CreateVersion7();
        InquiryLineNo = lineNo;
        MaterialId = materialId;
        Qty = qty;
        UnitPrice = unitPrice;
        LeadTimeDays = leadTimeDays;
        Brand = brand;
    }

    internal void ReplaceWith(decimal qty, Money unitPrice, int leadTimeDays, string? brand)
    {
        if (qty <= 0) throw new DomainException("报价数量必须大于 0");
        Qty = qty;
        UnitPrice = unitPrice;
        LeadTimeDays = leadTimeDays;
        Brand = brand;
    }

    private QuoteLine() { }

    public Money LineTotal => UnitPrice.Multiply(Qty);
}

/// <summary>改价留痕:供应商二次报价时,旧报价快照进修订记录,绝不物理覆盖。</summary>
public sealed record QuoteRevision(DateTimeOffset RevisedAt, IReadOnlyList<QuoteLine> LinesSnapshot,
    Money Freight, Money? Discount, string Reason);

/// <summary>某供应商在一张询价单下的一份报价(Inquiry 聚合内实体)。</summary>
public class SupplierQuote : Entity<SupplierQuoteId>
{
    public SupplierId SupplierId { get; private set; }
    public string SupplierName { get; private set; } = default!;
    public QuoteStatus Status { get; private set; }
    public DateTimeOffset QuotedAt { get; private set; }     // 正式报价时间(老字段 QuoteDate)
    public DateTimeOffset? ValidUntil { get; private set; }
    public string Currency { get; private set; } = default!;

    private readonly List<QuoteLine> _lines = [];
    public IReadOnlyList<QuoteLine> Lines => _lines.AsReadOnly();

    // 报价单级附加费(对齐老字段 TransportPrice/PackingPrice/ClearancePrice/Discount)
    public Money Freight { get; private set; } = default!;
    public Money? Discount { get; private set; }
    private readonly List<QuoteRevision> _revisions = [];
    public IReadOnlyList<QuoteRevision> Revisions => _revisions.AsReadOnly();

    internal SupplierQuote(SupplierId supplierId, string supplierName, string currency)
    {
        Id = SupplierQuoteId.New();
        SupplierId = supplierId;
        SupplierName = supplierName;
        Currency = currency.ToUpperInvariant();
        Status = QuoteStatus.Draft;
        Freight = Money.Zero(Currency);
    }

    internal void Submit(IEnumerable<QuoteLine> lines, Money freight, Money? discount,
        DateTimeOffset quotedAt, DateTimeOffset? validUntil, DateTimeOffset now, string? reason = null)
    {
        var materialized = lines as IList<QuoteLine> ?? lines.ToList();
        if (materialized.Count == 0) throw new DomainException("报价至少要有一行");

        if (Status == QuoteStatus.Submitted)
        {
            // 二次报价:旧值快照留痕,回答"这价是谁什么时候改成现在这样的"
            _revisions.Add(new QuoteRevision(QuotedAt, _lines.ToList(), Freight, Discount,
                reason ?? "供应商重新报价"));
        }

        if (freight.Currency != Currency || (discount is not null && discount.Value.Currency != Currency))
            throw new DomainException("报价附加费币种必须与报价币种一致");

        foreach (var line in materialized)
        {
            if (line.UnitPrice.Currency != Currency)
                throw new DomainException($"行单价币种 {line.UnitPrice.Currency} 与报价单币种 {Currency} 不一致");
            var existing = _lines.FirstOrDefault(l => l.InquiryLineNo == line.InquiryLineNo);
            if (existing is null) _lines.Add(line);
            else existing.ReplaceWith(line.Qty, line.UnitPrice, line.LeadTimeDays, line.Brand);
        }

        Freight = freight;
        Discount = discount;
        QuotedAt = quotedAt;
        ValidUntil = validUntil;
        Status = QuoteStatus.Submitted;
    }

    internal void Supersede() => Status = QuoteStatus.Superseded;

    private SupplierQuote() { }

    /// <summary>总到港成本(landed cost):行金额合计 + 运/包/清 − 折扣。比价的唯一口径。</summary>
    public Money TotalCost
    {
        get
        {
            var total = _lines.Aggregate(Money.Zero(Currency), (acc, l) => acc + l.LineTotal) + Freight;
            return Discount is null ? total : total - Discount.Value;
        }
    }
}

5.3 询价聚合根

csharp 复制代码
// Domain/Purchasing/Inquiry.cs
namespace Pms.Domain.Purchasing;

using Pms.Domain.Events;
using Pms.Domain.Exceptions;
using Pms.Domain.SharedKernel;

public enum InquiryStatus { Draft, Quoting, Quoted, Closed, Cancelled }

public class Inquiry : AggregateRoot<InquiryId>
{
    public string InquiryNo { get; private set; } = default!;       // 如 (2026)HKMW-TEC-RFQ-0001
    public MaterialApplyId ApplyId { get; private set; }
    public string ShipCode { get; private set; } = default!;
    public InquiryStatus Status { get; private set; }
    public DateTimeOffset Deadline { get; private set; }           // 报价截止时间
    public SupplierQuoteId? SelectedQuoteId { get; private set; }
    public PurchaseOrderId? PurchaseOrderId { get; private set; }   // 转单后回写,幂等返回靠它
    public DateTimeOffset CreatedAt { get; private set; }

    private readonly List<InquiryItem> _items = [];
    private readonly List<SupplierQuote> _quotes = [];
    public IReadOnlyList<InquiryItem> Items => _items.AsReadOnly();
    public IReadOnlyList<SupplierQuote> Quotes => _quotes.AsReadOnly();

    private Inquiry() { }

    public static Inquiry Create(string inquiryNo, MaterialApplyId applyId, string shipCode,
        DateTimeOffset deadline, DateTimeOffset now, IEnumerable<InquiryItem> items)
    {
        var list = items as IList<InquiryItem> ?? items.ToList();
        if (list.Count == 0) throw new DomainException("询价单至少要有一行物料");
        if (deadline <= now) throw new DomainException("报价截止时间必须晚于当前时间");

        var inquiry = new Inquiry
        {
            Id = InquiryId.New(),
            InquiryNo = inquiryNo,
            ApplyId = applyId,
            ShipCode = shipCode,
            Deadline = deadline,
            Status = InquiryStatus.Draft,
            CreatedAt = now
        };
        inquiry._items.AddRange(list);
        inquiry.AddDomainEvent(new InquiryCreatedEvent(inquiry.Id, inquiry.ApplyId, inquiryNo));
        return inquiry;
    }

    public void SendToQuoting(DateTimeOffset now)
    {
        if (Status != InquiryStatus.Draft) throw new DomainException("只有草稿状态的询价单可以发布");
        if (_items.Count == 0) throw new DomainException("询价单没有明细行");
        var invited = _quotes.Select(q => q.SupplierId).Distinct().Count();
        if (invited < 2) throw new DomainException("至少邀请两家供应商后才能发布询价");
        if (Deadline <= now) throw new DomainException("报价已截止,不能发布");
        Status = InquiryStatus.Quoting;
    }

    public void InviteSupplier(SupplierId supplierId, string supplierName, string currency)
    {
        if (Status != InquiryStatus.Draft)
            throw new DomainException("询价单发布后不能再邀请新供应商");
        if (_quotes.Any(q => q.SupplierId == supplierId))
            throw new DomainException($"供应商 {supplierName} 已在邀请名单中");
        _quotes.Add(new SupplierQuote(supplierId, supplierName, currency));
    }

    public void SubmitQuote(SupplierId supplierId, IEnumerable<QuoteLine> lines,
        Money freight, Money? discount, DateTimeOffset quotedAt, DateTimeOffset? validUntil,
        DateTimeOffset now, string? reason = null)
    {
        if (Status != InquiryStatus.Quoting) throw new DomainException("询价单不在报价中状态");
        if (now > Deadline) throw new DomainException("报价已截止,供应商不能再报价或改价");

        var quote = _quotes.FirstOrDefault(q => q.SupplierId == supplierId)
            ?? throw new DomainException("该供应商不在询价邀请名单中");

        // 报价行必须覆盖询价行口径,防止"只报了便宜的那几行"参与比价
        var lineNos = lines.Select(l => l.InquiryLineNo).ToHashSet();
        foreach (var item in _items)
            if (!lineNos.Contains(item.LineNo))
                throw new DomainException($"报价缺少第 {item.LineNo} 行({item.MaterialName})");

        quote.Submit(lines, freight, discount, quotedAt, validUntil, now, reason);
        AddDomainEvent(new InquiryQuoteReceivedEvent(Id, supplierId, quote.TotalCost));
    }

    /// <summary>人工截止(到期定时任务也走这个方法)。</summary>
    public void CloseQuoting()
    {
        if (Status != InquiryStatus.Quoting) throw new DomainException("只有报价中的询价单可以截止");
        Status = InquiryStatus.Quoted;
    }

    /// <summary>规则比价:总到港成本最低者胜。异币种直接拒绝,不替业务人员赌汇率。</summary>
    public SupplierQuote PickBestQuote()
    {
        EnsureReadyToSelect();
        var currencies = _quotes
            .Where(q => q.Status == QuoteStatus.Submitted).Select(q => q.Currency).Distinct().ToList();
        if (currencies.Count > 1)
            throw new DomainException($"多家报价币种不一致({string.Join("/", currencies)}),请先按统一汇率折算后人工选定");

        var best = _quotes.Where(q => q.Status == QuoteStatus.Submitted)
            .OrderBy(q => q.TotalCost.Amount).First();
        SelectedQuoteId = best.Id;
        return best;
    }

    /// <summary>人工选定(允许不选最低价,但"不足两家"这条底线不变)。</summary>
    public void SelectQuote(SupplierId supplierId)
    {
        EnsureReadyToSelect();
        var quote = _quotes.FirstOrDefault(q => q.SupplierId == supplierId && q.Status == QuoteStatus.Submitted)
            ?? throw new DomainException("供应商报价不存在或尚未正式提交");
        SelectedQuoteId = quote.Id;
    }

    private void EnsureReadyToSelect()
    {
        if (Status != InquiryStatus.Quoted) throw new DomainException("询价单尚未截止,不能比价选定");
        var submitted = _quotes.Count(q => q.Status == QuoteStatus.Submitted);
        if (submitted < 2) throw new DomainException("至少收到两家供应商的正式报价后才能比价选定");
    }

    /// <summary>由 PurchaseOrder 工厂/应用层在成功转单后回调,完成幂等闭环。</summary>
    internal void MarkConverted(PurchaseOrderId purchaseOrderId)
    {
        if (Status == InquiryStatus.Closed && PurchaseOrderId is not null) return; // 已转过:幂等 no-op
        if (SelectedQuoteId is null) throw new DomainException("尚未选定供应商,不能转采购单");
        if (Status != InquiryStatus.Quoted) throw new DomainException("当前状态不能转采购单");
        PurchaseOrderId = purchaseOrderId;
        Status = InquiryStatus.Closed;
    }

    public void Cancel()
    {
        if (Status is InquiryStatus.Closed) throw new DomainException("已转采购单的询价单不能取消");
        Status = InquiryStatus.Cancelled;
    }
}

注意 SubmitQuote 的截止判断用的是方法调用时刻的 now ,而不是 QuoteDate 字段是否为空------事故三"草价下单"在新模型里有两道防线:状态不到 Quoted 不能选定,报价不到 Submitted(必须显式调 SubmitQuote 写入 QuotedAt)不进比价集合。


6. 充血模型(下):PurchaseOrder 聚合与跨聚合不变量

6.1 采购订单聚合

csharp 复制代码
// Domain/Purchasing/PurchaseOrder.cs
namespace Pms.Domain.Purchasing;

using Pms.Domain.Events;
using Pms.Domain.Exceptions;
using Pms.Domain.SharedKernel;

public enum PurchaseOrderStatus
{
    Draft, Submitted, Confirmed, PartialReceived, Received, Closed, Cancelled
}

public class PurchaseOrderItem : Entity<Guid>
{
    public int LineNo { get; private set; }
    public MaterialId MaterialId { get; private set; }
    public string MaterialName { get; private set; } = default!;
    public string Unit { get; private set; } = default!;
    public decimal OrderQty { get; private set; }      // 订单数量(老字段 OrderNum)
    public decimal ReceivedQty { get; private set; }   // 累计到货(老字段 ArrNum)
    public decimal RejectedQty { get; private set; }   // 累计拒收(老字段 RejectQuantity)
    public Money UnitPrice { get; private set; } = default!;
    public DateOnly? PromisedDate { get; private set; }

    internal PurchaseOrderItem(int lineNo, MaterialId materialId, string name, string unit,
        decimal orderQty, Money unitPrice, DateOnly? promisedDate)
    {
        if (orderQty <= 0) throw new DomainException("订单数量必须大于 0");
        Id = Guid.CreateVersion7();
        LineNo = lineNo; MaterialId = materialId; MaterialName = name; Unit = unit;
        OrderQty = orderQty; UnitPrice = unitPrice; PromisedDate = promisedDate;
    }

    internal void Receive(decimal accepted, decimal rejected)
    {
        if (accepted < 0 || rejected < 0) throw new DomainException("收货/拒收数量不可为负");
        if (ReceivedQty + accepted + RejectedQty + rejected > OrderQty)
            throw new DomainException($"物料 {MaterialName} 累计收货数量不能超过订单数量 {OrderQty}");
        ReceivedQty += accepted;
        RejectedQty += rejected;
    }

    private PurchaseOrderItem() { }

    public Money LineTotal => UnitPrice.Multiply(OrderQty);
}

public class PurchaseOrder : AggregateRoot<PurchaseOrderId>
{
    public string OrderNo { get; private set; } = default!;        // 如 (2026)HKMW-TEC-PO-0001
    public InquiryId InquiryId { get; private set; }
    public MaterialApplyId ApplyId { get; private set; }           // 跨聚合只持 Id
    public SupplierId SupplierId { get; private set; }
    public string SupplierName { get; private set; } = default!;
    public string ShipCode { get; private set; } = default!;
    public string Currency { get; private set; } = default!;
    public PurchaseOrderStatus Status { get; private set; }
    public string? ConfirmNo { get; private set; }                 // 老字段 ComfirmNum
    public DateTimeOffset? ConfirmedAt { get; private set; }
    public DateTimeOffset OrderDate { get; private set; }
    public Money Freight { get; private set; } = default!;
    public Money? Discount { get; private set; }

    private readonly List<PurchaseOrderItem> _items = [];
    public IReadOnlyList<PurchaseOrderItem> Items => _items.AsReadOnly();

    private PurchaseOrder() { }

    /// <summary>
    /// 从已选定报价的询价单生成采购订单。
    /// 注意:本工厂只校验"聚合内能看见的"规则;
    /// "不超过申请批准数量"是跨聚合不变量,由 IPurchaseQuotaPolicy(第 7 节)在调用前保证。
    /// </summary>
    public static PurchaseOrder CreateFromInquiry(
        Inquiry inquiry,
        SupplierQuote winningQuote,
        string orderNo,
        DateTimeOffset orderDate,
        IReadOnlyDictionary<MaterialId, decimal>? qtyOverrides = null)
    {
        if (inquiry.SelectedQuoteId != winningQuote.Id)
            throw new DomainException("只能按询价单已选定的供应商报价生成采购订单");
        if (winningQuote.Status != QuoteStatus.Submitted)
            throw new DomainException("报价尚未正式提交,不能生成采购订单");

        var po = new PurchaseOrder
        {
            Id = PurchaseOrderId.New(),
            OrderNo = orderNo,
            InquiryId = inquiry.Id,
            ApplyId = inquiry.ApplyId,
            SupplierId = winningQuote.SupplierId,
            SupplierName = winningQuote.SupplierName,
            ShipCode = inquiry.ShipCode,
            Currency = winningQuote.Currency,
            Status = PurchaseOrderStatus.Draft,
            OrderDate = orderDate,
            Freight = winningQuote.Freight,
            Discount = winningQuote.Discount
        };

        var lineNo = 1;
        foreach (var quoteLine in winningQuote.Lines)
        {
            var inquiryItem = inquiry.Items.First(i => i.LineNo == quoteLine.InquiryLineNo);
            var qty = qtyOverrides is not null && qtyOverrides.TryGetValue(quoteLine.MaterialId, out var ov)
                ? ov : quoteLine.Qty;

            // 聚合内可见的上限:本次下单 ≤ 本次询价数量 ≤ 批准数量(后者是快照,最终约束仍在政策对象)
            if (qty > inquiryItem.InquireQty)
                throw new DomainException(
                    $"物料 {inquiryItem.MaterialName} 下单数量 {qty} 超过本次询价数量 {inquiryItem.InquireQty}");

            var promised = orderDate.Date.AddDays(quoteLine.LeadTimeDays);
            po._items.Add(new PurchaseOrderItem(lineNo++, quoteLine.MaterialId,
                inquiryItem.MaterialName, inquiryItem.Unit, qty, quoteLine.UnitPrice,
                DateOnly.FromDateTime(promised)));
        }

        po.AddDomainEvent(new PurchaseOrderCreatedEvent(
            po.Id, po.OrderNo, inquiry.Id, inquiry.ApplyId, winningQuote.SupplierId, po.TotalAmount));
        return po;
    }

    public void Submit()
    {
        if (Status != PurchaseOrderStatus.Draft) throw new DomainException("只有草稿订单可以提交");
        Status = PurchaseOrderStatus.Submitted;
    }

    public void Confirm(string confirmNo, DateTimeOffset confirmedAt)
    {
        if (Status != PurchaseOrderStatus.Submitted) throw new DomainException("只有已提交的订单可以确认");
        if (string.IsNullOrWhiteSpace(confirmNo)) throw new DomainException("供应商确认单号不能为空");
        ConfirmNo = confirmNo;
        ConfirmedAt = confirmedAt;
        Status = PurchaseOrderStatus.Confirmed;
        AddDomainEvent(new PurchaseOrderConfirmedEvent(Id, OrderNo, confirmNo));
    }

    /// <summary>分批收货:Confirmed → PartialReceived → Received。</summary>
    public void RecordReceipt(IEnumerable<(MaterialId Material, decimal Accepted, decimal Rejected)> lines,
        DateTimeOffset receivedAt)
    {
        if (Status is not (PurchaseOrderStatus.Confirmed or PurchaseOrderStatus.PartialReceived))
            throw new DomainException("只有已确认或部分到货的订单可以登记收货");

        foreach (var (material, accepted, rejected) in lines)
        {
            var item = _items.FirstOrDefault(i => i.MaterialId == material)
                ?? throw new DomainException($"订单中不存在该物料行:{material}");
            item.Receive(accepted, rejected);
        }

        Status = _items.All(i => i.ReceivedQty + i.RejectedQty >= i.OrderQty)
            ? PurchaseOrderStatus.Received
            : PurchaseOrderStatus.PartialReceived;

        AddDomainEvent(new PurchaseOrderReceivedEvent(Id, OrderNo, receivedAt,
            _items.Sum(i => i.ReceivedQty)));
    }

    public void Close()
    {
        if (Status is not (PurchaseOrderStatus.Received or PurchaseOrderStatus.Confirmed))
            throw new DomainException("只有已确认或已收齐的订单可以关闭");
        Status = PurchaseOrderStatus.Closed;
    }

    public void Cancel()
    {
        if (Status is PurchaseOrderStatus.Received or PurchaseOrderStatus.Closed)
            throw new DomainException("已收货或已关闭的订单不能取消");
        Status = PurchaseOrderStatus.Cancelled;
    }

    public Money TotalAmount
    {
        get
        {
            var subtotal = _items.Aggregate(Money.Zero(Currency), (acc, i) => acc + i.LineTotal) + Freight;
            return Discount is null ? subtotal : subtotal - Discount.Value;
        }
    }
}

6.2 讲透那个跨聚合不变量

"下单数量不得超过申请批准数量"写出来是这样一条谓词:

text 复制代码
对于申请单 A 的每一物料行 m:
    Σ 所有引用 A 的、未取消采购订单上 m 的 OrderQty  + 本次下单 qty(m)  ≤  ApprovedQty(A, m)

为什么 PurchaseOrder.CreateFromInquiry 里只校验了 qty ≤ InquireQty 就停了?因为完整谓词需要的数据不在 PurchaseOrder 聚合内存里 :批准数量在 MaterialApply 聚合,已订数量散落在此前生成的多张 PurchaseOrder 聚合中。聚合的纪律是"一个事务只修改一个聚合实例",所以这条规则必须在聚合之外、应用服务的编排中被满足。这不是 DDD 的缺陷,而是跨聚合不变量的本来面目------你只有三个选择:强一致领域服务、最终一致事件、Saga 补偿。下一节给全。

另外注意:InquiryItem.ApprovedQty 快照能挡住"这一次询价就超量",但挡不住"分两次询价/两张 PO 合计超量"。快照是第一道便宜的防线,不是完整答案。


7. 跨聚合一致性:领域服务、最终一致与 Saga 的决策表

7.1 三种策略对比

维度 ① 领域服务 + 单事务强一致 ② 领域事件最终一致 ③ Saga / 流程管理器
做法 应用层一次性加载 MaterialApply 与 PurchaseOrder,政策校验通过后在同一事务/同一 SaveChanges 保存 PO 提交事件,订阅方异步回写申请已订数量/超量挂起 编排多个本地事务 + 补偿动作(撤单、释放额度)
一致性 立即一致,提交后约束必然成立 短暂不一致窗口(秒级),需对账/幂等消费 最终一致,流程状态持久化
适用拓扑 同进程、同库(船端 SQLite 单进程) 跨进程/跨库、岸基多服务 跨服务长流程、多步回滚(如 PO→收货→付款)
代价 同时加载多个聚合;高争用上行锁竞争 接受延迟;要处理乱序、重复、补偿 复杂度最高,要定义每步的补偿
失败形态 校验失败即整体回滚,无脏数据 事件可能延迟/重复,消费必须幂等 中间状态需可观测、可人工干预

决策规则(结论):同进程同库用领域服务 + 事务;跨进程跨库用事件 + Outbox;跨服务多步长事务才用 Saga。 船端是单机单进程 SQLite,引入消息队列除了增加断网故障面没有任何收益------不要为了"纯"在单机场景硬上 MQ。Saga 与 CAP 的完整实现见本系列 Saga 篇与 CAP+Outbox 篇,本篇不展开。

7.2 策略①实现:领域服务(接口在 Domain,实现在 Infrastructure)

csharp 复制代码
// Domain/Purchasing/IPurchaseQuotaPolicy.cs ------ 领域服务接口属于 Domain 层
namespace Pms.Domain.Purchasing;

using Pms.Domain.SharedKernel;

public sealed record QuotaLine(MaterialId MaterialId, decimal WantedQty);

public interface IPurchaseQuotaPolicy
{
    /// <summary>校验"申请已订 + 本次下单 ≤ 批准数量",不满足抛 DomainException。</summary>
    Task EnsureWithinApprovedQuotaAsync(
        MaterialApplyId applyId,
        IReadOnlyCollection<QuotaLine> wanted,
        CancellationToken ct = default);
}
csharp 复制代码
// Infrastructure/Purchasing/PurchaseQuotaPolicy.cs
namespace Pms.Infrastructure.Purchasing;

using Microsoft.EntityFrameworkCore;
using Pms.Domain.Exceptions;
using Pms.Domain.Purchasing;
using Pms.Domain.SharedKernel;

public sealed class PurchaseQuotaPolicy(PmsDbContext db) : IPurchaseQuotaPolicy
{
    public async Task EnsureWithinApprovedQuotaAsync(
        MaterialApplyId applyId, IReadOnlyCollection<QuotaLine> wanted, CancellationToken ct = default)
    {
        // 批准数量来自 M1 聚合的持久化(mrp_applydesc/mrp_apply 模型见 M1 篇)
        var approved = await db.MaterialApplyItems
            .Where(i => i.ApplyId == applyId)
            .ToDictionaryAsync(i => i.MaterialId, i => i.ApprovedQty, ct);

        // 已订数量:所有未取消 PO 的行汇总。用读模型查询而不是把所有 PO 聚合物料化
        var ordered = await db.PurchaseOrderItems
            .Where(pi => pi.Order.ApplyId == applyId
                         && pi.Order.Status != PurchaseOrderStatus.Cancelled)
            .GroupBy(pi => pi.MaterialId)
            .Select(g => new { g.Key, Ordered = g.Sum(x => x.OrderQty) })
            .ToDictionaryAsync(x => x.Key, x => x.Ordered, ct);

        foreach (var line in wanted)
        {
            if (!approved.TryGetValue(line.MaterialId, out var quota))
                throw new DomainException($"申请单中不存在该物料行,不能下单:{line.MaterialId}");
            ordered.TryGetValue(line.MaterialId, out var already);
            if (already + line.WantedQty > quota)
                throw new DomainException(
                    $"物料下单数量 {already + line.WantedQty} 超过申请批准数量 {quota}(已订 {already},本次 {line.WantedQty})");
        }
    }
}

这里刻意用只读查询 而不是加载 MaterialApply 聚合:政策校验只需要两个数字,物化整张申请单(可能几十行、带三级审批冗余字段)是浪费。DDD 不禁止读模型,禁止的是绕过聚合修改状态。而真正的写操作(回写申请单"已转采购/已订数量")仍然只发生在 M1 聚合的方法里------下一节的事件处理器负责调用它。

7.3 策略②在岸基长什么样(点到为止)

岸基把申请、采购拆成独立服务/独立库后,采购服务无法在同一事务里读申请库。流程变为:PO 服务本地事务提交 PO + outbox 事件 → 消息中间件投递 PurchaseOrderCreated → 申请服务消费、累加已订数量,若发现超量(并发下两个 PO 同时通过了各自的预检)则发布 PurchaseOrderRejected/挂起 事件,PO 服务把订单置为"待额度确认"。这是典型的预留额度/事后修正 模式,可靠投递由第 9 节的 Outbox 保证,消费幂等由 EventId 保证。


8. 领域事件机制:两种分发时机与失败窗口

8.1 领域事件 vs 集成事件

微软官方在 eShop 参考架构文档里对二者的区分非常明确(Domain events: Design and implementation):

领域事件 Domain Event 集成事件 Integration Event
作用范围 进程内,同一个 bounded context 跨服务、跨进程、可能跨机器
负载形态 可直接携带领域对象/强类型 Id,随模型一起演进化 只能传序列化 DTO,必须考虑版本兼容(字段只加不删、消费者容忍未知字段)
传输 MediatR INotification 等进程内分发 消息队列/事件总线(RabbitMQ/Kafka...)
事务语义 可与触发它的状态修改在同一事务 必须靠 Outbox 保证"业务提交"与"事件发出"的原子性

M2 的 PurchaseOrderCreatedEvent 先作为领域事件存在(进程内回写申请单已订数量、写审计),需要通知岸基其他上下文时,再由处理器翻译 为一份版本化的集成事件 PurchaseOrderCreatedIntegrationEvent 推上总线------领域事件永远不要直接序列化出站。

事件本身长这样(EventId 在创建时固定,第 13 节坑 6 解释为什么不能发布时才生成):

csharp 复制代码
// Domain/SharedKernel/IIntegrationEventSource.cs
namespace Pms.Domain.SharedKernel;

/// <summary>标注该领域事件需要翻译为出站集成事件;返回扁平、可版本兼容的 DTO。</summary>
public interface IIntegrationEventSource
{
    object ToIntegrationEvent();
}

// Domain/Events/PurchaseOrderEvents.cs
namespace Pms.Domain.Events;

using Pms.Domain.SharedKernel;

public sealed record PurchaseOrderCreatedEvent(
    PurchaseOrderId PurchaseOrderId,
    string OrderNo,
    InquiryId InquiryId,
    MaterialApplyId ApplyId,
    SupplierId SupplierId,
    Money TotalAmount) : IDomainEvent, IIntegrationEventSource
{
    public Guid EventId { get; } = Guid.CreateVersion7();
    public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;

    // 领域事件 → 集成事件:负载扁平化、只暴露基元与强类型 Id,字段只加不删
    public object ToIntegrationEvent() => new PurchaseOrderCreatedIntegrationEvent(
        EventId, OccurredOn, OrderNo, ApplyId, SupplierId,
        TotalAmount.Amount, TotalAmount.Currency);
}

/// <summary>出站集成事件 DTO:消费者必须容忍未来新增字段。</summary>
public sealed record PurchaseOrderCreatedIntegrationEvent(
    Guid EventId,
    DateTimeOffset OccurredOn,
    string OrderNo,
    MaterialApplyId ApplyId,
    SupplierId SupplierId,
    decimal TotalAmount,
    string Currency);

// 其余领域事件(进程内使用,不出站)保持同一模式:
public sealed record InquiryCreatedEvent(InquiryId InquiryId, MaterialApplyId ApplyId, string InquiryNo) : IDomainEvent
{
    public Guid EventId { get; } = Guid.CreateVersion7();
    public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
}

public sealed record InquiryQuoteReceivedEvent(
    InquiryId InquiryId, SupplierId SupplierId, Money TotalCost) : IDomainEvent
{
    public Guid EventId { get; } = Guid.CreateVersion7();
    public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
}

public sealed record PurchaseOrderConfirmedEvent(
    PurchaseOrderId PurchaseOrderId, string OrderNo, string ConfirmNo) : IDomainEvent
{
    public Guid EventId { get; } = Guid.CreateVersion7();
    public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
}

public sealed record PurchaseOrderReceivedEvent(
    PurchaseOrderId PurchaseOrderId, string OrderNo,
    DateTimeOffset ReceivedAt, decimal TotalAcceptedQty) : IDomainEvent
{
    public Guid EventId { get; } = Guid.CreateVersion7();
    public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
}

8.2 三个时机,三个失败窗口

事件挂在聚合上之后,"什么时候分发"决定了系统的故障语义:

text 复制代码
方式 A:事务提交后分发(最常见,也最容易踩坑)
  SaveChanges ──────► COMMIT 成功 ──────► mediator.Publish(事件)
                       │                      │
                       │                    进程崩溃 / 发布抛异常
                       │                      ▼
                       └────────────────► 库里改了,订阅方没收到:永久不一致
                                         ("库已改、事件丢了"窗口)

方式 B:事务提交前分发(eShop 的做法)
  BEGIN TX ──► SaveChanges(未提交) ──► mediator.Publish(处理器同 DbContext 再改再 Save)
                  │                                          │
                  │                              处理器抛异常 ──► 整事务回滚(原子,无脏数据)
                  ▼
              COMMIT ──► 提交后进程崩溃?事件已随业务状态落库才是真原子(见方式 C)

方式 C:Outbox(原子落库,后台发布)
  BEGIN TX ──► SaveChanges(业务表 + outbox 表同一事务) ──► COMMIT
                                                              │
                              后台轮询/CDC ──► 发布总线 ──► 成功后标记 Processed
                              崩溃?重启后从 outbox 继续,事件不丢(至少一次,消费端幂等)

选型结论

  • 船端 :进程内副作用(回写已订数量、审计、通知标记)用方式 B------与业务同事务,立即强一致,和第 7 节的领域服务策略叠加后,船端根本不依赖事件做关键约束;
  • 岸基 :跨服务通知一律方式 C(Outbox),关键额度约束仍以前置同步校验(gRPC 调申请服务)+ 事件事后修正双保险;
  • 方式 A(提交后直接分发)只允许用于丢了也无所谓 的旁路(比如打一条 debug 日志),不允许承载任何一致性职责。

EF Core 默认单次 SaveChanges 自身就在一个事务里(Using Transactions),方式 B 不需要显式开事务;处理器只要复用同一个 scoped DbContext,其 SaveChanges 会自然并入外层事务。

8.3 MediatR 进程内分发与"提交前分发"包装

MediatR 的用法本身不复杂:事件实现 INotification,处理器实现 INotificationHandler<T>await mediator.Publish(evt) 按注册顺序多播(官方推荐写法见上面的 eShop 域事件文档,处理器里通过仓储加载别的聚合并执行副作用)。本篇的重点是包装在 SaveChanges 的什么位置

csharp 复制代码
// Infrastructure/UnitOfWork.cs ------ 方式 B:提交前分发,处理器副作用与业务修改同一事务
namespace Pms.Infrastructure;

using MediatR;
using Microsoft.EntityFrameworkCore;
using Pms.Domain.SharedKernel;

public interface IUnitOfWork
{
    Task<int> SaveChangesAndDispatchAsync(CancellationToken ct = default);
}

public sealed class UnitOfWork(PmsDbContext db, IPublisher publisher) : IUnitOfWork
{
    public async Task<int> SaveChangesAndDispatchAsync(CancellationToken ct = default)
    {
        // 1) 先把各聚合挂账的事件取出来(SaveChanges 会让对象状态变化,先快照)
        var aggregates = db.ChangeTracker
            .Entries<AggregateRoot<Guid>>()
            .Where(e => e.Entity.DomainEvents.Count != 0)
            .Select(e => e.Entity)
            .ToList();
        var events = aggregates.SelectMany(a => a.DomainEvents).ToList();
        aggregates.ForEach(a => a.ClearDomainEvents());   // 防止重试时重复发布

        // 2) 先持久化业务修改(仍在外层事务中,未提交)
        var result = await db.SaveChangesAsync(ct);

        // 3) 提交前分发:处理器若再改别的聚合并 SaveChanges,并入同一事务
        //    处理器抛异常 → 整个 SaveChanges 的事务回滚,业务修改也不会留下
        foreach (var domainEvent in events)
            await publisher.Publish(domainEvent, ct);

        // 4) 集成事件在此刻写 outbox(第 9 节),随后由 EF 事务统一 COMMIT
        await db.EnqueueIntegrationEventsAsync(events, ct);
        return result;
    }
}

处理器示例------回写 M1 申请单已订数量:

csharp 复制代码
// Application/Purchasing/EventHandlers/UpdateOrderedQtyHandler.cs
namespace Pms.Application.Purchasing.EventHandlers;

using MediatR;
using Pms.Domain.Events;
using Pms.Domain.MaterialRequests;

public sealed class UpdateOrderedQtyOnPoCreatedHandler(
    IMaterialApplyRepository applies,
    IUnitOfWork uow)
    : INotificationHandler<PurchaseOrderCreatedEvent>
{
    public async Task Handle(PurchaseOrderCreatedEvent e, CancellationToken ct)
    {
        // 注意:这里加载的是 M1 聚合,调用它的方法修改,绝不直接 UPDATE 表
        var apply = await applies.GetByIdAsync(e.ApplyId, ct)
            ?? throw new InvalidOperationException($"申请单 {e.ApplyId} 不存在");
        apply.RecordOrdered(e.Lines);
        // 不单独 SaveChanges:由外层同一请求的 UoW 在事件全部处理完后统一提交
    }
}

三个必须记住的坑(第 13 节还有完整版):处理器里新开一个 DbContext/Scope 会让它的 SaveChanges 自成事务、先于外层提交,破坏原子性;处理器再次发布事件 要小心无限递归;处理器之间没有顺序保证(可配置 ForeachAwait 顺序发布),两个处理器改同一聚合行可能死锁。


9. Outbox 精简实现:同事务落库 + BackgroundService 轮询

岸基跨服务通知(以及船岸同步链路里需要补发的事件)需要方式 C。CAP 是生产环境的成熟选择(本地消息表 + 重试 + 仪表盘,CAP 官方文档);理解原理后,精简版自有实现只要三张东西。

9.1 Outbox 表与同事务写入

csharp 复制代码
// Infrastructure/Outbox/OutboxMessage.cs
namespace Pms.Infrastructure.Outbox;

public sealed class OutboxMessage
{
    public Guid Id { get; set; }
    public required string EventType { get; set; }     // 集成事件类型全名,用于反序列化
    public required string JsonPayload { get; set; }
    public DateTimeOffset OccurredOn { get; set; }
    public DateTimeOffset? ProcessedAt { get; set; }
    public int RetryCount { get; set; }
    public string? LastError { get; set; }
}
csharp 复制代码
// Infrastructure/PmsDbContext.cs 中的关键片段
public async Task EnqueueIntegrationEventsAsync(IReadOnlyCollection<IDomainEvent> events, CancellationToken ct)
{
    foreach (var domainEvent in events)
    {
        if (domainEvent is not IIntegrationEventSource source) continue;  // 只有标注要出站的才翻译
        var integration = source.ToIntegrationEvent();
        OutboxMessages.Add(new OutboxMessage
        {
            Id = integration.EventId,                  // 复用领域事件 Id:下游天然幂等去重
            EventType = integration.GetType().FullName!,
            JsonPayload = JsonSerializer.Serialize(integration, integration.GetType()),
            OccurredOn = integration.OccurredOn
        });
    }
    await Task.CompletedTask;
}

关键不变量:OutboxMessage 与业务行在同一个 SaveChanges 事务里写入 ------这是整个模式唯一的正确性来源。事件和业务数据要么同时可见,要么都不可见,不存在"库已改事件丢了"的窗口。唯一索引建在 Id 上,重复发布在下游也能用同一个 EventId 去重。

9.2 后台轮询发布

csharp 复制代码
// Infrastructure/Outbox/OutboxPublisherService.cs
namespace Pms.Infrastructure.Outbox;

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

/// <summary>轮询未处理的 outbox 消息,投递到集成事件总线;成功后回写 ProcessedAt。</summary>
public sealed class OutboxPublisherService(
    IServiceScopeFactory scopeFactory,
    IIntegrationEventBus bus,
    TimeProvider time,
    ILogger<OutboxPublisherService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // .NET 10 行为变更:ExecuteAsync 全程在后台线程运行,
        // 首个 await 之前的同步段不再阻塞其他托管服务启动。
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await PumpOnceAsync(stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Outbox 发布轮询失败,将在下一周期重试");
            }
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }

    private async Task PumpOnceAsync(CancellationToken ct)
    {
        using var scope = scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<PmsDbContext>();

        var batch = await db.OutboxMessages
            .Where(m => m.ProcessedAt == null && m.RetryCount < 10)
            .OrderBy(m => m.OccurredOn)
            .Take(20)
            .ToListAsync(ct);

        foreach (var message in batch)
        {
            try
            {
                await bus.PublishAsync(message.EventType, message.JsonPayload, ct);
                message.ProcessedAt = time.GetUtcNow();
                message.LastError = null;
            }
            catch (Exception ex)
            {
                message.RetryCount++;
                message.LastError = ex.Message;   // 留痕,超过 10 次进死信,靠对账任务人工介入
            }
        }
        await db.SaveChangesAsync(ct);
    }
}

BackgroundService 是 .NET 通用主机的长驻基类(Worker services in .NET)。注意一个 .NET 10 的行为变更:ExecuteAsync 现在整体在后台线程运行 ,首个 await 前的同步代码不再阻塞其他托管服务启动(.NET 10 兼容性说明),老代码若依赖"构造期同步阻塞启动顺序"需要改用 StartAsync 重写或 IHostedLifecycleService

生产选型上:船端断网期 bus.PublishAsync 持续失败,消息留在 outbox 表(这正是本系列船岸同步篇的 spool 思路),复联后继续;岸基直接用 CAP,重试退避、仪表盘、死信都不用自己写。


10. 应用服务编排:幂等转单与 Minimal API

应用服务只编排:加载聚合 → 调领域方法/政策 → 工作单元保存。核心是"从已批准申请创建询价"到"生成采购订单"这条链。

csharp 复制代码
// Application/Purchasing/InquiryAppService.cs(关键方法)
namespace Pms.Application.Purchasing;

using MediatR;
using Pms.Domain.Exceptions;
using Pms.Domain.MaterialRequests;
using Pms.Domain.Purchasing;
using Pms.Domain.SharedKernel;

public sealed record CreateInquiryCommand(
    Guid RequestId,                       // 客户端生成的幂等键
    MaterialApplyId ApplyId,
    string ShipCode,
    DateTimeOffset Deadline,
    IReadOnlyList<CreateInquiryLine> Lines);

public sealed record CreateInquiryLine(
    MaterialId MaterialId, string? ImpaCode, string Name, string Unit,
    decimal ApprovedQty, decimal InquireQty);

public sealed class InquiryAppService(
    IMaterialApplyRepository applies,
    IInquiryRepository inquiries,
    IInquiryNumberGenerator numberGenerator,
    IUnitOfWork uow,
    PmsDbContext db)
{
    public async Task<InquiryId> CreateAsync(CreateInquiryCommand cmd, CancellationToken ct = default)
    {
        // 幂等第一层:同 RequestId 直接返回既有结果(重复点击/超时重试)
        var existing = await db.IdempotentRequests
            .Where(r => r.RequestId == cmd.RequestId && r.Kind == "CreateInquiry")
            .Select(r => r.ResultId).FirstOrDefaultAsync(ct);
        if (existing is not null) return new InquiryId(existing.Value);

        // 前置不变量:只有 Approved 的申请才能发起询价(M1 聚合的状态机兜底)
        var apply = await applies.GetByIdAsync(cmd.ApplyId, ct)
            ?? throw new DomainException("物料申请单不存在");
        if (apply.Status != MaterialApplyStatus.Approved)
            throw new DomainException("只有审批通过的物料申请才能发起询价");

        var items = cmd.Lines
            .Select((l, idx) => new InquiryItem(idx + 1, l.MaterialId, l.ImpaCode,
                l.Name, l.Unit, l.ApprovedQty, l.InquireQty))
            .ToList();

        var inquiryNo = await numberGenerator.NextInquiryNoAsync(ct);
        var inquiry = Inquiry.Create(inquiryNo, cmd.ApplyId, cmd.ShipCode,
            cmd.Deadline, DateTimeOffset.UtcNow, items);

        await inquiries.AddAsync(inquiry, ct);
        await uow.SaveChangesAndDispatchAsync(ct);

        // 幂等凭证与业务在同一事务写入,唯一索引是最终防线(第 11 节建索引)
        db.IdempotentRequests.Add(new IdempotentRequest(
            cmd.RequestId, "CreateInquiry", inquiry.Id.Value));
        await uow.SaveChangesAndDispatchAsync(ct);
        return inquiry.Id;
    }
}
csharp 复制代码
// Application/Purchasing/PurchaseAppService.cs(转单编排:领域服务 + 两个聚合 + 单事务)
namespace Pms.Application.Purchasing;

using Pms.Domain.Exceptions;
using Pms.Domain.Purchasing;
using Pms.Domain.SharedKernel;

public sealed record GeneratePurchaseOrderCommand(
    Guid RequestId, InquiryId InquiryId, SupplierId SupplierId,
    IReadOnlyDictionary<MaterialId, decimal>? QtyOverrides);

public sealed class PurchaseAppService(
    IInquiryRepository inquiries,
    IPurchaseOrderRepository orders,
    IPurchaseQuotaPolicy quotaPolicy,
    IPurchaseOrderNumberGenerator numberGenerator,
    IUnitOfWork uow)
{
    public async Task<PurchaseOrderId> GenerateAsync(GeneratePurchaseOrderCommand cmd, CancellationToken ct = default)
    {
        var inquiry = await inquiries.GetWithQuotesAsync(cmd.InquiryId, ct)
            ?? throw new DomainException("询价单不存在");

        // 幂等闭环:已转过单,无论点多少次都返回同一个采购单号
        if (inquiry.Status == InquiryStatus.Closed && inquiry.PurchaseOrderId is not null)
            return inquiry.PurchaseOrderId.Value;

        var quote = inquiry.Quotes.First(q => q.SupplierId == cmd.SupplierId);
        var orderNo = await numberGenerator.NextOrderNoAsync(ct);

        var wanted = quote.Lines.Select(l => new QuotaLine(
            l.MaterialId,
            cmd.QtyOverrides is not null && cmd.QtyOverrides.TryGetValue(l.MaterialId, out var ov)
                ? ov : l.Qty)).ToList();

        // ① 跨聚合不变量:不通过直接抛异常,后面什么都不会发生
        await quotaPolicy.EnsureWithinApprovedQuotaAsync(inquiry.ApplyId, wanted, ct);

        // ② 工厂内只做聚合内校验(状态、报价有效性、单量≤询价量)
        var order = PurchaseOrder.CreateFromInquiry(
            inquiry, quote, orderNo, DateTimeOffset.UtcNow, cmd.QtyOverrides);

        // ③ 询价单闭环(内部对"已转"幂等)
        MarkConverted(inquiry, order.Id);

        await orders.AddAsync(order, ct);
        await uow.SaveChangesAndDispatchAsync(ct);   // ④ 事件在同一事务内回写申请已订数量
        return order.Id;
    }

    private static void MarkConverted(Inquiry inquiry, PurchaseOrderId poId) =>
        typeof(Inquiry).GetMethod("MarkConverted",
            System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
            .Invoke(inquiry, [poId]);
}

生产代码里 MarkConverted 应声明为 internal 并通过 InternalsVisibleTo 暴露给 Application 层,这里用反射只是为了示例自洽,别照抄这 5 行。

Minimal API 骨架(Minimal APIs 概述):

csharp 复制代码
// Api/Endpoints/PurchasingEndpoints.cs
namespace Pms.Api.Endpoints;

public static class PurchasingEndpoints
{
    public static void MapPurchasing(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/purchasing").WithTags("采购");

        group.MapPost("/inquiries", async (CreateInquiryCommand cmd, InquiryAppService svc, CancellationToken ct)
            => Results.Created($"/purchasing/inquiries/{await svc.CreateAsync(cmd, ct)}", null));

        group.MapPost("/inquiries/{id:guid}/quotes/{supplierId:guid}",
            async (Guid id, Guid supplierId, SubmitQuoteRequest req,
                   InquiryAppService svc, CancellationToken ct) =>
            {
                await svc.SubmitQuoteAsync(new InquiryId(id), new SupplierId(supplierId), req, ct);
                return Results.NoContent();
            });

        group.MapPost("/inquiries/{id:guid}/close",
            async (Guid id, InquiryAppService svc, CancellationToken ct) =>
            {
                await svc.CloseAsync(new InquiryId(id), ct);
                return Results.NoContent();
            });

        group.MapPost("/inquiries/{id:guid}/pick-best",
            async (Guid id, PurchaseAppService svc, CancellationToken ct) =>
                Results.Ok(await svc.PickBestAsync(new InquiryId(id), ct)));

        group.MapPost("/purchase-orders", async (GeneratePurchaseOrderCommand cmd,
            PurchaseAppService svc, CancellationToken ct) =>
            Results.Created($"/purchasing/purchase-orders/{await svc.GenerateAsync(cmd, ct)}", null));
    }
}

幂等有两层:应用层先查 IdempotentRequest 返回旧结果(友好路径),数据库 UNIQUE(request_id, kind) 唯一索引兜底(并发路径,两个请求同时穿过第一层时,第二个提交必败)。唯一索引的用法见 EF Core Indexes,幂等模式的完整讨论见本系列幂等篇。


11. EF Core 持久化:表结构、映射配置与双 Provider 差异

11.1 表结构(mrp_ 命名风格对齐老系统)

sql 复制代码
-- 船端 SQLite(岸基 SQL Server 仅类型不同:TEXT→NVARCHAR、BLOB→UNIQUEIDENTIFIER)
CREATE TABLE mrp_inquiry (
    id                 BLOB PRIMARY KEY,          -- InquiryId 强类型 Id(值转换器)
    inquiry_no         TEXT NOT NULL,
    apply_id           BLOB NOT NULL,
    ship_code          TEXT NOT NULL,
    status             TEXT NOT NULL,             -- 枚举转字符串
    deadline           TEXT NOT NULL,             -- DateTimeOffset 统一存 ISO-8601 文本
    selected_quote_id  BLOB NULL,
    purchase_order_id  BLOB NULL,
    created_at         TEXT NOT NULL,
    row_version        INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX ux_inquiry_no ON mrp_inquiry(inquiry_no);
CREATE INDEX ix_inquiry_apply ON mrp_inquiry(apply_id);

CREATE TABLE mrp_inquiryitem (
    id BLOB PRIMARY KEY, inquiry_id BLOB NOT NULL, line_no INTEGER NOT NULL,
    material_id BLOB NOT NULL, impa_code TEXT NOT NULL, material_name TEXT NOT NULL,
    unit TEXT NOT NULL, approved_qty REAL NOT NULL, inquire_qty REAL NOT NULL
);

CREATE TABLE mrp_inquiryquote (
    id BLOB PRIMARY KEY, inquiry_id BLOB NOT NULL, supplier_id BLOB NOT NULL,
    supplier_name TEXT NOT NULL, status TEXT NOT NULL, quoted_at TEXT NULL,
    valid_until TEXT NULL, currency TEXT NOT NULL,
    freight_amount REAL NOT NULL, freight_currency TEXT NOT NULL,   -- Money ComplexProperty 内联列
    discount_amount REAL NULL, discount_currency TEXT NULL
);
CREATE UNIQUE INDEX ux_quote_inquiry_supplier ON mrp_inquiryquote(inquiry_id, supplier_id)
    WHERE status <> 'Superseded';   -- 同一询价单同一供应商只允许一份有效报价(SQLite 过滤索引)

CREATE TABLE mrp_po (
    id BLOB PRIMARY KEY, order_no TEXT NOT NULL, inquiry_id BLOB NOT NULL,
    apply_id BLOB NOT NULL, supplier_id BLOB NOT NULL, supplier_name TEXT NOT NULL,
    ship_code TEXT NOT NULL, currency TEXT NOT NULL, status TEXT NOT NULL,
    confirm_no TEXT NULL, confirmed_at TEXT NULL, order_date TEXT NOT NULL,
    freight_amount REAL NOT NULL, freight_currency TEXT NOT NULL,
    discount_amount REAL NULL, discount_currency TEXT NULL,
    total_amount REAL NOT NULL, row_version INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX ux_po_no ON mrp_po(order_no);
CREATE UNIQUE INDEX ux_po_inquiry_supplier ON mrp_po(inquiry_id, supplier_id);
-- ↑ 事故一的数据库级终局防线:一张询价单对同一供应商物理上不可能有两张 PO

CREATE TABLE mrp_poitem (
    id BLOB PRIMARY KEY, po_id BLOB NOT NULL, line_no INTEGER NOT NULL,
    material_id BLOB NOT NULL, material_name TEXT NOT NULL, unit TEXT NOT NULL,
    order_qty REAL NOT NULL, received_qty REAL NOT NULL, rejected_qty REAL NOT NULL,
    price_amount REAL NOT NULL, price_currency TEXT NOT NULL, promised_date TEXT NULL
);

CREATE TABLE outbox_message (
    id BLOB PRIMARY KEY, event_type TEXT NOT NULL, json_payload TEXT NOT NULL,
    occurred_on TEXT NOT NULL, processed_at TEXT NULL,
    retry_count INTEGER NOT NULL DEFAULT 0, last_error TEXT NULL
);

CREATE TABLE idempotent_request (
    request_id BLOB NOT NULL, kind TEXT NOT NULL, result_id BLOB NOT NULL,
    PRIMARY KEY (request_id, kind)
);

11.2 映射配置

csharp 复制代码
// Infrastructure/Configurations/InquiryConfiguration.cs
namespace Pms.Infrastructure.Configurations;

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Pms.Domain.Purchasing;
using Pms.Domain.SharedKernel;

public sealed class InquiryConfiguration : IEntityTypeConfiguration<Inquiry>
{
    public void Configure(EntityTypeBuilder<Inquiry> b)
    {
        b.ToTable("mrp_inquiry");
        b.HasKey(x => x.Id);
        b.Property(x => x.InquiryNo).HasMaxLength(40).IsRequired();
        b.Property(x => x.Status).HasConversion<string>().HasMaxLength(20);
        b.Property(x => x.RowVersion).IsRowVersion();   // SQLite 下 EF 自动退化为普通并发令牌列

        // 内部实体:OwnsMany 映射到独立表,身份由"所属聚合 + 主键"定义,不暴露 DbSet
        b.OwnsMany(x => x.Items, ib =>
        {
            ib.ToTable("mrp_inquiryitem");
            ib.WithOwner().HasForeignKey("inquiry_id");
            ib.Property(x => x.LineNo);
            ib.Property(x => x.ApprovedQty);
            ib.Property(x => x.InquireQty);
        });

        b.OwnsMany(x => x.Quotes, qb =>
        {
            qb.ToTable("mrp_inquiryquote");
            qb.WithOwner().HasForeignKey("inquiry_id");
            qb.Property(q => q.Status).HasConversion<string>().HasMaxLength(20);

            // Money 用 EF8+ ComplexProperty:真正的值语义,列内联进报价表
            qb.ComplexProperty(q => q.Freight, money =>
            {
                money.Property(m => m.Amount).HasColumnName("freight_amount");
                money.Property(m => m.Currency).HasColumnName("freight_currency").HasMaxLength(3);
            });
            qb.ComplexProperty(q => q.Discount, money =>
            {
                money.Property(m => m.Amount).HasColumnName("discount_amount");
                money.Property(m => m.Currency).HasColumnName("discount_currency").HasMaxLength(3);
            });
            qb.Ignore(q => q.Revisions);   // 修订留痕若需持久化,见第 13 节坑 8

            qb.OwnsMany(q => q.Lines, lb =>
            {
                lb.ToTable("mrp_inquiryquoteline");
                lb.WithOwner().HasForeignKey("quote_id");
                lb.ComplexProperty(l => l.UnitPrice, money =>
                {
                    money.Property(m => m.Amount).HasColumnName("price_amount");
                    money.Property(m => m.Currency).HasColumnName("price_currency").HasMaxLength(3);
                });
            });
        });

        b.Ignore(x => x.DomainEvents);  // 事件列表永不映射
    }
}

ComplexProperty 与旧 Owned 类型的本质区别(Complex Type 无隐藏键、按值比较、列内联)见官方 Complex Types 文档;枚举转字符串、强类型 Id 的通用机制是值转换器(Value Conversions)。强类型 Id 用预约定模型配置一次性批量注册,不必每个属性写一遍:

csharp 复制代码
protected override void ConfigureConventions(ModelConfigurationBuilder cfg)
{
    cfg.Properties<InquiryId>().HaveConversion<StrongIdConverter<InquiryId>>();
    cfg.Properties<PurchaseOrderId>().HaveConversion<StrongIdConverter<PurchaseOrderId>>();
    cfg.Properties<MaterialApplyId>().HaveConversion<StrongIdConverter<MaterialApplyId>>();
    cfg.Properties<SupplierId>().HaveConversion<StrongIdConverter<SupplierId>>();
    cfg.Properties<MaterialId>().HaveConversion<StrongIdConverter<MaterialId>>();
}

// 一个泛型转换器通吃所有 readonly record struct Id(值对象篇有完整版,含 ValueComparer)
public sealed class StrongIdConverter<T> : ValueConverter<T, Guid>
    where T : struct, IStrongId            // IStrongId { Guid Value { get; } }
{
    public StrongIdConverter()
        : base(id => id.Value, g => (T)Activator.CreateInstance(typeof(T), g)!) { }
}

11.3 查询:聚合加载与列表投影分家

加载聚合做决策时,把报价行一起拉出来------两层集合导航(items 与 quotes 平级)是笛卡尔爆炸 场景,用 AsSplitQuery 避免 5 家 × 30 行的交叉积(Single vs. Split Queries):

csharp 复制代码
public async Task<Inquiry?> GetWithQuotesAsync(InquiryId id, CancellationToken ct = default)
    => await db.Set<Inquiry>()
        .Include(x => x.Items)
        .Include(x => x.Quotes).ThenInclude(q => q.Lines)
        .AsSplitQuery()
        .SingleOrDefaultAsync(x => x.Id == id, ct);

而询价单列表页不走聚合:直接投影到 DTO,不 Include、不物化报价集合。读模型与写模型分家,M1 篇与查询性能篇讲过的原则这里原样适用。

11.4 双 Provider 只点新增差异

M1 篇已覆盖的(两套迁移集、DateTimeOffset 处理、查询过滤器、EnsureCreated vs Migrations)不重复,M2 新增三点:

  1. 过滤索引语法不同 :SQLite 支持 WHERE status <> 'Superseded' 部分索引;SQL Server 也支持过滤索引但谓词语法需在两套配置里分别写------用 IProviderConfigdb.Database.IsSqlite() 分支,别指望一条迁移两边通吃;
  2. IsRowVersion :SQL Server 映射 ROWVERSION,SQLite 无原生类型,EF 退化为普通并发令牌列,更新时 WHERE row_version = @p 语义一致(M1 篇的并发测试在两个 Provider 上都要跑一遍);
  3. Outbox 轮询的并发度 :SQLite 是单写者 数据库,BackgroundService 与 API 请求并发写时靠 SQLITE_BUSY 重试(船岸同步篇的 Mutex 方案);SQL Server 则可以多 worker 并发取批,用 SKIP LOCKED 语义(UPDATE ... OUTPUTREADPAST/UPDLOCK)避免多个岸基实例抢同一批消息。

12. 测试:状态机、事件负载与 SQLite 跨聚合集成测试

测试工程通过 [assembly: InternalsVisibleTo("Pms.Tests")] 访问聚合的 internal 工厂/方法;外部生产代码仍只能走聚合根的公开方法。状态迁移异常统一断言 DomainException

12.1 状态机单元测试

csharp 复制代码
// Tests/Domain/InquiryStatusTests.cs
namespace Pms.Tests.Domain;

using Pms.Domain.Exceptions;
using Pms.Domain.Purchasing;
using Pms.Domain.SharedKernel;
using Xunit;

public class InquiryStatusTests
{
    private static (Inquiry inquiry, SupplierId a, SupplierId b) BuildReadyInquiry(DateTimeOffset now)
    {
        var applyId = MaterialApplyId.New();
        var item = new InquiryItem(1, MaterialId.New(), "350101", "气动滤器", "个", 100m, 50m);
        var inquiry = Inquiry.Create("(2026)HKMW-TEC-RFQ-0001", applyId, "SHIP01",
            now.AddDays(3), now, [item]);
        var a = new SupplierId(Guid.CreateVersion7());
        var b = new SupplierId(Guid.CreateVersion7());
        inquiry.InviteSupplier(a, "某供应商A", "CNY");
        inquiry.InviteSupplier(b, "某供应商B", "CNY");
        inquiry.SendToQuoting(now);
        return (inquiry, a, b);
    }

    private static List<QuoteLine> OneLine(decimal price) =>
        [new QuoteLine(1, default, 50m, new Money(price, "CNY"), 14, "原厂")];

    [Fact]
    public void 截止后再报价必须抛异常()
    {
        var now = DateTimeOffset.UtcNow;
        var (inq, a, _) = BuildReadyInquiry(now);
        inq.SubmitQuote(a, OneLine(100m), Money.Zero("CNY"), null, now, null, now);
        inq.CloseQuoting();
        Assert.Throws<DomainException>(() =>
            inq.SubmitQuote(a, OneLine(90m), Money.Zero("CNY"), null,
                now.AddDays(1), null, now.AddDays(2)));
    }

    [Fact]
    public void 不足两家正式报价不能比价选定()
    {
        var now = DateTimeOffset.UtcNow;
        var (inq, a, _) = BuildReadyInquiry(now);
        inq.SubmitQuote(a, OneLine(100m), Money.Zero("CNY"), null, now, null, now);
        inq.CloseQuoting();
        Assert.Throws<DomainException>(inq.PickBestQuote);
    }

    [Fact]
    public void 二次报价必须保留修订痕迹()
    {
        var now = DateTimeOffset.UtcNow;
        var (inq, a, _) = BuildReadyInquiry(now);
        inq.SubmitQuote(a, OneLine(100m), Money.Zero("CNY"), null, now, null, now, "首轮");
        inq.SubmitQuote(a, OneLine(92m), Money.Zero("CNY"), null, now, null, now, "降价");
        var quote = inq.Quotes.Single(q => q.SupplierId == a);
        Assert.Equal(92m, quote.Lines.Single().UnitPrice.Amount);
        Assert.Single(quote.Revisions);
    }
}

12.2 跨聚合约束与幂等测试

csharp 复制代码
// Tests/Domain/PurchaseQuotaTests.cs
public class PurchaseQuotaTests
{
    [Fact]
    public async Task 累计下单超过批准数量必须被政策对象拒绝()
    {
        var policy = new FakeQuotaPolicy(approved: 100m, alreadyOrdered: 80m);
        var wanted = new[] { new QuotaLine(MaterialId.New(), 30m) };  // 80 + 30 > 100
        await Assert.ThrowsAsync<DomainException>(
            () => policy.EnsureWithinApprovedQuotaAsync(MaterialApplyId.New(), wanted));
    }

    [Fact]
    public void 对同一供应商重复转单返回同一个采购订单号()
    {
        var now = DateTimeOffset.UtcNow;
        var (inq, a, b) = InquiryStatusTests.BuildReadyInquiry(now);  // 测试工厂复用
        inq.SubmitQuote(a, OneLine(100m), Money.Zero("CNY"), null, now, null, now);
        inq.SubmitQuote(b, OneLine(110m), Money.Zero("CNY"), null, now, null, now);
        inq.CloseQuoting();
        var winner = inq.PickBestQuote();

        var po1 = PurchaseOrder.CreateFromInquiry(inq, winner, "(2026)HKMW-TEC-PO-0001", now);
        inq.MarkConverted(po1.Id);
        // 模拟双击:聚合已 Closed 且有 PurchaseOrderId,应用层直接返回旧 Id,不再 new 第二张
        Assert.Equal(InquiryStatus.Closed, inq.Status);
        Assert.Equal(po1.Id, inq.PurchaseOrderId!.Value);
    }

    [Fact]
    public void 创建采购订单后必须携带负载正确的领域事件()
    {
        var now = DateTimeOffset.UtcNow;
        var (inq, a, b) = InquiryStatusTests.BuildReadyInquiry(now);
        inq.SubmitQuote(a, OneLine(100m), new Money(200m, "CNY"), new Money(50m, "CNY"), now, null, now);
        inq.SubmitQuote(b, OneLine(110m), Money.Zero("CNY"), null, now, null, now);
        inq.CloseQuoting();
        var winner = inq.PickBestQuote();

        var po = PurchaseOrder.CreateFromInquiry(inq, winner, "(2026)HKMW-TEC-PO-0002", now);

        var evt = Assert.Single(po.DomainEvents);
        var created = Assert.IsType<PurchaseOrderCreatedEvent>(evt);
        Assert.Equal(po.Id, created.PurchaseOrderId);
        Assert.Equal(inq.Id, created.InquiryId);
        Assert.Equal(a, created.SupplierId);
        // 50 × 100 + 运费 200 − 折扣 50 = 5150
        Assert.Equal(5150m, created.TotalAmount.Amount);
        Assert.NotEqual(Guid.Empty, created.EventId);
    }
}

12.3 SQLite 临时文件跨聚合集成测试

csharp 复制代码
// Tests/Integration/GeneratePurchaseOrderIntegrationTests.cs
namespace Pms.Tests.Integration;

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Pms.Domain.Purchasing;
using Pms.Infrastructure;
using Pms.Infrastructure.Purchasing;
using Xunit;

public sealed class GeneratePurchaseOrderIntegrationTests : IDisposable
{
    // 内存 SQLite 必须保持连接打开;文件型临时库则用 Path.GetTempFileName() + 测后删除
    private readonly SqliteConnection _conn =
        new("Data Source=InMemory_M2;Mode=Memory;Cache=Shared");

    public GeneratePurchaseOrderIntegrationTests()
    {
        _conn.Open();
        using var ctx = NewContext();
        ctx.Database.EnsureCreated();
    }

    [Fact]
    public async Task 转单后申请已订数量回写且outbox落一条待发事件()
    {
        var applyId = await SeedApprovedApplyAsync(approvedQty: 100m);

        await using (var arrange = NewContext())
        {
            var now = DateTimeOffset.UtcNow;
            var item = new InquiryItem(1, MaterialId.New(), "350101", "气动滤器", "个", 100m, 50m);
            var inquiry = Inquiry.Create("(2026)HKMW-TEC-RFQ-0001", applyId, "SHIP01",
                now.AddDays(3), now, [item]);
            var supplierA = new SupplierId(Guid.CreateVersion7());
            var supplierB = new SupplierId(Guid.CreateVersion7());
            inquiry.InviteSupplier(supplierA, "某供应商A", "CNY");
            inquiry.InviteSupplier(supplierB, "某供应商B", "CNY");
            inquiry.SendToQuoting(now);
            inquiry.SubmitQuote(supplierA,
                [new QuoteLine(1, item.MaterialId, 50m, new Money(100m, "CNY"), 14, "原厂")],
                Money.Zero("CNY"), null, now, null, now);
            inquiry.SubmitQuote(supplierB,
                [new QuoteLine(1, item.MaterialId, 50m, new Money(110m, "CNY"), 21, "副厂")],
                Money.Zero("CNY"), null, now, null, now);
            inquiry.CloseQuoting();
            var winner = inquiry.PickBestQuote();
            var po = PurchaseOrder.CreateFromInquiry(inquiry, winner,
                "(2026)HKMW-TEC-PO-0001", now);
            inquiry.MarkConverted(po.Id);

            arrange.Set<Inquiry>().Add(inquiry);
            arrange.Set<PurchaseOrder>().Add(po);

            // 模拟事件处理器:同事务回写申请单已订数量(生产中由 UpdateOrderedQtyOnPoCreatedHandler 完成)
            var apply = await arrange.MaterialApplyItems.SingleAsync(x => x.ApplyId == applyId);
            apply.MarkOrdered(50m);

            // 模拟 UoW 的 outbox 翻译:PurchaseOrderCreatedEvent 落一条待发消息
            arrange.OutboxMessages.Add(new OutboxMessage
            {
                Id = po.DomainEvents.OfType<PurchaseOrderCreatedEvent>().Single().EventId,
                EventType = typeof(PurchaseOrderCreatedIntegrationEvent).FullName!,
                JsonPayload = "{}",
                OccurredOn = now
            });
            await arrange.SaveChangesAsync();
        }

        await using (var assert = NewContext())
        {
            var applyItem = await assert.MaterialApplyItems.SingleAsync();
            Assert.Equal(50m, applyItem.OrderedQty);          // 事件处理器同事务回写成功

            var outbox = await assert.OutboxMessages.ToListAsync();
            Assert.Single(outbox);                            // 同事务写入,尚未被后台发布
            Assert.Null(outbox[0].ProcessedAt);
            Assert.Contains("PurchaseOrderCreated", outbox[0].EventType);
        }

        // 并发终局防线:同 (inquiry_id, supplier_id) 再插一张 PO,唯一索引必须报错
        await using (var dup = NewContext())
        {
            await dup.Database.ExecuteSqlRawAsync("""
                INSERT INTO mrp_po
                  (id, order_no, inquiry_id, apply_id, supplier_id, supplier_name,
                   ship_code, currency, status, order_date, freight_amount, freight_currency, total_amount)
                SELECT X'01', 'DUP-NO', inquiry_id, apply_id, supplier_id, 'dup',
                       ship_code, currency, 'Draft', order_date, 0, 'CNY', 0
                FROM mrp_po
                """);
            await Assert.ThrowsAnyAsync<DbUpdateException>(() => dup.SaveChangesAsync());
        }
    }

    /// <summary>测试夹具:直接按 M1 表结构落一张已三级审批通过的申请行。</summary>
    private async Task<MaterialApplyId> SeedApprovedApplyAsync(decimal approvedQty)
    {
        await using var ctx = NewContext();
        var applyId = MaterialApplyId.New();
        // ExecuteSqlInterpolated:插值自动绑定为参数,Guid 以 BLOB 绑定,与 11.1 的表结构一致
        await ctx.Database.ExecuteSqlInterpolatedAsync($"""
            INSERT INTO mrp_applydesc (id, apply_no, status, row_version)
            VALUES ({applyId.Value}, '(2026)HKMW-TEC-MRP-0001', 'Approved', 0);
            """);
        await ctx.Database.ExecuteSqlInterpolatedAsync($"""
            INSERT INTO mrp_apply
              (id, apply_id, material_id, material_name, unit, approved_qty, ordered_qty, line_no)
            VALUES ({Guid.CreateVersion7()}, {applyId.Value},
                    {Guid.CreateVersion7()}, '气动滤器', '个', {approvedQty}, 0, 1);
            """);
        return applyId;
    }

    private PmsDbContext NewContext()
    {
        var options = new DbContextOptionsBuilder<PmsDbContext>()
            .UseSqlite(_conn)
            .Options;
        return new PmsDbContext(options);
    }

    public void Dispose() => _conn.Dispose();
}

说明两点:① MaterialApplyItem.MarkOrdered(50m) 是 M2 在 M1 聚合上新增的方法 ------OrderedQty 私有 set,回写必须走聚合方法,配套迁移给 mrp_apply 加一列 ordered_qty(迁移演进见本系列迁移篇);② 生产编排里第 ① 步回写由 UpdateOrderedQtyOnPoCreatedHandler 完成、outbox 由 UnitOfWork 统一翻译,测试里把它们显式展开是为了让断言点清楚可见。

集成测试要覆盖的三条断言正好对应三个事故:回写数量正确 (事故二的防线活着)、outbox 有事件 (第 8 节的原子性活着)、唯一索引会拦重复单(事故一的终局防线活着)。


13. 踩坑清单、Checklist 与下一篇引子

13.1 踩坑清单(10 条)

  1. 把 PO 建成询价的子实体:报价、收货、发票、付款全塞一个大聚合,每次登记到货都加载 5 家全部报价,并发令牌互相阻塞------收货频繁后 SQLite 写锁排队。聚合边界先按生命周期切,再按一致性规则校验。
  2. 跨聚合持对象引用PurchaseOrder.Inquiry 导航属性一开,序列化带循环、懒加载 N+1、应用层随手 po.Inquiry.Quotes... 改询价状态,边界名存实亡。跨聚合只持强类型 Id。
  3. 事件在聚合构造函数里抛异常导致发不出去 :更隐蔽的变体是 AddDomainEvent 放在 SaveChanges 之后才执行的代码路径里。事件必须与状态迁移在同一个领域方法里挂账。
  4. SaveChanges 成功后再 dispatch 关键事件:进程重启即永久不一致。关键一致性事件要么提交前分发(同事务),要么 Outbox,没有第三种可靠姿势。
  5. 事件处理器里新开 Scope/SaveChanges 嵌套事务:处理器的修改先提交了,外层业务后回滚,产生"没有订单的已订数量"。处理器复用同一个 scoped DbContext,统一由 UoW 提交。
  6. 事件 Id 在发布时才 Guid.NewGuid() :重试一次就生成新 Id,下游无法幂等去重,同一张 PO 的已订数量回写两遍。EventId 在事件创建(聚合方法内)时就固定。
  7. 聚合根集合暴露 public setpublic List<QuoteLine> Lines { get; set; } 让外部绕过 SubmitQuote 直接加行,截止校验、留痕全失效。private readonly List + IReadOnlyList 是底线。
  8. 供应商改价用 UPDATE 物理覆盖 :事后无法回答"上周四下的单按的是哪个价"。二次报价必须把旧行快照进 QuoteRevision;要持久化修订记录就映射为 JSON 列(EF Core ToJson)或自有明细表,别 Ignore 掉还宣称留痕。
  9. 比价静默处理异币种:按某个写死汇率算出"最低价",汇率波动选错供应商。异币种直接拒绝并要求显式折算,把决策留给业务。
  10. 只做应用层幂等不建唯一索引:双击穿过"先查后插"间隙时数据库沉默收下第二张单。幂等永远要落到唯一约束,应用层查询只是友好的快速路径。

13.2 落地 Checklist

  • 报价实体在询价聚合内,PO 是独立聚合,跨聚合只见强类型 Id
  • 每个状态迁移都有守卫方法,枚举以字符串落库,非法状态迁移抛 DomainException
  • 截止后报价、不足两家选定、报价未提交转单、超量下单四类异常都有单测
  • 跨聚合数量约束:船端领域服务同事务;岸基事件 + Outbox,消费端按 EventId 幂等
  • 关键事件提交前分发或 Outbox 同事务落库;处理器复用同一 DbContext
  • mrp_po(inquiry_id, supplier_id)mrp_inquiry(inquiry_no)、幂等表三组唯一索引就位
  • 聚合加载 AsSplitQuery;列表页投影 DTO,不物化聚合
  • Money 全程值对象,异币种运算/比价显式拒绝
  • SQLite(单写者/Mutex)与 SQL Server(并发取批/过滤索引谓词)两套配置与测试都跑过

13.3 下一篇引子

采购订单确认之后,物料还在海上。M3 要解决的是库存交易 :分批发货、到港验收、拒收、入库出库流水(老表 mrp_storetransTransNum > 0 入库、< 0 出库、出库余额不得为负),以及 PO 的 PartialReceived 状态如何由收货流水驱动、断网期间船端先入流水、复联后与岸基库存对账的冲突解决。下一篇:《M3 库存交易与出入库流水:不可变流水、余额物化与船岸对账》 。在此之前,也可能先补一篇聚合设计原则复盘------M1/M2/M3 三个里程碑走下来,"怎么切聚合"已经攒够了正反案例。


14. 官方参考资料

  1. EF Core 复杂类型(ComplexProperty / 值对象映射,含与 Owned 类型的完整对比):https://learn.microsoft.com/en-us/ef/core/modeling/complex-types
  2. EF Core 值转换器(枚举转字符串、强类型 Id):https://learn.microsoft.com/en-us/ef/core/modeling/value-conversions
  3. EF Core 事务(SaveChanges 默认事务行为、跨上下文事务):https://learn.microsoft.com/en-us/ef/core/saving/transactions
  4. 领域事件的设计与实现(领域事件 vs 集成事件、MediatR 分发、提交前/后时机,微软 eShop 指南):https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation
  5. Worker Service 与 BackgroundService 官方文档:https://learn.microsoft.com/en-us/dotnet/core/extensions/workers
  6. .NET 10 兼容性变更:BackgroundService.ExecuteAsync 全程后台线程运行:https://learn.microsoft.com/en-us/dotnet/core/compatibility/extensions/10.0/backgroundservice-executeasync-task
  7. EF Core 单次查询与拆分查询(笛卡尔爆炸与 AsSplitQuery):https://learn.microsoft.com/en-us/ef/core/querying/single-split-queries
  8. EF Core 索引(复合索引、唯一索引、过滤索引):https://learn.microsoft.com/en-us/ef/core/modeling/indexes
  9. ASP.NET Core Minimal APIs 概述:https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview
  10. CAP 官方文档(本地消息表/Outbox 模式、事件总线):https://cap.dotnetcore.xyz/
  11. Martin Fowler:Value Object(值对象相等性语义):https://martinfowler.com/bliki/ValueObject.html
  12. EF Core 8 新特性(复杂类型引入背景:Owned 类型的隐藏键问题):https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-8.0/whatsnew

相关推荐
速易达网络1 小时前
用 Three.js 打造一座会呼吸的海岛:海风之屿飞艇巡航的技术实现
开发语言·javascript·ecmascript
yujunl1 小时前
U9 BE插件的调试
开发语言
牛油果子哥q1 小时前
多模态大模型工程入门:图文Embedding、图文RAG、图片解析、C++多模态接口封装实战
开发语言·c++·embedding
AIFQuant1 小时前
Python股票实时价格告警系统:WebSocket订阅与REST快照实战
开发语言·python·websocket·a股行情
wuyk5552 小时前
从零吃透 MQTT 通信|第 10 章 阿里云 / 腾讯云 MQTT 设备完整对接实战,三元组、签名、设备上云调试
c语言·开发语言·stm32·学习·阿里云·云计算·腾讯云
ocean21032 小时前
2025-2026年Java语言及生态面试高频知识点洞察
java·开发语言·面试
2302_1112 小时前
java时间类型
java·开发语言
小七在进步2 小时前
类和对象(三)
java·开发语言