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);
}
}