Compare commits

...

11 Commits

Author SHA1 Message Date
Sasanka 1c5b05e315 feat: enhance stage quantity management for running stock inputs
- Updated `UpdateStageQuantitiesRequest` to include `reasonCodeId` for lowering planned quantities below consumed amounts.
- Modified `ProductionRunService` to allow adjustments to planned quantities for running stock inputs, enforcing reason code requirements for decreases.
- Enhanced `StageDrawer` and `StageEditorPanel` components to support reason code selection and display available stock.
- Added utility functions for stock management and improved user feedback for stock adjustments.
2026-08-17 14:30:55 +05:30
ImanThiyanga ee9fa40edf Merge pull request 'initiated migrations fix in warranty' (#46) from fix/migrations-warrenty into Dev
Reviewed-on: #46
2026-08-13 09:42:02 +00:00
Sasanka b1b0fe4bac initiated migrations fix in warranty 2026-08-13 14:52:11 +05:30
ImanThiyanga 8555702a76 Merge branch 'production' into Dev 2026-08-13 06:52:48 +00:00
ImanThiyanga fd95e92eb1 Merge pull request 'feat: add warranty tracking for items and GRN lines' (#44) from warranty-fileds-and-ui-updates into Dev
Reviewed-on: #44
2026-08-13 06:51:10 +00:00
ImanThiyanga 6e008773db Merge pull request 'feat: add warranty tracking for items and GRN lines' (#43) from warranty-fileds-and-ui-updates into production
Reviewed-on: #43
2026-08-13 06:42:51 +00:00
Sasanka 179d5b0803 feat: add warranty tracking for items and GRN lines
- Enhanced ItemService to include warranty and warranty period months in item DTOs.
- Implemented validation for warranty periods in ItemService.
- Updated frontend item detail and new item pages to handle warranty selection and input.
- Added warranty number handling in GRN creation and detail pages.
- Introduced new GrnLineWarrantyNumber entity to capture warranty numbers for received items.
- Created Warranty enum and associated allowed months for warranty coverage.
- Updated validation logic for GRN lines to ensure warranty numbers are captured correctly.
2026-08-13 12:01:11 +05:30
ImanThiyanga 342012a321 Merge pull request 'Dev' (#42) from Dev into production
Reviewed-on: #42
2026-08-12 19:45:10 +00:00
ImanThiyanga ee6ac913f1 Merge branch 'production' into Dev 2026-08-12 19:44:58 +00:00
ImanThiyanga 2661169351 Merge pull request 'pushing migration changes from now -on' (#41) from fix/migration-issue into Dev
Reviewed-on: #41
2026-08-12 07:05:09 +00:00
ImanThiyanga 7219480ca0 Merge pull request 'Merge pull request 'Fix/general ledger v01' (#30) from fix/general-ledger-v01 into Dev' (#31) from Dev into production
Reviewed-on: #31
2026-08-05 07:22:32 +00:00
26 changed files with 8473 additions and 195 deletions
@@ -58,4 +58,11 @@ public class GrnLine
public decimal LineTotal { get; set; }
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
/// <summary>
/// One row per received unit when <see cref="Item.Warranty"/> is
/// <see cref="Enums.Warranty.Warranty"/> — count must equal <see cref="Qty"/>.
/// Empty for a non-warranty item.
/// </summary>
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
}
@@ -0,0 +1,20 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One warranty number captured against a single received unit of a warranty-tracked
/// item (<see cref="Item.Warranty"/> = <see cref="Enums.Warranty.Warranty"/>). A GRN line
/// for such an item must carry exactly <see cref="GrnLine.Qty"/> of these — one per unit —
/// mirroring how a Serial-tracked item requires one serial per unit (docs/10 Part C.3).
/// </summary>
public class GrnLineWarrantyNumber
{
public int GrnLineWarrantyNumberId { get; set; }
public int GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public string WarrantyNo { get; set; } = string.Empty;
/// <summary>Warranty coverage length in months, selected at receipt (e.g. 3/6/12/18).</summary>
public int WarrantyPeriodMonths { get; set; }
}
+3
View File
@@ -38,6 +38,9 @@ public class Item
public StockNature StockNature { get; set; }
public TrackingMode TrackingMode { get; set; }
public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// <summary>Coverage length in months (see <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; null otherwise.</summary>
public int? WarrantyPeriodMonths { get; set; }
public string? TaxClass { get; set; }
/// <summary>
+17
View File
@@ -0,0 +1,17 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Whether an item is sold under warranty (FR-MD-01). Stored as a string, same
/// convention as <see cref="StockNature"/> and <see cref="TrackingMode"/>.
/// </summary>
public enum Warranty
{
NonWarranty,
Warranty
}
/// <summary>Allowed warranty coverage lengths, in months — set once on the item (FR-MD-01).</summary>
public static class WarrantyPeriods
{
public static readonly int[] AllowedMonths = { 3, 6, 12, 18 };
}
+8 -1
View File
@@ -5,12 +5,14 @@ namespace ERPCore.Dtos.Grn;
// Responses (docs/11 §4) --------------------------------------------------------
public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths);
public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int? BinId,
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
HoldStatus HoldStatus, int? BatchId);
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
@@ -58,6 +60,11 @@ public sealed class CreateGrnLineInput
[Range(0, 100)] public decimal VatPct { get; set; }
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
public BatchInput? Batch { get; set; }
/// <summary>
/// Required, one per unit (count must equal <see cref="Qty"/>), when the item is
/// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>). Ignored otherwise.
/// </summary>
public List<string>? WarrantyNumbers { get; set; }
}
public sealed class CreateGrnRequest
+8
View File
@@ -9,6 +9,7 @@ namespace ERPCore.Dtos.Items;
public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
Warranty Warranty, int? WarrantyPeriodMonths,
string? TaxClass, decimal? SalePrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
@@ -29,6 +30,7 @@ public sealed record ItemDetailDto(
int ItemId, string Sku, string Name, string? Description, int CategoryId,
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
Warranty Warranty, int? WarrantyPeriodMonths,
string? TaxClass, decimal? SalePrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
@@ -59,6 +61,9 @@ public sealed class CreateItemRequest
public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
public int? WarrantyPeriodMonths { get; set; }
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
@@ -86,6 +91,9 @@ public sealed class UpdateItemRequest
public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
public int? WarrantyPeriodMonths { get; set; }
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
+11 -2
View File
@@ -99,8 +99,10 @@ public sealed class CreateRunRequest
}
/// <summary>
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
/// (<c>409 STAGE_NOT_EDITABLE</c>).
/// Per-run scaling override (FR-MFG-08). Accepted before the stage starts, and — for a Stock
/// input only — while it is running too: raising <see cref="StageQuantityLine.PlannedQty"/>
/// draws the extra amount from FIFO, lowering it returns the excess (<c>409
/// STAGE_NOT_EDITABLE</c> for every other status).
/// </summary>
public sealed class UpdateStageQuantitiesRequest
{
@@ -116,4 +118,11 @@ public sealed class StageQuantityLine
[Range(0.0001, double.MaxValue)]
public decimal PlannedQty { get; set; }
/// <summary>
/// Required only when this line lowers a running Stock input below what it has already
/// consumed — the excess is returned to stock the same way <c>ReturnLeftoverAsync</c>
/// does, and that always needs a reason (FR-MFG-14). Ignored otherwise.
/// </summary>
public int? ReasonCodeId { get; set; }
}
@@ -53,3 +53,21 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguration<GrnLineWarrantyNumber>
{
public void Configure(EntityTypeBuilder<GrnLineWarrantyNumber> builder)
{
builder.ToTable("grn_line_warranty_numbers");
builder.HasKey(w => w.GrnLineWarrantyNumberId);
builder.Property(w => w.WarrantyNo).IsRequired().HasMaxLength(100);
builder.HasOne(w => w.GrnLine).WithMany(l => l.WarrantyNumbers)
.HasForeignKey(w => w.GrnLineId).OnDelete(DeleteBehavior.Cascade);
// A warranty number entered twice on the same line is almost certainly a typo —
// catch it at the DB, not just client-side.
builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique();
}
}
@@ -36,6 +36,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.TrackingMode)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.Warranty)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(Warranty.NonWarranty);
builder.Property(i => i.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,196 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class warrenty : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Warranty",
table: "items",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "NonWarranty");
migrationBuilder.AddColumn<int>(
name: "WarrantyPeriodMonths",
table: "items",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "grn_line_warranty_numbers",
columns: table => new
{
GrnLineWarrantyNumberId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
GrnLineId = table.Column<int>(type: "integer", nullable: false),
WarrantyNo = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
WarrantyPeriodMonths = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_line_warranty_numbers", x => x.GrnLineWarrantyNumberId);
table.ForeignKey(
name: "FK_grn_line_warranty_numbers_grn_lines_GrnLineId",
column: x => x.GrnLineId,
principalTable: "grn_lines",
principalColumn: "GrnLineId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "sales_returns",
columns: table => new
{
ReturnId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
CustomerId = table.Column<int>(type: "integer", nullable: false),
WarehouseId = table.Column<int>(type: "integer", nullable: false),
ReasonCodeId = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedBy = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_returns", x => x.ReturnId);
table.ForeignKey(
name: "FK_sales_returns_customers_CustomerId",
column: x => x.CustomerId,
principalTable: "customers",
principalColumn: "CustomerId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_returns_reason_codes_ReasonCodeId",
column: x => x.ReasonCodeId,
principalTable: "reason_codes",
principalColumn: "ReasonCodeId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_returns_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_returns_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "sales_return_lines",
columns: table => new
{
ReturnLineId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ReturnId = table.Column<int>(type: "integer", nullable: false),
SalesInvoiceLineId = table.Column<int>(type: "integer", nullable: true),
ItemId = table.Column<int>(type: "integer", nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_return_lines", x => x.ReturnLineId);
table.ForeignKey(
name: "FK_sales_return_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_return_lines_sales_invoice_lines_SalesInvoiceLineId",
column: x => x.SalesInvoiceLineId,
principalTable: "sales_invoice_lines",
principalColumn: "SalesInvoiceLineId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_return_lines_sales_returns_ReturnId",
column: x => x.ReturnId,
principalTable: "sales_returns",
principalColumn: "ReturnId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_grn_line_warranty_numbers_GrnLineId_WarrantyNo",
table: "grn_line_warranty_numbers",
columns: new[] { "GrnLineId", "WarrantyNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_return_lines_ItemId",
table: "sales_return_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_sales_return_lines_ReturnId",
table: "sales_return_lines",
column: "ReturnId");
migrationBuilder.CreateIndex(
name: "IX_sales_return_lines_SalesInvoiceLineId",
table: "sales_return_lines",
column: "SalesInvoiceLineId");
migrationBuilder.CreateIndex(
name: "IX_sales_returns_CreatedBy",
table: "sales_returns",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_sales_returns_CustomerId",
table: "sales_returns",
column: "CustomerId");
migrationBuilder.CreateIndex(
name: "IX_sales_returns_DocNo",
table: "sales_returns",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_returns_ReasonCodeId",
table: "sales_returns",
column: "ReasonCodeId");
migrationBuilder.CreateIndex(
name: "IX_sales_returns_WarehouseId",
table: "sales_returns",
column: "WarehouseId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "grn_line_warranty_numbers");
migrationBuilder.DropTable(
name: "sales_return_lines");
migrationBuilder.DropTable(
name: "sales_returns");
migrationBuilder.DropColumn(
name: "Warranty",
table: "items");
migrationBuilder.DropColumn(
name: "WarrantyPeriodMonths",
table: "items");
}
}
}
@@ -1537,6 +1537,33 @@ namespace ERPCore.Migrations
b.ToTable("grn_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
{
b.Property<int>("GrnLineWarrantyNumberId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnLineWarrantyNumberId"));
b.Property<int>("GrnLineId")
.HasColumnType("integer");
b.Property<string>("WarrantyNo")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("WarrantyPeriodMonths")
.HasColumnType("integer");
b.HasKey("GrnLineWarrantyNumberId");
b.HasIndex("GrnLineId", "WarrantyNo")
.IsUnique();
b.ToTable("grn_line_warranty_numbers", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b =>
{
b.Property<int>("HrDocumentTypeId")
@@ -1685,6 +1712,16 @@ namespace ERPCore.Migrations
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Warranty")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("NonWarranty");
b.Property<int?>("WarrantyPeriodMonths")
.HasColumnType("integer");
b.HasKey("ItemId");
b.HasIndex("BaseUomId");
@@ -3881,6 +3918,87 @@ namespace ERPCore.Migrations
b.ToTable("sales_invoice_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{
b.Property<int>("ReturnId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReturnId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedBy")
.HasColumnType("integer");
b.Property<int>("CustomerId")
.HasColumnType("integer");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<int>("ReasonCodeId")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("WarehouseId")
.HasColumnType("integer");
b.HasKey("ReturnId");
b.HasIndex("CreatedBy");
b.HasIndex("CustomerId");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("ReasonCodeId");
b.HasIndex("WarehouseId");
b.ToTable("sales_returns", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
{
b.Property<int>("ReturnLineId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReturnLineId"));
b.Property<int>("ItemId")
.HasColumnType("integer");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("ReturnId")
.HasColumnType("integer");
b.Property<int?>("SalesInvoiceLineId")
.HasColumnType("integer");
b.HasKey("ReturnLineId");
b.HasIndex("ItemId");
b.HasIndex("ReturnId");
b.HasIndex("SalesInvoiceLineId");
b.ToTable("sales_return_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
{
b.Property<int>("SalesSlipId")
@@ -5713,6 +5831,17 @@ namespace ERPCore.Migrations
b.Navigation("PoLine");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
{
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
.WithMany("WarrantyNumbers")
.HasForeignKey("GrnLineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("GrnLine");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
@@ -6338,6 +6467,67 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode")
.WithMany()
.HasForeignKey("ReasonCodeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("Customer");
b.Navigation("ReasonCode");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.SalesReturn", "Return")
.WithMany("Lines")
.HasForeignKey("ReturnId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine")
.WithMany()
.HasForeignKey("SalesInvoiceLineId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Item");
b.Navigation("Return");
b.Navigation("SalesInvoiceLine");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
@@ -6837,6 +7027,11 @@ namespace ERPCore.Migrations
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
{
b.Navigation("WarrantyNumbers");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
{
b.Navigation("ReorderSettings");
@@ -6911,6 +7106,11 @@ namespace ERPCore.Migrations
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
{
b.Navigation("Lines");
+30 -3
View File
@@ -91,7 +91,7 @@ public sealed class GrnService : IGrnService
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
{
var grn = await _grns.Query().AsNoTracking()
.Include(g => g.Lines)
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
return grn is null ? null : Map(grn);
}
@@ -165,6 +165,7 @@ public sealed class GrnService : IGrnService
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty);
lines.Add(new GrnLine
{
@@ -181,7 +182,8 @@ public sealed class GrnService : IGrnService
VatAmount = vatAmount,
ReceivedValue = receivedValue,
LineTotal = receivedValue + vatAmount,
HoldStatus = input.HoldStatus
HoldStatus = input.HoldStatus,
WarrantyNumbers = warrantyNumbers
});
}
@@ -341,6 +343,30 @@ public sealed class GrnService : IGrnService
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
}
/// <summary>
/// A warranty-tracked item requires exactly one warranty number per received unit —
/// same shape of rule as <see cref="ResolveBatchAsync"/> for batch tracking. The coverage
/// period is not entered at receipt; it is snapshotted from <see cref="Item.WarrantyPeriodMonths"/>,
/// which the item must have been given at creation (<see cref="ItemService"/> enforces that).
/// </summary>
private static List<GrnLineWarrantyNumber> ResolveWarrantyNumbers(Item item, List<string>? numbers, decimal qty)
{
if (item.Warranty != Warranty.Warranty) return new List<GrnLineWarrantyNumber>();
if (item.WarrantyPeriodMonths is null)
throw new DomainException(
ErrorCodes.Validation, $"Item {item.Sku} is under warranty but has no warranty period configured.", 422);
var trimmed = (numbers ?? new List<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList();
if (trimmed.Count != qty)
throw new DomainException(
ErrorCodes.Validation,
$"Item {item.Sku} is under warranty; provide exactly {qty} warranty number(s), got {trimmed.Count}.", 422);
if (trimmed.Distinct(StringComparer.OrdinalIgnoreCase).Count() != trimmed.Count)
throw new DomainException(ErrorCodes.Validation, $"Warranty numbers for item {item.Sku} must be unique.", 422);
return trimmed.Select(n => new GrnLineWarrantyNumber { WarrantyNo = n, WarrantyPeriodMonths = item.WarrantyPeriodMonths.Value }).ToList();
}
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
{
if (poId is null) return;
@@ -382,5 +408,6 @@ public sealed class GrnService : IGrnService
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero),
l.HoldStatus, l.BatchId)).ToList());
l.HoldStatus, l.BatchId,
l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList())).ToList());
}
+25 -2
View File
@@ -85,7 +85,7 @@ public sealed class ItemService : IItemService
.Select(i => new ItemListItemDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status))
.ToListAsync(ct);
@@ -113,6 +113,7 @@ public sealed class ItemService : IItemService
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
var item = new Item
{
@@ -126,6 +127,8 @@ public sealed class ItemService : IItemService
DefaultVendorId = request.DefaultVendorId,
StockNature = request.StockNature,
TrackingMode = request.TrackingMode,
Warranty = request.Warranty,
WarrantyPeriodMonths = warrantyPeriodMonths,
TaxClass = request.TaxClass,
SalePrice = request.SalePrice,
ContentQty = request.ContentQty,
@@ -172,6 +175,7 @@ public sealed class ItemService : IItemService
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
@@ -183,6 +187,8 @@ public sealed class ItemService : IItemService
item.DefaultVendorId = request.DefaultVendorId;
item.StockNature = request.StockNature;
item.TrackingMode = request.TrackingMode;
item.Warranty = request.Warranty;
item.WarrantyPeriodMonths = warrantyPeriodMonths;
item.TaxClass = request.TaxClass;
item.SalePrice = request.SalePrice;
item.ContentQty = request.ContentQty;
@@ -326,6 +332,23 @@ public sealed class ItemService : IItemService
}
}
/// <summary>
/// A warranty-tracked item must declare a coverage period (one of
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
/// any value sent for one is silently dropped rather than trusted from the client.
/// </summary>
private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths)
{
if (warranty != Warranty.Warranty) return null;
if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value))
throw new DomainException(
ErrorCodes.Validation,
$"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422);
return warrantyPeriodMonths;
}
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
{
try
@@ -341,7 +364,7 @@ public sealed class ItemService : IItemService
private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice,
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice,
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
i.Status,
i.ReorderSettings
@@ -294,17 +294,58 @@ public sealed class ProductionRunService : IProductionRunService
stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId)
?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}.");
// FR-MFG-08: editable until the stage starts. Once started, consumption has
// already happened against the planned figures.
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready))
// FR-MFG-08: freely editable before the stage starts. Once it is running, a Stock
// input can still move — raising it draws the extra amount via FIFO (mirroring
// StartStageAsync's initial draw), lowering it returns the excess to stock
// (mirroring ReturnLeftoverAsync) — but every other status stays locked.
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready or ProductionStageStatus.InProgress))
throw new DomainException(ErrorCodes.StageNotEditable,
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts.", 409);
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts or while it is running.", 409);
var now = DateTime.UtcNow;
var balances = new Dictionary<int, decimal>();
var consumed = new List<ConsumedInputDto>();
var returned = new List<object>();
foreach (var line in request.Inputs)
{
var input = stage.Inputs.FirstOrDefault(i => i.RunInputId == line.Id)
?? throw new DomainException(ErrorCodes.Validation,
$"Input {line.Id} is not on stage '{stage.Name}'.", 422);
if (stage.Status == ProductionStageStatus.InProgress && input.Source == StageInputSource.Stock)
{
var item = await _items.Query().AsNoTracking()
.FirstOrDefaultAsync(i => i.ItemId == input.ItemId, token)
?? throw new DomainException(ErrorCodes.Validation,
$"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422);
var plannedBase = _measure.ToPacks(item, line.PlannedQty, input.QtyUnit);
var netConsumed = input.ConsumedQty - input.ReturnedQty;
var delta = plannedBase - netConsumed;
if (delta > 0)
{
var (dto, _) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
consumed.Add(dto);
}
else if (delta < 0)
{
var qty = -delta;
if (line.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired,
$"A reason code is required to lower input '{input.RunInputId}' below what has already been consumed.", 400);
await EnsureProductionReasonAsync(line.ReasonCodeId.Value, token);
var (unitCost, returnValue, _, _) = await ReturnInputAsync(run, input, qty, balances, now, token);
returned.Add(new
{
runInputId = input.RunInputId, qty, unitCost, value = returnValue,
reasonCodeId = line.ReasonCodeId
});
}
}
input.PlannedQty = line.PlannedQty;
}
@@ -319,12 +360,15 @@ public sealed class ProductionRunService : IProductionRunService
// Raising an upstream input's planned quantity means the deliveries that made
// this stage Ready no longer cover it, so it must fall back to Waiting. docs/30
// does not state this; it is the only behaviour consistent with FR-MFG-09.
// No-ops once the stage is running (RecomputeReadiness only moves Waiting/Ready).
RecomputeReadiness(stage);
AddEvent(run, stage, RunStageEventType.QuantityEdit, null, new
{
inputs = request.Inputs.Select(i => new { runInputId = i.Id, plannedQty = i.PlannedQty }),
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty })
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty }),
consumed = consumed.Select(c => new { c.RunInputId, c.ItemId, c.Qty, c.Value }),
returned
});
}, ct);
@@ -333,6 +377,87 @@ public sealed class ProductionRunService : IProductionRunService
return refreshed.Value.Stages.First(s => s.RunStageId == runStageId);
}
/// <summary>
/// FIFO-consumes <paramref name="delta"/> base units of a Stock input, posts the Out
/// ledger entry, and updates the input's ConsumedQty/ConsumedValue. Shared by
/// <see cref="StartStageAsync"/> (initial draw) and <see cref="UpdateStageQuantitiesAsync"/>
/// (raising planned qty on a running stage) so the two never drift apart.
/// </summary>
private async Task<(ConsumedInputDto Dto, StockLedger Entry)> ConsumeInputAsync(
ProductionRun run, RunStageInput input, Item item, decimal delta,
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
{
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, ct);
var value = segments.Sum(s => s.Qty * s.UnitCost);
var unitCost = delta == 0 ? 0m : value / delta;
if (!balances.TryGetValue(item.ItemId, out var balance))
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, ct);
balance -= delta;
balances[item.ItemId] = balance;
var entry = await _fifo.PostLedgerAsync(
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, _currentUser.AuditUserId,
Direction.Out, delta, unitCost, balance,
LedgerSourceTypes.ProductionIssue, run.RunId, now, ct);
input.ConsumedQty += delta;
input.ConsumedValue += value;
var dto = new ConsumedInputDto(
input.RunInputId, item.ItemId, delta, value,
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList());
return (dto, entry);
}
/// <summary>
/// Returns <paramref name="qty"/> base units of a Stock input's consumption back to
/// stock: a new inbound FIFO layer at the input's weighted consumed cost, plus the In
/// ledger entry. Cost-preserving — a full return zeroes the input's contribution to the
/// cost pool exactly, with no rounding crumb left behind (see the full-return branch
/// below). Shared by <see cref="ReturnLeftoverAsync"/> and
/// <see cref="UpdateStageQuantitiesAsync"/> (lowering planned qty on a running stage).
/// </summary>
private async Task<(decimal UnitCost, decimal Value, StockLayer Layer, StockLedger Entry)> ReturnInputAsync(
ProductionRun run, RunStageInput input, decimal qty,
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
{
// Weighted cost actually consumed for THIS input — cost-preserving by construction,
// so what leaves the pool is exactly what entered it.
var weighted = input.ConsumedValue / input.ConsumedQty;
var unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and then
// multiplying lets successive partial returns accumulate drift until the last one
// can exceed ConsumedValue.
var returnValue = input.ReturnedQty + qty == input.ConsumedQty
// Full return: take the exact residual so the input's contribution to the pool
// lands on precisely zero rather than a rounding crumb.
? input.ConsumedValue - input.ReturnedValue
: Math.Round(qty * weighted, 4, MidpointRounding.AwayFromZero);
var layer = await _fifo.CreateInboundLayerAsync(
input.ItemId!.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
qty, unitCost, now, ct);
if (!balances.TryGetValue(input.ItemId.Value, out var balance))
balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, ct);
balance += qty;
balances[input.ItemId.Value] = balance;
// binId stays null: this is raw material going back to general stock, not into the
// run's finished-goods output bin.
var entry = await _fifo.PostLedgerAsync(
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
_currentUser.AuditUserId, Direction.In, qty, unitCost, balance,
LedgerSourceTypes.ProductionReturn, run.RunId, now, ct, valueOverride: returnValue);
input.ReturnedQty += qty;
input.ReturnedValue += returnValue;
return (unitCost, returnValue, layer, entry);
}
// --- stage actions -------------------------------------------------------
public async Task<StartStageResultDto> StartStageAsync(
@@ -350,7 +475,6 @@ public sealed class ProductionRunService : IProductionRunService
$"Stage '{stage.Name}' is {stage.Status}; only a Ready stage can be started.", 409);
var now = DateTime.UtcNow;
var actor = _currentUser.AuditUserId;
// Running balances accumulated per item across the loop: layers written in this
// transaction are invisible to GetOnHandAsync until SaveChanges, so two inputs of
@@ -375,27 +499,9 @@ public sealed class ProductionRunService : IProductionRunService
var delta = plannedBase - input.ConsumedQty;
if (delta <= 0) continue; // rework restart with no increase — nothing to draw
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, token);
var value = segments.Sum(s => s.Qty * s.UnitCost);
var unitCost = delta == 0 ? 0m : value / delta;
if (!balances.TryGetValue(item.ItemId, out var balance))
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, token);
balance -= delta;
balances[item.ItemId] = balance;
var entry = await _fifo.PostLedgerAsync(
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, actor,
Direction.Out, delta, unitCost, balance,
LedgerSourceTypes.ProductionIssue, run.RunId, now, token);
var (dto, entry) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
ledgerRefs.Add(entry);
input.ConsumedQty += delta;
input.ConsumedValue += value;
consumed.Add(new ConsumedInputDto(
input.RunInputId, item.ItemId, delta, value,
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList()));
consumed.Add(dto);
}
stage.Status = ProductionStageStatus.InProgress;
@@ -638,36 +744,9 @@ public sealed class ProductionRunService : IProductionRunService
var now = DateTime.UtcNow;
var stage = run.Stages.First(s => s.RunStageId == input.RunStageId);
// Weighted cost actually consumed for THIS input — cost-preserving by
// construction, so what leaves the pool is exactly what entered it.
var weighted = input.ConsumedValue / input.ConsumedQty;
unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
returnedQty = request.Qty;
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and
// then multiplying lets successive partial returns accumulate drift until the
// last one can exceed ConsumedValue.
returnValue = input.ReturnedQty + request.Qty == input.ConsumedQty
// Full return: take the exact residual so the input's contribution to the
// pool lands on precisely zero rather than a rounding crumb.
? input.ConsumedValue - input.ReturnedValue
: Math.Round(request.Qty * weighted, 4, MidpointRounding.AwayFromZero);
layer = await _fifo.CreateInboundLayerAsync(
input.ItemId.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
request.Qty, unitCost, now, token);
var balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, token) + request.Qty;
// binId stays null: this is raw material going back to general stock, not into the
// run's finished-goods output bin.
entry = await _fifo.PostLedgerAsync(
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
_currentUser.AuditUserId, Direction.In, request.Qty, unitCost, balance,
LedgerSourceTypes.ProductionReturn, run.RunId, now, token, valueOverride: returnValue);
input.ReturnedQty += request.Qty;
input.ReturnedValue += returnValue;
(unitCost, returnValue, layer, entry) =
await ReturnInputAsync(run, input, request.Qty, new Dictionary<int, decimal>(), now, token);
var consumedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
var returnedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
@@ -14,12 +14,14 @@ import {
RunStage,
RunStageInput,
RunStageOutput,
StageQtyUnit,
StageQuantityLine,
TransferLine,
} from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
import { baseUomLabel, contentUnitLabel, uomLabel } from "@/lib/uom-label"
import { ReasonCode } from "@/types/stock"
import { OnHand, ReasonCode } from "@/types/stock"
import { stockApi } from "@/lib/api/stock"
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
@@ -60,6 +62,22 @@ function minutesSince(iso: string, now: number): number {
return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))
}
/**
* Mirrors the server's `IItemMeasure.ToPacks` (Services/Stock/ItemMeasure.cs) closely enough to
* tell whether an edited planned qty is a raise or a lower — a UI-only estimate to decide when
* to ask for a reason up front. The server always recomputes it authoritatively.
*/
function toPacksClient(items: ItemListItem[], itemId: number | null, qty: number, unit: StageQtyUnit): number {
if (unit === "Pack" || itemId === null) return qty
const contentBaseQty = items.find((i) => i.itemId === itemId)?.contentBaseQty
if (!contentBaseQty) return qty
// Rounded the same way as the server's `ToPacks` (4dp, half away from zero) — ConsumedQty was
// stored at that rounded value, so comparing against the raw division here would flag an
// untouched Content-unit input (300 ml / 175 ml = 1.714285714... vs the stored 1.7143) as a
// decrease and demand a reason code nothing actually changed.
return Math.round((qty / contentBaseQty) * 10000) / 10000
}
function StatusPill({ status }: { status: StageStatus }) {
const color = STAGE_STATUS_COLOR[status]
return (
@@ -102,9 +120,13 @@ export function StageDrawer({
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Editable planned quantities (Ready only) keyed by run input/output id.
// Editable planned quantities keyed by run input/output id — Ready only, plus a running
// Stock input's planned qty (raises draw more via FIFO, lowers return the excess).
const [plannedInputs, setPlannedInputs] = useState<Record<number, string>>({})
const [plannedOutputs, setPlannedOutputs] = useState<Record<number, string>>({})
// Reason code for a running Stock input whose planned qty is being lowered below what it
// has already consumed — required by the server the same way a leftover return is.
const [inputReasonCode, setInputReasonCode] = useState<Record<number, number | null>>({})
// Complete-form state.
const [produced, setProduced] = useState<Record<number, string>>({})
@@ -135,6 +157,7 @@ export function StageDrawer({
setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId])))
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
setInputReasonCode({})
setError(null)
idempotencyKey.current = crypto.randomUUID()
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -212,7 +235,11 @@ export function StageDrawer({
function saveQuantities() {
const inputs: StageQuantityLine[] = stage!.inputs
.map((i) => ({ id: i.runInputId, plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty) }))
.map((i) => ({
id: i.runInputId,
plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty),
reasonCodeId: inputReasonCode[i.runInputId] ?? null,
}))
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
const outputs: StageQuantityLine[] = stage!.outputs
.map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) }))
@@ -284,6 +311,21 @@ export function StageDrawer({
const scrapReasons = reasonCodes.filter((r) => r.context === "Production")
// A running Stock input's planned qty can still move: raising it draws more via FIFO,
// lowering it returns the excess and — same rule a leftover return already enforces
// server-side — needs a reason. Upstream WIP has no stock behind it, so it stays read-only.
const runningStockInputs = stage.inputs.filter((i) => i.source === "Stock")
const isInputDecrease = (input: RunStageInput) => {
const plannedBase = toPacksClient(
items, input.itemId, Number(plannedInputs[input.runInputId] ?? input.plannedQty), input.qtyUnit
)
return Number.isFinite(plannedBase) && plannedBase < input.consumedQty - input.returnedQty
}
const missingInputReasons =
stage.status === "InProgress"
? runningStockInputs.filter((i) => isInputDecrease(i) && !inputReasonCode[i.runInputId]).map((i) => itemName(i.itemId))
: []
const missingFields = missingRequiredFields(stage.fieldDefs, fieldValues)
const missingScrapReasons = stage.outputs
.filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId])
@@ -339,6 +381,7 @@ export function StageDrawer({
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
unitLabel={inputUnit}
warehouseId={run.warehouseId}
/>
))}
</section>
@@ -424,6 +467,39 @@ export function StageDrawer({
</section>
)}
{stage.status === "InProgress" && runningStockInputs.length > 0 && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Adjust input quantities</h3>
<p className="text-sm text-muted-foreground">
Raising a quantity draws the extra amount from stock; lowering it returns the excess — pick a
reason for any decrease.
</p>
{runningStockInputs.map((input) => (
<RunningStockInputRow
key={input.runInputId}
input={input}
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
unitLabel={inputUnit}
warehouseId={run.warehouseId}
decreasing={isInputDecrease(input)}
reasonCodeId={inputReasonCode[input.runInputId] ?? null}
onReasonChange={(id) => setInputReasonCode((prev) => ({ ...prev, [input.runInputId]: id }))}
scrapReasons={scrapReasons}
/>
))}
{missingInputReasons.length > 0 && (
<p className="text-sm text-destructive">
Pick a reason for lowering: {missingInputReasons.join(", ")}.
</p>
)}
<Button variant="outline" onClick={saveQuantities} disabled={busy || missingInputReasons.length > 0}>
Save quantities
</Button>
</section>
)}
{stage.status === "InProgress" && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Record output</h3>
@@ -662,6 +738,56 @@ export function StageDrawer({
}
/** Split out only because the input card has three mutually exclusive quantity presentations. */
/**
* Live available-stock lookup for a Stock input — shared by the Ready-state InputCard and the
* running-stage "Adjust input quantities" row, so the two never fetch it differently.
*/
function useAvailableStock(itemId: number | null, warehouseId: number, enabled: boolean) {
const [onHand, setOnHand] = useState<OnHand | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!enabled || itemId === null) {
setOnHand(null)
setError(null)
return
}
setLoading(true)
setError(null)
stockApi
.onHand(itemId, warehouseId)
.then(setOnHand)
.catch((err) => {
setOnHand(null)
setError(errorMessage(err))
})
.finally(() => setLoading(false))
}, [enabled, itemId, warehouseId])
return { onHand, loading, error }
}
/** `null` while loading/unavailable, otherwise the "Available: X" line InputCard and
* RunningStockInputRow both show under an editable Planned qty. */
function AvailableStockLine({
loading, error, onHand,
}: {
loading: boolean
error: string | null
onHand: OnHand | null
}) {
return (
<p className="text-right text-xs text-muted-foreground">
{loading
? "Checking available stock…"
: error
? "Available stock unknown"
: onHand
? `Available: ${fmt(onHand.available)}`
: null}
</p>
)
}
function InputCard({
input,
editable,
@@ -669,6 +795,7 @@ function InputCard({
onValueChange,
itemName,
unitLabel,
warehouseId,
}: {
input: RunStageInput
editable: boolean
@@ -676,10 +803,16 @@ function InputCard({
onValueChange: (value: string) => void
itemName: (id: number | null) => string
unitLabel: (input: RunStageInput) => string
warehouseId: number
}) {
const isUpstream = input.source === "Upstream"
const short = isUpstream && input.deliveredQty < input.plannedQty
// Available stock is only meaningful while the planned qty is still editable (Ready) and
// for a real stock item — Upstream inputs are WIP handed off from a parent stage, not stock.
const { onHand, loading: onHandLoading, error: onHandError } =
useAvailableStock(input.itemId, warehouseId, editable && !isUpstream)
return (
<div className="rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
@@ -691,17 +824,22 @@ function InputCard({
<div className="mt-2 flex flex-col gap-1">
{editable ? (
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
<>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
{!isUpstream && input.itemId !== null && (
<AvailableStockLine loading={onHandLoading} error={onHandError} onHand={onHand} />
)}
</>
) : (
<Row label="Planned">{fmt(input.plannedQty)}</Row>
)}
@@ -735,6 +873,69 @@ function InputCard({
)
}
/** A running Stock input's editable Planned qty, with the same live "Available: X" lookup
* InputCard shows before the stage starts, plus the reason picker a lowered qty requires. */
function RunningStockInputRow({
input,
value,
onValueChange,
itemName,
unitLabel,
warehouseId,
decreasing,
reasonCodeId,
onReasonChange,
scrapReasons,
}: {
input: RunStageInput
value: string
onValueChange: (value: string) => void
itemName: (id: number | null) => string
unitLabel: (input: RunStageInput) => string
warehouseId: number
decreasing: boolean
reasonCodeId: number | null
onReasonChange: (id: number) => void
scrapReasons: ReasonCode[]
}) {
const { onHand, loading, error } = useAvailableStock(input.itemId, warehouseId, true)
return (
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium text-foreground">{itemName(input.itemId)}</p>
<span className="shrink-0 text-xs text-muted-foreground">{unitLabel(input)}</span>
</div>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
<AvailableStockLine loading={loading} error={error} onHand={onHand} />
{decreasing && (
<Select<number> value={reasonCodeId} onValueChange={(v) => v && onReasonChange(v)}>
<SelectTrigger className="h-9! w-full text-sm">
<SelectValue placeholder="Reason for lowering (required)" />
</SelectTrigger>
<SelectContent>
{scrapReasons.map((r) => (
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-sm">
{r.code} — {r.description}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}
/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */
function CustomFields({
stage,
@@ -27,6 +27,10 @@ function slugify(label: string) {
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
}
function fmtStock(available: number | undefined) {
return (available ?? 0).toLocaleString(undefined, { maximumFractionDigits: 4 })
}
const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"]
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
@@ -128,6 +132,7 @@ export function StageEditorPanel({
upstreamOptions,
items,
uoms,
stockByItem,
readOnly,
onChange,
onDelete,
@@ -138,6 +143,8 @@ export function StageEditorPanel({
upstreamOptions: UpstreamOutputOption[]
items: ItemListItem[]
uoms: Uom[]
/** Available stock per item, summed across every warehouse — templates aren't warehouse-scoped. */
stockByItem: Map<number, number>
readOnly: boolean
onChange: (patch: Partial<StageNodeData>) => void
onDelete: () => void
@@ -239,18 +246,19 @@ export function StageEditorPanel({
<Field>
<FieldLabel>Role label</FieldLabel>
<Input
<Select<string>
value={data.roleLabel}
disabled={readOnly}
onChange={(e) => onChange({ roleLabel: e.target.value })}
placeholder="e.g. QA"
list="role-suggestions"
/>
<datalist id="role-suggestions">
{ROLE_SUGGESTIONS.map((r) => (
<option key={r} value={r} />
))}
</datalist>
onValueChange={(v) => v && onChange({ roleLabel: v })}
>
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick a role" />
</SelectTrigger>
<SelectContent>
{ROLE_SUGGESTIONS.map((r) => (
<SelectItem key={r} value={r} className="text-sm">{r}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
@@ -312,6 +320,9 @@ export function StageEditorPanel({
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
<span className="text-muted-foreground">
· Stock: {fmtStock(stockByItem.get(i.itemId))}
</span>
</SelectItem>
))}
</SelectContent>
@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { stockApi } from "@/lib/api/stock"
import { errorMessage } from "@/lib/error-map"
import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
@@ -172,6 +173,7 @@ function TemplateBuilderContent() {
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [stockByItem, setStockByItem] = useState<Map<number, number>>(new Map())
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
@@ -229,10 +231,21 @@ function TemplateBuilderContent() {
}, [isNew, templateId, load])
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list({ pageSize: 200 })])
.then(([itemRes, uomRes]) => {
Promise.all([
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list({ pageSize: 200 }),
stockApi.onHandList({ pageSize: 200 }),
])
.then(([itemRes, uomRes, onHandRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
// Templates aren't tied to a warehouse, so the picker shows availability summed across
// every warehouse the item holds stock in.
const byItem = new Map<number, number>()
for (const row of onHandRes.items) {
byItem.set(row.itemId, (byItem.get(row.itemId) ?? 0) + row.available)
}
setStockByItem(byItem)
})
.catch((err) => toast.error("Could not load items and UOMs", errorMessage(err)))
}, [])
@@ -804,6 +817,7 @@ function TemplateBuilderContent() {
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={items}
stockByItem={stockByItem}
uoms={uoms}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
@@ -12,7 +12,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Item, MeasureUnit, StockNature, TrackingMode } from "@/types/master-data"
import { Item, MeasureUnit, StockNature, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
@@ -54,6 +54,9 @@ export default function ItemDetailPage() {
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
// Not editable here — carried through unchanged so a save doesn't silently reset it.
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
const [taxClass, setTaxClass] = useState("")
// Raw string: an empty box means "no content size", which is not the same as 0.
const [contentQty, setContentQty] = useState("")
@@ -80,6 +83,8 @@ export default function ItemDetailPage() {
setDefaultVendorId(data.defaultVendorId)
setStockNature(data.stockNature)
setTrackingMode(data.trackingMode)
setWarranty(data.warranty)
setWarrantyPeriodMonths(data.warrantyPeriodMonths)
setTaxClass(data.taxClass ?? "")
setContentQty(data.contentQty === null ? "" : String(data.contentQty))
setContentUnit(data.contentUnit)
@@ -123,7 +128,7 @@ export default function ItemDetailPage() {
item.itemId,
{
sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths,
taxClass: taxClass || null,
contentQty: contentQty.trim() ? Number(contentQty) : null,
contentUnit: contentQty.trim() ? contentUnit : null,
@@ -20,7 +20,18 @@ import {
validateVariantPrices,
} from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Brand, Category, ItemType, MeasureUnit, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
import {
Brand,
Category,
ItemType,
MeasureUnit,
ProductConfig,
StockNature,
SubCategory,
WARRANTY_PERIOD_MONTHS_OPTIONS,
Warranty,
WarrantyPeriodMonths,
} from "@/types/master-data"
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
@@ -125,6 +136,10 @@ export default function NewItemPage() {
const [pricesByKey, setPricesByKey] = useState<Record<string, string>>({})
const [priceErrors, setPriceErrors] = useState<Record<string, string>>({})
// Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override.
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
@@ -340,6 +355,9 @@ export default function NewItemPage() {
if (measurableCheckedCount > 1) {
nextErrors.measurable = "Only one measurement dimension can be used at a time."
}
if (warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period"
}
// Should never fire — addValue is the real gate — so it catches stale state only.
const contentSweep = validateVariantContent(
variants.map((v) => v.key),
@@ -381,6 +399,8 @@ export default function NewItemPage() {
baseUomId,
stockNature,
trackingMode: "None",
warranty,
warrantyPeriodMonths: warranty === "Warranty" ? warrantyPeriodMonths : null,
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
// Each variant carries its OWN size when a measurement dimension supplied one;
// otherwise the shared form-level pair, which is correct when the varying dimension
@@ -672,6 +692,64 @@ export default function NewItemPage() {
)}
</div>
{/* Warranty (FR-MD-01). Frontend-only toggle, mirrors the Sale price bar above. */}
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
Mark whether every generated variant is sold under warranty.
</p>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Non-Warranty
</button>
<button
type="button"
onClick={() => setWarranty("Warranty")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Warranty
</button>
</div>
{warranty === "Warranty" && (
<div className="flex flex-col gap-1.5">
<Label>Warranty period</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
</div>
)}
</div>
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
{config?.itemTypesEnabled && (
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils"
import { ConfirmGrnResponse, Grn } from "@/types/grn"
import { Bin, ItemListItem, Uom } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -176,7 +177,20 @@ export default function GrnDetailPage() {
const item = itemFor(line.itemId)
return (
<TableRow key={line.grnLineId}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-2">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
{item?.warranty === "Warranty" && <Badge variant="outline" className="text-xs">Warranty</Badge>}
</div>
{line.warrantyNumbers.length > 0 && (
<p
className="mt-1 text-xs text-muted-foreground"
title={line.warrantyNumbers.map((w) => `${w.warrantyNo} (${w.warrantyPeriodMonths}mo)`).join(", ")}
>
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p>
)}
</TableCell>
<TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
@@ -13,12 +13,13 @@ import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { validateLine, splitSerials, grnHeaderSchema } from "@/lib/validations/grn"
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
import { cn } from "@/lib/utils"
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -27,8 +28,41 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
type Mode = "po" | "direct"
type LineTab = "lines" | "warranty"
/** Whole units a line's qty represents — one warranty number is captured per unit. */
function unitCount(qty: string): number {
const n = Math.floor(Number(qty))
return Number.isFinite(n) && n > 0 ? n : 0
}
/** One-shot hint next to the warranty button — shows on mount, then fades out on its own. */
function WarrantyHintBubble() {
const [visible, setVisible] = useState(true)
useEffect(() => {
const timer = setTimeout(() => setVisible(false), 3000)
return () => clearTimeout(timer)
}, [])
return (
<div
role="status"
className={cn(
"absolute bottom-full right-0 z-10 mb-2 flex w-max max-w-xs items-center gap-2 rounded-xl border border-info/40 bg-white p-3 shadow-xl transition-opacity duration-700",
visible ? "opacity-100" : "pointer-events-none opacity-0"
)}
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-info/15 text-info">
<ShieldCheck className="size-4" />
</span>
<span className="min-w-0 text-sm font-semibold text-info">Add warranty numbers for this item</span>
</div>
)
}
interface DraftLine {
key: string
@@ -42,9 +76,8 @@ interface DraftLine {
discountPct: string
vatPct: string
holdStatus: HoldStatus
batchNo: string
expiryDate: string
serialNumbersText: string
/** One entry per received unit, index-aligned; only meaningful when the item is warranty-tracked. */
warrantyNumbers: string[]
}
/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */
@@ -77,9 +110,7 @@ function emptyLine(): DraftLine {
discountPct: "0",
vatPct: "0",
holdStatus: "Available",
batchNo: "",
expiryDate: "",
serialNumbersText: "",
warrantyNumbers: [],
}
}
@@ -101,6 +132,7 @@ export default function NewGrnPage() {
const [poId, setPoId] = useState<number | null>(null)
const [poLoading, setPoLoading] = useState(false)
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
const [lineTab, setLineTab] = useState<LineTab>("lines")
const [headerError, setHeaderError] = useState<string | null>(null)
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
@@ -174,9 +206,7 @@ export default function NewGrnPage() {
discountPct: "0",
vatPct: "0",
holdStatus: "Available",
batchNo: "",
expiryDate: "",
serialNumbersText: "",
warrantyNumbers: [],
})
)
)
@@ -214,6 +244,24 @@ export default function NewGrnPage() {
return items?.find((i) => i.itemId === itemId) ?? null
}
function setWarrantyNumberAt(lineKey: string, index: number, value: string) {
setLines((prev) =>
prev.map((l) => {
if (l.key !== lineKey) return l
const next = [...l.warrantyNumbers]
while (next.length <= index) next.push("")
next[index] = value
return { ...l, warrantyNumbers: next }
})
)
}
// Lines whose item is warranty-tracked and carry at least one unit — these are the rows
// the "Warranty numbers" tab needs to capture, one input per unit.
const warrantyLines = lines
.map((l) => ({ line: l, item: itemFor(l.itemId), units: unitCount(l.qty) }))
.filter((w) => w.item?.warranty === "Warranty" && w.units > 0)
async function handleSubmit() {
setSubmitError(null)
setHeaderError(null)
@@ -242,26 +290,31 @@ export default function NewGrnPage() {
const nextLineErrors: Record<string, Record<string, string>> = {}
for (const line of lines) {
const item = itemFor(line.itemId)
const errors = validateLine({
itemId: line.itemId,
qty: line.qty,
unitCost: line.unitCost,
discountPct: line.discountPct,
vatPct: line.vatPct,
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
batchNo: line.batchNo,
serialNumbersText: line.serialNumbersText,
warranty: item?.warranty ?? null,
warrantyNumbers: line.warrantyNumbers,
})
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
}
setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) {
// Jump to whichever tab actually shows the offending field(s).
const onlyWarrantyErrors = Object.values(nextLineErrors).every(
(errs) => Object.keys(errs).every((k) => k === "warrantyNumbers")
)
setLineTab(onlyWarrantyErrors ? "warranty" : "lines")
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None"
const item = itemFor(l.itemId)
return {
poLineId: l.poLineId,
itemId: l.itemId as number,
@@ -271,8 +324,10 @@ export default function NewGrnPage() {
discountPct: Number(l.discountPct) || 0,
vatPct: Number(l.vatPct) || 0,
holdStatus: l.holdStatus,
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
warrantyNumbers:
item?.warranty === "Warranty"
? l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
: null,
}
})
@@ -432,23 +487,49 @@ export default function NewGrnPage() {
</div>
</div>
<div className="flex w-fit gap-2 rounded-full border border-input bg-muted/40 p-1">
<Button
type="button"
size="sm"
variant={lineTab === "lines" ? "default" : "ghost"}
className="rounded-full"
onClick={() => setLineTab("lines")}
>
Lines
</Button>
<Button
type="button"
size="sm"
variant={lineTab === "warranty" ? "default" : "ghost"}
className="rounded-full"
onClick={() => setLineTab("warranty")}
>
Warranty numbers
{warrantyLines.length > 0 && (
<Badge variant="outline" className="ml-1.5 h-5 px-1.5 text-xs">
{warrantyLines.reduce((sum, w) => sum + w.units, 0)}
</Badge>
)}
</Button>
</div>
{poLoading && <Skeleton className="h-24 w-full" />}
{!poLoading && lines.length > 0 && (
{!poLoading && lineTab === "lines" && lines.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
<TableHead className="h-12 w-88 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-6 px-1 text-sm">Action</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
@@ -460,40 +541,47 @@ export default function NewGrnPage() {
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
<div className="flex h-11 items-center gap-2 text-sm">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
{item?.warranty === "Warranty" && (
<Badge variant="outline" className="text-xs">Warranty</Badge>
)}
</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.sku} — {i.name}
{i.warranty === "Warranty" ? " (Warranty)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
{item?.warranty === "Warranty" && (
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
)}
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<div className="flex h-11 items-center text-base text-muted-foreground">
<div className="flex h-11 items-center text-sm text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectTrigger className="h-11! w-full text-sm">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{bins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-base">
<SelectItem key={b.binId} value={b.binId} className="text-sm">
{b.code}
</SelectItem>
))}
@@ -508,7 +596,7 @@ export default function NewGrnPage() {
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
className="h-11 text-sm"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
@@ -520,7 +608,7 @@ export default function NewGrnPage() {
value={line.unitCost}
aria-invalid={!!errors.unitCost}
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
className="h-11 text-base"
className="h-11 text-sm"
/>
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
@@ -538,7 +626,7 @@ export default function NewGrnPage() {
value={line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
className="h-11 text-base"
className="h-11 text-sm"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</TableCell>
@@ -551,7 +639,7 @@ export default function NewGrnPage() {
value={line.vatPct}
aria-invalid={!!errors.vatPct}
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
className="h-11 text-base"
className="h-11 text-sm"
/>
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
</TableCell>
@@ -573,51 +661,44 @@ export default function NewGrnPage() {
value={line.holdStatus}
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
>
<SelectTrigger className="h-11! w-full text-base">
<SelectTrigger className="h-11! w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Available" className="text-base">Available</SelectItem>
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
<SelectItem value="Available" className="text-sm">Available</SelectItem>
<SelectItem value="OnHold" className="text-sm">On hold (inspection)</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
{item?.trackingMode === "Batch" && (
<div className="flex flex-col gap-1.5">
<Input
placeholder="Batch no."
value={line.batchNo}
aria-invalid={!!errors.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
className="h-9 text-sm"
/>
<Input
type="date"
value={line.expiryDate}
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
className="h-9 text-sm"
/>
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
<TableCell className="py-3 pr-0 pl-1 align-top">
{item?.warranty === "Warranty" ? (
<div className="relative flex flex-col gap-1">
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setLineTab("warranty")}
aria-label="Add warranty numbers"
/>
}
>
<ShieldCheck className="size-4" />
</TooltipTrigger>
<TooltipContent>Add warranty numbers</TooltipContent>
</Tooltip>
{errors.warrantyNumbers && (
<FieldError errors={[{ message: errors.warrantyNumbers }]} />
)}
<WarrantyHintBubble />
</div>
)}
{item?.trackingMode === "Serial" && (
<div className="flex flex-col gap-1.5">
<textarea
placeholder="One serial per line"
value={line.serialNumbersText}
aria-invalid={!!errors.serialNumbers}
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
</div>
)}
{(!item || item.trackingMode === "None") && (
) : (
<span className="text-sm text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<TableCell className="py-3 pr-3 pl-0 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
</Button>
@@ -630,7 +711,86 @@ export default function NewGrnPage() {
</div>
)}
{!poLoading && lines.length > 0 && (
{!poLoading && lineTab === "warranty" && (
<div className="flex flex-col gap-3">
<Button
type="button"
variant="outline"
size="sm"
className="w-fit self-end rounded-full"
onClick={() => setLineTab("lines")}
>
<ArrowLeft className="size-4" />
Back to lines
</Button>
{warrantyLines.length === 0 ? (
<p className="rounded-lg border border-dashed p-5 text-base text-muted-foreground">
No warranty-tracked items on this GRN yet — add a line for an item marked
“Warranty” to capture its numbers here.
</p>
) : (
<div className="flex flex-col gap-6">
{warrantyLines.map(({ line, item, units }) => {
const errors = lineErrors[line.key] ?? {}
return (
<div key={line.key} className="flex flex-col gap-2">
<div className="text-sm font-semibold text-foreground">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-10 w-20 px-3 text-sm">Unit</TableHead>
<TableHead className="h-10 px-3 text-sm">Warranty number</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: units }, (_, i) => (
<TableRow key={`${line.key}-${i}`}>
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">
{i + 1} of {units}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
value={line.warrantyNumbers[i] ?? ""}
aria-invalid={!!errors.warrantyNumbers}
onChange={(e) => setWarrantyNumberAt(line.key, i, e.target.value)}
placeholder="e.g. WTY-000123"
className="h-10 max-w-xs text-sm"
/>
{i === units - 1 && (
<FieldError
errors={[errors.warrantyNumbers ? { message: errors.warrantyNumbers } : undefined]}
/>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
})}
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
className="w-fit self-end rounded-full"
onClick={() => setLineTab("lines")}
>
<ArrowLeft className="size-4" />
Back to lines
</Button>
</div>
)}
{!poLoading && lineTab === "lines" && lines.length > 0 && (
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
<span className="text-muted-foreground">Document total (incl. VAT)</span>
<span className="font-semibold tabular-nums">
@@ -644,14 +804,16 @@ export default function NewGrnPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<div className="flex justify-end gap-3">
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create GRN"}
</Button>
</div>
{lineTab === "lines" && (
<div className="flex justify-end gap-3">
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create GRN"}
</Button>
</div>
)}
</>
)}
</div>
+11 -23
View File
@@ -3,7 +3,7 @@
// (over-receipt tolerance, referential existence, concurrency) are never
// re-implemented here; the server's ProblemDetails is the final word (§3.3).
import { z } from "zod"
import { TrackingMode } from "@/types/master-data"
import { Warranty } from "@/types/master-data"
export const grnHeaderSchema = z.object({
warehouseId: z.number({ error: "Select a warehouse" }).positive("Select a warehouse"),
@@ -17,9 +17,8 @@ export function validateLine(input: {
unitCost: string
discountPct: string
vatPct: string
trackingMode: TrackingMode | null
batchNo: string
serialNumbersText: string
warranty: Warranty | null
warrantyNumbers: string[]
}): Record<string, string> {
const errors: Record<string, string> = {}
@@ -39,27 +38,16 @@ export function validateLine(input: {
if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100))
errors.vatPct = "VAT must be 0–100%"
if (input.trackingMode === "Batch" && !input.batchNo.trim()) {
errors.batchNo = "Batch number is required for this item"
}
if (input.trackingMode === "Serial") {
const serials = splitSerials(input.serialNumbersText)
if (serials.length === 0) {
errors.serialNumbers = "Enter one serial number per unit"
} else if (!Number.isNaN(qty) && serials.length !== qty) {
errors.serialNumbers = `Enter exactly ${qty || 0} serial number(s) — got ${serials.length}`
} else if (new Set(serials).size !== serials.length) {
errors.serialNumbers = "Serial numbers must be unique"
if (input.warranty === "Warranty") {
const numbers = input.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
if (numbers.length === 0) {
errors.warrantyNumbers = "Enter one warranty number per unit"
} else if (!Number.isNaN(qty) && numbers.length !== qty) {
errors.warrantyNumbers = `Enter exactly ${qty || 0} warranty number(s) — got ${numbers.length}`
} else if (new Set(numbers.map((s) => s.toLowerCase())).size !== numbers.length) {
errors.warrantyNumbers = "Warranty numbers must be unique"
}
}
return errors
}
export function splitSerials(text: string): string[] {
return text
.split(/[\n,]/)
.map((s) => s.trim())
.filter((s) => s.length > 0)
}
+15
View File
@@ -22,6 +22,15 @@ export interface BatchInput {
expiryDate?: string | null
}
/**
* A captured warranty number as returned by the server. The coverage period is not chosen at
* receipt — it is snapshotted from the item's own `warrantyPeriodMonths` (set at item create).
*/
export interface GrnLineWarrantyNumber {
warrantyNo: string
warrantyPeriodMonths: number
}
/** One received line on create (docs/11 §4.1). */
export interface CreateGrnLineInput {
poLineId?: number | null
@@ -40,6 +49,11 @@ export interface CreateGrnLineInput {
vatPct?: number
holdStatus: HoldStatus
batch?: BatchInput | null
/**
* Required, one per received unit (count must equal `qty`), when the item is
* warranty-tracked (`Item.warranty === "Warranty"`). Ignored otherwise.
*/
warrantyNumbers?: string[] | null
}
export interface CreateGrnRequest {
@@ -73,6 +87,7 @@ export interface GrnLine {
priceVariance: number
holdStatus: HoldStatus
batchId: number | null
warrantyNumbers: GrnLineWarrantyNumber[]
}
export interface Grn {
+15
View File
@@ -9,6 +9,11 @@ import { EntityStatus } from "@/types/common"
*/
export type StockNature = "Stocked" | "NonStocked" | "Service"
export type TrackingMode = "None" | "Batch" | "Serial"
export type Warranty = "NonWarranty" | "Warranty"
/** Warranty coverage lengths offered when an item is marked Warranty. */
export const WARRANTY_PERIOD_MONTHS_OPTIONS = [3, 6, 12, 18] as const
export type WarrantyPeriodMonths = (typeof WARRANTY_PERIOD_MONTHS_OPTIONS)[number]
/**
* Unit of an item's content size — how much one stocked pack holds.
@@ -40,6 +45,9 @@ export interface ItemListItem extends ItemContent {
defaultVendorId: number | null
stockNature: StockNature
trackingMode: TrackingMode
warranty: Warranty
/** Coverage length in months; set when `warranty` is `"Warranty"`, null otherwise. */
warrantyPeriodMonths: WarrantyPeriodMonths | null
taxClass: string | null
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
salePrice: number | null
@@ -72,6 +80,9 @@ export interface Item extends ItemContent {
defaultVendorId: number | null
stockNature: StockNature
trackingMode: TrackingMode
warranty: Warranty
/** Coverage length in months; set when `warranty` is `"Warranty"`, null otherwise. */
warrantyPeriodMonths: WarrantyPeriodMonths | null
taxClass: string | null
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
salePrice: number | null
@@ -95,6 +106,10 @@ export interface CreateItemRequest {
defaultVendorId?: number | null
stockNature: StockNature
trackingMode: TrackingMode
/** Defaults to `NonWarranty` server-side when omitted. */
warranty?: Warranty
/** Required (one of `WARRANTY_PERIOD_MONTHS_OPTIONS`) when `warranty` is `"Warranty"`; ignored otherwise. */
warrantyPeriodMonths?: WarrantyPeriodMonths | null
taxClass?: string | null
/** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */
salePrice?: number | null
+2
View File
@@ -338,6 +338,8 @@ export interface CreateRunRequest {
export interface StageQuantityLine {
id: number
plannedQty: number
/** Required only when this lowers a running Stock input below what it has already consumed. */
reasonCodeId?: number | null
}
export interface UpdateStageQuantitiesRequest {