using System.Globalization; using OpenNest; namespace OpenNest.Data; /// One set of a release plan: a set read from the server and how many kits of it. public sealed record SetReleasePlanLine(DrawingSetRecord Set, int Quantity); /// How many of an item one set of the release asked for. public sealed record SetReleaseItemSource(Guid SetId, int Quantity); /// /// One library drawing as released: its current record and the quantity summed over every /// set of the release that lists it, kits included. /// public sealed class SetReleaseItem { internal SetReleaseItem(DrawingSetMember member, DrawingLibraryRecord drawing, IReadOnlyList sources) { Member = member; Drawing = drawing; Sources = sources; Quantity = checked(sources.Sum(s => s.Quantity)); } /// The member that first listed the drawing, in set order. public DrawingSetMember Member { get; } /// /// The drawing's current library record, as joined by the server when the sets were read; /// the newest revision when sets read at different times disagree. /// public DrawingLibraryRecord Drawing { get; } /// Each set's share: its member quantity times its kits, in set order. public IReadOnlyList Sources { get; } /// The total over . public int Quantity { get; } /// The drawing is archived and was ticked back in. public bool Archived => Drawing.Archived; } /// /// The members that can share one nest: the same material name, grade, thickness, units /// and cut type (S3). Each group becomes one nest when the set is released. /// public sealed class SetReleaseGroup { internal SetReleaseGroup(string key, DrawingLibraryRecord first, bool unassigned, IReadOnlyList items, IReadOnlyList warnings, string refusal) { Key = key; MaterialName = (first.MaterialName ?? "").Trim(); MaterialGrade = (first.MaterialGrade ?? "").Trim(); Thickness = DrawingLibraryRules.NormalizeThickness(first.Thickness); Units = first.Units; CutType = first.CutType; IsUnassigned = unassigned; Items = items; Warnings = warnings; Refusal = refusal; } /// Identifies the group across rebuilds of the plan (for example after the multiplier changes). public string Key { get; } /// The material as the group's first member spells it. public string MaterialName { get; } public string MaterialGrade { get; } /// Thickness in , rounded to four decimals. public double Thickness { get; } public Units Units { get; } public CutType CutType { get; } /// /// The members whose drawings have no material or no thickness. They cannot be released /// until the library drawings are given one, so the group is always refused. /// public bool IsUnassigned { get; } /// The members, in set order. public IReadOnlyList Items { get; } /// Sum of the released quantities. public int TotalQuantity => Items.Sum(i => i.Quantity); /// Operator-facing notes that do not stop the release. public IReadOnlyList Warnings { get; } /// Why this group cannot be released; empty when it can. public string Refusal { get; } public bool CanRelease => Refusal.Length == 0; } /// /// How one or more drawing set revisions split into nests, before anything is downloaded or /// created. Pure: it reads the members' joined drawing records only. A drawing listed by /// several sets becomes one item with the quantities summed, each set's share kept. Items are /// grouped by material name and grade (compared by ), /// thickness (rounded as the library stores it), units and cut type. Archived drawings are /// skipped unless included (S5). A group is refused when two of its drawings share a name, /// because a nest keys drawings by name (S6). /// public sealed class SetReleasePlan { private SetReleasePlan(IReadOnlyList lines, bool includeArchived, IReadOnlyList groups, IReadOnlyList skippedArchived) { Lines = lines; IncludeArchived = includeArchived; Groups = groups; SkippedArchived = skippedArchived; var customers = lines.Select(l => (l.Set.Customer ?? "").Trim()).Distinct(StringComparer.Ordinal).ToList(); Customer = customers.Count == 1 ? customers[0] : ""; } /// The sets released and how many kits of each, as read from the server. public IReadOnlyList Lines { get; } /// The customer every set names, or blank when they differ. public string Customer { get; } public bool IncludeArchived { get; } /// /// Groups ordered by material, grade, thickness, units and cut type, with the /// unassigned group last. /// public IReadOnlyList Groups { get; } /// Members left out because their drawing is archived and archived drawings were not included. public IReadOnlyList SkippedArchived { get; } /// A set read from the server, so every member carries its drawing's current record. public static SetReleasePlan Build(DrawingSetRecord set, int multiplier, bool includeArchived) => Build(new[] { new SetReleasePlanLine(set, multiplier) }, includeArchived); /// Sets read from the server, so every member carries its drawing's current record. public static SetReleasePlan Build(IReadOnlyList lines, bool includeArchived) { ArgumentNullException.ThrowIfNull(lines); if (lines.Count == 0 || lines.Count > SetReleaseRules.MaxSetLines) throw new ArgumentException($"Release between 1 and {SetReleaseRules.MaxSetLines} sets.", nameof(lines)); var seenSets = new HashSet(); foreach (var line in lines) { var set = line?.Set ?? throw new ArgumentException("Every line needs a set.", nameof(lines)); if (line.Quantity < 1 || line.Quantity > SetReleaseRules.MaxQuantity) throw new ArgumentOutOfRangeException(nameof(lines), line.Quantity, $"The number of kits must be between 1 and {SetReleaseRules.MaxQuantity}."); if (set.Id == Guid.Empty || set.Revision < 1) throw new ArgumentException("Only a set read from the server can be released.", nameof(lines)); if (!seenSets.Add(set.Id)) throw new ArgumentException($"Set '{set.Name}' is listed twice. Release it once with more kits.", nameof(lines)); var members = set.Members ?? throw new ArgumentException("The set has no member list.", nameof(lines)); if (members.Any(m => m?.Drawing is null || m.Drawing.Id != m.DrawingId)) throw new ArgumentException("A set member is missing its library drawing. Reload the set.", nameof(lines)); } // One entry per drawing, in first-listed order; each set's share kept. var skipped = new List(); var order = new List(); var entries = new Dictionary Sources)>(); foreach (var line in lines) { foreach (var member in line.Set.Members) { if (member.Drawing!.Archived && !includeArchived) { skipped.Add(member); continue; } var share = new SetReleaseItemSource(line.Set.Id, checked(member.Quantity * line.Quantity)); if (entries.TryGetValue(member.DrawingId, out var entry)) { entry.Sources.Add(share); if (member.Drawing.Revision > entry.Drawing.Revision) entries[member.DrawingId] = (entry.Member, member.Drawing, entry.Sources); } else { order.Add(member.DrawingId); entries[member.DrawingId] = (member, member.Drawing, new List { share }); } } } var items = order.Select(id => { var (member, drawing, sources) = entries[id]; var unassigned = IsUnassigned(drawing); return (Key: unassigned ? UnassignedKey : GroupKey(drawing), Unassigned: unassigned, Item: new SetReleaseItem(member, drawing, sources)); }).ToList(); var groups = items .GroupBy(i => i.Key, StringComparer.Ordinal) .Select(g => BuildGroup(g.Key, g.First().Unassigned, g.Select(i => i.Item).ToList())) .OrderBy(g => g.IsUnassigned) .ThenBy(g => SharedListNames.Key(g.MaterialName), StringComparer.Ordinal) .ThenBy(g => SharedListNames.Key(g.MaterialGrade), StringComparer.Ordinal) .ThenBy(g => g.Thickness) .ThenBy(g => g.Units) .ThenBy(g => g.CutType) .ToList(); return new SetReleasePlan(lines.ToList(), includeArchived, groups, skipped); } /// /// Draft nests on the server that could merge into: not deleted, /// status Draft, and the group's material, grade and thickness, newest saved first. Units /// and cut type are not in nest metadata, so the release checks them on the downloaded nest. /// public static async Task> FindDraftsAsync(INestRepository nests, SetReleaseGroup group, int limit = 50, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(nests); ArgumentNullException.ThrowIfNull(group); if (!group.CanRelease) return Array.Empty(); var page = await nests.QueryAsync(new NestQuery { Status = NestStatus.Draft, Material = group.MaterialName, MaterialGrade = group.MaterialGrade, Thickness = group.Thickness, Limit = limit, }, cancellationToken); return page.Items; } /// The group key of a drawing that has a material and thickness. public static string GroupKey(DrawingLibraryRecord drawing) { ArgumentNullException.ThrowIfNull(drawing); return string.Join('|', SharedListNames.Key(drawing.MaterialName), SharedListNames.Key(drawing.MaterialGrade), DrawingLibraryRules.NormalizeThickness(drawing.Thickness).ToString("0.####", CultureInfo.InvariantCulture), drawing.Units.ToString(), drawing.CutType.ToString()); } /// The key of the group holding every drawing without a material or thickness. public const string UnassignedKey = "unassigned"; private static bool IsUnassigned(DrawingLibraryRecord drawing) => SharedListNames.Key(drawing.MaterialName).Length == 0 || !(DrawingLibraryRules.NormalizeThickness(drawing.Thickness) > 0); private static SetReleaseGroup BuildGroup(string key, bool unassigned, IReadOnlyList items) { var warnings = items.Where(i => i.Archived) .Select(i => $"'{i.Drawing.Name}' is archived in the library.") .ToList(); var refusal = ""; if (unassigned) { refusal = $"{Names(items)} {(items.Count == 1 ? "has" : "have")} no material or thickness. " + "Set them on the library drawings, then release again."; } else { // Matches LibraryDrawingInsert, which refuses a second drawing with the same name. var duplicate = items.GroupBy(i => i.Drawing.Name, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1); if (duplicate != null) refusal = $"{duplicate.Count()} drawings in this group are named '{duplicate.Key}'. " + "A nest keys drawings by name, so rename all but one in the library first."; } return new SetReleaseGroup(key, items[0].Drawing, unassigned, items, warnings, refusal); } private static string Names(IReadOnlyList items) { var names = items.Take(3).Select(i => $"'{i.Drawing.Name}'").ToList(); if (items.Count > 3) names.Add($"{items.Count - 3} more"); return string.Join(", ", names); } }