From cbc72ef830a5688acb61d2fbff94bf3347f333e4 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Wed, 5 Aug 2026 17:03:49 +0530 Subject: [PATCH] fix sales issues --- .../Controllers/BundleSalesController.cs | 3 +- Backend/ERPCore/Services/BundleSaleService.cs | 28 ++-- .../ERPCore/Services/SalesPostingService.cs | 30 ++-- .../SalesPromotionSuggestionService.cs | 22 +-- .../procurement/purchase-orders/[id]/page.tsx | 5 +- .../app/dashboard/sales/bundles/[id]/page.tsx | 146 +++++++++++++++++- .../app/dashboard/sales/bundles/new/page.tsx | 83 ++++++++-- .../app/dashboard/sales/bundles/page.tsx | 20 ++- .../dashboard/sales/invoices/[id]/page.tsx | 70 ++++++--- .../app/dashboard/sales/invoices/new/page.tsx | 5 + .../app/dashboard/sales/slips/[id]/page.tsx | 45 ++++-- .../app/dashboard/sales/slips/new/page.tsx | 12 +- .../erp-system/components/Layouts/Header.tsx | 19 ++- .../sales/FreeIssuePromotionSuggestions.tsx | 18 +-- 14 files changed, 392 insertions(+), 114 deletions(-) diff --git a/Backend/ERPCore/Controllers/BundleSalesController.cs b/Backend/ERPCore/Controllers/BundleSalesController.cs index 6b45382..b1d99a5 100644 --- a/Backend/ERPCore/Controllers/BundleSalesController.cs +++ b/Backend/ERPCore/Controllers/BundleSalesController.cs @@ -33,10 +33,11 @@ public sealed class BundleSalesController : ApiControllerBase [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] public async Task>> List( [FromQuery] PageQuery query, + [FromQuery] BundleSaleStatus? status, [FromQuery] int? customerId, [FromQuery] int? warehouseId, CancellationToken ct) - => Ok(await _bundles.ListAsync(query, customerId, warehouseId, ct)); + => Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct)); [HttpGet("{bundleSaleId:int}")] [ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Services/BundleSaleService.cs b/Backend/ERPCore/Services/BundleSaleService.cs index 072958e..1959a54 100644 --- a/Backend/ERPCore/Services/BundleSaleService.cs +++ b/Backend/ERPCore/Services/BundleSaleService.cs @@ -23,6 +23,7 @@ public sealed class BundleSaleService : IBundleSaleService private readonly IRepository _warehouses; private readonly IRepository _users; private readonly ISalesDomainService _sales; + private readonly IUomConverter _uomConverter; private readonly ISalesPostingService _posting; private readonly ICurrentUser _currentUser; private readonly INumberSequenceService _numbers; @@ -37,6 +38,7 @@ public sealed class BundleSaleService : IBundleSaleService IRepository warehouses, IRepository users, ISalesDomainService sales, + IUomConverter uomConverter, ISalesPostingService posting, ICurrentUser currentUser, INumberSequenceService numbers, @@ -50,6 +52,7 @@ public sealed class BundleSaleService : IBundleSaleService _warehouses = warehouses; _users = users; _sales = sales; + _uomConverter = uomConverter; _posting = posting; _currentUser = currentUser; _numbers = numbers; @@ -85,7 +88,7 @@ public sealed class BundleSaleService : IBundleSaleService x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList()); } - public async Task> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default) + public async Task> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) { IQueryable q = _bundles.Query().AsNoTracking().Include(x => x.Lines); if (!string.IsNullOrWhiteSpace(query.Q)) @@ -93,6 +96,7 @@ public sealed class BundleSaleService : IBundleSaleService var term = query.Q.Trim(); q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%")); } + if (status is not null) q = q.Where(x => x.Status == status); if (customerId is not null) q = q.Where(x => x.CustomerId == customerId); if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId); var total = await q.CountAsync(ct); @@ -204,21 +208,23 @@ public sealed class BundleSaleService : IBundleSaleService { if (r.Qty <= 0) throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422); - if (r.WarehouseId != warehouseId) - throw new DomainException(ErrorCodes.Validation, - $"Bundle line warehouse {r.WarehouseId} must match header warehouse {warehouseId}.", 422); + + // Bundle sales use the header warehouse as the source of truth for stock and pricing. + // Keep any per-line warehouse input from drifting away from the header. + var lineWarehouseId = warehouseId; + var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); - await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, 0m, null, ct); - var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct); - var calc = _sales.ComputeLine(r.Qty, 0m, resolved.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false); + await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, lineWarehouseId, r.Qty, 0m, null, ct); + var (qtyBase, unitCostBase) = await _uomConverter.ToBaseAsync(item, r.UomId, r.Qty, r.UnitPrice, ct); + var calc = _sales.ComputeLine(qtyBase, 0m, unitCostBase, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false); lines.Add(new BundleSaleLine { ItemId = r.ItemId, Description = item.Name, - Qty = r.Qty, - UomId = r.UomId, - WarehouseId = r.WarehouseId, - UnitPrice = resolved.UnitPrice, + Qty = qtyBase, + UomId = item.BaseUomId, + WarehouseId = lineWarehouseId, + UnitPrice = unitCostBase, LineTotal = calc.LineTotal, IncludeInBundle = r.IncludeInBundle, IsComponent = true, diff --git a/Backend/ERPCore/Services/SalesPostingService.cs b/Backend/ERPCore/Services/SalesPostingService.cs index 556ab58..1639b55 100644 --- a/Backend/ERPCore/Services/SalesPostingService.cs +++ b/Backend/ERPCore/Services/SalesPostingService.cs @@ -20,6 +20,7 @@ public sealed class SalesPostingService : ISalesPostingService private readonly IRepository _items; private readonly IFifoCostingService _fifo; private readonly ISalesDomainService _sales; + private readonly IUomConverter _uomConverter; private readonly ICurrentUser _currentUser; private readonly IUnitOfWork _uow; @@ -30,6 +31,7 @@ public sealed class SalesPostingService : ISalesPostingService IRepository items, IFifoCostingService fifo, ISalesDomainService sales, + IUomConverter uomConverter, ICurrentUser currentUser, IUnitOfWork uow) { @@ -39,6 +41,7 @@ public sealed class SalesPostingService : ISalesPostingService _items = items; _fifo = fifo; _sales = sales; + _uomConverter = uomConverter; _currentUser = currentUser; _uow = uow; } @@ -119,15 +122,14 @@ public sealed class SalesPostingService : ISalesPostingService { if (!await _sales.IsStockedItemAsync(line.ItemId, ct)) continue; + + var item = await _items.Query().AsNoTracking() + .FirstAsync(x => x.ItemId == line.ItemId, ct); var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct); if (available >= line.Qty) continue; - var item = await _items.Query().AsNoTracking() - .Where(x => x.ItemId == line.ItemId) - .Select(x => new { x.Sku, x.Name }) - .FirstAsync(ct); - - issues.Add(new BundleSalePostingIssueDto(line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available)); + issues.Add(new BundleSalePostingIssueDto( + line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available)); } return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues); @@ -139,10 +141,10 @@ public sealed class SalesPostingService : ISalesPostingService notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.", statusSelector: x => x.Status, ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.", - getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)), + getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)), setPosted: x => x.Status = SalesInvoiceStatus.Posted, setUpdated: x => x.UpdatedAt = DateTime.UtcNow, - sourceDocType: nameof(SalesInvoice), + sourceDocType: DocumentTypes.SalesInvoice, getDocId: x => x.SalesInvoiceId, ct: ct); @@ -152,10 +154,10 @@ public sealed class SalesPostingService : ISalesPostingService notFoundMessage: $"Sales slip {salesSlipId} was not found.", statusSelector: x => x.Status, ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.", - getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)), + getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)), setPosted: x => x.Status = SalesSlipStatus.Posted, setUpdated: x => x.UpdatedAt = DateTime.UtcNow, - sourceDocType: nameof(SalesSlip), + sourceDocType: DocumentTypes.SalesSlip, getDocId: x => x.SalesSlipId, ct: ct); @@ -165,10 +167,12 @@ public sealed class SalesPostingService : ISalesPostingService notFoundMessage: $"Bundle sale {bundleSaleId} was not found.", statusSelector: x => x.Status, ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.", - getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)), + // Bundle lines are normalized to base UOM on save, so posting should consume the + // stored base quantity directly instead of converting again. + getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty, l.Qty, 0m)), setPosted: x => x.Status = BundleSaleStatus.Posted, setUpdated: x => x.UpdatedAt = DateTime.UtcNow, - sourceDocType: nameof(BundleSale), + sourceDocType: DocumentTypes.BundleSale, getDocId: x => x.BundleSaleId, ct: ct); @@ -210,5 +214,5 @@ public sealed class SalesPostingService : ISalesPostingService }, ct); } - private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty); + private sealed record PostingLine(int ItemId, int WarehouseId, int UomId, decimal Qty, decimal PaidQty, decimal FreeQty); } diff --git a/Backend/ERPCore/Services/SalesPromotionSuggestionService.cs b/Backend/ERPCore/Services/SalesPromotionSuggestionService.cs index bdec4a0..d14ffdd 100644 --- a/Backend/ERPCore/Services/SalesPromotionSuggestionService.cs +++ b/Backend/ERPCore/Services/SalesPromotionSuggestionService.cs @@ -9,8 +9,6 @@ namespace ERPCore.Services; public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionService { - private const decimal FreeIssueThreshold = 10m; - private readonly IRepository _slips; private readonly IRepository _items; @@ -27,7 +25,14 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS if (slip is null) return null; - var itemIds = slip.Lines.Select(x => x.ItemId).Distinct().ToList(); + var freeIssueLines = slip.Lines + .Where(x => x.IsFreeIssue || x.FreeQty > 0m) + .ToList(); + + if (freeIssueLines.Count == 0) + return new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty()); + + var itemIds = freeIssueLines.Select(x => x.ItemId).Distinct().ToList(); var candidateItems = await _items.Query().AsNoTracking() .Where(x => itemIds.Contains(x.ItemId) && x.Status == EntityStatus.Active) .ToListAsync(ct); @@ -35,13 +40,10 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS var byItemId = candidateItems.ToDictionary(x => x.ItemId); var suggestions = new List(); - foreach (var line in slip.Lines.Where(x => x.Qty >= FreeIssueThreshold)) + foreach (var line in freeIssueLines) { if (!byItemId.TryGetValue(line.ItemId, out var item)) continue; - var freeQty = Math.Floor(line.Qty / FreeIssueThreshold); - if (freeQty <= 0m) continue; - var rewardOptions = new List { new(item.ItemId, item.Sku, item.Name, item.SalePrice) @@ -62,8 +64,8 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS item.Sku, item.Name, line.Qty, - freeQty, - FreeIssueThreshold, + line.FreeQty, + line.Qty, rewardOptions)); } @@ -71,4 +73,4 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS ? new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty()) : new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions); } -} \ No newline at end of file +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx index a51cde1..ede82d6 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx @@ -286,10 +286,7 @@ export default function PurchaseOrderDetailPage() { <> + ) : ( + line.includeInBundle ? "Included" : "Excluded" + )} + {editing && isDraft ? : null} ))} + {lines.length === 0 ? ( + + + No component rows yet. Click Add line to start. + + + ) : null} + {bundle.status === "Draft" && shortageIssues.length > 0 ? ( +
+
+
+
Stock shortage detected before posting
+
This bundle cannot be posted until every included component has enough available stock in the selected warehouse.
+
+ + + Create GRN + +
+
+ + + + + + + + + + {shortageIssues.map((issue) => ( + + + + + + ))} + +
ItemWarehouseRequested
+
{issue.itemSku}
+
{issue.itemName}
+
{issue.warehouseId}{issue.requestedQty.toFixed(0)}
+
+
+ ) : null} +
Component subtotal
{componentSubtotal.toFixed(2)}
diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx index 65ac586..ce49664 100644 --- a/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/bundles/new/page.tsx @@ -3,7 +3,7 @@ import { Suspense, useEffect, useMemo, useState } from "react" import { useRouter, useSearchParams } from "next/navigation" import Link from "next/link" -import { ArrowLeft, Minus, Plus, Save } from "lucide-react" +import { ArrowLeft, ExternalLink, Minus, Plus, Save } from "lucide-react" import { bundleApi } from "@/lib/api/bundles" import { customersApi } from "@/lib/api/customers" @@ -27,7 +27,17 @@ import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary, type EditableLine = BundleSaleTemplateLine & { key: string } -const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() }) +const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({ + key: crypto.randomUUID(), + bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0, + itemId: templateLine?.itemId ?? 0, + uomId: templateLine?.uomId ?? 0, + warehouseId: templateLine?.warehouseId ?? 0, + qty: templateLine?.qty ?? 1, + unitPrice: templateLine?.unitPrice ?? 0, + includeInBundle: templateLine?.includeInBundle ?? true, + sortOrder: templateLine?.sortOrder ?? 0, +}) function NewBundleSaleContent() { const router = useRouter() @@ -81,10 +91,20 @@ function NewBundleSaleContent() { if (!templateId) return bundleApi.getTemplate(templateId).then((res) => { setTemplate(res) - setLines(res.lines.map(blankLine)) + setLines( + res.lines.length > 0 + ? res.lines.map((line) => { + const item = items.find((candidate) => candidate.itemId === line.itemId) + return createBlankLine({ + ...line, + uomId: item?.baseUomId ?? line.uomId, + }) + }) + : [createBlankLine()] + ) setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0)) }).catch((err) => setSubmitError(errorMessage(err))) - }, [templateId]) + }, [items, templateId]) const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template]) const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines]) @@ -94,9 +114,7 @@ function NewBundleSaleContent() { } function addLine() { - const source = lines[lines.length - 1] ?? template?.lines[0] - if (!source) return - setLines((prev) => [...prev, blankLine(source)]) + setLines((prev) => [...prev, createBlankLine(template?.lines[0])]) } function removeLine(key: string) { @@ -151,6 +169,22 @@ function NewBundleSaleContent() { {submitError ?
{submitError}
: null} +
+
+
+
Need stock before saving this bundle?
+
Save the bundle as a draft first. After that, open the draft detail page to check shortages and create a GRN if needed.
+
+ + + Create GRN + +
+
+
@@ -222,13 +256,14 @@ function NewBundleSaleContent() { {lines.map((line) => ( - { const itemId = Number(v) - const item = items.find((candidate) => candidate.itemId === itemId) - updateLine(line.key, { - itemId, - unitPrice: item?.salePrice ?? line.unitPrice, - }) + const item = items.find((candidate) => candidate.itemId === itemId) + updateLine(line.key, { + itemId, + uomId: item?.baseUomId ?? line.uomId, + unitPrice: item?.salePrice ?? line.unitPrice, + }) }}> @@ -243,7 +278,7 @@ function NewBundleSaleContent() { - updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled> @@ -258,12 +293,30 @@ function NewBundleSaleContent() { updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /> updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /> - {line.includeInBundle ? "Yes" : "No"} + + + ))} + {lines.length === 0 && ( + + + No component rows yet. Click Add component to start. + + + )}
diff --git a/Frontend/erp-system/app/dashboard/sales/bundles/page.tsx b/Frontend/erp-system/app/dashboard/sales/bundles/page.tsx index 927fdc0..5d84432 100644 --- a/Frontend/erp-system/app/dashboard/sales/bundles/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/bundles/page.tsx @@ -84,7 +84,18 @@ export default function BundleSalesPage() { .catch((err) => setError(errorMessage(err))) }, [page, status, query, customerId, warehouseId]) - const visibleRows = useMemo(() => rows ?? [], [rows]) + const visibleRows = useMemo( + () => + rows?.filter((row) => { + const matchesStatus = status === "All" || row.status === status + const matchesQuery = + `${row.bundleNo} ${row.bundleName} ${row.customerSnapshotName} ${row.bundleCode}` + .toLowerCase() + .includes(query.toLowerCase()) + return matchesStatus && matchesQuery + }) ?? [], + [rows, query, status] + ) const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0) const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}` @@ -125,7 +136,12 @@ export default function BundleSalesPage() { ))}
- setSearchInput(e.target.value)} placeholder="Filter by bundle, code, or customer" className="h-12 w-full lg:max-w-sm" /> + setSearchInput(e.target.value)} + placeholder="Filter by bundle, code, or customer" + className="h-12 w-full lg:max-w-sm" + />