|
|
@@ -15,7 +15,7 @@ public class ProductionScheduleGenerationService : ITransient
|
|
|
_kittingCheck = kittingCheck;
|
|
|
}
|
|
|
|
|
|
- public async Task<ScheduleGenerationResult> GenerateAsync(long tenantId, string? domain, string account, bool enableCapacityConstraint = false)
|
|
|
+ public async Task<ScheduleGenerationResult> GenerateAsync(long tenantId, string? domain, string account, bool enableCapacityConstraint = true)
|
|
|
{
|
|
|
var workOrders = await LoadPendingWorkOrdersAsync(tenantId);
|
|
|
if (workOrders.Count == 0)
|
|
|
@@ -29,7 +29,7 @@ public class ProductionScheduleGenerationService : ITransient
|
|
|
string workOrd,
|
|
|
string? domain,
|
|
|
string account,
|
|
|
- bool enableCapacityConstraint = false)
|
|
|
+ bool enableCapacityConstraint = true)
|
|
|
{
|
|
|
var rows = await _db.Ado.SqlQueryAsync<PendingWorkOrderRow>(
|
|
|
"""
|
|
|
@@ -53,7 +53,7 @@ public class ProductionScheduleGenerationService : ITransient
|
|
|
string? domain,
|
|
|
string account,
|
|
|
List<PendingWorkOrderRow> workOrders,
|
|
|
- bool enableCapacityConstraint = false)
|
|
|
+ bool enableCapacityConstraint = true)
|
|
|
{
|
|
|
var totalSw = Stopwatch.StartNew();
|
|
|
var now = DateTime.Now;
|
|
|
@@ -91,7 +91,7 @@ public class ProductionScheduleGenerationService : ITransient
|
|
|
var woSw = Stopwatch.StartNew();
|
|
|
|
|
|
// 0. 先加载工艺路线,无路线的工单跳过齐套检查(性能优化)
|
|
|
- var routings = await LoadRoutingsAsync(tenantId, wo.WorkOrd);
|
|
|
+ var routings = await LoadRoutingsAsync(tenantId, wo.WorkOrd, wo.ItemNum);
|
|
|
if (routings.Count == 0)
|
|
|
{
|
|
|
skipped.Add($"{wo.WorkOrd}(无工艺路线)");
|
|
|
@@ -200,20 +200,31 @@ public class ProductionScheduleGenerationService : ITransient
|
|
|
var slotKey = BuildOccupancyKey(routing.WorkCtr, planDate);
|
|
|
occupiedSlots.Add(slotKey);
|
|
|
|
|
|
+ // 始终解析设备编码和技能编号(供 ScheduleResultOpMaster 写入)
|
|
|
+ var capacityMachineCode = await ResolveMachineCodeAsync(tenantId, routing.Machine, wo.ItemNum, routing.Op);
|
|
|
+ var capacitySkillNo = await ResolveSkillNoAsync(tenantId, routing.Engineer, wo.ItemNum, routing.Op);
|
|
|
+
|
|
|
+ var isNonManual = !string.IsNullOrWhiteSpace(routing.WorkCode)
|
|
|
+ ? routing.WorkCode.Trim().ToUpperInvariant() != "P"
|
|
|
+ : (!string.IsNullOrWhiteSpace(routing.OpType)
|
|
|
+ && routing.OpType.Trim().ToUpperInvariant() != "P");
|
|
|
+
|
|
|
+ // 非人工工序:无设备可用时写入排产异常
|
|
|
+ if (isNonManual && string.IsNullOrWhiteSpace(capacityMachineCode))
|
|
|
+ {
|
|
|
+ await InsertScheduleExceptionAsync(tenantId, wo, domain, "无设备可用",
|
|
|
+ $"工单{wo.WorkOrd}工序{routing.Op}无设备可用");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 始终查询设备可用数量(用于产能约束和兜底上限)
|
|
|
+ var equipCount = await LoadAvailableEquipmentCountAsync(tenantId, capacityMachineCode);
|
|
|
+
|
|
|
// 产能约束:非人工工序,根据设备和人员瓶颈限制排产数量
|
|
|
decimal? effectiveQty = null;
|
|
|
- string? capacityMachineCode = null;
|
|
|
- string? capacitySkillNo = null;
|
|
|
int? capacityDeviceAllocation = null;
|
|
|
decimal? capacityAssignedPersonnel = null;
|
|
|
- if (enableCapacityConstraint
|
|
|
- && !string.IsNullOrWhiteSpace(routing.WorkCode)
|
|
|
- && routing.WorkCode.Trim().ToUpperInvariant() != "P")
|
|
|
+ if (enableCapacityConstraint && isNonManual)
|
|
|
{
|
|
|
- capacityMachineCode = await ResolveMachineCodeAsync(tenantId, routing.Machine, wo.ItemNum, routing.Op);
|
|
|
- capacitySkillNo = await ResolveSkillNoAsync(tenantId, routing.Engineer, wo.ItemNum, routing.Op);
|
|
|
-
|
|
|
- var equipCount = await LoadAvailableEquipmentCountAsync(tenantId, capacityMachineCode);
|
|
|
var personnelCount = await LoadAvailablePersonnelCountAsync(tenantId, capacitySkillNo);
|
|
|
|
|
|
var maxAvailable = Math.Min(equipCount, personnelCount);
|
|
|
@@ -238,19 +249,24 @@ public class ProductionScheduleGenerationService : ITransient
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // 设备占用数兜底:取 MachinesperOp(但不超过实际设备可用数),默认 1
|
|
|
+ if (!capacityDeviceAllocation.HasValue)
|
|
|
+ {
|
|
|
+ var desired = routing.MachinestPerOp > 0 ? routing.MachinestPerOp.Value : 1;
|
|
|
+ capacityDeviceAllocation = equipCount > 0 ? Math.Min(desired, equipCount) : desired;
|
|
|
+ }
|
|
|
+
|
|
|
var recId = await NextPeriodRecIdAsync();
|
|
|
await InsertScheduleRowAsync(
|
|
|
recId, woDomain, routing, wo, planDate, seq, account, now, tenantId,
|
|
|
capacityQty: effectiveQty);
|
|
|
|
|
|
- // 产能约束启用时,将设备/人员分配数据写入 ScheduleResultOpMaster
|
|
|
- if (capacityDeviceAllocation.HasValue || !string.IsNullOrWhiteSpace(capacityMachineCode) || !string.IsNullOrWhiteSpace(capacitySkillNo))
|
|
|
- {
|
|
|
- var resultRecId = await NextScheduleResultOpRecIdAsync();
|
|
|
- await InsertScheduleResultOpAsync(
|
|
|
- resultRecId, woDomain, routing, wo, planDate, seq, now, tenantId,
|
|
|
- capacityMachineCode, capacityDeviceAllocation, capacitySkillNo, capacityAssignedPersonnel);
|
|
|
- }
|
|
|
+ // 始终写入 ScheduleResultOpMaster(基础排产数据 + 可选产能分配数据)
|
|
|
+ var resultRecId = await NextScheduleResultOpRecIdAsync();
|
|
|
+ await InsertScheduleResultOpAsync(
|
|
|
+ resultRecId, woDomain, routing, wo, planDate, seq, now, tenantId,
|
|
|
+ capacityMachineCode, capacityDeviceAllocation, capacitySkillNo, capacityAssignedPersonnel,
|
|
|
+ periodDetRecId: recId);
|
|
|
seq++;
|
|
|
rowCount++;
|
|
|
}
|
|
|
@@ -436,25 +452,69 @@ public class ProductionScheduleGenerationService : ITransient
|
|
|
new SugarParameter("@TenantId", tenantId));
|
|
|
}
|
|
|
|
|
|
- private async Task<List<RoutingRow>> LoadRoutingsAsync(long tenantId, string workOrd)
|
|
|
+ private async Task<List<RoutingRow>> LoadRoutingsAsync(long tenantId, string workOrd, string? itemNum = null)
|
|
|
{
|
|
|
- return await _db.Ado.SqlQueryAsync<RoutingRow>(
|
|
|
+ var routings = await _db.Ado.SqlQueryAsync<RoutingRow>(
|
|
|
"""
|
|
|
- SELECT OP AS Op, ProdLine, WorkCtr, WorkCtr AS Site,
|
|
|
- IFNULL(StdOp, '') AS StdOp,
|
|
|
- IFNULL(Machine, '') AS Machine,
|
|
|
- IFNULL(Engineer, '') AS Engineer,
|
|
|
- IFNULL(RunCrew, 0) AS RunCrew,
|
|
|
- IFNULL(MachBdnRate, 0) AS MachBdnRate,
|
|
|
- IFNULL(MachinesperOp, 0) AS MachinestPerOp,
|
|
|
- IFNULL(RunTime, 0) AS RunTime,
|
|
|
- IFNULL(WorkCode, '') AS WorkCode
|
|
|
- FROM WorkOrdRouting
|
|
|
- WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND IFNULL(IsActive, 0) = 1
|
|
|
- ORDER BY (OP + 0), OP
|
|
|
+ SELECT r.OP AS Op,
|
|
|
+ CASE WHEN IFNULL(r.ProdLine, '') = '' THEN pd.Line ELSE r.ProdLine END AS ProdLine,
|
|
|
+ r.WorkCtr,
|
|
|
+ CASE WHEN IFNULL(r.WorkCtr, '') = '' THEN IFNULL(pd.Site, '') ELSE r.WorkCtr END AS Site,
|
|
|
+ IFNULL(r.StdOp, '') AS StdOp,
|
|
|
+ IFNULL(r.Machine, '') AS Machine,
|
|
|
+ IFNULL(r.Engineer, '') AS Engineer,
|
|
|
+ IFNULL(r.RunCrew, 0) AS RunCrew,
|
|
|
+ IFNULL(r.MachBdnRate, 0) AS MachBdnRate,
|
|
|
+ IFNULL(r.MachinesperOp, 0) AS MachinestPerOp,
|
|
|
+ IFNULL(r.RunTime, 0) AS RunTime,
|
|
|
+ IFNULL(r.WorkCode, '') AS WorkCode,
|
|
|
+ IFNULL(pd.OpType, '') AS OpType,
|
|
|
+ IFNULL(r.Descr, '') AS OpDescr,
|
|
|
+ IFNULL(r.StdSetupTime, 0) AS SetupTime
|
|
|
+ FROM WorkOrdRouting r
|
|
|
+ LEFT JOIN ProdLineDetail pd ON pd.Part = r.ItemNum AND pd.Op = r.OP
|
|
|
+ WHERE r.tenant_id = @TenantId AND r.WorkOrd = @WorkOrd AND IFNULL(r.IsActive, 0) = 1
|
|
|
+ ORDER BY (r.OP + 0), r.OP
|
|
|
""",
|
|
|
new SugarParameter("@TenantId", tenantId),
|
|
|
new SugarParameter("@WorkOrd", workOrd));
|
|
|
+
|
|
|
+ // ── C# 层补充回退:若 SQL JOIN 未命中,用 WorkOrdMaster.ItemNum 直接查 ProdLineDetail ──
|
|
|
+ var needFill = routings.Any(r => string.IsNullOrWhiteSpace(r.ProdLine)
|
|
|
+ || string.IsNullOrWhiteSpace(r.Site) || string.IsNullOrWhiteSpace(r.OpType));
|
|
|
+ if (needFill && !string.IsNullOrWhiteSpace(itemNum))
|
|
|
+ {
|
|
|
+ var fallback = await _db.Ado.SqlQueryAsync<ProdLineFallbackRow>(
|
|
|
+ """
|
|
|
+ SELECT Op, Line, Site, OpType
|
|
|
+ FROM ProdLineDetail
|
|
|
+ WHERE Part = @Part
|
|
|
+ """,
|
|
|
+ new SugarParameter("@Part", itemNum));
|
|
|
+ var fbMap = fallback.ToDictionary(f => f.Op, f => f);
|
|
|
+ foreach (var r in routings)
|
|
|
+ {
|
|
|
+ if (fbMap.TryGetValue(r.Op, out var fb))
|
|
|
+ {
|
|
|
+ if (string.IsNullOrWhiteSpace(r.ProdLine) && !string.IsNullOrWhiteSpace(fb.Line))
|
|
|
+ r.ProdLine = fb.Line;
|
|
|
+ if (string.IsNullOrWhiteSpace(r.Site) && !string.IsNullOrWhiteSpace(fb.Site))
|
|
|
+ r.Site = fb.Site;
|
|
|
+ if (string.IsNullOrWhiteSpace(r.OpType) && !string.IsNullOrWhiteSpace(fb.OpType))
|
|
|
+ r.OpType = fb.OpType;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return routings;
|
|
|
+ }
|
|
|
+
|
|
|
+ private sealed class ProdLineFallbackRow
|
|
|
+ {
|
|
|
+ public int Op { get; set; }
|
|
|
+ public string? Line { get; set; }
|
|
|
+ public string? Site { get; set; }
|
|
|
+ public string? OpType { get; set; }
|
|
|
}
|
|
|
|
|
|
public async Task DeactivateExistingScheduleAsync(long tenantId, string workOrd)
|
|
|
@@ -468,6 +528,15 @@ public class ProductionScheduleGenerationService : ITransient
|
|
|
new SugarParameter("@Now", DateTime.Now),
|
|
|
new SugarParameter("@TenantId", tenantId),
|
|
|
new SugarParameter("@WorkOrd", workOrd));
|
|
|
+
|
|
|
+ // 清理旧的排产结果数据
|
|
|
+ await _db.Ado.ExecuteCommandAsync(
|
|
|
+ """
|
|
|
+ DELETE FROM ScheduleResultOpMaster
|
|
|
+ WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
|
|
|
+ """,
|
|
|
+ new SugarParameter("@TenantId", tenantId),
|
|
|
+ new SugarParameter("@WorkOrd", workOrd));
|
|
|
}
|
|
|
|
|
|
private static string ResolveDomain(string? woDomain, string? requestDomain, long tenantId)
|
|
|
@@ -497,25 +566,109 @@ public class ProductionScheduleGenerationService : ITransient
|
|
|
return max + 1;
|
|
|
}
|
|
|
|
|
|
- /// <summary>查询可用设备数量(仅排产设备)。</summary>
|
|
|
+ /// <summary>
|
|
|
+ /// 查询可用设备数量(仅排产设备)。
|
|
|
+ /// 支持复合编码:"/" 分隔表示任一可用(SUM),"+" 分隔表示需同时使用(MIN)。
|
|
|
+ /// </summary>
|
|
|
private async Task<int> LoadAvailableEquipmentCountAsync(long tenantId, string? machineCode)
|
|
|
{
|
|
|
if (string.IsNullOrWhiteSpace(machineCode)) return 0;
|
|
|
+
|
|
|
+ var code = machineCode.Trim();
|
|
|
+
|
|
|
+ // "+" 分隔:需要所有设备同时使用 → 取 MIN
|
|
|
+ if (code.Contains('+'))
|
|
|
+ {
|
|
|
+ var parts = code.Split('+', StringSplitOptions.RemoveEmptyEntries)
|
|
|
+ .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
|
|
|
+ if (parts.Length == 0) return 0;
|
|
|
+ var counts = new List<int>();
|
|
|
+ foreach (var part in parts)
|
|
|
+ {
|
|
|
+ var c = await CountSingleEquipmentAsync(part);
|
|
|
+ counts.Add(c);
|
|
|
+ }
|
|
|
+ return counts.Count > 0 ? counts.Min() : 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // "/" 分隔:任一设备可用 → 取 SUM
|
|
|
+ if (code.Contains('/'))
|
|
|
+ {
|
|
|
+ var parts = code.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
|
|
+ .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
|
|
|
+ if (parts.Length == 0) return 0;
|
|
|
+ var total = 0;
|
|
|
+ foreach (var part in parts)
|
|
|
+ {
|
|
|
+ total += await CountSingleEquipmentAsync(part);
|
|
|
+ }
|
|
|
+ return total;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 单设备编码
|
|
|
+ return await CountSingleEquipmentAsync(code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>查询单个设备编码的可用设备数量。</summary>
|
|
|
+ private async Task<int> CountSingleEquipmentAsync(string code)
|
|
|
+ {
|
|
|
return await _db.Ado.GetIntAsync(
|
|
|
"""
|
|
|
SELECT COUNT(*) FROM EquipmentList
|
|
|
WHERE InternalEquipmentCode = @Code AND IFNULL(IsSchedulable, 0) = 1
|
|
|
""",
|
|
|
- new SugarParameter("@Code", machineCode.Trim()));
|
|
|
+ new SugarParameter("@Code", code));
|
|
|
}
|
|
|
|
|
|
- /// <summary>查询可用人员数量(持有指定技能的人员)。</summary>
|
|
|
+ /// <summary>
|
|
|
+ /// 查询可用人员数量(持有指定技能的人员)。
|
|
|
+ /// 支持复合编码:"/" 分隔表示任一技能可用(SUM),"+" 分隔表示需同时持有(MIN)。
|
|
|
+ /// </summary>
|
|
|
private async Task<int> LoadAvailablePersonnelCountAsync(long tenantId, string? skillNo)
|
|
|
{
|
|
|
if (string.IsNullOrWhiteSpace(skillNo)) return 0;
|
|
|
+
|
|
|
+ var code = skillNo.Trim();
|
|
|
+
|
|
|
+ // "+" 分隔:需要同时持有所有技能 → 取 MIN
|
|
|
+ if (code.Contains('+'))
|
|
|
+ {
|
|
|
+ var parts = code.Split('+', StringSplitOptions.RemoveEmptyEntries)
|
|
|
+ .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
|
|
|
+ if (parts.Length == 0) return 0;
|
|
|
+ var counts = new List<int>();
|
|
|
+ foreach (var part in parts)
|
|
|
+ {
|
|
|
+ var c = await CountSingleSkillAsync(part);
|
|
|
+ counts.Add(c);
|
|
|
+ }
|
|
|
+ return counts.Count > 0 ? counts.Min() : 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // "/" 分隔:任一技能可用 → 取 SUM
|
|
|
+ if (code.Contains('/'))
|
|
|
+ {
|
|
|
+ var parts = code.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
|
|
+ .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
|
|
|
+ if (parts.Length == 0) return 0;
|
|
|
+ var total = 0;
|
|
|
+ foreach (var part in parts)
|
|
|
+ {
|
|
|
+ total += await CountSingleSkillAsync(part);
|
|
|
+ }
|
|
|
+ return total;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 单技能编码
|
|
|
+ return await CountSingleSkillAsync(code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>查询持有单个技能编码的人员数量。</summary>
|
|
|
+ private async Task<int> CountSingleSkillAsync(string skillNo)
|
|
|
+ {
|
|
|
return await _db.Ado.GetIntAsync(
|
|
|
"SELECT COUNT(*) FROM EmpSkills WHERE SkillNo = @SkillNo",
|
|
|
- new SugarParameter("@SkillNo", skillNo.Trim()));
|
|
|
+ new SugarParameter("@SkillNo", skillNo));
|
|
|
}
|
|
|
|
|
|
/// <summary>解析设备编码:WorkOrdRouting.Machine 为空时回退查 ProdLineDetail。</summary>
|
|
|
@@ -589,19 +742,24 @@ public class ProductionScheduleGenerationService : ITransient
|
|
|
string? machineCode,
|
|
|
int? deviceAllocationCount,
|
|
|
string? skillNo,
|
|
|
- decimal? assignedPersonnelCount)
|
|
|
+ decimal? assignedPersonnelCount,
|
|
|
+ long? periodDetRecId)
|
|
|
{
|
|
|
await _db.Ado.ExecuteCommandAsync(
|
|
|
"""
|
|
|
INSERT INTO ScheduleResultOpMaster (
|
|
|
RecID, `Domain`, WorkOrd, WorkCtr, Line, ItemNum, Op,
|
|
|
- WorkDate, WorkQty, WorkSort,
|
|
|
- InternalEquipmentCode, DeviceAllocationCount, SkillNo, AssignedPersonnelCount,
|
|
|
+ WorkDate, WorkQty, WorkSort, OpDescr, SetupTime, WorkActivateTime,
|
|
|
+ InternalEquipmentCode, DeviceAllocationCount, MoldAllocationCount,
|
|
|
+ SkillNo, AssignedPersonnelCount,
|
|
|
+ PeriodDetRecId,
|
|
|
Remark, CreateTime, tenant_id
|
|
|
) VALUES (
|
|
|
@RecId, @Domain, @WorkOrd, @WorkCtr, @Line, @ItemNum, @Op,
|
|
|
- @WorkDate, @WorkQty, @WorkSort,
|
|
|
- @InternalEquipmentCode, @DeviceAllocationCount, @SkillNo, @AssignedPersonnelCount,
|
|
|
+ @WorkDate, @WorkQty, @WorkSort, @OpDescr, @SetupTime, @WorkActivateTime,
|
|
|
+ @InternalEquipmentCode, @DeviceAllocationCount, @MoldAllocationCount,
|
|
|
+ @SkillNo, @AssignedPersonnelCount,
|
|
|
+ @PeriodDetRecId,
|
|
|
@Remark, @CreateTime, @TenantId
|
|
|
)
|
|
|
""",
|
|
|
@@ -615,10 +773,15 @@ public class ProductionScheduleGenerationService : ITransient
|
|
|
new SugarParameter("@WorkDate", planDate),
|
|
|
new SugarParameter("@WorkQty", wo.QtyOrded ?? 0),
|
|
|
new SugarParameter("@WorkSort", seq),
|
|
|
+ new SugarParameter("@OpDescr", string.IsNullOrWhiteSpace(routing.OpDescr) ? (object)DBNull.Value : routing.OpDescr.Trim()),
|
|
|
+ new SugarParameter("@SetupTime", routing.SetupTime ?? (object)DBNull.Value),
|
|
|
+ new SugarParameter("@WorkActivateTime", now),
|
|
|
new SugarParameter("@InternalEquipmentCode", string.IsNullOrWhiteSpace(machineCode) ? (object)DBNull.Value : machineCode.Trim()),
|
|
|
new SugarParameter("@DeviceAllocationCount", deviceAllocationCount ?? (object)DBNull.Value),
|
|
|
+ new SugarParameter("@MoldAllocationCount", deviceAllocationCount ?? (object)DBNull.Value),
|
|
|
new SugarParameter("@SkillNo", string.IsNullOrWhiteSpace(skillNo) ? (object)DBNull.Value : skillNo.Trim()),
|
|
|
new SugarParameter("@AssignedPersonnelCount", assignedPersonnelCount ?? (object)DBNull.Value),
|
|
|
+ new SugarParameter("@PeriodDetRecId", periodDetRecId ?? (object)DBNull.Value),
|
|
|
new SugarParameter("@Remark", string.Empty),
|
|
|
new SugarParameter("@CreateTime", now),
|
|
|
new SugarParameter("@TenantId", tenantId));
|
|
|
@@ -758,6 +921,9 @@ public class ProductionScheduleGenerationService : ITransient
|
|
|
public int? MachinestPerOp { get; set; }
|
|
|
public decimal? RunTime { get; set; }
|
|
|
public string? WorkCode { get; set; }
|
|
|
+ public string? OpType { get; set; }
|
|
|
+ public string? OpDescr { get; set; }
|
|
|
+ public decimal? SetupTime { get; set; }
|
|
|
}
|
|
|
|
|
|
private sealed class CalendarRow
|