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); /// /// One library drawing released on its own, outside any set: its current record, as read from /// the library, and how many of it. /// public sealed record SetReleaseDrawingLine(DrawingLibraryRecord Drawing, int Quantity); /// /// How many of an item one set of the release asked for, kits included; /// is null for the drawing released on its own. /// 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, and over the drawing released on its own. /// 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; for a drawing that no set /// lists, a member made from its line released on its own. /// 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, then the quantity /// released on its own (with no set). /// 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, IReadOnlyList refusedItems) { 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; RefusedItems = refusedItems; } /// 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; /// /// The items whose library drawings must be edited before the group can be released (all /// of an unassigned group, or those sharing a name), in set order; empty when it can be. /// public IReadOnlyList RefusedItems { get; } } /// /// How drawing set revisions and library drawings released on their own split into nests, /// before anything is downloaded or created. Pure: it reads the members' joined drawing /// records and the drawings' records only. A drawing listed by several sets, or by a set and /// on its own, becomes one item with the quantities summed, each 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, IReadOnlyList drawings, bool includeArchived, IReadOnlyList groups, IReadOnlyList skippedArchived) { Lines = lines; Drawings = drawings; IncludeArchived = includeArchived; Groups = groups; SkippedArchived = skippedArchived; var customers = lines.Select(l => l.Set.Customer).Concat(drawings.Select(d => d.Drawing.Customer)) .Select(c => (c ?? "").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 library drawings released on their own and how many of each, as read from the library. public IReadOnlyList Drawings { get; } /// The customer every set and every drawing on its own 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; a drawing released on its own is listed as a member made from its line. /// 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) => Build(lines, Array.Empty(), includeArchived); /// Sets read from the server, so every member carries its drawing's current record; may be empty. /// Library drawings released on their own, as read from the library; may be empty. public static SetReleasePlan Build(IReadOnlyList lines, IReadOnlyList drawings, bool includeArchived) { ArgumentNullException.ThrowIfNull(lines); ArgumentNullException.ThrowIfNull(drawings); // Both empty makes an empty plan: the release dialog starts that way and fills in. if (lines.Count > SetReleaseRules.MaxSetLines) throw new ArgumentException($"Release at most {SetReleaseRules.MaxSetLines} sets.", nameof(lines)); if (drawings.Count > SetReleaseRules.MaxDrawingLines) throw new ArgumentException($"Release at most {SetReleaseRules.MaxDrawingLines} drawings on their own.", nameof(drawings)); 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 quantity of a set 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. List it once with a larger quantity.", 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)); } var seenDrawings = new HashSet(); foreach (var line in drawings) { var drawing = line?.Drawing ?? throw new ArgumentException("Every drawing line needs a drawing.", nameof(drawings)); if (line.Quantity < 1 || line.Quantity > SetReleaseRules.MaxDrawingQuantity) throw new ArgumentOutOfRangeException(nameof(drawings), line.Quantity, $"The quantity must be between 1 and {SetReleaseRules.MaxDrawingQuantity}."); if (drawing.Id == Guid.Empty || drawing.Revision < 1) throw new ArgumentException("Only a drawing read from the library can be released.", nameof(drawings)); if (!seenDrawings.Add(drawing.Id)) throw new ArgumentException($"Drawing '{drawing.Name}' is listed twice. List it once with a larger quantity.", nameof(drawings)); } // One entry per drawing, in first-listed order (sets, then drawings on their own); each share kept. var skipped = new List(); var order = new List(); var entries = new Dictionary Sources)>(); var shares = lines.SelectMany(line => line.Set.Members.Select(member => (Member: member, Share: new SetReleaseItemSource(line.Set.Id, checked(member.Quantity * line.Quantity))))) .Concat(drawings.Select(line => (Member: new DrawingSetMember { DrawingId = line.Drawing.Id, Quantity = line.Quantity, Drawing = line.Drawing }, Share: new SetReleaseItemSource(null, line.Quantity)))); foreach (var (member, share) in shares) { if (member.Drawing!.Archived && !includeArchived) { skipped.Add(member); continue; } 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(), drawings.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 = ""; IReadOnlyList refused = Array.Empty(); if (unassigned) { refusal = $"{Names(items)} {(items.Count == 1 ? "has" : "have")} no material or thickness. " + "Set them with Fix Drawings or Edit Drawing."; refused = items; } 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 needs unique names, so rename all but one (Fix Drawings or Edit Drawing)."; refused = duplicate.ToList(); } } return new SetReleaseGroup(key, items[0].Drawing, unassigned, items, warnings, refusal, refused); } 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); } }