|
|
@@ -10,13 +10,32 @@ namespace Admin.NET.Plugin.AiDOP.Production;
|
|
|
[NonUnify]
|
|
|
public class ExecutableDailyPlanService : IDynamicApiController, ITransient
|
|
|
{
|
|
|
+ /// <summary>
|
|
|
+ /// S8 Stage-2「产品设计」的 End 事件码。
|
|
|
+ ///
|
|
|
+ /// <para>语义:某张真实销售订单<b>首次</b>有至少一条日计划由「未下达」成功变为
|
|
|
+ /// <c>Status='Y'</c>,即该订单首次具备可执行生产日计划。</para>
|
|
|
+ ///
|
|
|
+ /// <para>Stage-2 End 取本事件 <b>FIRST SUCCESS 的 end_time</b>(不是 start_time)——
|
|
|
+ /// end_time 写在业务状态提交成功之后,与 Stage-1 的 <c>S1_DELIVERY_CONFIRM</c> 同口径。</para>
|
|
|
+ ///
|
|
|
+ /// <para>⚠️ 动作码前缀 <c>S2_</c> 只是本系统的动作命名,<b>不代表 S8 五阶段归属</b>;
|
|
|
+ /// 本事件服务于 S8 的 PRODUCT_DESIGN 阶段。</para>
|
|
|
+ /// </summary>
|
|
|
+ public const string ActionDailyPlanRelease = "S2_DAILY_PLAN_RELEASE";
|
|
|
+
|
|
|
private readonly ISqlSugarClient _db;
|
|
|
private readonly UserManager _userManager;
|
|
|
+ private readonly AidopActionRunLogWriter _runLog;
|
|
|
|
|
|
- public ExecutableDailyPlanService(ISqlSugarClient db, UserManager userManager)
|
|
|
+ public ExecutableDailyPlanService(
|
|
|
+ ISqlSugarClient db,
|
|
|
+ UserManager userManager,
|
|
|
+ AidopActionRunLogWriter runLog)
|
|
|
{
|
|
|
_db = db;
|
|
|
_userManager = userManager;
|
|
|
+ _runLog = runLog;
|
|
|
}
|
|
|
|
|
|
/// <summary>分页列表 GET /api/Production/daily-plan/list</summary>
|
|
|
@@ -136,28 +155,142 @@ public class ExecutableDailyPlanService : IDynamicApiController, ITransient
|
|
|
var account = _userManager.Account ?? "system";
|
|
|
var now = DateTime.Now;
|
|
|
var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
|
|
|
+ if (tenantId <= 0)
|
|
|
+ throw Oops.Oh("无法确定当前租户,日计划下达已拒绝"); // fail closed,绝不按默认租户写业务状态
|
|
|
const string releasedFlag = "Y";
|
|
|
|
|
|
- var n = await _db.Ado.ExecuteCommandAsync(
|
|
|
+ var idList = string.Join(",", ids);
|
|
|
+
|
|
|
+ // ── 1. 只挑【本次真正会发生状态跃迁】的计划行,并当场解析其销售订单身份 ──
|
|
|
+ // 为什么在业务写路径里直读源表:事件要记录的就是"源系统刚刚发生了什么",
|
|
|
+ // 让 release 事务反向依赖 mdp_std_so 才能完成会把方向搞反(Source → MDP → S8,
|
|
|
+ // 不是 Source → MDP → 再决定能不能 release)。S8 的「只读中台」约束管的是消费侧。
|
|
|
+ //
|
|
|
+ // ID 桥(全程 tenant 内,禁止 SalesJob 文本归因):
|
|
|
+ // PeriodSequenceDet.WorkOrds → WorkOrdMaster.WorkOrd
|
|
|
+ // → WorkOrdMaster.BusinessID (= crm_seorderentry.Id)
|
|
|
+ // → crm_seorderentry.seorder_id (= crm_seorder.Id)
|
|
|
+ var transitions = await _db.Ado.SqlQueryAsync<PlanReleaseTransitionRow>(
|
|
|
$"""
|
|
|
- UPDATE PeriodSequenceDet
|
|
|
- SET `Status` = @Flag,
|
|
|
- UpdateUser = @UpdateUser,
|
|
|
- UpdateTime = @UpdateTime
|
|
|
- WHERE RecID IN ({string.Join(",", ids)})
|
|
|
- AND `Domain` = @Domain
|
|
|
- AND tenant_id = @TenantId
|
|
|
+ SELECT p.RecID AS PlanRecId,
|
|
|
+ p.WorkOrds AS WorkOrd,
|
|
|
+ o.Id AS SalesOrderId,
|
|
|
+ o.bill_no AS SalesOrderNo
|
|
|
+ FROM PeriodSequenceDet p
|
|
|
+ JOIN WorkOrdMaster w ON w.tenant_id = p.tenant_id AND w.WorkOrd = p.WorkOrds
|
|
|
+ JOIN crm_seorderentry e ON e.tenant_id = p.tenant_id AND e.Id = w.BusinessID
|
|
|
+ JOIN crm_seorder o ON o.tenant_id = p.tenant_id AND o.Id = e.seorder_id
|
|
|
+ WHERE p.tenant_id = @TenantId
|
|
|
+ AND p.`Domain` = @Domain
|
|
|
+ AND p.RecID IN ({idList})
|
|
|
+ AND IFNULL(p.`Status`, '') <> @Flag
|
|
|
""",
|
|
|
new List<SugarParameter>
|
|
|
{
|
|
|
- new("@Flag", releasedFlag),
|
|
|
- new("@UpdateUser", account),
|
|
|
- new("@UpdateTime", now),
|
|
|
+ new("@TenantId", tenantId),
|
|
|
new("@Domain", domain),
|
|
|
- new("@TenantId", tenantId)
|
|
|
+ new("@Flag", releasedFlag)
|
|
|
});
|
|
|
|
|
|
- return new { message = "下达成功", affected = n };
|
|
|
+ // ── 2. 按销售订单去重:一次 release 内,同一订单最多一条里程碑事件 ──
|
|
|
+ // 不是一条计划行一条事件 —— Stage KPI 的 Grain 是订单,不是工序行。
|
|
|
+ var byOrder = (transitions ?? [])
|
|
|
+ .GroupBy(t => t.SalesOrderId)
|
|
|
+ .Select(g => new
|
|
|
+ {
|
|
|
+ SalesOrderId = g.Key,
|
|
|
+ SalesOrderNo = g.First().SalesOrderNo,
|
|
|
+ PlanRecIds = g.Select(x => x.PlanRecId).Distinct().ToList(),
|
|
|
+ WorkOrders = g.Select(x => x.WorkOrd).Where(x => !string.IsNullOrEmpty(x)).Distinct().ToList()
|
|
|
+ })
|
|
|
+ .ToList();
|
|
|
+
|
|
|
+ // ── 3. 无真实跃迁 → 不写任何里程碑 ──
|
|
|
+ // 重复点击下达(目标行已是 'Y')在这里被拦住:普通重复 UPDATE 不是业务里程碑。
|
|
|
+ if (byOrder.Count == 0)
|
|
|
+ {
|
|
|
+ var repeated = await _db.Ado.ExecuteCommandAsync(
|
|
|
+ $"""
|
|
|
+ UPDATE PeriodSequenceDet
|
|
|
+ SET UpdateUser = @UpdateUser, UpdateTime = @UpdateTime
|
|
|
+ WHERE RecID IN ({idList}) AND `Domain` = @Domain AND tenant_id = @TenantId
|
|
|
+ AND IFNULL(`Status`, '') = @Flag
|
|
|
+ """,
|
|
|
+ new List<SugarParameter>
|
|
|
+ {
|
|
|
+ new("@UpdateUser", account),
|
|
|
+ new("@UpdateTime", now),
|
|
|
+ new("@Domain", domain),
|
|
|
+ new("@TenantId", tenantId),
|
|
|
+ new("@Flag", releasedFlag)
|
|
|
+ });
|
|
|
+ return new { message = repeated > 0 ? "所选计划已处于下达状态,未产生新的下达里程碑" : "没有可下达的计划行",
|
|
|
+ affected = 0, milestoneCount = 0 };
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 4. 先开事件(RUNNING),再做业务写;成败与事务结果严格一致 ──
|
|
|
+ var runLogIds = new Dictionary<long, long>();
|
|
|
+ foreach (var ord in byOrder)
|
|
|
+ runLogIds[ord.SalesOrderId] = await _runLog.StartAsync(
|
|
|
+ ActionDailyPlanRelease, tenantId, "crm_seorder", ord.SalesOrderId, ord.SalesOrderNo);
|
|
|
+
|
|
|
+ int n;
|
|
|
+ try
|
|
|
+ {
|
|
|
+ // 只更新"本次确实要跃迁"的那些行,不把已下达行卷进来
|
|
|
+ var transitionIds = string.Join(",", byOrder.SelectMany(o => o.PlanRecIds).Distinct());
|
|
|
+ n = await _db.Ado.ExecuteCommandAsync(
|
|
|
+ $"""
|
|
|
+ UPDATE PeriodSequenceDet
|
|
|
+ SET `Status` = @Flag,
|
|
|
+ UpdateUser = @UpdateUser,
|
|
|
+ UpdateTime = @UpdateTime
|
|
|
+ WHERE RecID IN ({transitionIds})
|
|
|
+ AND `Domain` = @Domain
|
|
|
+ AND tenant_id = @TenantId
|
|
|
+ AND IFNULL(`Status`, '') <> @Flag
|
|
|
+ """,
|
|
|
+ new List<SugarParameter>
|
|
|
+ {
|
|
|
+ new("@Flag", releasedFlag),
|
|
|
+ new("@UpdateUser", account),
|
|
|
+ new("@UpdateTime", now),
|
|
|
+ new("@Domain", domain),
|
|
|
+ new("@TenantId", tenantId)
|
|
|
+ });
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ // 业务写失败 → 每张订单的事件都必须落 FAILED,绝不允许留下 SUCCESS 里程碑
|
|
|
+ foreach (var ord in byOrder)
|
|
|
+ await _runLog.FailedAsync(runLogIds[ord.SalesOrderId], ex.Message,
|
|
|
+ new { salesOrderId = ord.SalesOrderId, salesOrderNo = ord.SalesOrderNo });
|
|
|
+ throw;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 5. 业务写成功后才 SUCCESS —— end_time 即 Stage-2 End Authority ──
|
|
|
+ foreach (var ord in byOrder)
|
|
|
+ await _runLog.SuccessAsync(runLogIds[ord.SalesOrderId],
|
|
|
+ $"日计划下达:订单 {ord.SalesOrderNo},计划行 {ord.PlanRecIds.Count} 条,工单 {ord.WorkOrders.Count} 个",
|
|
|
+ new
|
|
|
+ {
|
|
|
+ salesOrderId = ord.SalesOrderId,
|
|
|
+ salesOrderNo = ord.SalesOrderNo,
|
|
|
+ releasedPlanCount = ord.PlanRecIds.Count,
|
|
|
+ planRecIds = ord.PlanRecIds,
|
|
|
+ workOrderCount = ord.WorkOrders.Count
|
|
|
+ });
|
|
|
+
|
|
|
+ return new { message = "下达成功", affected = n, milestoneCount = byOrder.Count };
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>日计划行 → 销售订单的归因投影(仅用于 release 事件,走 ID 桥不走单号文本)。</summary>
|
|
|
+ private sealed class PlanReleaseTransitionRow
|
|
|
+ {
|
|
|
+ public long PlanRecId { get; set; }
|
|
|
+ public string? WorkOrd { get; set; }
|
|
|
+ public long SalesOrderId { get; set; }
|
|
|
+ public string? SalesOrderNo { get; set; }
|
|
|
}
|
|
|
|
|
|
/// <summary>工单状态 PATCH POST /api/Production/daily-plan/patch-wo-status</summary>
|