From c9a84e235b3565d6c8228d84724fa7c5644e9c9d Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Wed, 15 Jul 2026 18:11:35 +0530 Subject: [PATCH 01/11] feat: add brands and variant categories management - Implemented CRUD operations for brands and variant categories in the API. - Created UI components for managing brands and variant categories, including listing, creating, editing, and deleting. - Enhanced the sidebar navigation to include links for brands and variant categories. - Updated the categories API to support pagination and filtering. - Added validation for brand and variant category names. - Integrated toast notifications for user feedback on actions. --- Frontend/PROGRESS.md | 22 +- .../app/dashboard/products/brands/page.tsx | 296 ++++++++++ .../dashboard/products/categories/page.tsx | 256 ++++++--- .../app/dashboard/products/new/page.tsx | 509 ++++++++++++++---- .../app/dashboard/products/variants/page.tsx | 216 ++++++++ .../components/Layouts/AppSidebar.tsx | 62 ++- Frontend/erp-system/lib/api/brands.ts | 64 +++ Frontend/erp-system/lib/api/categories.ts | 58 +- Frontend/erp-system/lib/api/items.ts | 4 + Frontend/erp-system/lib/api/mock-data.ts | 30 +- Frontend/erp-system/lib/api/variants.ts | 49 ++ .../erp-system/lib/validations/master-data.ts | 22 + Frontend/erp-system/types/master-data.ts | 39 ++ docs/20-FRONTEND.md | 5 + 14 files changed, 1438 insertions(+), 194 deletions(-) create mode 100644 Frontend/erp-system/app/dashboard/products/brands/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/products/variants/page.tsx create mode 100644 Frontend/erp-system/lib/api/brands.ts create mode 100644 Frontend/erp-system/lib/api/variants.ts diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 3bdc995..709ce4a 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -24,9 +24,11 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API ## 2. Master Data screens -- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. +- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. **2026-07-15, rebuilt twice this session — see the two same-day entries below for the full history; current state:** `/new` is now Category → Subcategory (always visible, disabled with "No subcategories" when the category has none) → Brand → a checkbox-driven **variant builder** sourced live from `variantCategoriesApi`, with no SKU/Name/Description/Default vendor/Tax class/Item type/Tracking mode/Base UOM fields left on the form at all (Item type/tracking mode/base UOM are now fixed constants — `"Stocked"`/`"None"`/`uomId 1` — baked into the submit call, not user-facing). Checking a category (Color, Size, or any custom one) reveals its value-entry UI; Color gets a native color picker + a required name field (stored internally as `"name|hex"`, decoded everywhere it's displayed/used for SKU) while every other category is free-text chips. A flat table (one column per checked category + SKU + Quantity) generalizes to any number of active categories via a cartesian-product `useMemo`, replacing the earlier hardcoded 2-column Color×Size matrix. Submitting loops `itemsApi.create()` once per combination; SKU is `--...`; item name is ` - /...`. Added optional `brandId`/`initialQty` to `Item`/`CreateItemRequest`/`ItemListItem` (`types/master-data.ts`) — **deviation:** neither field is in the documented Item DTO (`docs/11-BACKEND-PHASE1.md` §2.1); `initialQty` is captured but not wired into the Stock Core ledger (informational only, no warehouse/GRN behind it). - [~] UOM + conversions (`app/dashboard/products/uoms` flat list + create dialog; conversions edited inline on the Item detail page via `PUT /items/{itemId}/uom-conversions`) — FR-MD-02/03 -- [~] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04 +- [~] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04. **2026-07-15:** added debounced search + Previous/Next pagination (`categoriesApi.list()` now takes `page`/`pageSize`/`q`/`sortOrder`, page size 5), matching the Vendor list's pagination pattern. +- [~] Brands (`app/dashboard/products/brands` list + create/edit dialog + delete) — **not a documented FR/endpoint**; `lib/api/brands.ts` treats it as a standalone name-only master, same shape as Categories, since Item has no `brandId` in the doc. **2026-07-15:** added the same debounced search + pagination as Categories; `Item`/`CreateItemRequest`/`ItemListItem` gained `brandId` so the new-item variant builder (above) can attach a brand. +- [~] Variant Categories (`app/dashboard/products/variants` list + create/edit dialog + delete) — **frontend-only, not a documented FR/endpoint.** A flat, name-only master list of variant dimensions (seeded with "Color", "Size") that the Item `/new` variant builder now genuinely drives from (see the Items bullet above) — checking a category there shows its value-entry UI, and a "+" on that same page can create a brand-new category (e.g. "Material") inline via `variantCategoriesApi.create`, which then also shows up back here. Values themselves (Red, Blue, S, M...) are still not managed on this page — only entered per-Item on `/new` — so `variant_values` (the individual Red/Blue/S/M records) still isn't a real backend entity; flag to whoever owns the backend contract if that should change. New `types/master-data.ts` (`VariantCategory`/`CreateVariantCategoryRequest`/`UpdateVariantCategoryRequest`), `lib/api/variants.ts` (`variantCategoriesApi`), `lib/validations/master-data.ts` (`validateVariantCategoryName`). Sidebar gained a "Variant" entry under Products (`components/Layouts/AppSidebar.tsx`). - [~] Vendors (`app/dashboard/vendors` list + search/status-filter + create dialog, `[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-06. First screen this session to exercise the ETag/`If-Match`/412 pattern end-to-end (`lib/api-client.ts`'s `ApiResult` was built earlier but unused until now). - [~] Warehouses + Bins (`app/dashboard/warehouse` list + create-warehouse dialog, `[id]` bin list + create-bin dialog) — FR-WH-01/FR-MD-07. Frontend-only (see note below); no ETag handling since there's no edit/delete yet, only create. - [~] Item reorder settings — edited inline on the Item detail page (per-warehouse point/qty rows) via `PUT /items/{itemId}/reorder` — FR-MD-05 @@ -132,3 +134,19 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Housekeeping:** removed `app/dashboard/vendors/view vendors/` — confirmed byte-for-byte identical to `vendors/[id]/page.tsx` and untracked in git, same IDE-artifact pattern (malformed file-open path) as the garbled GRN duplicate folders removed in the Warehouse Management pass; noted here rather than silently dropped. Left `app/dashboard/receiving/grn/[id]/edit/` alone — it's untracked too but is a distinct, non-duplicate GRN-edit screen, not an artifact. - Same `[~]` posture as every other module this session: built against the documented+assumed Master Data contract (`docs/11-BACKEND-PHASE1.md` §2), no Master Data backend exists (`Backend/PROGRESS.md` §1 unchanged). - Verified: `tsc --noEmit` clean after clearing a stale `.next` type cache that still referenced the just-deleted `view vendors` route (same pre-existing `login/page.tsx` error only remains); `eslint` clean aside from the same established `set-state-in-effect` pattern; `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure, unrelated). All 5 new/changed routes confirmed rendering 200 with no error boundary against the dev server (one false-alarm 500 during testing traced to an unrelated stale process already bound to port 3000, not this code — retested clean on the actual dev server port). + +### 2026-07-15 — Categories/Brands pagination, Item variant builder (Category→Subcategory→Brand→Color/Size), Variant Categories master (frontend-only; no backend changes) +- **Pagination:** `categoriesApi.list()`/`brandsApi.list()` (`lib/api/categories.ts`/`lib/api/brands.ts`) changed from returning everything on one page to real `page`/`pageSize`/`q`/`sortOrder` filtering+slicing (page size 5), matching the Vendor list's existing pattern. Both list screens gained debounced search + Previous/Next controls with a "Showing X–Y of Z" caption. **Follow-on fix:** the `new/page.tsx` item-create form and anywhere else fetching the full category/brand list for a `` swatch picker *plus* a required "Color name" text field — picking red alone isn't enough, a name is mandatory too. The pair is encoded as a single string `"|"` in `valuesByCategory` (helpers `encodeColorValue`/`decodeColorValue`/`partLabel` in `new/page.tsx`) so the existing generic value-list plumbing didn't need a parallel data shape; every place that displays or SKU-generates from a color value decodes it back to just the name (the hex only ever drives the swatch dot next to chips and table cells) — so SKUs read `HAR-CRI` (from "Crimson"), never `HAR-EF4`. +- Verified: `tsc --noEmit` clean after every step (same pre-existing `login/page.tsx` error only, confirmed unchanged throughout). Each change was driven end-to-end through a headless Playwright session against the dev server and screenshotted — field removal, subcategory always-visible + disabled state, checkbox show/hide of category builders, cartesian flat table with 2+ active categories, inline category creation followed by its builder appearing immediately, and the color picker + name → chip swatch → table swatch → final SKU/item name chain — with `console --errors` clean at every step and at least one full create-and-redirect-to-Items-list confirmed per major change. diff --git a/Frontend/erp-system/app/dashboard/products/brands/page.tsx b/Frontend/erp-system/app/dashboard/products/brands/page.tsx new file mode 100644 index 0000000..df1d92f --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/brands/page.tsx @@ -0,0 +1,296 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag, Trash2 } from "lucide-react" + +import { brandsApi } from "@/lib/api/brands" +import { errorMessage } from "@/lib/error-map" +import { validateBrandName } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { Brand } from "@/types/master-data" + +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +type SortOrder = "asc" | "desc" + +const PAGE_SIZE = 5 + +export default function BrandsPage() { + const [brands, setBrands] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [search, setSearch] = useState("") + const [sortOrder, setSortOrder] = useState("asc") + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [deletingId, setDeletingId] = useState(null) + + useEffect(() => { + const timeout = setTimeout(() => setSearch(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => { + setPage(1) + }, [search, sortOrder]) + + function load() { + setError(null) + brandsApi + .list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE }) + .then((res) => { + setBrands(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [search, sortOrder, page]) + + const hasFilters = search.trim().length > 0 + + function openCreateDialog() { + setEditing(null) + setName("") + setErrors({}) + setOpen(true) + } + + function openEditDialog(brand: Brand) { + setEditing(brand) + setName(brand.name) + setErrors({}) + setOpen(true) + } + + async function handleSubmit() { + const nextErrors = validateBrandName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const brand = editing + ? await brandsApi.update(editing.brandId, { name }) + : await brandsApi.create({ name }) + toast.success(editing ? "Brand updated" : "Brand created", brand.name) + setOpen(false) + setName("") + setEditing(null) + setErrors({}) + load() + } catch (err) { + setErrors({ name: errorMessage(err) }) + toast.error(editing ? "Could not update brand" : "Could not create brand", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function handleDelete(brand: Brand) { + setDeletingId(brand.brandId) + try { + await brandsApi.remove(brand.brandId) + toast.success("Brand deleted", brand.name) + load() + } catch (err) { + toast.error("Could not delete brand", errorMessage(err)) + } finally { + setDeletingId(null) + } + } + + return ( +
+
+
+ + + +
+

Brands

+

Manage product brands.

+
+
+ + + Add Brand} /> + + + {editing ? "Edit brand" : "New brand"} + Give the brand a name. + + + + Name + setName(e.target.value)} placeholder="Bosch" aria-invalid={!!errors.name} /> + + + +
+ + +
+
+
+
+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search brands…" + className="h-14 w-full pl-11 text-base" + aria-label="Search brands" + /> +
+ value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}> + + + + + Name (A–Z) + Name (Z–A) + + +
+ + {error && ( +
{error}
+ )} + + {!error && brands === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && brands !== null && brands.length === 0 && ( +
+ +

+ {hasFilters ? "No brands match your search." : "No brands yet."} +

+
+ )} + + {!error && brands !== null && brands.length > 0 && ( + <> + + + + ID + Name + Created At + Actions + + + + {brands.map((b) => ( + + #{b.brandId} + {b.name} + {new Date(b.createdAt).toLocaleDateString()} + +
+ + + + + } + > + + + handleDelete(b)} + /> + +
+
+
+ ))} +
+
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+
+ )} + + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx index 19037b4..18909ec 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -2,85 +2,121 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ListTree, Plus } from "lucide-react" +import { ArrowLeft, ChevronLeft, ChevronRight, ListTree, Pencil, Plus, Search, Trash2 } from "lucide-react" import { categoriesApi } from "@/lib/api/categories" import { errorMessage } from "@/lib/error-map" import { validateCategoryName } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { Category, CategoryTreeNode } from "@/types/master-data" +import { PaginationMeta } from "@/types/common" +import { Category } from "@/types/master-data" +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" -function TreeNode({ node, depth }: { node: CategoryTreeNode; depth: number }) { - return ( -
-
- - {node.name} - #{node.categoryId} -
- {node.children.map((child) => ( - - ))} -
- ) -} +type SortOrder = "asc" | "desc" + +const PAGE_SIZE = 5 export default function CategoriesPage() { - const [tree, setTree] = useState(null) - const [flat, setFlat] = useState([]) + const [categories, setCategories] = useState(null) + const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) + const [searchInput, setSearchInput] = useState("") + const [search, setSearch] = useState("") + const [sortOrder, setSortOrder] = useState("asc") + const [page, setPage] = useState(1) + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) const [name, setName] = useState("") - const [parentId, setParentId] = useState(null) const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) + const [deletingId, setDeletingId] = useState(null) + + useEffect(() => { + const timeout = setTimeout(() => setSearch(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => { + setPage(1) + }, [search, sortOrder]) function load() { setError(null) - Promise.all([categoriesApi.tree(), categoriesApi.list()]) - .then(([t, f]) => { - setTree(t) - setFlat(f.items) + categoriesApi + .list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE }) + .then((res) => { + setCategories(res.items) + setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) } - useEffect(load, []) + useEffect(load, [search, sortOrder, page]) - async function handleCreate() { + const hasFilters = search.trim().length > 0 + + function openCreateDialog() { + setEditing(null) + setName("") + setErrors({}) + setOpen(true) + } + + function openEditDialog(category: Category) { + setEditing(category) + setName(category.name) + setErrors({}) + setOpen(true) + } + + async function handleSubmit() { const nextErrors = validateCategoryName(name) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return setSubmitting(true) try { - const category = await categoriesApi.create({ name, parentId }) - toast.success("Category created", category.name) + const category = editing + ? await categoriesApi.update(editing.categoryId, { name }) + : await categoriesApi.create({ name }) + toast.success(editing ? "Category updated" : "Category created", category.name) setOpen(false) setName("") - setParentId(null) + setEditing(null) setErrors({}) load() } catch (err) { setErrors({ name: errorMessage(err) }) - toast.error("Could not create category", errorMessage(err)) + toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err)) } finally { setSubmitting(false) } } + async function handleDelete(category: Category) { + setDeletingId(category.categoryId) + try { + await categoriesApi.remove(category.categoryId) + toast.success("Category deleted", category.name) + load() + } catch (err) { + toast.error("Could not delete category", errorMessage(err)) + } finally { + setDeletingId(null) + } + } + return (
@@ -90,16 +126,16 @@ export default function CategoriesPage() {

Categories

-

Hierarchical item category structure (FR-MD-04).

+

Item category master (FR-MD-04).

- New Category} /> + New Category} /> - New category - Optionally nest it under an existing category. + {editing ? "Edit category" : "New category"} + Give the category a name. @@ -107,59 +143,153 @@ export default function CategoriesPage() { setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} /> - - Parent (optional) - value={parentId} onValueChange={setParentId}> - - - - - {flat.map((c) => ( - - {c.name} - - ))} - - -
-
+
+
+ + setSearchInput(e.target.value)} + placeholder="Search categories…" + className="h-14 w-full pl-11 text-base" + aria-label="Search categories" + /> +
+ value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}> + + + + + Name (A–Z) + Name (Z–A) + + +
+ {error && (
{error}
)} - {!error && tree === null && ( + {!error && categories === null && (
{Array.from({ length: 3 }).map((_, i) => ( - + ))}
)} - {!error && tree !== null && tree.length === 0 && ( + {!error && categories !== null && categories.length === 0 && (
-

No categories yet.

+

+ {hasFilters ? "No categories match your search." : "No categories yet."} +

)} - {!error && tree !== null && tree.length > 0 && ( -
- {tree.map((node) => ( - - ))} -
+ {!error && categories !== null && categories.length > 0 && ( + <> + + + + ID + Name + Created At + Actions + + + + {categories.map((c) => ( + + #{c.categoryId} + {c.name} + {new Date(c.createdAt).toLocaleDateString()} + +
+ + + + + } + > + + + handleDelete(c)} + /> + +
+
+
+ ))} +
+
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+
+ )} + )} ) diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index eda1169..a5666c7 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -1,91 +1,226 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { ArrowLeft } from "lucide-react" +import { ArrowLeft, Plus, X } from "lucide-react" import { itemsApi } from "@/lib/api/items" import { categoriesApi } from "@/lib/api/categories" -import { uomsApi } from "@/lib/api/uoms" -import { vendorsApi } from "@/lib/api/vendors" -import { errorMessage, fieldErrors } from "@/lib/error-map" -import { validateItemForm } from "@/lib/validations/master-data" +import { brandsApi } from "@/lib/api/brands" +import { variantCategoriesApi } from "@/lib/api/variants" +import { errorMessage } from "@/lib/error-map" +import { validateVariantCategoryName, validateVariantItemForm } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { ItemType, TrackingMode } from "@/types/master-data" +import { Category, VariantCategory } from "@/types/master-data" +import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { FieldError } from "@/components/ui/field" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" +function skuSegment(text: string, maxLen: number): string { + const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "") + return cleaned.slice(0, maxLen) || "GEN" +} + +function buildVariantSku(categoryLabel: string, values: string[]): string { + return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-") +} + +function isColorCategory(categoryName: string): boolean { + return categoryName.trim().toLowerCase() === "color" +} + +function encodeColorValue(name: string, hex: string): string { + return `${name}|${hex}` +} + +function decodeColorValue(value: string): { name: string; hex: string } { + const separatorIndex = value.indexOf("|") + if (separatorIndex === -1) return { name: value, hex: "#d4d4d8" } + return { name: value.slice(0, separatorIndex), hex: value.slice(separatorIndex + 1) } +} + +function partLabel(part: { name: string; value: string }): string { + return isColorCategory(part.name) ? decodeColorValue(part.value).name : part.value +} + +// No Base UOM field on this form — every variant created here uses the base "EA" unit (uomId 1 in the seed data). +const DEFAULT_BASE_UOM_ID = 1 + export default function NewItemPage() { const router = useRouter() - const [categories, setCategories] = useState<{ categoryId: number; name: string }[] | null>(null) - const [uoms, setUoms] = useState<{ uomId: number; name: string }[] | null>(null) - const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[] | null>(null) + const [categories, setCategories] = useState(null) + const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null) + const [variantCategories, setVariantCategories] = useState(null) const [loadError, setLoadError] = useState(null) - const [sku, setSku] = useState("") - const [name, setName] = useState("") - const [description, setDescription] = useState("") const [categoryId, setCategoryId] = useState(null) - const [baseUomId, setBaseUomId] = useState(null) - const [defaultVendorId, setDefaultVendorId] = useState(null) - const [itemType, setItemType] = useState("Stocked") - const [trackingMode, setTrackingMode] = useState("None") - const [taxClass, setTaxClass] = useState("STD") + const [subCategoryId, setSubCategoryId] = useState(null) + const [brandId, setBrandId] = useState(null) + + const [checkedVariantCategoryIds, setCheckedVariantCategoryIds] = useState([]) + const [valuesByCategory, setValuesByCategory] = useState>({}) + const [inputByCategory, setInputByCategory] = useState>({}) + const [colorNameByCategory, setColorNameByCategory] = useState>({}) + const [quantities, setQuantities] = useState>({}) + + const [addingCategory, setAddingCategory] = useState(false) + const [newCategoryName, setNewCategoryName] = useState("") + const [newCategoryError, setNewCategoryError] = useState(null) + const [addingCategorySubmitting, setAddingCategorySubmitting] = useState(false) const [errors, setErrors] = useState>({}) const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) useEffect(() => { - Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200, status: "Active" })]) - .then(([cat, uo, ve]) => { + Promise.all([categoriesApi.list({ pageSize: 200 }), brandsApi.list({ pageSize: 200 }), variantCategoriesApi.list()]) + .then(([cat, br, vc]) => { setCategories(cat.items) - setUoms(uo.items) - setVendors(ve.items) + setBrands(br.items) + setVariantCategories(vc.items) }) .catch((err) => setLoadError(errorMessage(err))) }, []) + const topCategories = useMemo(() => (categories ?? []).filter((c) => c.parentId === null), [categories]) + const subCategoryOptions = useMemo( + () => (categories ?? []).filter((c) => c.parentId === categoryId), + [categories, categoryId] + ) + const effectiveCategoryId = subCategoryId ?? categoryId + const effectiveCategoryLabel = + (categories ?? []).find((c) => c.categoryId === effectiveCategoryId)?.name ?? "" + const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? "" + + function handleCategoryChange(value: number | null) { + setCategoryId(value) + setSubCategoryId(null) + } + + function toggleVariantCategory(variantCategoryId: number) { + setCheckedVariantCategoryIds((prev) => + prev.includes(variantCategoryId) ? prev.filter((id) => id !== variantCategoryId) : [...prev, variantCategoryId] + ) + setQuantities({}) + } + + async function handleAddVariantCategory() { + const nextErrors = validateVariantCategoryName(newCategoryName) + if (nextErrors.name) { + setNewCategoryError(nextErrors.name) + return + } + setAddingCategorySubmitting(true) + try { + const category = await variantCategoriesApi.create({ name: newCategoryName }) + setVariantCategories((prev) => [...(prev ?? []), category]) + setCheckedVariantCategoryIds((prev) => [...prev, category.variantCategoryId]) + setNewCategoryName("") + setNewCategoryError(null) + setAddingCategory(false) + toast.success("Variant category created", category.name) + } catch (err) { + setNewCategoryError(errorMessage(err)) + } finally { + setAddingCategorySubmitting(false) + } + } + + function addValue(variantCategoryId: number, overrideValue?: string) { + const value = (overrideValue ?? inputByCategory[variantCategoryId] ?? "").trim() + if (value) { + setValuesByCategory((prev) => { + const existing = prev[variantCategoryId] ?? [] + if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev + return { ...prev, [variantCategoryId]: [...existing, value] } + }) + setQuantities({}) + } + setInputByCategory((prev) => ({ ...prev, [variantCategoryId]: "" })) + } + + function removeValue(variantCategoryId: number, value: string) { + setValuesByCategory((prev) => ({ + ...prev, + [variantCategoryId]: (prev[variantCategoryId] ?? []).filter((v) => v !== value), + })) + setQuantities({}) + } + + const activeCategories = useMemo( + () => + (variantCategories ?? []) + .filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId)) + .map((vc) => ({ ...vc, values: valuesByCategory[vc.variantCategoryId] ?? [] })) + .filter((vc) => vc.values.length > 0), + [variantCategories, checkedVariantCategoryIds, valuesByCategory] + ) + + const variants = useMemo(() => { + if (activeCategories.length === 0) return [] + let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }] + for (const cat of activeCategories) { + const next: typeof combinations = [] + for (const combo of combinations) { + for (const value of cat.values) { + next.push({ + key: combo.key ? `${combo.key}::${value}` : value, + parts: [...combo.parts, { name: cat.name, value }], + }) + } + } + combinations = next + } + return combinations.map((c) => ({ + ...c, + sku: buildVariantSku(effectiveCategoryLabel, c.parts.map(partLabel)), + })) + }, [activeCategories, effectiveCategoryLabel]) + async function handleSubmit() { setSubmitError(null) - const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId }) + const nextErrors = validateVariantItemForm({ categoryId: effectiveCategoryId, hasVariants: variants.length > 0 }) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return setSubmitting(true) try { - const { data: item } = await itemsApi.create({ - sku, - name, - description: description || null, - categoryId: categoryId as number, - baseUomId: baseUomId as number, - defaultVendorId, - itemType, - trackingMode, - taxClass: taxClass || null, - }) - toast.success("Item created", `${item.sku} — ${item.name}`) - router.push(`/dashboard/products/${item.itemId}`) + let created = 0 + for (const variant of variants) { + const qty = Number(quantities[variant.key] || 0) + await itemsApi.create({ + sku: variant.sku, + name: `${brandLabel ? brandLabel + " " : ""}${effectiveCategoryLabel} - ${variant.parts.map(partLabel).join("/")}`, + categoryId: effectiveCategoryId as number, + brandId, + baseUomId: DEFAULT_BASE_UOM_ID, + itemType: "Stocked", + trackingMode: "None", + initialQty: Number.isFinite(qty) ? qty : 0, + }) + created += 1 + } + toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`) + router.push("/dashboard/products") } catch (err) { - const fe = fieldErrors(err) - if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku })) setSubmitError(errorMessage(err)) - toast.error("Could not create item", errorMessage(err)) + toast.error("Could not create variants", errorMessage(err)) } finally { setSubmitting(false) } } - const loading = !categories || !uoms || !vendors + const loading = !categories || !brands || !variantCategories return (
@@ -95,7 +230,7 @@ export default function NewItemPage() {

New Item

-

SKU, category, base UOM, item type, and tracking mode (FR-MD-01).

+

Category, subcategory, brand, and variant categories (FR-MD-01).

@@ -108,28 +243,14 @@ export default function NewItemPage() { {!loading && ( <>
-
- - setSku(e.target.value)} placeholder="ITM-1004" aria-invalid={!!errors.sku} className="h-12 text-base" /> - -
-
- - setName(e.target.value)} placeholder="Steel Washer M8" aria-invalid={!!errors.name} className="h-12 text-base" /> - -
-
- - setDescription(e.target.value)} className="h-12 text-base" /> -
- value={categoryId} onValueChange={setCategoryId}> + value={categoryId} onValueChange={handleCategoryChange}> - {(categories ?? []).map((c) => ( + {topCategories.map((c) => ( {c.name} @@ -139,66 +260,250 @@ export default function NewItemPage() {
- - value={baseUomId} onValueChange={setBaseUomId}> - - - - - {(uoms ?? []).map((u) => ( - - {u.name} - - ))} - - - -
-
- - value={defaultVendorId} onValueChange={setDefaultVendorId}> + + value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}> - + - {(vendors ?? []).map((v) => ( - - {v.code} — {v.name} + {subCategoryOptions.map((c) => ( + + {c.name} ))}
- - setTaxClass(e.target.value)} placeholder="STD" className="h-12 text-base" /> -
-
- - value={itemType} onValueChange={(v) => v && setItemType(v)}> + + value={brandId} onValueChange={setBrandId}> - + - Stocked - Non-stocked - Service + {(brands ?? []).map((b) => ( + + {b.name} + + ))}
-
- - value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}> - - - - - None - Batch - Serial - - +
+ +
+
+

Variants

+

+ Check the variant categories that apply, then add their values to generate a SKU per combination. +

+ +
+ {(variantCategories ?? []).map((vc) => ( + + ))} + {!addingCategory && ( + + )} +
+ + {addingCategory && ( +
+
+ setNewCategoryName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddVariantCategory() + } + }} + placeholder="Material" + className="h-11 max-w-xs text-base" + aria-invalid={!!newCategoryError} + autoFocus + /> + + +
+ +
+ )} + + + + {checkedVariantCategoryIds.length > 0 && ( +
+ {(variantCategories ?? []) + .filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId)) + .map((vc) => { + const isColor = isColorCategory(vc.name) + const currentInput = inputByCategory[vc.variantCategoryId] ?? "" + const currentColorName = colorNameByCategory[vc.variantCategoryId] ?? "" + + function addColor() { + const name = currentColorName.trim() + if (!name) return + addValue(vc.variantCategoryId, encodeColorValue(name, currentInput || "#EF4444")) + setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: "" })) + } + + return ( +
+ +
+ {isColor ? ( + <> + setInputByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))} + className="h-11 w-11 shrink-0 cursor-pointer rounded-md border border-input p-0.5" + aria-label="Pick color" + /> + setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addColor() + } + }} + placeholder="Color name (e.g. Red)" + className="h-11 text-base" + /> + + ) : ( + setInputByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addValue(vc.variantCategoryId) + } + }} + placeholder={vc.name} + className="h-11 text-base" + /> + )} + +
+
+ {(valuesByCategory[vc.variantCategoryId] ?? []).map((v) => { + const decoded = isColor ? decodeColorValue(v) : null + return ( + + {decoded && ( + + ) + })} +
+
+ ) + })} +
+ )} + + {variants.length > 0 && ( +
+ + + + {activeCategories.map((cat) => ( + {cat.name} + ))} + SKU + Quantity + + + + {variants.map((variant) => ( + + {variant.parts.map((part, i) => { + const decoded = isColorCategory(part.name) ? decodeColorValue(part.value) : null + return ( + + + {decoded && ( + + + ) + })} + {variant.sku} + + setQuantities((prev) => ({ ...prev, [variant.key]: e.target.value }))} + placeholder="0" + className="h-9 w-24 text-sm" + /> + + + ))} + +
+
+ )}
{submitError && ( @@ -210,7 +515,7 @@ export default function NewItemPage() { Cancel
diff --git a/Frontend/erp-system/app/dashboard/products/variants/page.tsx b/Frontend/erp-system/app/dashboard/products/variants/page.tsx new file mode 100644 index 0000000..051ab76 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/variants/page.tsx @@ -0,0 +1,216 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Pencil, Plus, SwatchBook, Trash2 } from "lucide-react" + +import { variantCategoriesApi } from "@/lib/api/variants" +import { errorMessage } from "@/lib/error-map" +import { validateVariantCategoryName } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { VariantCategory } from "@/types/master-data" + +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +export default function VariantsPage() { + const [categories, setCategories] = useState(null) + const [error, setError] = useState(null) + + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [deletingId, setDeletingId] = useState(null) + + function load() { + setError(null) + variantCategoriesApi + .list() + .then((res) => setCategories(res.items)) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, []) + + function openCreateDialog() { + setEditing(null) + setName("") + setErrors({}) + setOpen(true) + } + + function openEditDialog(category: VariantCategory) { + setEditing(category) + setName(category.name) + setErrors({}) + setOpen(true) + } + + async function handleSubmit() { + const nextErrors = validateVariantCategoryName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const category = editing + ? await variantCategoriesApi.update(editing.variantCategoryId, { name }) + : await variantCategoriesApi.create({ name }) + toast.success(editing ? "Variant category updated" : "Variant category created", category.name) + setOpen(false) + setName("") + setEditing(null) + setErrors({}) + load() + } catch (err) { + setErrors({ name: errorMessage(err) }) + toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function handleDelete(category: VariantCategory) { + setDeletingId(category.variantCategoryId) + try { + await variantCategoriesApi.remove(category.variantCategoryId) + toast.success("Variant category deleted", category.name) + load() + } catch (err) { + toast.error("Could not delete category", errorMessage(err)) + } finally { + setDeletingId(null) + } + } + + return ( +
+
+
+ + + +
+

Variants

+

Variant categories used by the item variant builder (e.g. Color, Size, Material).

+
+
+ + + Add Category} /> + + + {editing ? "Edit variant category" : "New variant category"} + Give the category a name. + + + + Name + setName(e.target.value)} + placeholder="Material" + aria-invalid={!!errors.name} + /> + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && categories === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && categories !== null && categories.length === 0 && ( +
+ +

No variant categories yet.

+
+ )} + + {!error && categories !== null && categories.length > 0 && ( + + + + ID + Name + Created At + Actions + + + + {categories.map((c) => ( + + #{c.variantCategoryId} + {c.name} + {new Date(c.createdAt).toLocaleDateString()} + +
+ + + + + } + > + + + handleDelete(c)} + /> + +
+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index 0236a2f..8bddb2a 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -4,16 +4,20 @@ import { useEffect, useState } from "react" import Link from "next/link" import { usePathname } from "next/navigation" import { + Boxes, Building2, ChevronRight, ClipboardList, HelpCircle, LayoutGrid, + ListTree, Menu, Package, PackageCheck, Settings, ShoppingCart, + SwatchBook, + Tag, Truck, Warehouse, X, @@ -22,9 +26,26 @@ import { import { cn } from "@/lib/utils" -const navItems: { title: string; href: string; icon: LucideIcon; chevron?: boolean }[] = [ +const navItems: { + title: string + href: string + icon: LucideIcon + chevron?: boolean + children?: { title: string; href: string; icon: LucideIcon }[] +}[] = [ { title: "Dashboard", href: "/dashboard", icon: LayoutGrid }, - { title: "Products", href: "/dashboard/products", icon: Package, chevron: true }, + { + title: "Products", + href: "/dashboard/products", + icon: Package, + chevron: true, + children: [ + { title: "Item", href: "/dashboard/products", icon: Boxes }, + { title: "Category", href: "/dashboard/products/categories", icon: ListTree }, + { title: "Brand", href: "/dashboard/products/brands", icon: Tag }, + { title: "Variant", href: "/dashboard/products/variants", icon: SwatchBook }, + ], + }, { title: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true }, { title: "Procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true }, { title: "Receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true }, @@ -109,12 +130,47 @@ function SidebarContent({ {(!collapsed || isMobile) && ( <> {item.title} - {item.chevron && !isActive && ( + {item.chevron && !item.children && !isActive && ( )} )} + + {item.children && (!collapsed || isMobile) && ( +
    + {(() => { + // Longest-matching href wins so a shared prefix (e.g. "Item" and + // "Category" both live under /dashboard/products) doesn't light up + // more than one sub-item at once. + const activeChild = [...item.children] + .filter((c) => pathname === c.href || pathname.startsWith(`${c.href}/`)) + .sort((a, b) => b.href.length - a.href.length)[0] + return item.children.map((child) => { + const childActive = child.href === activeChild?.href + return ( +
  • + + + {child.title} + +
  • + ) + }) + })()} +
+ )} ) })} diff --git a/Frontend/erp-system/lib/api/brands.ts b/Frontend/erp-system/lib/api/brands.ts new file mode 100644 index 0000000..7d9622b --- /dev/null +++ b/Frontend/erp-system/lib/api/brands.ts @@ -0,0 +1,64 @@ +// One typed client method per Brand endpoint, mirroring lib/api/uoms.ts. +// In-memory sample data (lib/api/mock-data.ts) — no backend API calls. +import { PagedResponse } from "@/types/common" +import { Brand, CreateBrandRequest, UpdateBrandRequest } from "@/types/master-data" +import { allocateBrandId, mockBrands, mockDelay } from "@/lib/api/mock-data" + +export interface ListBrandsParams { + page?: number + pageSize?: number + q?: string + sortOrder?: "asc" | "desc" +} + +export const brandsApi = { + list(params: ListBrandsParams = {}): Promise> { + const term = params.q?.trim().toLowerCase() + const sortOrder = params.sortOrder ?? "asc" + const filtered = mockBrands + .filter((b) => !term || b.name.toLowerCase().includes(term)) + .sort((a, b) => (sortOrder === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name))) + + const page = params.page ?? 1 + const pageSize = params.pageSize ?? 5 + const start = (page - 1) * pageSize + const items = filtered.slice(start, start + pageSize) + const totalItems = filtered.length + const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize) + + return mockDelay({ + items, + pagination: { page, pageSize, totalItems, totalPages }, + }) + }, + + create(request: CreateBrandRequest): Promise { + const name = request.name.trim() + if (!name) return Promise.reject(new Error("Brand name is required.")) + if (mockBrands.some((b) => b.name.toLowerCase() === name.toLowerCase())) { + return Promise.reject(new Error(`Brand "${name}" already exists.`)) + } + const brand: Brand = { brandId: allocateBrandId(), name, createdAt: new Date().toISOString() } + mockBrands.push(brand) + return mockDelay(brand) + }, + + update(brandId: number, request: UpdateBrandRequest): Promise { + const name = request.name.trim() + if (!name) return Promise.reject(new Error("Brand name is required.")) + const brand = mockBrands.find((b) => b.brandId === brandId) + if (!brand) return Promise.reject(new Error("Brand not found.")) + if (mockBrands.some((b) => b.brandId !== brandId && b.name.toLowerCase() === name.toLowerCase())) { + return Promise.reject(new Error(`Brand "${name}" already exists.`)) + } + brand.name = name + return mockDelay(brand) + }, + + remove(brandId: number): Promise { + const index = mockBrands.findIndex((b) => b.brandId === brandId) + if (index === -1) return Promise.reject(new Error("Brand not found.")) + mockBrands.splice(index, 1) + return mockDelay(undefined) + }, +} diff --git a/Frontend/erp-system/lib/api/categories.ts b/Frontend/erp-system/lib/api/categories.ts index 45c473a..23b211c 100644 --- a/Frontend/erp-system/lib/api/categories.ts +++ b/Frontend/erp-system/lib/api/categories.ts @@ -1,35 +1,37 @@ // One typed client method per Category endpoint (docs/11-BACKEND-PHASE1.md §2.3, FR-MD-04). // In-memory sample data (lib/api/mock-data.ts) — no backend API calls. import { PagedResponse } from "@/types/common" -import { Category, CategoryTreeNode, CreateCategoryRequest } from "@/types/master-data" +import { Category, CreateCategoryRequest, UpdateCategoryRequest } from "@/types/master-data" import { allocateCategoryId, mockCategories, mockDelay } from "@/lib/api/mock-data" -function buildTree(categories: Category[]): CategoryTreeNode[] { - const nodes = new Map(categories.map((c) => [c.categoryId, { ...c, children: [] }])) - const roots: CategoryTreeNode[] = [] - for (const node of nodes.values()) { - if (node.parentId !== null && nodes.has(node.parentId)) { - nodes.get(node.parentId)!.children.push(node) - } else { - roots.push(node) - } - } - return roots +export interface ListCategoriesParams { + page?: number + pageSize?: number + q?: string + sortOrder?: "asc" | "desc" } export const categoriesApi = { - list(): Promise> { - const items = [...mockCategories].sort((a, b) => a.name.localeCompare(b.name)) + list(params: ListCategoriesParams = {}): Promise> { + const term = params.q?.trim().toLowerCase() + const sortOrder = params.sortOrder ?? "asc" + const filtered = mockCategories + .filter((c) => !term || c.name.toLowerCase().includes(term)) + .sort((a, b) => (sortOrder === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name))) + + const page = params.page ?? 1 + const pageSize = params.pageSize ?? 5 + const start = (page - 1) * pageSize + const items = filtered.slice(start, start + pageSize) + const totalItems = filtered.length + const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize) + return mockDelay({ items, - pagination: { page: 1, pageSize: 200, totalItems: items.length, totalPages: 1 }, + pagination: { page, pageSize, totalItems, totalPages }, }) }, - tree(): Promise { - return mockDelay(buildTree(mockCategories)) - }, - create(request: CreateCategoryRequest): Promise { const name = request.name.trim() if (!name) return Promise.reject(new Error("Category name is required.")) @@ -37,8 +39,24 @@ export const categoriesApi = { if (parentId !== null && !mockCategories.some((c) => c.categoryId === parentId)) { return Promise.reject(new Error("Selected parent category does not exist.")) } - const category: Category = { categoryId: allocateCategoryId(), name, parentId } + const category: Category = { categoryId: allocateCategoryId(), name, parentId, createdAt: new Date().toISOString() } mockCategories.push(category) return mockDelay(category) }, + + update(categoryId: number, request: UpdateCategoryRequest): Promise { + const name = request.name.trim() + if (!name) return Promise.reject(new Error("Category name is required.")) + const category = mockCategories.find((c) => c.categoryId === categoryId) + if (!category) return Promise.reject(new Error("Category not found.")) + category.name = name + return mockDelay(category) + }, + + remove(categoryId: number): Promise { + const index = mockCategories.findIndex((c) => c.categoryId === categoryId) + if (index === -1) return Promise.reject(new Error("Category not found.")) + mockCategories.splice(index, 1) + return mockDelay(undefined) + }, } diff --git a/Frontend/erp-system/lib/api/items.ts b/Frontend/erp-system/lib/api/items.ts index c31e1e9..2d7fd38 100644 --- a/Frontend/erp-system/lib/api/items.ts +++ b/Frontend/erp-system/lib/api/items.ts @@ -36,6 +36,7 @@ function toListItem(item: Item): ItemListItem { sku: item.sku, name: item.name, categoryId: item.categoryId, + brandId: item.brandId ?? null, baseUomId: item.baseUomId, defaultVendorId: item.defaultVendorId, itemType: item.itemType, @@ -89,6 +90,7 @@ export const itemsApi = { name: request.name.trim(), description: request.description?.trim() || null, categoryId: request.categoryId, + brandId: request.brandId ?? null, baseUomId: request.baseUomId, defaultVendorId: request.defaultVendorId ?? null, itemType: request.itemType, @@ -97,6 +99,7 @@ export const itemsApi = { status: "Active", reorder: [], conversions: [], + initialQty: request.initialQty ?? null, createdAt: new Date().toISOString(), updatedAt: null, } @@ -121,6 +124,7 @@ export const itemsApi = { item.name = request.name.trim() item.description = request.description?.trim() || null item.categoryId = request.categoryId + item.brandId = request.brandId ?? null item.baseUomId = request.baseUomId item.defaultVendorId = request.defaultVendorId ?? null item.itemType = request.itemType diff --git a/Frontend/erp-system/lib/api/mock-data.ts b/Frontend/erp-system/lib/api/mock-data.ts index fb35ae8..32f9e97 100644 --- a/Frontend/erp-system/lib/api/mock-data.ts +++ b/Frontend/erp-system/lib/api/mock-data.ts @@ -1,7 +1,7 @@ // In-memory sample data backing every lib/api/*.ts module — the app has no // fetch-based backend connection (lib/api-client.ts and lib/auth-token.ts were // removed). Shapes mirror docs/11-BACKEND-PHASE1.md. -import { Bin, Category, Item, Uom, Vendor, Warehouse } from "@/types/master-data" +import { Bin, Brand, Category, Item, Uom, Vendor, VariantCategory, Warehouse } from "@/types/master-data" import { PurchaseOrder, Quotation, Requisition, Rfq, PurchaseReturn } from "@/types/procurement" import { Grn } from "@/types/grn" import { @@ -51,9 +51,9 @@ export function allocateUomId() { } export const mockCategories: Category[] = [ - { categoryId: 3, name: "Hardware", parentId: null }, - { categoryId: 12, name: "Fasteners", parentId: 3 }, - { categoryId: 20, name: "Power Tools", parentId: null }, + { categoryId: 3, name: "Hardware", parentId: null, createdAt: "2026-06-01T08:00:00Z" }, + { categoryId: 12, name: "Fasteners", parentId: 3, createdAt: "2026-06-01T08:05:00Z" }, + { categoryId: 20, name: "Power Tools", parentId: null, createdAt: "2026-06-02T09:00:00Z" }, ] let nextCategoryId = 21 @@ -62,6 +62,28 @@ export function allocateCategoryId() { return nextCategoryId++ } +export const mockBrands: Brand[] = [ + { brandId: 1, name: "Bosch", createdAt: "2026-06-01T08:00:00Z" }, + { brandId: 2, name: "Makita", createdAt: "2026-06-02T09:00:00Z" }, +] + +let nextBrandId = 3 + +export function allocateBrandId() { + return nextBrandId++ +} + +export const mockVariantCategories: VariantCategory[] = [ + { variantCategoryId: 1, name: "Color", createdAt: "2026-06-01T08:00:00Z" }, + { variantCategoryId: 2, name: "Size", createdAt: "2026-06-01T08:00:00Z" }, +] + +let nextVariantCategoryId = 3 + +export function allocateVariantCategoryId() { + return nextVariantCategoryId++ +} + export const mockVendors: Vendor[] = [ { vendorId: 5, diff --git a/Frontend/erp-system/lib/api/variants.ts b/Frontend/erp-system/lib/api/variants.ts new file mode 100644 index 0000000..4d275e2 --- /dev/null +++ b/Frontend/erp-system/lib/api/variants.ts @@ -0,0 +1,49 @@ +// One typed client method per Variant Category endpoint, mirroring lib/api/brands.ts. +// In-memory sample data (lib/api/mock-data.ts) — no backend API calls. +import { PagedResponse } from "@/types/common" +import { CreateVariantCategoryRequest, UpdateVariantCategoryRequest, VariantCategory } from "@/types/master-data" +import { allocateVariantCategoryId, mockVariantCategories, mockDelay } from "@/lib/api/mock-data" + +export const variantCategoriesApi = { + list(): Promise> { + const items = [...mockVariantCategories].sort((a, b) => a.name.localeCompare(b.name)) + return mockDelay({ + items, + pagination: { page: 1, pageSize: items.length || 1, totalItems: items.length, totalPages: 1 }, + }) + }, + + create(request: CreateVariantCategoryRequest): Promise { + const name = request.name.trim() + if (!name) return Promise.reject(new Error("Category name is required.")) + if (mockVariantCategories.some((c) => c.name.toLowerCase() === name.toLowerCase())) { + return Promise.reject(new Error(`Variant category "${name}" already exists.`)) + } + const category: VariantCategory = { + variantCategoryId: allocateVariantCategoryId(), + name, + createdAt: new Date().toISOString(), + } + mockVariantCategories.push(category) + return mockDelay(category) + }, + + update(variantCategoryId: number, request: UpdateVariantCategoryRequest): Promise { + const name = request.name.trim() + if (!name) return Promise.reject(new Error("Category name is required.")) + const category = mockVariantCategories.find((c) => c.variantCategoryId === variantCategoryId) + if (!category) return Promise.reject(new Error("Variant category not found.")) + if (mockVariantCategories.some((c) => c.variantCategoryId !== variantCategoryId && c.name.toLowerCase() === name.toLowerCase())) { + return Promise.reject(new Error(`Variant category "${name}" already exists.`)) + } + category.name = name + return mockDelay(category) + }, + + remove(variantCategoryId: number): Promise { + const index = mockVariantCategories.findIndex((c) => c.variantCategoryId === variantCategoryId) + if (index === -1) return Promise.reject(new Error("Variant category not found.")) + mockVariantCategories.splice(index, 1) + return mockDelay(undefined) + }, +} diff --git a/Frontend/erp-system/lib/validations/master-data.ts b/Frontend/erp-system/lib/validations/master-data.ts index 548b514..0afd6e8 100644 --- a/Frontend/erp-system/lib/validations/master-data.ts +++ b/Frontend/erp-system/lib/validations/master-data.ts @@ -50,3 +50,25 @@ export function validateCategoryName(name: string): Record { if (!name.trim()) errors.name = "Category name is required" return errors } + +export function validateBrandName(name: string): Record { + const errors: Record = {} + if (!name.trim()) errors.name = "Brand name is required" + return errors +} + +export function validateVariantCategoryName(name: string): Record { + const errors: Record = {} + if (!name.trim()) errors.name = "Category name is required" + return errors +} + +export function validateVariantItemForm(input: { + categoryId: number | null + hasVariants: boolean +}): Record { + const errors: Record = {} + if (!input.categoryId) errors.categoryId = "Select a category" + if (!input.hasVariants) errors.variants = "Check at least one variant category and add its values" + return errors +} diff --git a/Frontend/erp-system/types/master-data.ts b/Frontend/erp-system/types/master-data.ts index 83a0457..7a16bd3 100644 --- a/Frontend/erp-system/types/master-data.ts +++ b/Frontend/erp-system/types/master-data.ts @@ -9,6 +9,7 @@ export interface ItemListItem { sku: string name: string categoryId: number + brandId?: number | null baseUomId: number defaultVendorId: number | null itemType: ItemType @@ -46,6 +47,7 @@ export interface Item { name: string description: string | null categoryId: number + brandId?: number | null baseUomId: number defaultVendorId: number | null itemType: ItemType @@ -54,6 +56,8 @@ export interface Item { status: EntityStatus reorder: ItemReorderSetting[] conversions: UomConversion[] + /** Quantity captured at creation time (e.g. from the variant builder). Not wired into the Stock Core ledger — informational only. */ + initialQty?: number | null createdAt: string updatedAt: string | null } @@ -63,11 +67,13 @@ export interface CreateItemRequest { name: string description?: string | null categoryId: number + brandId?: number | null baseUomId: number defaultVendorId?: number | null itemType: ItemType trackingMode: TrackingMode taxClass?: string | null + initialQty?: number | null } export type UpdateItemRequest = CreateItemRequest @@ -124,6 +130,7 @@ export interface Category { categoryId: number name: string parentId: number | null + createdAt: string } export interface CategoryTreeNode extends Category { @@ -134,3 +141,35 @@ export interface CreateCategoryRequest { name: string parentId?: number | null } + +export interface UpdateCategoryRequest { + name: string +} + +export interface Brand { + brandId: number + name: string + createdAt: string +} + +export interface CreateBrandRequest { + name: string +} + +export interface UpdateBrandRequest { + name: string +} + +export interface VariantCategory { + variantCategoryId: number + name: string + createdAt: string +} + +export interface CreateVariantCategoryRequest { + name: string +} + +export interface UpdateVariantCategoryRequest { + name: string +} diff --git a/docs/20-FRONTEND.md b/docs/20-FRONTEND.md index 54ac3c4..3c4167c 100644 --- a/docs/20-FRONTEND.md +++ b/docs/20-FRONTEND.md @@ -125,6 +125,11 @@ Each screen calls the endpoints in `11-BACKEND-PHASE1.md`. System steps (blue) a | Adjustment | Adjustment | `POST /stock-adjustments` | | Count | Count | `POST /stock-counts`, `PUT /stock-counts/{id}/counts`, `POST /stock-counts/{id}/post` | +### 2.2 Master data screens (supporting, outside the core flow) +Vendors, Items, Categories, UOM, Warehouses, Brands, and Variant Categories are supporting master-data CRUD screens the flow above depends on but doesn't itself route through, so they're intentionally absent from the diagram/table. List screens follow one pagination convention: `page`/`pageSize`/`q`/`sortOrder` params, page size 5, debounced search, Previous/Next controls. + +**Brand** (`app/dashboard/products/brands`) and **Variant Category** (`app/dashboard/products/variants`) are UI-only additions with no corresponding endpoint in `11-BACKEND-PHASE1.md` — Item's `brandId` is built the same way. The Item variant builder on `/dashboard/products/new` reads the Variant Category list live: checking a category (Color, Size, or any custom one added inline from that same page) reveals a value-entry section for it, and one Item is auto-created per combination across however many categories are checked, with an auto-generated SKU. Flag Brand/Variant Category to whoever owns the backend contract if they should become real entities rather than staying frontend-only; see `Frontend/PROGRESS.md` (2026-07-15 entries) for the full rationale and discarded design iterations. + --- ## 3. Validation posture (read carefully) From 7c5faabc2d7f7503c9c881f356ef32b8724f62e4 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Thu, 16 Jul 2026 14:23:23 +0530 Subject: [PATCH 02/11] ath fix --- Backend/ERPCore/Controllers/AuthController.cs | 252 ++++++++++++++++++ Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs | 42 +++ Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs | 45 ++++ Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs | 179 +++++++++++++ .../ERPCore/Infra/Auth/AuthCookieWriter.cs | 52 ++++ .../Infra/Auth/AuthHex/AuthHexClient.cs | 162 +++++++++++ .../Infra/Auth/AuthHex/AuthHexEnvelope.cs | 37 +++ .../Infra/Auth/AuthHex/IAuthHexClient.cs | 42 +++ .../ERPCore/Infra/Auth/JwtAuthExtensions.cs | 29 ++ .../Infra/Auth/ValidateCsrfAttribute.cs | 35 +++ Backend/ERPCore/Program.cs | 13 + .../ERPCore/Services/Auth/AuthAltService.cs | 41 +++ .../Services/Auth/AuthRecoveryService.cs | 25 ++ .../Services/Auth/AuthSessionResult.cs | 25 ++ .../ERPCore/Services/Auth/AuthUserService.cs | 111 ++++++++ .../Services/Interfaces/IAuthAltService.cs | 12 + .../Interfaces/IAuthRecoveryService.cs | 12 + .../Services/Interfaces/IAuthUserService.cs | 26 ++ Backend/ERPCore/System/Errors/ErrorCodes.cs | 6 + Backend/ERPCore/appsettings.Development.json | 3 + Backend/ERPCore/appsettings.Production.json | 3 + Backend/ERPCore/appsettings.json | 3 + Backend/PROGRESS.md | 14 + docs/02-SECURITY.md | 12 +- docs/10-BACKEND-PHASE1.md | 7 +- docs/11-BACKEND-PHASE1.md | 55 +++- 26 files changed, 1225 insertions(+), 18 deletions(-) create mode 100644 Backend/ERPCore/Controllers/AuthController.cs create mode 100644 Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs create mode 100644 Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs create mode 100644 Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs create mode 100644 Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs create mode 100644 Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs create mode 100644 Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs create mode 100644 Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs create mode 100644 Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs create mode 100644 Backend/ERPCore/Services/Auth/AuthAltService.cs create mode 100644 Backend/ERPCore/Services/Auth/AuthRecoveryService.cs create mode 100644 Backend/ERPCore/Services/Auth/AuthSessionResult.cs create mode 100644 Backend/ERPCore/Services/Auth/AuthUserService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IAuthAltService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IAuthUserService.cs diff --git a/Backend/ERPCore/Controllers/AuthController.cs b/Backend/ERPCore/Controllers/AuthController.cs new file mode 100644 index 0000000..5900a4a --- /dev/null +++ b/Backend/ERPCore/Controllers/AuthController.cs @@ -0,0 +1,252 @@ +using ERPCore.Dtos.Auth; +using ERPCore.Infra.Auth; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Fronts the external AuthHex identity service (API_REFERENCE.md) so the +/// frontend never calls AuthHex directly. Sessions are delivered as httpOnly +/// Secure cookies (docs/02-SECURITY.md §B.2) via +/// — response bodies never carry raw tokens. Does not inherit +/// : most actions here are pre-session and need +/// , and the ETag/If-Match handling that +/// base provides doesn't apply to auth flows. +/// +[ApiController] +[Produces("application/json")] +[Route("api/v1/auth")] +[Authorize(JwtAuthExtensions.ErpAccessPolicy)] +public sealed class AuthController : ControllerBase +{ + private readonly IAuthUserService _users; + private readonly IAuthRecoveryService _recovery; + private readonly IAuthAltService _alt; + + public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt) + { + _users = users; + _recovery = recovery; + _alt = alt; + } + + // ---- Session-issuing (UserManager) ------------------------------------ + + [HttpPost("register")] + [AllowAnonymous] + [ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)] + public async Task> Register([FromBody] RegisterRequest request, CancellationToken ct) + { + var result = await _users.RegisterAsync(request, ct); + AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn); + return Ok(result.Body); + } + + [HttpPost("login")] + [AllowAnonymous] + [ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)] + public async Task> Login([FromBody] LoginRequest request, CancellationToken ct) + { + var result = await _users.LoginAsync(request, ct); + AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn); + return Ok(result.Body); + } + + [HttpPost("login/otp/verify")] + [AllowAnonymous] + [ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)] + public async Task> VerifyLoginOtp([FromBody] VerifyOtpForLoginRequest request, CancellationToken ct) + { + var result = await _users.VerifyOtpForLoginAsync(request, ct); + AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn); + return Ok(result.Body); + } + + [HttpPost("refresh-token")] + [AllowAnonymous] + [ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)] + public async Task> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken ct) + { + if (!Request.Cookies.TryGetValue(JwtAuthExtensions.RefreshTokenCookie, out var refreshToken) || string.IsNullOrEmpty(refreshToken)) + throw new DomainException(ErrorCodes.RefreshTokenMissing, "No refresh session cookie present.", 401); + + var result = await _users.RefreshTokenAsync(refreshToken, request, ct); + AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn); + return Ok(result.Body); + } + + // ---- Profile / sessions (UserManager) --------------------------------- + + [HttpGet("users/{userId:guid}")] + [AllowAnonymous] + [ProducesResponseType(typeof(GetUserDetailsResponse), StatusCodes.Status200OK)] + public async Task> GetUserDetails(Guid userId, CancellationToken ct) + => Ok(await _users.GetUserDetailsAsync(userId, ct)); + + [HttpGet("sessions")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetSessions(CancellationToken ct) + => Ok(await _users.GetUserSessionsAsync(RequireBearerToken(), ct)); + + [HttpPost("status")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task ChangeStatus([FromBody] ChangeUserStatusRequest request, CancellationToken ct) + { + await _users.ChangeUserStatusAsync(request, RequireBearerToken(), ct); + return NoContent(); + } + + [HttpPost("lock")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task Lock([FromBody] LockUserAccountRequest request, CancellationToken ct) + { + await _users.LockUserAccountAsync(request, RequireBearerToken(), ct); + AuthCookieWriter.ClearSession(Response); + return NoContent(); + } + + [HttpPost("change-password")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct) + { + await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), ct); + AuthCookieWriter.ClearSession(Response); + return NoContent(); + } + + [HttpPost("verify-password")] + [ProducesResponseType(typeof(VerifyPasswordResponse), StatusCodes.Status200OK)] + public async Task> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct) + => Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct)); + + [HttpPost("logout")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task Logout([FromBody] LogoutRequest request, CancellationToken ct) + { + await _users.LogoutUserAsync(request, ct); + AuthCookieWriter.ClearSession(Response); + return NoContent(); + } + + [HttpPut("me")] + [ValidateCsrf] + [ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)] + public async Task> UpdateMe([FromBody] UpdateUserRequest request, CancellationToken ct) + => Ok(await _users.UpdateUserAsync(request, RequireBearerToken(), ct)); + + // ---- 2FA (UserManager) ------------------------------------------------- + + [HttpPost("2fa/initiate")] + [ValidateCsrf] + [ProducesResponseType(typeof(TwoFaSetupResponse), StatusCodes.Status200OK)] + public async Task> InitiateTwoFa(CancellationToken ct) + => Ok(await _users.InitiateTwoFaSetupAsync(RequireBearerToken(), ct)); + + [HttpPost("2fa/complete")] + [ValidateCsrf] + [ProducesResponseType(typeof(CompleteTwoFaSetupResponse), StatusCodes.Status200OK)] + public async Task> CompleteTwoFa([FromBody] CompleteTwoFaSetupRequest request, CancellationToken ct) + => Ok(await _users.CompleteTwoFaSetupAsync(request, RequireBearerToken(), ct)); + + [HttpPost("2fa/verify")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task VerifyTwoFa([FromBody] VerifyTwoFaRequest request, CancellationToken ct) + { + await _users.VerifyTwoFaAsync(request, RequireBearerToken(), ct); + return NoContent(); + } + + [HttpPost("2fa/disable")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task DisableTwoFa([FromBody] DisableTwoFaRequest request, CancellationToken ct) + { + await _users.DisableTwoFaAsync(request, RequireBearerToken(), ct); + return NoContent(); + } + + [HttpGet("2fa/status")] + [ProducesResponseType(typeof(TwoFaStatusResponse), StatusCodes.Status200OK)] + public async Task> GetTwoFaStatus(CancellationToken ct) + => Ok(await _users.GetTwoFaStatusAsync(RequireBearerToken(), ct)); + + // ---- Recovery ----------------------------------------------------------- + + [HttpPost("recovery/forgot-password")] + [AllowAnonymous] + [ProducesResponseType(typeof(ForgotPasswordResponse), StatusCodes.Status200OK)] + public async Task> ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken ct) + => Ok(await _recovery.ForgotPasswordAsync(request, ct)); + + [HttpPost("recovery/verify-otp")] + [AllowAnonymous] + [ProducesResponseType(typeof(VerifyRecoveryOtpResponse), StatusCodes.Status200OK)] + public async Task> VerifyRecoveryOtp([FromBody] VerifyRecoveryOtpRequest request, CancellationToken ct) + => Ok(await _recovery.VerifyOtpAsync(request, ct)); + + [HttpPost("recovery/reset-password")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken ct) + { + await _recovery.ResetPasswordAsync(request, ct); + return NoContent(); + } + + [HttpPost("recovery/reset-password-token")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task ResetPasswordWithToken([FromBody] ResetPasswordWithTokenRequest request, CancellationToken ct) + { + await _recovery.ResetPasswordWithTokenAsync(request, ct); + return NoContent(); + } + + // ---- Availability / OTP (AltOptionManager) ----------------------------- + + [HttpPost("availability")] + [AllowAnonymous] + [ProducesResponseType(typeof(IsAvailableResponse), StatusCodes.Status200OK)] + public async Task> CheckAvailability([FromBody] IsAvailableRequest request, CancellationToken ct) + => Ok(await _alt.IsAvailableAsync(request, ct)); + + [HttpPost("otp/send")] + [AllowAnonymous] + [ProducesResponseType(typeof(SendOtpResponse), StatusCodes.Status200OK)] + public async Task> SendOtp([FromBody] SendOtpRequest request, CancellationToken ct) + => Ok(await _alt.SendOtpAsync(request, ct)); + + [HttpPost("otp/verify")] + [AllowAnonymous] + [ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)] + public async Task> VerifyAltOtp([FromBody] VerifyAltOtpRequest request, CancellationToken ct) + { + var result = await _alt.VerifyOtpAsync(request, ct); + AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn); + return Ok(result.Body); + } + + // ---- Helpers -------------------------------------------------------------- + + /// The token that authenticated this request — Bearer header if present, else the session cookie. + private string RequireBearerToken() + { + var header = Request.Headers.Authorization.ToString(); + if (!string.IsNullOrEmpty(header) && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return header["Bearer ".Length..]; + + if (Request.Cookies.TryGetValue(JwtAuthExtensions.AccessTokenCookie, out var cookieToken) && !string.IsNullOrEmpty(cookieToken)) + return cookieToken; + + // [Authorize] already guaranteed one of the above was present to authenticate this request. + throw new DomainException(ErrorCodes.AuthUpstreamError, "No bearer token found on an authenticated request.", 500); + } +} diff --git a/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs new file mode 100644 index 0000000..26df8d5 --- /dev/null +++ b/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace ERPCore.Dtos.Auth; + +public sealed class IsAvailableRequest +{ + [Required] public string Identifier { get; set; } = string.Empty; + public string? Recovery { get; set; } +} + +public sealed class IsAvailableResponse +{ + public bool? IsAvailable { get; set; } + public string? Message { get; set; } + /// Passed through as-is when `Recovery` matched existing users — shape isn't in the documented catalog. + public JsonElement? ExistingUsers { get; set; } +} + +public sealed class SendOtpRequest +{ + [Required] public string Identifier { get; set; } = string.Empty; + public int NumberOfDigits { get; set; } = 6; + public bool NewUser { get; set; } +} + +public sealed class SendOtpResponse +{ + public string? ReferenceNumber { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? RecoveryType { get; set; } +} + +public sealed class VerifyAltOtpRequest +{ + [Required] public string ReferenceNumber { get; set; } = string.Empty; + [Required] public string OtpCode { get; set; } = string.Empty; + public string? Identifier { get; set; } + public Guid? UserId { get; set; } + public bool NewUser { get; set; } + public string? DeviceName { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs new file mode 100644 index 0000000..7aa7ba8 --- /dev/null +++ b/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; + +namespace ERPCore.Dtos.Auth; + +public sealed class ForgotPasswordRequest +{ + [Required] public string Identifier { get; set; } = string.Empty; + public bool UseResetLink { get; set; } + public int NumberOfDigits { get; set; } = 6; + public bool Welcome { get; set; } +} + +public sealed class ForgotPasswordResponse +{ + public string? ReferenceNumber { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? RecoveryType { get; set; } +} + +public sealed class VerifyRecoveryOtpRequest +{ + [Required] public string ReferenceNumber { get; set; } = string.Empty; + [Required] public string OtpCode { get; set; } = string.Empty; +} + +public sealed class VerifyRecoveryOtpResponse +{ + public string? ReferenceNumber { get; set; } + public Guid? UserId { get; set; } + public bool Verified { get; set; } +} + +public sealed class ResetPasswordRequest +{ + [Required] public string ReferenceNumber { get; set; } = string.Empty; + [Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty; + [Required] public string ConfirmPassword { get; set; } = string.Empty; +} + +public sealed class ResetPasswordWithTokenRequest +{ + [Required] public string ResetToken { get; set; } = string.Empty; + [Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty; + [Required] public string ConfirmPassword { get; set; } = string.Empty; +} diff --git a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs new file mode 100644 index 0000000..f75c193 --- /dev/null +++ b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs @@ -0,0 +1,179 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace ERPCore.Dtos.Auth; + +/// Shared AuthHex user projection (API_REFERENCE.md §3). Field set is +/// AuthHex's best-documented subset; unknown fields are ignored on deserialize. +public sealed class UserSummaryDto +{ + public Guid? UserId { get; set; } + public Guid? RoleId { get; set; } + public Guid? UserTypeId { get; set; } + public string? Fullname { get; set; } + public string? UserName { get; set; } + public string? Nic { get; set; } + public string? Email { get; set; } + public string? MobileNumber { get; set; } + public bool? EmailVerified { get; set; } + public bool? MobileNumberVerified { get; set; } + public bool? IsActive { get; set; } + public bool? IsLocked { get; set; } +} + +/// Body returned by every session-issuing endpoint. Tokens never appear +/// here — they are delivered only as httpOnly cookies (docs/02-SECURITY.md §B.2). +public sealed class AuthSessionResponse +{ + public UserSummaryDto? User { get; set; } + public int ExpiresIn { get; set; } +} + +public sealed class RegisterRequest +{ + /// Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted. + public Guid? UserId { get; set; } + [Required] public Guid RoleId { get; set; } + [Required] public Guid UserTypeId { get; set; } + public string? Fullname { get; set; } + public string? UserName { get; set; } + public string? Nic { get; set; } + public string? Email { get; set; } + public string? MobileNumber { get; set; } + public string? Password { get; set; } + public string? DeviceName { get; set; } + public bool? ChkUser { get; set; } +} + +public sealed class LoginRequest +{ + [Required] public string Identifier { get; set; } = string.Empty; + [Required] public string Password { get; set; } = string.Empty; + public Guid? UserTypeId { get; set; } + public string? DeviceName { get; set; } +} + +public sealed class VerifyOtpForLoginRequest +{ + [Required] public string ReferenceNumber { get; set; } = string.Empty; + [Required] public string OtpCode { get; set; } = string.Empty; + public string? DeviceName { get; set; } +} + +public sealed class OtpLoginVerifiedResponse +{ + public string? ReferenceNumber { get; set; } + public Guid? UserId { get; set; } + public bool Verified { get; set; } + public UserSummaryDto? User { get; set; } + public int ExpiresIn { get; set; } +} + +public sealed class RefreshTokenRequest +{ + public string? DeviceName { get; set; } +} + +public sealed class GetUserDetailsResponse +{ + public UserSummaryDto? User { get; set; } + /// Passed through as-is — AuthHex's Role/UserType shapes aren't in the documented catalog. + public JsonElement? Role { get; set; } + public JsonElement? UserType { get; set; } +} + +public sealed class SessionDto +{ + public string? SessionId { get; set; } + public string? DeviceName { get; set; } + public string? Browser { get; set; } + public string? OS { get; set; } + public string? IPAddress { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? ExpiresAt { get; set; } + public DateTime? RevokedAt { get; set; } + public bool? IsActive { get; set; } +} + +public sealed class ChangeUserStatusRequest +{ + [Required] public bool IsActive { get; set; } +} + +public sealed class LockUserAccountRequest +{ + [Required] public bool IsLocked { get; set; } +} + +public sealed class ChangeUserPasswordRequest +{ + [Required] public string CurrentPassword { get; set; } = string.Empty; + [Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty; +} + +public sealed class VerifyPasswordRequest +{ + [Required] public string Password { get; set; } = string.Empty; +} + +public sealed class VerifyPasswordResponse +{ + public bool Valid { get; set; } +} + +public sealed class UpdateUserRequest +{ + public string? FullName { get; set; } + public string? UserName { get; set; } + public string? Nic { get; set; } + public string? Address { get; set; } + public string? Optional1 { get; set; } + public string? Optional2 { get; set; } + public string? Email { get; set; } + public string? MobileNumber { get; set; } + public string? NewPassword { get; set; } + public string? CurrentPassword { get; set; } +} + +/// Passthrough — TOTP secret/QR payload shape is only loosely documented +/// ("secret key, QR/otpauth URL ... from the third-party service"). +public sealed class TwoFaSetupResponse +{ + public JsonElement Data { get; set; } +} + +public sealed class CompleteTwoFaSetupRequest +{ + [Required] public string SecretKey { get; set; } = string.Empty; + [Required] public string VerificationCode { get; set; } = string.Empty; +} + +public sealed class CompleteTwoFaSetupResponse +{ + public List BackupCodes { get; set; } = new(); + public UserSummaryDto? User { get; set; } +} + +public sealed class VerifyTwoFaRequest +{ + [Required] public string VerificationCode { get; set; } = string.Empty; +} + +public sealed class DisableTwoFaRequest +{ + [Required] public string VerificationCode { get; set; } = string.Empty; +} + +public sealed class TwoFaStatusResponse +{ + public bool IsMfaEnabled { get; set; } + public bool IsVerified { get; set; } + public DateTime? LastUsedAt { get; set; } + public DateTime? VerifiedAt { get; set; } + public bool HasBackupCodes { get; set; } +} + +public sealed class LogoutRequest +{ + [Required] public Guid UserId { get; set; } +} diff --git a/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs b/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs new file mode 100644 index 0000000..bc1ba9f --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; + +namespace ERPCore.Infra.Auth; + +/// +/// Writes/clears the httpOnly session cookies + non-httpOnly CSRF cookie +/// AuthController issues on every session-establishing call (docs/02-SECURITY.md +/// §B.2). SameSite=Strict assumes frontend and ERPCore share a registrable +/// domain (e.g. both on `localhost`, different ports) — a cross-domain +/// deployment would need SameSite=None (+ Secure, which is already set). +/// +public static class AuthCookieWriter +{ + public static void WriteSession(HttpResponse response, string accessToken, string refreshToken, int expiresInSeconds) + { + response.Cookies.Append(JwtAuthExtensions.AccessTokenCookie, accessToken, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Path = "/", + MaxAge = TimeSpan.FromSeconds(expiresInSeconds) + }); + + response.Cookies.Append(JwtAuthExtensions.RefreshTokenCookie, refreshToken, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Path = "/api/v1/auth/refresh-token", + MaxAge = TimeSpan.FromDays(30) + }); + + response.Cookies.Append(JwtAuthExtensions.CsrfCookie, GenerateCsrfToken(), new CookieOptions + { + HttpOnly = false, + Secure = true, + SameSite = SameSiteMode.Strict, + Path = "/", + MaxAge = TimeSpan.FromSeconds(expiresInSeconds) + }); + } + + public static void ClearSession(HttpResponse response) + { + response.Cookies.Delete(JwtAuthExtensions.AccessTokenCookie, new CookieOptions { Path = "/" }); + response.Cookies.Delete(JwtAuthExtensions.RefreshTokenCookie, new CookieOptions { Path = "/api/v1/auth/refresh-token" }); + response.Cookies.Delete(JwtAuthExtensions.CsrfCookie, new CookieOptions { Path = "/" }); + } + + private static string GenerateCsrfToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); +} diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs new file mode 100644 index 0000000..125230f --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs @@ -0,0 +1,162 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using ERPCore.Dtos.Auth; +using ERPCore.System.Errors; + +namespace ERPCore.Infra.Auth.AuthHex; + +/// +/// HTTP implementation of . Registered as a typed +/// client (`AddHttpClient<IAuthHexClient, AuthHexClient>`) with its +/// `BaseAddress` bound from `AuthHex:BaseUrl`. Every call POSTs AuthHex's +/// `{ functionName, payload, reference }` envelope to the matching manager +/// route and unwraps the `{ statusCode, success, message, data }` response, +/// translating upstream failures into . +/// +public sealed class AuthHexClient : IAuthHexClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + private readonly HttpClient _http; + + public AuthHexClient(HttpClient http) => _http = http; + + // ---- UserManager -------------------------------------------------- + + public Task RegisterAsync(RegisterRequest request, CancellationToken ct) + => CallAsync("user", "registerUser", request, null, ct); + + public Task LoginAsync(LoginRequest request, CancellationToken ct) + => CallAsync("user", "loginUser", request, null, ct); + + public Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct) + => CallAsync("user", "VerifyOtpForLogin", request, null, ct); + + public Task RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct) + => CallAsync("user", "refreshToken", new { refreshToken, deviceName }, null, ct); + + public Task GetUserDetailsAsync(Guid userId, CancellationToken ct) + => CallAsync("user", "getUserDetails", new { userId }, null, ct); + + public Task> GetUserSessionsAsync(string bearerToken, CancellationToken ct) + => CallAsync>("user", "getUserSessions", new { }, bearerToken, ct); + + public Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct) + => CallVoidAsync("user", "ChangeUserStatus", new { isActive }, bearerToken, ct); + + public Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct) + => CallVoidAsync("user", "LockUserAccount", new { isLocked }, bearerToken, ct); + + public Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct) + => CallVoidAsync("user", "ChangeUserPassword", request, bearerToken, ct); + + public Task VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct) + => CallAsync("user", "VerifyPassword", request, bearerToken, ct); + + public Task LogoutUserAsync(Guid userId, CancellationToken ct) + => CallVoidAsync("user", "LogoutUser", new { userId }, null, ct); + + public Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct) + => CallAsync("user", "UpdateUser", request, bearerToken, ct); + + public Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct) + => CallAsync("user", "initiateTwoFASetup", new { }, bearerToken, ct); + + public Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct) + => CallAsync("user", "completeTwoFASetup", request, bearerToken, ct); + + public Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct) + => CallVoidAsync("user", "verifyTwoFA", request, bearerToken, ct); + + public Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct) + => CallVoidAsync("user", "disableTwoFA", request, bearerToken, ct); + + public Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct) + => CallAsync("user", "getTwoFAStatus", new { }, bearerToken, ct); + + // ---- RecoveryManager ------------------------------------------------ + + public Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct) + => CallAsync("recovery", "forgotPassword", request, null, ct); + + public Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct) + => CallAsync("recovery", "verifyOTP", request, null, ct); + + public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct) + => CallVoidAsync("recovery", "resetPassword", request, null, ct); + + public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct) + => CallVoidAsync("recovery", "resetPasswordWithToken", request, null, ct); + + // ---- AltOptionManager ------------------------------------------------- + + public Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct) + => CallAsync("alt", "IsAvailable", request, null, ct); + + public Task SendOtpAsync(SendOtpRequest request, CancellationToken ct) + => CallAsync("alt", "sendOtp", request, null, ct); + + public Task VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct) + => CallAsync("alt", "VerifyOTP", request, null, ct); + + // ---- Transport -------------------------------------------------------- + + private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct) + => await CallAsync(routeGroup, functionName, payload, bearerToken, ct); + + private async Task CallAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct) + { + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/{routeGroup}"); + if (!string.IsNullOrEmpty(bearerToken)) + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + + httpRequest.Content = JsonContent.Create( + new AuthHexRequestBody { FunctionName = functionName, Payload = payload, Reference = string.Empty }, + options: JsonOptions); + + HttpResponseMessage httpResponse; + try + { + httpResponse = await _http.SendAsync(httpRequest, ct); + } + catch (HttpRequestException) + { + throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service is unreachable.", 503); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service timed out.", 503); + } + + AuthHexEnvelope? envelope; + try + { + envelope = await httpResponse.Content.ReadFromJsonAsync>(JsonOptions, ct); + } + catch (JsonException) + { + throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service returned an unreadable response.", 502); + } + + if (envelope is null) + throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service returned an empty response.", 502); + + // Trust the outer HTTP status over envelope.Success — AuthHex has been observed + // returning HTTP 500 with `success:true, data:null` on business failures (e.g. + // invalid credentials), which would otherwise slip through as a "success" and + // hand a null payload to the caller. + if (!httpResponse.IsSuccessStatusCode || !envelope.Success) + { + var statusCode = !httpResponse.IsSuccessStatusCode + ? (int)httpResponse.StatusCode + : envelope.StatusCode is >= 400 and < 600 ? envelope.StatusCode : 400; + throw new DomainException(ErrorCodes.AuthUpstreamError, envelope.Message ?? "Authentication request failed.", statusCode); + } + + return envelope.Data!; + } +} diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs new file mode 100644 index 0000000..02f7794 --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs @@ -0,0 +1,37 @@ +using ERPCore.Dtos.Auth; + +namespace ERPCore.Infra.Auth.AuthHex; + +/// Wire shape of AuthHex's `ApiResponse` envelope (API_REFERENCE.md §1). +public sealed class AuthHexEnvelope +{ + public int StatusCode { get; set; } + public bool Success { get; set; } + public string? Message { get; set; } + public T? Data { get; set; } +} + +/// Wire shape of AuthHex's `ApiRequest` envelope (API_REFERENCE.md §1). +public sealed class AuthHexRequestBody +{ + public string FunctionName { get; set; } = string.Empty; + public object Payload { get; set; } = new { }; + public string Reference { get; set; } = string.Empty; +} + +/// +/// Wire shape shared by every session-issuing AuthHex function (register, login, +/// OTP login verify, refresh, alt OTP verify). Carries the raw tokens — kept +/// internal so they never leak into a public Dtos/Auth response; AuthController +/// extracts them into httpOnly cookies and returns only . +/// +public sealed class AuthHexSessionResult +{ + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public int ExpiresIn { get; set; } + public UserSummaryDto? User { get; set; } + public string? ReferenceNumber { get; set; } + public Guid? UserId { get; set; } + public bool? Verified { get; set; } +} diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs new file mode 100644 index 0000000..081f74c --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs @@ -0,0 +1,42 @@ +using ERPCore.Dtos.Auth; + +namespace ERPCore.Infra.Auth.AuthHex; + +/// +/// Typed client for the external AuthHex identity service (API_REFERENCE.md). +/// Hides AuthHex's `functionName` dispatcher entirely — callers get one method +/// per function. Internal: only Services/Auth consumes this; the controller +/// boundary only ever sees Dtos/Auth types. +/// +public interface IAuthHexClient +{ + // UserManager (POST /api/user) + Task RegisterAsync(RegisterRequest request, CancellationToken ct); + Task LoginAsync(LoginRequest request, CancellationToken ct); + Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct); + Task RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct); + Task GetUserDetailsAsync(Guid userId, CancellationToken ct); + Task> GetUserSessionsAsync(string bearerToken, CancellationToken ct); + Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct); + Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct); + Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct); + Task VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct); + Task LogoutUserAsync(Guid userId, CancellationToken ct); + Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct); + Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct); + Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct); + Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct); + Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct); + Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct); + + // RecoveryManager (POST /api/recovery) + Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct); + Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct); + Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct); + Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct); + + // AltOptionManager (POST /api/alt) + Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct); + Task SendOtpAsync(SendOtpRequest request, CancellationToken ct); + Task VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct); +} diff --git a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs index ea879ca..658433f 100644 --- a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs +++ b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs @@ -18,6 +18,18 @@ public static class JwtAuthExtensions /// Authorization policy applied to every v1 controller (via ApiControllerBase). public const string ErpAccessPolicy = "ErpAccess"; + /// httpOnly cookie AuthController writes the AuthHex access token into (docs/02-SECURITY.md §B.2). + public const string AccessTokenCookie = "erp_at"; + + /// httpOnly cookie AuthController writes the AuthHex refresh token into, scoped to the refresh route. + public const string RefreshTokenCookie = "erp_rt"; + + /// Non-httpOnly CSRF cookie for the double-submit check on cookie-authenticated mutations. + public const string CsrfCookie = "XSRF-TOKEN"; + + /// Header the frontend echoes the CSRF cookie value back through. + public const string CsrfHeader = "X-XSRF-TOKEN"; + public static IServiceCollection AddErpJwtAuth(this IServiceCollection services, IConfiguration config) { var issuer = config["Auth:Issuer"]; @@ -49,6 +61,23 @@ public static class JwtAuthExtensions ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 }, ClockSkew = TimeSpan.FromSeconds(30) }; + // BFF cookie fallback: browsers hitting ERPCore through AuthController's + // httpOnly cookie session carry no Authorization header. Only used when + // that header is absent, so Bearer callers (Swagger, service-to-service, + // AuthHexClient forwarding) are unaffected. + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + if (string.IsNullOrEmpty(context.Token) && + context.Request.Cookies.TryGetValue(AccessTokenCookie, out var cookieToken) && + !string.IsNullOrEmpty(cookieToken)) + { + context.Token = cookieToken; + } + return Task.CompletedTask; + } + }; }); services.AddAuthorization(options => diff --git a/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs b/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs new file mode 100644 index 0000000..cf4a631 --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs @@ -0,0 +1,35 @@ +using ERPCore.System.Errors; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace ERPCore.Infra.Auth; + +/// +/// Double-submit-cookie CSRF check for cookie-authenticated, state-changing +/// AuthController actions (docs/02-SECURITY.md §B.2). Bearer-token callers +/// (Swagger, service-to-service) are exempt — CSRF only threatens requests a +/// browser sends automatically via cookies. Requires the X-XSRF-TOKEN +/// header to match the non-httpOnly XSRF-TOKEN cookie AuthController +/// issues alongside the session cookies. +/// +public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter +{ + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var request = context.HttpContext.Request; + + if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString())) + { + await next(); + return; + } + + if (!request.Cookies.TryGetValue(JwtAuthExtensions.CsrfCookie, out var cookieToken) || string.IsNullOrEmpty(cookieToken)) + throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403); + + var headerToken = request.Headers[JwtAuthExtensions.CsrfHeader].ToString(); + if (string.IsNullOrEmpty(headerToken) || !string.Equals(headerToken, cookieToken, StringComparison.Ordinal)) + throw new DomainException(ErrorCodes.CsrfTokenMismatch, "CSRF token mismatch.", 403); + + await next(); + } +} diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 635ec52..75b3156 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -1,10 +1,12 @@ using System.Text.Json.Serialization; using ERPCore.Infra.Auth; +using ERPCore.Infra.Auth.AuthHex; using ERPCore.Infra.Persistence; using ERPCore.Infra.UoW; using ERPCore.Repositories; using ERPCore.Repositories.Interfaces; using ERPCore.Services; +using ERPCore.Services.Auth; using ERPCore.Services.Interfaces; using ERPCore.Services.Stock; using ERPCore.System.Errors; @@ -35,6 +37,17 @@ builder.Services.AddExceptionHandler(); // Auth: validate external AuthHex RS256 tokens + ERP door policy (docs/10 A.4) builder.Services.AddErpJwtAuth(builder.Configuration); +// AuthController proxy → AuthHex (docs/11 §2.0) +builder.Services.AddHttpClient(c => +{ + var baseUrl = builder.Configuration["AuthHex:BaseUrl"] + ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured."); + c.BaseAddress = new Uri(baseUrl); +}); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + // Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation // JIT-provisions a local shadow user and injects the local `int` id as `nameid`. builder.Services.AddHttpContextAccessor(); diff --git a/Backend/ERPCore/Services/Auth/AuthAltService.cs b/Backend/ERPCore/Services/Auth/AuthAltService.cs new file mode 100644 index 0000000..15cb7ec --- /dev/null +++ b/Backend/ERPCore/Services/Auth/AuthAltService.cs @@ -0,0 +1,41 @@ +using ERPCore.Dtos.Auth; +using ERPCore.Infra.Auth.AuthHex; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; + +namespace ERPCore.Services.Auth; + +/// +public sealed class AuthAltService : IAuthAltService +{ + private readonly IAuthHexClient _authHex; + + public AuthAltService(IAuthHexClient authHex) => _authHex = authHex; + + public Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default) + => _authHex.IsAvailableAsync(request, ct); + + public Task SendOtpAsync(SendOtpRequest request, CancellationToken ct = default) + => _authHex.SendOtpAsync(request, ct); + + public async Task VerifyOtpAsync(VerifyAltOtpRequest request, CancellationToken ct = default) + { + var result = await _authHex.VerifyAltOtpAsync(request, ct); + if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken)) + throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502); + + return new OtpAuthSessionResult + { + AccessToken = result.AccessToken, + RefreshToken = result.RefreshToken, + Body = new OtpLoginVerifiedResponse + { + ReferenceNumber = result.ReferenceNumber, + UserId = result.UserId, + Verified = result.Verified ?? true, + User = result.User, + ExpiresIn = result.ExpiresIn + } + }; + } +} diff --git a/Backend/ERPCore/Services/Auth/AuthRecoveryService.cs b/Backend/ERPCore/Services/Auth/AuthRecoveryService.cs new file mode 100644 index 0000000..863b69f --- /dev/null +++ b/Backend/ERPCore/Services/Auth/AuthRecoveryService.cs @@ -0,0 +1,25 @@ +using ERPCore.Dtos.Auth; +using ERPCore.Infra.Auth.AuthHex; +using ERPCore.Services.Interfaces; + +namespace ERPCore.Services.Auth; + +/// +public sealed class AuthRecoveryService : IAuthRecoveryService +{ + private readonly IAuthHexClient _authHex; + + public AuthRecoveryService(IAuthHexClient authHex) => _authHex = authHex; + + public Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default) + => _authHex.ForgotPasswordAsync(request, ct); + + public Task VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default) + => _authHex.VerifyRecoveryOtpAsync(request, ct); + + public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default) + => _authHex.ResetPasswordAsync(request, ct); + + public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default) + => _authHex.ResetPasswordWithTokenAsync(request, ct); +} diff --git a/Backend/ERPCore/Services/Auth/AuthSessionResult.cs b/Backend/ERPCore/Services/Auth/AuthSessionResult.cs new file mode 100644 index 0000000..0622e4e --- /dev/null +++ b/Backend/ERPCore/Services/Auth/AuthSessionResult.cs @@ -0,0 +1,25 @@ +using ERPCore.Dtos.Auth; + +namespace ERPCore.Services.Auth; + +/// +/// Carries a freshly issued AuthHex session from a Services/Auth method back to +/// AuthController. Never serialized directly — the controller pulls +/// AccessToken/RefreshToken into httpOnly cookies (see AuthCookieWriter) and +/// returns only in the response. +/// +public sealed class AuthSessionResult +{ + public required string AccessToken { get; init; } + public required string RefreshToken { get; init; } + public required AuthSessionResponse Body { get; init; } +} + +/// Same purpose as , for the two OTP-verify +/// flows whose body also carries ReferenceNumber/Verified alongside the user/session. +public sealed class OtpAuthSessionResult +{ + public required string AccessToken { get; init; } + public required string RefreshToken { get; init; } + public required OtpLoginVerifiedResponse Body { get; init; } +} diff --git a/Backend/ERPCore/Services/Auth/AuthUserService.cs b/Backend/ERPCore/Services/Auth/AuthUserService.cs new file mode 100644 index 0000000..277d493 --- /dev/null +++ b/Backend/ERPCore/Services/Auth/AuthUserService.cs @@ -0,0 +1,111 @@ +using ERPCore.Dtos.Auth; +using ERPCore.Infra.Auth.AuthHex; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; + +namespace ERPCore.Services.Auth; + +/// +public sealed class AuthUserService : IAuthUserService +{ + private readonly IAuthHexClient _authHex; + + public AuthUserService(IAuthHexClient authHex) => _authHex = authHex; + + public async Task RegisterAsync(RegisterRequest request, CancellationToken ct = default) + { + request.UserId ??= Guid.NewGuid(); + var result = await _authHex.RegisterAsync(request, ct); + return ToSessionResult(result); + } + + public async Task LoginAsync(LoginRequest request, CancellationToken ct = default) + { + var result = await _authHex.LoginAsync(request, ct); + return ToSessionResult(result); + } + + public async Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default) + { + var result = await _authHex.VerifyOtpForLoginAsync(request, ct); + return ToOtpSessionResult(result); + } + + public async Task RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default) + { + var result = await _authHex.RefreshTokenAsync(refreshToken, request.DeviceName, ct); + return ToSessionResult(result); + } + + public Task GetUserDetailsAsync(Guid userId, CancellationToken ct = default) + => _authHex.GetUserDetailsAsync(userId, ct); + + public Task> GetUserSessionsAsync(string bearerToken, CancellationToken ct = default) + => _authHex.GetUserSessionsAsync(bearerToken, ct); + + public Task ChangeUserStatusAsync(ChangeUserStatusRequest request, string bearerToken, CancellationToken ct = default) + => _authHex.ChangeUserStatusAsync(request.IsActive, bearerToken, ct); + + public Task LockUserAccountAsync(LockUserAccountRequest request, string bearerToken, CancellationToken ct = default) + => _authHex.LockUserAccountAsync(request.IsLocked, bearerToken, ct); + + public Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct = default) + => _authHex.ChangeUserPasswordAsync(request, bearerToken, ct); + + public Task VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default) + => _authHex.VerifyPasswordAsync(request, bearerToken, ct); + + public Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default) + => _authHex.LogoutUserAsync(request.UserId, ct); + + public Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default) + => _authHex.UpdateUserAsync(request, bearerToken, ct); + + public Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default) + => _authHex.InitiateTwoFaSetupAsync(bearerToken, ct); + + public Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct = default) + => _authHex.CompleteTwoFaSetupAsync(request, bearerToken, ct); + + public Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct = default) + => _authHex.VerifyTwoFaAsync(request, bearerToken, ct); + + public Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct = default) + => _authHex.DisableTwoFaAsync(request, bearerToken, ct); + + public Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct = default) + => _authHex.GetTwoFaStatusAsync(bearerToken, ct); + + private static AuthSessionResult ToSessionResult(AuthHexSessionResult? result) + { + if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken)) + throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502); + + return new AuthSessionResult + { + AccessToken = result.AccessToken, + RefreshToken = result.RefreshToken, + Body = new AuthSessionResponse { User = result.User, ExpiresIn = result.ExpiresIn } + }; + } + + private static OtpAuthSessionResult ToOtpSessionResult(AuthHexSessionResult? result) + { + if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken)) + throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502); + + return new OtpAuthSessionResult + { + AccessToken = result.AccessToken, + RefreshToken = result.RefreshToken, + Body = new OtpLoginVerifiedResponse + { + ReferenceNumber = result.ReferenceNumber, + UserId = result.UserId, + Verified = result.Verified ?? true, + User = result.User, + ExpiresIn = result.ExpiresIn + } + }; + } +} diff --git a/Backend/ERPCore/Services/Interfaces/IAuthAltService.cs b/Backend/ERPCore/Services/Interfaces/IAuthAltService.cs new file mode 100644 index 0000000..10c5c8d --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAuthAltService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Auth; +using ERPCore.Services.Auth; + +namespace ERPCore.Services.Interfaces; + +/// AltOptionManager proxy business logic (API_REFERENCE.md §5), fronting AuthHex. +public interface IAuthAltService +{ + Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default); + Task SendOtpAsync(SendOtpRequest request, CancellationToken ct = default); + Task VerifyOtpAsync(VerifyAltOtpRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs b/Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs new file mode 100644 index 0000000..cb807c8 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Auth; + +namespace ERPCore.Services.Interfaces; + +/// RecoveryManager proxy business logic (API_REFERENCE.md §4), fronting AuthHex. +public interface IAuthRecoveryService +{ + Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default); + Task VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default); + Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default); + Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IAuthUserService.cs b/Backend/ERPCore/Services/Interfaces/IAuthUserService.cs new file mode 100644 index 0000000..785b50c --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAuthUserService.cs @@ -0,0 +1,26 @@ +using ERPCore.Dtos.Auth; +using ERPCore.Services.Auth; + +namespace ERPCore.Services.Interfaces; + +/// UserManager proxy business logic (API_REFERENCE.md §3), fronting AuthHex. +public interface IAuthUserService +{ + Task RegisterAsync(RegisterRequest request, CancellationToken ct = default); + Task LoginAsync(LoginRequest request, CancellationToken ct = default); + Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default); + Task RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default); + Task GetUserDetailsAsync(Guid userId, CancellationToken ct = default); + Task> GetUserSessionsAsync(string bearerToken, CancellationToken ct = default); + Task ChangeUserStatusAsync(ChangeUserStatusRequest request, string bearerToken, CancellationToken ct = default); + Task LockUserAccountAsync(LockUserAccountRequest request, string bearerToken, CancellationToken ct = default); + Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct = default); + Task VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default); + Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default); + Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default); + Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default); + Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct = default); + Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct = default); + Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct = default); + Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index 48a544b..a86e142 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -24,4 +24,10 @@ public static class ErrorCodes public const string ReasonCodeRequired = "REASON_CODE_REQUIRED"; public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT"; public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY"; + + // Auth proxy (AuthController → AuthHex, docs/11 §2.0) + public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR"; + public const string AuthServiceUnavailable = "AUTH_SERVICE_UNAVAILABLE"; + public const string CsrfTokenMismatch = "CSRF_TOKEN_MISMATCH"; + public const string RefreshTokenMissing = "REFRESH_TOKEN_MISSING"; } diff --git a/Backend/ERPCore/appsettings.Development.json b/Backend/ERPCore/appsettings.Development.json index 3d13631..28ac658 100644 --- a/Backend/ERPCore/appsettings.Development.json +++ b/Backend/ERPCore/appsettings.Development.json @@ -7,5 +7,8 @@ }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root" + }, + "AuthHex": { + "BaseUrl": "http://localhost:5011" } } diff --git a/Backend/ERPCore/appsettings.Production.json b/Backend/ERPCore/appsettings.Production.json index 838af85..f2762ae 100644 --- a/Backend/ERPCore/appsettings.Production.json +++ b/Backend/ERPCore/appsettings.Production.json @@ -4,5 +4,8 @@ }, "Jwt": { "SigningKey": "" + }, + "AuthHex": { + "BaseUrl": "" } } diff --git a/Backend/ERPCore/appsettings.json b/Backend/ERPCore/appsettings.json index d49cb2d..8645532 100644 --- a/Backend/ERPCore/appsettings.json +++ b/Backend/ERPCore/appsettings.json @@ -15,5 +15,8 @@ "RequiredUserTypeCode": "", "RequiredRoleCode": "" }, + "AuthHex": { + "BaseUrl": "CHANGE_ME" + }, "AllowedHosts": "*" } diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index 7e710c6..fa8ccd2 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -59,6 +59,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [x] Audit log on every mutation (who/when/old→new) — `AuditLog` (jsonb `changeSet`), written by an `ErpDbContext.SaveChanges` override (`AuditScribe`): Create captures the field set, Update captures **only changed fields as {old,new}**, Delete captures the prior row; PK/RowVersion excluded; ledger/layer/seq/self/journal excluded. Actor from `ICurrentUser` (system=1 until auth). Read via `GET /audit-logs`. **Verified** (Item create+update old→new; StockAdjustment create). This is the **AR-01 compensating control** (02-SECURITY B.3) — app-level append-only; DB-role UPDATE/DELETE revoke still deferred. - [x] Document numbering sequences (per type, per year) — `NumberSequence` + `NumberSequenceService` (atomic `INSERT … ON CONFLICT … RETURNING` inside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO. - [x] Auth: **external AuthHex IdP integration** (2026-07-14) — ERPCore is a resource server. `JwtAuthExtensions` validates **RS256** against AuthHex's RSA **public** key (config `Auth:RsaPublicKeyXml` → `RsaSecurityKey`; `MapInboundClaims=false`), issuer `AuthHex`, audience `AuthHexClient` (no JWKS → static key). `[Authorize(ErpAccess)]` on `ApiControllerBase` gates every v1 endpoint; the `ErpAccess` policy `RequireAuthenticatedUser` + optional `RequireClaim(UserTypeCode/RoleCode)` from `Auth:RequiredUserTypeCode`/`RequiredRoleCode` (empty ⇒ any valid ERP token — AuthHex is ERP-dedicated). **Shadow-user JIT provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`) maps the token's `UserId` **GUID** → a local `users` row (`auth_user_id` unique; Username/DisplayName = `NIC`), idempotent, and injects the local `int` id as `nameid` so `ICurrentUser.AuditUserId` resolves the real actor. Migration `AddAuthUserId`. **Verified:** no token→401; `/health`,`/api/meta`,Swagger anonymous; valid token→200; shadow user provisioned (User 2, Username=NIC, AuthUserId=GUID); item Create **audited as the shadow user (id 2, not system)**; re-request reuses the same user; door gate → **403** on UserType mismatch, **200** on match. +- [x] Auth proxy: **`AuthController` fronting AuthHex** (2026-07-16) — the frontend no longer calls AuthHex directly; `Controllers/AuthController.cs` + `Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService}` proxy all 24 AuthHex functions (register/login/OTP-login/refresh/sessions/status/lock/change-password/verify-password/logout/update/2FA×5/recovery×4/alt×3) via `Infra/Auth/AuthHex/{IAuthHexClient,AuthHexClient}` (`AuthHex:BaseUrl` config). Sessions delivered as httpOnly Secure `erp_at`/`erp_rt` cookies + `XSRF-TOKEN` double-submit cookie (`Infra/Auth/AuthCookieWriter.cs`, 02-SECURITY §B.2); `ValidateCsrfAttribute` guards every mutating action; the JWT bearer handler now also accepts `erp_at` as a fallback (`JwtAuthExtensions`'s `OnMessageReceived`) so every other v1 controller keeps working unchanged. See `docs/11-BACKEND-PHASE1.md §2.0` for the full route table and `docs/02-SECURITY.md` AR-07/AR-08 for the two carried-over exposures (anonymous `getUserDetails`/`LogoutUser`, no rate limiting yet). **Not done this pass:** CORS (needed once a browser frontend calls these endpoints cross-origin), rate limiting, and the frontend wiring itself (`lib/api/auth.ts` + login/OTP/reset pages) — all deliberately deferred follow-ups. - [x] JournalEntryStub emitted per stock movement (data only) — `JournalEntryStub` written in `FifoCostingService.PostLedgerAsync` for every ledger entry (In → Dr Inventory `1300` / Cr Clearing `2100`; Out reverses; amount = movement value). Placeholder accounts until a chart of accounts exists. Read via `GET /journal-entries`. **Verified** (GRN In 700, ADJ Out 70). - [x] Negative-stock policy enforcement (default block) — enforced in `FifoCostingService.ConsumeAsync` → `409 STOCK_NEGATIVE_BLOCKED` (verified). Per-item override still a config stub. - [~] FEFO picking for perishables; block expired / on-hold issue — **issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built. @@ -133,3 +134,16 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Shadow-user provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`, scoped) maps token `UserId` GUID → local `users` row (`auth_user_id` unique, Username/DisplayName=`NIC`), idempotent w/ race-safe re-read, injects local `int` id as `nameid`. `User.AuthUserId` (Guid?) + `AuthHexClaims` consts + migration `AddAuthUserId`. - **Verified (minted AuthHex-shaped RS256 token, signed with AuthHex's real private key):** no token→401; `/health`,`/api/meta`,Swagger→200 anon; valid token→200; POST item→201 **audited as shadow user id 2** (Username=NIC, AuthUserId=GUID), not system; repeat request reuses user (1 provision); door gate `RequiredUserTypeCode=WAREHOUSE` → ERP-type token **403**, WAREHOUSE-type token **200**. Build clean; migration applied. - **§6 COMPLETE.** Only intentional deferral left in Phase 1: FEFO pick-ordering (§6, `[~]`). Follow-ups: set the real ERP `Auth:RequiredUserTypeCode`/`RoleCode` for production; secure the RSA key rotation process. + +### 2026-07-16 — Auth proxy: `AuthController` fronting AuthHex (frontend no longer calls AuthHex directly) +- **Architecture reversal:** the 2026-07-14 "resource-server-only, no login proxy" decision (docs/10, docs/11 §2.0) is reversed — the frontend was found to have **zero** existing AuthHex integration (login/OTP/reset screens were UI-only mocks with no network calls), so this was greenfield backend work, not a migration. `docs/10-BACKEND-PHASE1.md` (header, A.4, NFR-03) and `docs/11-BACKEND-PHASE1.md §2.0` updated in place; `docs/02-SECURITY.md` gained AR-07/AR-08 and ticked 3 of 5 B.2 boxes. +- **`Infra/Auth/AuthHex/`** — `IAuthHexClient`/`AuthHexClient` (typed `HttpClient`, `AuthHex:BaseUrl` config = `http://localhost:5011` dev), one C# method per AuthHex `functionName`, hides the `{functionName,payload,reference}`/`{statusCode,success,message,data}` dispatcher envelope entirely; upstream failures → `DomainException` (`AUTH_UPSTREAM_ERROR`/`AUTH_SERVICE_UNAVAILABLE`). +- **`Dtos/Auth/*`** — REST-shaped request/response DTOs per function (not a functionName-dispatcher passthrough), matching ERPCore's existing DTO-at-boundary convention. Session-issuing responses (`AuthSessionResponse`, `OtpLoginVerifiedResponse`) deliberately omit tokens. +- **`Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService}`** — orchestrate `IAuthHexClient` calls; `AuthSessionResult`/`OtpAuthSessionResult` (`Services/Auth/AuthSessionResult.cs`) carry tokens from service → controller only, never serialized. +- **`Controllers/AuthController.cs`** — `api/v1/auth/*`, 24 actions (see `docs/11 §2.0` table); inherits `ControllerBase` directly (not `ApiControllerBase`) since most actions need `[AllowAnonymous]` and its ETag/If-Match handling doesn't apply here. +- **Cookie/CSRF (`Infra/Auth/AuthCookieWriter.cs`, `ValidateCsrfAttribute.cs`, `JwtAuthExtensions.cs`):** `erp_at` (Path `/`), `erp_rt` (Path `/api/v1/auth/refresh-token`, scoped so it's only sent to the refresh call), `XSRF-TOKEN` (non-httpOnly) — all `HttpOnly`(except CSRF)/`Secure`/`SameSite=Strict`. `ValidateCsrfAttribute` double-submit-checks `X-XSRF-TOKEN` against the cookie on every mutating action, exempting Bearer-header callers. `JwtAuthExtensions`'s `OnMessageReceived` falls back to the `erp_at` cookie when no `Authorization` header is present — every existing v1 controller keeps working unchanged under either auth mode. +- **Verified:** `dotnet build` clean (0 warn/0 err) after two passes — first pass hit `CS0051` (a public interface/constructor can't expose an `internal` parameter type) on `IAuthHexClient` and its supporting `AuthHex*` wire types, fixed by making them `public`; second pass caught a cookie-path bug (`erp_rt`'s `Path` was written as `/api/auth/refresh-token`, not matching the actual `/api/v1/auth/refresh-token` route — the browser would never have sent the cookie back on refresh) before it shipped. +- **Not done this pass (tracked as follow-ups, not silently skipped):** CORS (needed once a browser frontend calls cross-origin — `docs/02-SECURITY.md §B.2` left unticked), rate limiting on the anonymous endpoints (`docs/02-SECURITY.md` AR-08), and the frontend wiring itself (`lib/api/auth.ts` + wiring `app/login/**`'s mock pages to these endpoints) — deliberately out of scope per user decision. +- **Live-verified against the running AuthHex instance (`:5011`) and ERPCore (`:5224`, dev):** `register` → `200` with `Set-Cookie: erp_at`(httpOnly/Secure/Strict/maxAge=3600) + `erp_rt`(httpOnly/Secure/Strict/Path=`/api/v1/auth/refresh-token`/30d) + `XSRF-TOKEN`(Secure/Strict, JS-readable), body carries `user`+`expiresIn` only, **no tokens**; the `erp_at` cookie alone (zero `Authorization` header) authenticated `GET /api/v1/items` — confirms the `OnMessageReceived` cookie fallback works for every existing v1 controller unchanged; `GET /api/v1/auth/sessions` (protected, cookie-authenticated) → `200`; mutating `POST /api/v1/auth/change-password` without `X-XSRF-TOKEN` → `403 CSRF_TOKEN_MISMATCH`, with the matching header → `204` + all three cookies cleared, exactly as designed. +- **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing. +- **Login/refresh/logout left unverified live** — `loginUser` currently fails with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` on AuthHex **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — this is a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. Blocked on an AuthHex-side fix; re-run the register→login→cookie→refresh→logout pass once that's resolved. diff --git a/docs/02-SECURITY.md b/docs/02-SECURITY.md index 0adb00f..f8c166d 100644 --- a/docs/02-SECURITY.md +++ b/docs/02-SECURITY.md @@ -18,6 +18,8 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin | **AR-04** | **GRN-without-PO** — receive (and value) goods with no order; a fraud vector. | Emergency/direct receipt is useful (FR-GRN-02). | Permission-gated (when RBAC on); flagged for review; audit; cost entered here gets extra scrutiny (see C.3). | With RBAC / policy on direct receipts. | | **AR-05** | **In-transit loss window** — dispatched-but-not-received stock is untracked shrinkage risk. | Inherent to in-transit transfers (FR-STK-05). | In-transit aging monitoring report. | Add stuck-transfer alert (Part D). | | **AR-06** | **Localhost dev secrets** in `appsettings.Development.json`. | Local-dev convenience, current phase. | `.gitignore` + localhost only. | Before any shared/staging/prod → User Secrets / env vars; rotate. | +| **AR-07** | **`getUserDetails` / `LogoutUser` callable without a bearer token** — `GET /api/v1/auth/users/{userId}` and `POST /api/v1/auth/logout` resolve the target user from the URL/payload, not the caller's session, so any anonymous caller can fetch a profile or log out an arbitrary user's sessions by GUID. | Carried over verbatim from AuthHex's own dispatcher contract (API_REFERENCE.md §3) — ERPCore's `AuthController` proxies it as-is rather than silently tightening a contract it doesn't own. | GUIDs are not enumerable; every call is written to `AuthEventLogs` upstream in AuthHex. | Revisit once AuthHex exposes a token-scoped variant, or add ERPCore-side rate limiting / auth requirement ahead of AuthHex. | +| **AR-08** | **No rate limiting on `AuthController`'s anonymous endpoints** (login, register, refresh, recovery, OTP send/verify) — brute-force and account-enumeration exposure. | Not built in this pass (docs/11 §2.0, added 2026-07-16); AuthHex may rate-limit server-side but ERPCore does not add its own layer yet. | AuthHex's own lockout/backoff (per docs/10 NFR-03), immutable audit trail. | Add ASP.NET Core rate limiting middleware to `AuthController` before any non-local deployment. | --- @@ -33,11 +35,11 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin - [ ] Generic auth-failure messages (no account-enumeration signal) ### B.2 Token storage & CSRF *(httpOnly-cookie decision)* -- [ ] Token in an **httpOnly, Secure** cookie (never localStorage) — removes XSS token theft -- [ ] `SameSite=Strict` (or `Lax`) on the auth cookie -- [ ] **CSRF protection on every state-changing request** (anti-forgery / double-submit token) — cookies are sent automatically, so CSRF is now in scope -- [ ] CORS locked to the known frontend origin(s); credentials mode aligned with the cookie -- [ ] Cookie scoped minimally (path/domain), Secure flag on +- [x] Token in an **httpOnly, Secure** cookie (never localStorage) — removes XSS token theft (`AuthCookieWriter.WriteSession`, `erp_at`/`erp_rt`, 2026-07-16) +- [x] `SameSite=Strict` on the auth cookie (`AuthCookieWriter`; assumes frontend + ERPCore share a registrable domain — revisit if deployed cross-domain) +- [x] **CSRF protection on every state-changing request** (double-submit `XSRF-TOKEN` cookie + `X-XSRF-TOKEN` header, `ValidateCsrfAttribute`, applied to every mutating `AuthController` action; Bearer-header callers exempt since they aren't cookie-driven) +- [ ] CORS locked to the known frontend origin(s); credentials mode aligned with the cookie — **not yet configured**; required before any browser frontend can call these endpoints cross-origin (tracked with the frontend-wiring follow-up) +- [x] Cookie scoped minimally (path/domain), Secure flag on (`erp_rt` scoped to `/api/v1/auth/refresh-token`; all three cookies `Secure=true`) ### B.3 Audit integrity *(this is the compensating control for AR-01 — it must hold)* - [ ] Audit log **and** stock ledger are append-only **at the DB level** (the app's DB role has no `UPDATE`/`DELETE` on those tables) diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md index 591e8cb..089f757 100644 --- a/docs/10-BACKEND-PHASE1.md +++ b/docs/10-BACKEND-PHASE1.md @@ -3,7 +3,7 @@ > **Authoritative for:** backend architecture, business rules, and the data model (the 38-entity schema). > **Navigation:** you arrived here from `00-CORE.md`. API request/response contracts are in `11-BACKEND-PHASE1.md`. Frontend rules are in `20-FRONTEND.md`. Record work in `Backend/PROGRESS.md`. > **Scope basis:** SRS v1.1. Costing = FIFO · Multi-warehouse · Single-tenant · RBAC deferred (user identity stamped) · approvals auto/config-gated · vendor invoice + 3-way match deferred to Accounting. -> **Authentication:** delegated to the **external AuthHex identity provider** (separate service). ERPCore is a **resource server** that only *validates* AuthHex's RS256 JWTs — it does not issue tokens or own a login endpoint. See A.4 (Authentication / Audit actor). RBAC (per-endpoint) still deferred. +> **Authentication:** identity is owned by the **external AuthHex identity provider** (separate service), but as of 2026-07-16 the frontend no longer calls AuthHex directly — all login/registration/recovery/2FA/session traffic is proxied through ERPCore's own `AuthController` (`Controllers/AuthController.cs`, `Services/Auth/*`), which forwards to AuthHex and delivers the resulting session as httpOnly Secure cookies (docs/02-SECURITY.md §B.2). ERPCore still does not mint or sign tokens itself — it only forwards to and validates AuthHex's RS256 JWTs. See A.4 (Authentication / Audit actor) and `11-BACKEND-PHASE1.md §2.0` for the endpoint list. RBAC (per-endpoint) still deferred. --- @@ -49,7 +49,8 @@ HTTP ─► Controller ─► Service ─► Repository ─► UnitOfWork / ErpD ## A.4 Cross-cutting - **Errors:** RFC 7807 `ProblemDetails` (framework default). Domain exceptions in `System/Errors` carry a stable `code`; a middleware maps them to `ProblemDetails`. Catalog in `11-BACKEND-PHASE1.md §7`. -- **Authentication:** ERPCore is a **resource server**. It validates JWTs issued by the **external AuthHex IdP** — algorithm **RS256** (asymmetric RSA), issuer `AuthHex`, audience `AuthHexClient`. AuthHex exposes **no JWKS/OIDC discovery**, so ERPCore is configured with AuthHex's **RSA public key statically** (rotation is a manual config update). Tokens live ~1000 min (prod) / 60 min (dev). A single **door authorization policy** requires an ERP `UserTypeCode`/`RoleCode` claim (AuthHex is a shared IdP, so a valid token alone is not enough); **per-endpoint RBAC stays deferred**. +- **Authentication:** ERPCore validates JWTs issued by the **external AuthHex IdP** — algorithm **RS256** (asymmetric RSA), issuer `AuthHex`, audience `AuthHexClient`. AuthHex exposes **no JWKS/OIDC discovery**, so ERPCore is configured with AuthHex's **RSA public key statically** (rotation is a manual config update). Tokens live ~1000 min (prod) / 60 min (dev). A single **door authorization policy** requires an ERP `UserTypeCode`/`RoleCode` claim (AuthHex is a shared IdP, so a valid token alone is not enough); **per-endpoint RBAC stays deferred**. +- **Auth proxy:** `AuthController` (`Controllers/AuthController.cs`) is ERPCore's only endpoint group that talks to AuthHex over HTTP — via `IAuthHexClient` (`Infra/Auth/AuthHex/AuthHexClient.cs`, `AuthHex:BaseUrl` config) — and the only place that issues httpOnly `erp_at`/`erp_rt` session cookies (`Infra/Auth/AuthCookieWriter.cs`) plus the `XSRF-TOKEN` double-submit cookie checked by `ValidateCsrfAttribute` on mutating actions. The JWT bearer handler also accepts the `erp_at` cookie as a fallback (`JwtAuthExtensions`'s `OnMessageReceived`) when no `Authorization` header is present, so every other controller keeps working unchanged whether a caller sends a Bearer header or relies on the cookie session. - **Audit actor:** the token carries no `sub`/`nameid`; identity is AuthHex's custom **`UserId` (GUID)** claim. An `ICurrentUser` abstraction (`Infra/Auth`) resolves the acting user from a **local shadow user** — the GUID is mapped (JIT-provisioned) to a local `int` `users.user_id` that all FKs reference (see C.7). Services stamp mutations with it. **Never** trust a `createdBy` from the request body. - **Concurrency:** mutable resources carry a `RowVersion` (`[Timestamp] byte[]`), surfaced as `ETag`; `PUT`/`PATCH` require `If-Match` → `412` on mismatch. - **Numbering:** document numbers come from `NumberSequence` (per doc type, per year), issued inside the same transaction as the document. @@ -200,7 +201,7 @@ UI: responsive; count/pick screens handheld-friendly; status badges; mandatory-f |---|---|---| | NFR-01 | Performance | Single item/warehouse enquiry + valuation < 2s under normal load; ledger posting transactional, < 1s per line. | | NFR-02 | Integrity | FIFO layer consumption atomic and concurrency-safe; no double-consumption of remaining qty. | -| NFR-03 | Security | Users authenticated via the external AuthHex IdP; **password hashing (BCrypt) is AuthHex's responsibility** — ERPCore validates tokens only. Every action attributed to a user and logged. (Role-based enforcement deferred, FR-X-01.) | +| NFR-03 | Security | Users authenticated via the external AuthHex IdP, proxied through ERPCore's `AuthController` (docs/11 §2.0); **password hashing (BCrypt) is AuthHex's responsibility** — ERPCore forwards credentials and validates the resulting tokens only, never storing or hashing passwords itself. Every action attributed to a user and logged. (Role-based enforcement deferred, FR-X-01.) | | NFR-04 | Auditability | Audit trail immutable, retained per policy; ledger append-only. | | NFR-05 | Reliability | No stock transaction partially commits; full rollback on failure. | | NFR-06 | Scalability | Growth in items/warehouses/ledger without redesign; ledger indexed for time-series queries. | diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index 8d1a94d..a570116 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -18,7 +18,7 @@ Path-based versioning. Breaking changes bump the major version. ``` Authorization: Bearer ``` -- Every endpoint requires a valid **Bearer JWT**; unauthenticated → `401`. Tokens are issued by the **external AuthHex IdP** (not ERPCore) — **RS256**, issuer `AuthHex`, audience `AuthHexClient`. ERPCore validates them against AuthHex's static RSA public key (no JWKS) and admits only holders of the configured ERP `UserType`/`Role` (door policy) → otherwise `403`. +- Every endpoint requires a valid **Bearer JWT**, sent either as an `Authorization: Bearer ` header or as the `erp_at` httpOnly cookie issued by `AuthController` (§2.0); unauthenticated → `401`. Tokens are issued by the **external AuthHex IdP** (not ERPCore) — **RS256**, issuer `AuthHex`, audience `AuthHexClient`. ERPCore validates them against AuthHex's static RSA public key (no JWKS) and admits only holders of the configured ERP `UserType`/`Role` (door policy) → otherwise `403`. - **Per-endpoint RBAC is NOT enforced in Phase 1** (FR-X-01): any ERP-admitted user may call any endpoint. - The **audit actor** is AuthHex's custom **`UserId` (GUID)** claim, mapped to a local shadow user (`int`). Clients never send `createdBy`; the server derives it (docs/10 A.4). @@ -59,15 +59,50 @@ Domain errors add a stable `code` (catalog §7): ## 2. Master Data -### 2.0 Auth — **external (AuthHex IdP); not an ERPCore endpoint** -> **Superseded (2026-07-14).** ERPCore no longer exposes `/auth/login`. Login, registration and recovery are owned by the -> separate **AuthHex** service (e.g. `POST /api/loginUser` with `{ identifier, password }`), which returns an **RS256** JWT -> (issuer `AuthHex`, audience `AuthHexClient`; claims `UserId` (GUID), `UserTypeCode`, `RoleCode`, `NIC`, …). ERPCore only -> **validates** that Bearer token and provisions a local shadow user (docs/10 A.4). The old shape is retained here for history: -```json -POST {AuthHex}/api/loginUser → { "identifier": "…", "password": "••••••••" } -// 200 → an RS256 access token (Bearer). Bad credentials → 401. Token → ERPCore Authorization: Bearer . -``` +### 2.0 Auth — **`AuthController`, proxying the external AuthHex IdP** +> **Superseding note (2026-07-16).** Un-superseded: the frontend no longer calls AuthHex directly. All of the endpoints +> below live on ERPCore under `/api/v1/auth/*` (`Controllers/AuthController.cs`), each forwarding to the matching AuthHex +> `functionName` (see the project-root `API_REFERENCE.md` for AuthHex's own contract) via `IAuthHexClient`. Session-issuing +> endpoints deliver AuthHex's access/refresh tokens as **httpOnly Secure cookies** (`erp_at`, `erp_rt`) plus a non-httpOnly +> `XSRF-TOKEN` cookie (docs/02-SECURITY.md §B.2) — response bodies never contain raw tokens. Mutating, cookie-authenticated +> requests must echo the CSRF cookie value in an `X-XSRF-TOKEN` header or receive `403 CSRF_TOKEN_MISMATCH`; Bearer-header +> callers (Swagger, service-to-service) are exempt. The JWT bearer handler also accepts the `erp_at` cookie in place of an +> `Authorization` header (docs/10 A.4 Auth proxy), so every other `/api/v1/*` controller keeps working unchanged either way. + +| Route | AuthHex function | Auth | +|---|---|---| +| `POST /api/v1/auth/register` | registerUser | Anonymous | +| `POST /api/v1/auth/login` | loginUser | Anonymous | +| `POST /api/v1/auth/login/otp/verify` | VerifyOtpForLogin | Anonymous | +| `POST /api/v1/auth/refresh-token` | refreshToken | Anonymous (reads `erp_rt` cookie) | +| `GET /api/v1/auth/users/{userId}` | getUserDetails | Anonymous* | +| `GET /api/v1/auth/sessions` | getUserSessions | Required | +| `POST /api/v1/auth/status` | ChangeUserStatus | Required + CSRF | +| `POST /api/v1/auth/lock` | LockUserAccount | Required + CSRF | +| `POST /api/v1/auth/change-password` | ChangeUserPassword | Required + CSRF | +| `POST /api/v1/auth/verify-password` | VerifyPassword | Required | +| `POST /api/v1/auth/logout` | LogoutUser | Anonymous* | +| `PUT /api/v1/auth/me` | UpdateUser | Required + CSRF | +| `POST /api/v1/auth/2fa/initiate` | initiateTwoFASetup | Required + CSRF | +| `POST /api/v1/auth/2fa/complete` | completeTwoFASetup | Required + CSRF | +| `POST /api/v1/auth/2fa/verify` | verifyTwoFA | Required + CSRF | +| `POST /api/v1/auth/2fa/disable` | disableTwoFA | Required + CSRF | +| `GET /api/v1/auth/2fa/status` | getTwoFAStatus | Required | +| `POST /api/v1/auth/recovery/forgot-password` | forgotPassword | Anonymous | +| `POST /api/v1/auth/recovery/verify-otp` | verifyOTP | Anonymous | +| `POST /api/v1/auth/recovery/reset-password` | resetPassword | Anonymous | +| `POST /api/v1/auth/recovery/reset-password-token` | resetPasswordWithToken | Anonymous | +| `POST /api/v1/auth/availability` | IsAvailable | Anonymous | +| `POST /api/v1/auth/otp/send` | sendOtp | Anonymous | +| `POST /api/v1/auth/otp/verify` | VerifyOTP (Alt) | Anonymous | + +\* `getUserDetails` and `LogoutUser` are anonymous because AuthHex itself resolves them from the request payload rather +than the bearer token — carried over from AuthHex's own design, not introduced by this proxy. Tracked as an accepted risk +in docs/02-SECURITY.md Part A. + +Request/response field shapes match AuthHex's own payloads one-for-one (project-root `API_REFERENCE.md` §3–§5), except +session-issuing responses omit `AccessToken`/`RefreshToken` (cookie-delivered instead) and `refreshToken` is read from the +`erp_rt` cookie rather than the request body. ### 2.1 Items #### `GET /items` From f72b24fcaa28c4f4f485e786cf563112747156ca Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Fri, 17 Jul 2026 00:23:33 +0530 Subject: [PATCH 03/11] completed invetory updates --- .../ERPCore/Controllers/BrandsController.cs | 67 + .../Controllers/CategoriesController.cs | 81 +- .../Controllers/ItemTypesController.cs | 73 + .../ERPCore/Controllers/ItemsController.cs | 4 +- .../Controllers/ProductConfigController.cs | 46 + .../Controllers/SubCategoriesController.cs | 56 + Backend/ERPCore/Domain/Entities/Brand.cs | 21 + Backend/ERPCore/Domain/Entities/Category.cs | 20 +- Backend/ERPCore/Domain/Entities/Item.cs | 9 +- Backend/ERPCore/Domain/Entities/ItemType.cs | 31 + .../ERPCore/Domain/Entities/ProductConfig.cs | 35 + .../ERPCore/Domain/Entities/SubCategory.cs | 26 + Backend/ERPCore/Domain/Enums/ItemType.cs | 12 - Backend/ERPCore/Domain/Enums/StockNature.cs | 14 + Backend/ERPCore/Dtos/Brands/BrandDtos.cs | 26 + .../ERPCore/Dtos/Categories/CategoryDtos.cs | 51 +- .../ERPCore/Dtos/Config/ProductConfigDtos.cs | 27 + .../ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs | 31 + Backend/ERPCore/Dtos/Items/ItemDtos.cs | 23 +- .../Configurations/BrandConfiguration.cs | 29 + .../Configurations/CategoryConfiguration.cs | 16 +- .../Configurations/ItemConfiguration.cs | 13 +- .../Configurations/ItemTypeConfiguration.cs | 33 + .../ProductConfigConfiguration.cs | 37 + .../SubCategoryConfiguration.cs | 35 + .../ERPCore/Infra/Persistence/DataSeeder.cs | 55 +- .../ERPCore/Infra/Persistence/ErpDbContext.cs | 8 +- ...oriesItemTypesAndProductConfig.Designer.cs | 2454 +++++++++++++++++ ...sSubcategoriesItemTypesAndProductConfig.cs | 442 +++ .../Migrations/ErpDbContextModelSnapshot.cs | 263 +- Backend/ERPCore/Program.cs | 3 + Backend/ERPCore/Services/BrandService.cs | 114 + Backend/ERPCore/Services/CategoryService.cs | 204 +- .../Services/Interfaces/IBrandService.cs | 16 + .../Services/Interfaces/ICategoryService.cs | 24 +- .../Services/Interfaces/IItemService.cs | 3 +- .../Services/Interfaces/IItemTypeService.cs | 20 + .../Interfaces/IProductConfigService.cs | 13 + Backend/ERPCore/Services/ItemService.cs | 97 +- Backend/ERPCore/Services/ItemTypeService.cs | 118 + .../ERPCore/Services/ProductConfigService.cs | 71 + Backend/ERPCore/System/Errors/ErrorCodes.cs | 1 + Backend/PROGRESS.md | 37 +- docs/00-CORE.md | 2 +- docs/10-BACKEND-PHASE1.md | 42 +- docs/11-BACKEND-PHASE1.md | 163 +- docs/20-FRONTEND.md | 19 +- 47 files changed, 4849 insertions(+), 136 deletions(-) create mode 100644 Backend/ERPCore/Controllers/BrandsController.cs create mode 100644 Backend/ERPCore/Controllers/ItemTypesController.cs create mode 100644 Backend/ERPCore/Controllers/ProductConfigController.cs create mode 100644 Backend/ERPCore/Controllers/SubCategoriesController.cs create mode 100644 Backend/ERPCore/Domain/Entities/Brand.cs create mode 100644 Backend/ERPCore/Domain/Entities/ItemType.cs create mode 100644 Backend/ERPCore/Domain/Entities/ProductConfig.cs create mode 100644 Backend/ERPCore/Domain/Entities/SubCategory.cs delete mode 100644 Backend/ERPCore/Domain/Enums/ItemType.cs create mode 100644 Backend/ERPCore/Domain/Enums/StockNature.cs create mode 100644 Backend/ERPCore/Dtos/Brands/BrandDtos.cs create mode 100644 Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs create mode 100644 Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs create mode 100644 Backend/ERPCore/Services/BrandService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IBrandService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IItemTypeService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IProductConfigService.cs create mode 100644 Backend/ERPCore/Services/ItemTypeService.cs create mode 100644 Backend/ERPCore/Services/ProductConfigService.cs diff --git a/Backend/ERPCore/Controllers/BrandsController.cs b/Backend/ERPCore/Controllers/BrandsController.cs new file mode 100644 index 0000000..7d4c5b3 --- /dev/null +++ b/Backend/ERPCore/Controllers/BrandsController.cs @@ -0,0 +1,67 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Brands; +using ERPCore.Dtos.Common; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6). +[Route("api/v1/brands")] +public sealed class BrandsController : ApiControllerBase +{ + private readonly IBrandService _brands; + + public BrandsController(IBrandService brands) => _brands = brands; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _brands.ListAsync(query, status, ct)); + + [HttpGet("{brandId:int}")] + [ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int brandId, CancellationToken ct) + { + var result = await _brands.GetAsync(brandId, ct); + if (result is null) return NotFound(); + + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(BrandDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateBrandRequest request, CancellationToken ct) + { + var result = await _brands.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/brands/{result.Value.BrandId}", result.Value); + } + + [HttpPut("{brandId:int}")] + [ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int brandId, [FromBody] UpdateBrandRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _brands.UpdateAsync(brandId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08). + [HttpPatch("{brandId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int brandId, [FromBody] UpdateBrandStatusRequest request, CancellationToken ct) + { + await _brands.SetStatusAsync(brandId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/CategoriesController.cs b/Backend/ERPCore/Controllers/CategoriesController.cs index dab7c60..3fcd0e2 100644 --- a/Backend/ERPCore/Controllers/CategoriesController.cs +++ b/Backend/ERPCore/Controllers/CategoriesController.cs @@ -1,3 +1,4 @@ +using ERPCore.Domain.Enums; using ERPCore.Dtos.Categories; using ERPCore.Dtos.Common; using ERPCore.Services.Interfaces; @@ -5,7 +6,11 @@ using Microsoft.AspNetCore.Mvc; namespace ERPCore.Controllers; -/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3). +/// +/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3), including the subcategories +/// nested beneath each category. The hierarchy is exactly two levels deep — the old +/// ?tree=true parameter is gone along with the self-nesting model. +/// [Route("api/v1/categories")] public sealed class CategoriesController : ApiControllerBase { @@ -13,19 +18,79 @@ public sealed class CategoriesController : ApiControllerBase public CategoriesController(ICategoryService categories) => _categories = categories; - /// Flat paged list, or a nested tree when tree=true. [HttpGet] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] - public async Task List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct) - => tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct)); + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _categories.ListAsync(query, status, ct)); + + [HttpGet("{categoryId:int}")] + [ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int categoryId, CancellationToken ct) + { + var result = await _categories.GetAsync(categoryId, ct); + if (result is null) return NotFound(); + + SetETag(result.RowVersion); + return Ok(result.Value); + } [HttpPost] [ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task> Create([FromBody] CreateCategoryRequest request, CancellationToken ct) { - var dto = await _categories.CreateAsync(request, ct); - return Created($"/api/v1/categories/{dto.CategoryId}", dto); + var result = await _categories.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/categories/{result.Value.CategoryId}", result.Value); + } + + [HttpPut("{categoryId:int}")] + [ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update( + int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _categories.UpdateAsync(categoryId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08). + [HttpPatch("{categoryId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus( + int categoryId, [FromBody] UpdateCategoryStatusRequest request, CancellationToken ct) + { + await _categories.SetStatusAsync(categoryId, request.Status, ct); + return NoContent(); + } + + // Subcategories — nested under their parent category (docs/11 §2.3). + // Updates live on SubCategoriesController at /api/v1/subcategories/{id}. + + [HttpGet("{categoryId:int}/subcategories")] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> ListSubCategories( + int categoryId, [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _categories.ListSubCategoriesAsync(categoryId, query, status, ct)); + + [HttpPost("{categoryId:int}/subcategories")] + [ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> CreateSubCategory( + int categoryId, [FromBody] CreateSubCategoryRequest request, CancellationToken ct) + { + var result = await _categories.CreateSubCategoryAsync(categoryId, request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/subcategories/{result.Value.SubCategoryId}", result.Value); } } diff --git a/Backend/ERPCore/Controllers/ItemTypesController.cs b/Backend/ERPCore/Controllers/ItemTypesController.cs new file mode 100644 index 0000000..efbc4f7 --- /dev/null +++ b/Backend/ERPCore/Controllers/ItemTypesController.cs @@ -0,0 +1,73 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.ItemTypes; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Item type master endpoints (docs/11-BACKEND-PHASE1.md §2.7) — the Color/Size/Material +/// dimension names. GET is the reason this master exists: it populates the item +/// builder's dropdown. Items never reference an item type; the chosen values are encoded +/// into the client-generated SKU (docs/10 Part C.9). +/// +[Route("api/v1/item-types")] +public sealed class ItemTypesController : ApiControllerBase +{ + private readonly IItemTypeService _itemTypes; + + public ItemTypesController(IItemTypeService itemTypes) => _itemTypes = itemTypes; + + /// Feeds the frontend item-builder dropdown; filter status=Active for selectable rows. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _itemTypes.ListAsync(query, status, ct)); + + [HttpGet("{itemTypeId:int}")] + [ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int itemTypeId, CancellationToken ct) + { + var result = await _itemTypes.GetAsync(itemTypeId, ct); + if (result is null) return NotFound(); + + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateItemTypeRequest request, CancellationToken ct) + { + var result = await _itemTypes.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/item-types/{result.Value.ItemTypeId}", result.Value); + } + + [HttpPut("{itemTypeId:int}")] + [ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int itemTypeId, [FromBody] UpdateItemTypeRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _itemTypes.UpdateAsync(itemTypeId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08). + [HttpPatch("{itemTypeId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int itemTypeId, [FromBody] UpdateItemTypeStatusRequest request, CancellationToken ct) + { + await _itemTypes.SetStatusAsync(itemTypeId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs index c98493a..fe4ed11 100644 --- a/Backend/ERPCore/Controllers/ItemsController.cs +++ b/Backend/ERPCore/Controllers/ItemsController.cs @@ -21,9 +21,11 @@ public sealed class ItemsController : ApiControllerBase [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] int? categoryId, + [FromQuery] int? subCategoryId, + [FromQuery] int? brandId, [FromQuery] TrackingMode? trackingMode, CancellationToken ct) - => Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct)); + => Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct)); /// Get a single item; returns an ETag for optimistic concurrency. [HttpGet("{itemId:int}")] diff --git a/Backend/ERPCore/Controllers/ProductConfigController.cs b/Backend/ERPCore/Controllers/ProductConfigController.cs new file mode 100644 index 0000000..300f446 --- /dev/null +++ b/Backend/ERPCore/Controllers/ProductConfigController.cs @@ -0,0 +1,46 @@ +using ERPCore.Dtos.Config; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Product configuration endpoints (docs/11-BACKEND-PHASE1.md §2.8) — the singleton +/// feature gate for subcategories/brands/item-types. +/// +/// Authorization: writes are admitted by the inherited ERP door policy only. +/// A dedicated CONFIG_MANAGE permission is reserved for when per-endpoint RBAC +/// lands (FR-X-01, currently deferred) — at that point this action gets the attribute +/// with no other change. Until then any ERP-admitted user can flip these flags; that is +/// the accepted Phase-1 posture, consistent with every other endpoint. +/// +/// +[Route("api/v1/product-config")] +public sealed class ProductConfigController : ApiControllerBase +{ + private readonly IProductConfigService _config; + + public ProductConfigController(IProductConfigService config) => _config = config; + + [HttpGet] + [ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)] + public async Task> Get(CancellationToken ct) + { + var result = await _config.GetAsync(ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPut] + [ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update( + [FromBody] UpdateProductConfigRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _config.UpdateAsync(request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } +} diff --git a/Backend/ERPCore/Controllers/SubCategoriesController.cs b/Backend/ERPCore/Controllers/SubCategoriesController.cs new file mode 100644 index 0000000..162a89e --- /dev/null +++ b/Backend/ERPCore/Controllers/SubCategoriesController.cs @@ -0,0 +1,56 @@ +using ERPCore.Dtos.Categories; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Subcategory endpoints addressed by their own id (docs/11-BACKEND-PHASE1.md §2.3). +/// Listing and creation live under the parent category on , +/// since a subcategory only exists in the context of one. +/// +[Route("api/v1/subcategories")] +public sealed class SubCategoriesController : ApiControllerBase +{ + private readonly ICategoryService _categories; + + public SubCategoriesController(ICategoryService categories) => _categories = categories; + + [HttpGet("{subCategoryId:int}")] + [ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int subCategoryId, CancellationToken ct) + { + var result = await _categories.GetSubCategoryAsync(subCategoryId, ct); + if (result is null) return NotFound(); + + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Renames a subcategory. It cannot be moved to another category — see the request DTO. + [HttpPut("{subCategoryId:int}")] + [ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update( + int subCategoryId, [FromBody] UpdateSubCategoryRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _categories.UpdateSubCategoryAsync(subCategoryId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08). + [HttpPatch("{subCategoryId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus( + int subCategoryId, [FromBody] UpdateSubCategoryStatusRequest request, CancellationToken ct) + { + await _categories.SetSubCategoryStatusAsync(subCategoryId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Domain/Entities/Brand.cs b/Backend/ERPCore/Domain/Entities/Brand.cs new file mode 100644 index 0000000..1544349 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Brand.cs @@ -0,0 +1,21 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Brand master (FR-MD-09). Referenced optionally by . +/// Mutable aggregate with a ETag token. Deactivated, not +/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +public class Brand +{ + public int BrandId { get; set; } + public string Name { get; set; } = string.Empty; + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Category.cs b/Backend/ERPCore/Domain/Entities/Category.cs index 6c34b87..7d04a6d 100644 --- a/Backend/ERPCore/Domain/Entities/Category.cs +++ b/Backend/ERPCore/Domain/Entities/Category.cs @@ -1,15 +1,25 @@ +using ERPCore.Domain.Enums; + namespace ERPCore.Domain.Entities; /// -/// Hierarchical item category (FR-MD-04). A null denotes a -/// root category. Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// Item category (FR-MD-04) — the top level of a two-level hierarchy. The optional level +/// below is ; categories no longer self-nest (the former +/// parent_id tree was replaced in migration #2). +/// Mutable aggregate with a ETag token. Deactivated, not +/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1. /// public class Category { public int CategoryId { get; set; } public string Name { get; set; } = string.Empty; + public EntityStatus Status { get; set; } = EntityStatus.Active; - public int? ParentId { get; set; } - public Category? Parent { get; set; } - public ICollection Children { get; set; } = new List(); + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } + + public ICollection SubCategories { get; set; } = new List(); } diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs index f308dd3..ba9ed6c 100644 --- a/Backend/ERPCore/Domain/Entities/Item.cs +++ b/Backend/ERPCore/Domain/Entities/Item.cs @@ -17,13 +17,20 @@ public class Item public int CategoryId { get; set; } public Category? Category { get; set; } + /// Optional second level below ; must belong to it. + public int? SubCategoryId { get; set; } + public SubCategory? SubCategory { get; set; } + + public int? BrandId { get; set; } + public Brand? Brand { get; set; } + public int BaseUomId { get; set; } public Uom? BaseUom { get; set; } public int? DefaultVendorId { get; set; } public Vendor? DefaultVendor { get; set; } - public ItemType ItemType { get; set; } + public StockNature StockNature { get; set; } public TrackingMode TrackingMode { get; set; } public string? TaxClass { get; set; } public EntityStatus Status { get; set; } = EntityStatus.Active; diff --git a/Backend/ERPCore/Domain/Entities/ItemType.cs b/Backend/ERPCore/Domain/Entities/ItemType.cs new file mode 100644 index 0000000..668b1b7 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/ItemType.cs @@ -0,0 +1,31 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Item type master (FR-MD-10) — a selectable dimension name such as Color, Size or +/// Material. +/// +/// Deliberately unlinked. Nothing references this entity and it references +/// nothing: there is no value table and no join to . Its only job is +/// to feed the frontend's item-builder dropdown via GET /item-types. The chosen +/// values (Red, S, M) are encoded by the client into the generated SKU +/// (e.g. BL-100-0003) and are never stored or parsed server-side — the item list +/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9. +/// +/// Not to be confused with (Stocked/NonStocked/Service), +/// which is what the old ItemType enum became. +/// Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +public class ItemType +{ + public int ItemTypeId { get; set; } + public string Name { get; set; } = string.Empty; + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/ProductConfig.cs b/Backend/ERPCore/Domain/Entities/ProductConfig.cs new file mode 100644 index 0000000..4120cdc --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/ProductConfig.cs @@ -0,0 +1,35 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Product configuration (FR-MD-11) — a singleton row (single-tenant, docs/00-CORE §1) +/// gating optional product master-data features. +/// +/// and are enforced +/// server-side: an Item write carrying a subcategory/brand while the flag is off is +/// rejected with CONFIG_DISABLED. is +/// advisory only — items carry no item-type reference (see ), +/// so there is nothing on a write to reject; the frontend honours it by hiding the +/// builder's type section. Reads are never gated, so existing data stays visible after a +/// flag is switched off. +/// +/// Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +public class ProductConfig +{ + /// Always 1 — the singleton row's id. + public const int SingletonId = 1; + + public int ConfigId { get; set; } + + public bool SubcategoriesEnabled { get; set; } = true; + public bool BrandsEnabled { get; set; } = true; + public bool ItemTypesEnabled { get; set; } = true; + + public DateTime? UpdatedAt { get; set; } + + public int? UpdatedBy { get; set; } + public User? UpdatedByUser { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SubCategory.cs b/Backend/ERPCore/Domain/Entities/SubCategory.cs new file mode 100644 index 0000000..a270f41 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SubCategory.cs @@ -0,0 +1,26 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Subcategory — the single optional level below (FR-MD-04). +/// Replaces the former self-referencing CATEGORY.parent_id tree: the hierarchy is +/// exactly two levels deep and cannot nest further. Referenced optionally by +/// . Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +public class SubCategory +{ + public int SubCategoryId { get; set; } + public string Name { get; set; } = string.Empty; + + public int CategoryId { get; set; } + public Category? Category { get; set; } + + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Enums/ItemType.cs b/Backend/ERPCore/Domain/Enums/ItemType.cs deleted file mode 100644 index c81a1d6..0000000 --- a/Backend/ERPCore/Domain/Enums/ItemType.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ERPCore.Domain.Enums; - -/// -/// Item classification (FR-MD-01). Values match the itemType enum in -/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database. -/// -public enum ItemType -{ - Stocked, - NonStocked, - Service -} diff --git a/Backend/ERPCore/Domain/Enums/StockNature.cs b/Backend/ERPCore/Domain/Enums/StockNature.cs new file mode 100644 index 0000000..7e75517 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/StockNature.cs @@ -0,0 +1,14 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Whether an item holds stock (FR-MD-01). Values match the stockNature enum in +/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database. +/// Renamed from ItemType so that name could be taken by the ItemType master +/// entity (Color/Size/Material) — the two concepts are unrelated (docs/10 Part C.9). +/// +public enum StockNature +{ + Stocked, + NonStocked, + Service +} diff --git a/Backend/ERPCore/Dtos/Brands/BrandDtos.cs b/Backend/ERPCore/Dtos/Brands/BrandDtos.cs new file mode 100644 index 0000000..b5f6c80 --- /dev/null +++ b/Backend/ERPCore/Dtos/Brands/BrandDtos.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Brands; + +/// Brand resource (docs/11-BACKEND-PHASE1.md §2.6). +public sealed record BrandDto( + int BrandId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +// Request DTOs — narrow: server-controlled fields (status, ids, timestamps) +// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ---- + +public sealed class CreateBrandRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateBrandRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateBrandStatusRequest +{ + [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs index 744026e..ed27120 100644 --- a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs +++ b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs @@ -1,15 +1,56 @@ using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; namespace ERPCore.Dtos.Categories; -/// Flat category resource (docs/11-BACKEND-PHASE1.md §2.3). -public sealed record CategoryDto(int CategoryId, string Name, int? ParentId); +// Category (docs/11-BACKEND-PHASE1.md §2.3) ------------------------------------ +// The hierarchy is exactly two levels: Category → SubCategory. The former +// self-nesting tree (parentId / ?tree=true / CategoryTreeDto) was removed in +// migration #2 — see docs/10 Part C.1. -/// Nested category node for GET /categories?tree=true. -public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList Children); +/// Category resource — the top level. +public sealed record CategoryDto( + int CategoryId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +/// Subcategory resource — the single optional level below a category. +public sealed record SubCategoryDto( + int SubCategoryId, int CategoryId, string Name, EntityStatus Status, + DateTime CreatedAt, DateTime? UpdatedAt); + +// Request DTOs — narrow: server-controlled fields (status, ids, timestamps) +// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ---- public sealed class CreateCategoryRequest { [Required, StringLength(200)] public string Name { get; set; } = string.Empty; - public int? ParentId { get; set; } +} + +public sealed class UpdateCategoryRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateCategoryStatusRequest +{ + [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } +} + +/// Body for POST /categories/{categoryId}/subcategories; the parent comes from the route. +public sealed class CreateSubCategoryRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +/// +/// Body for PUT /subcategories/{id}. Name only — a subcategory cannot be reparented, +/// since moving one would silently invalidate the category of every item referencing it. +/// +public sealed class UpdateSubCategoryRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateSubCategoryStatusRequest +{ + [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } } diff --git a/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs new file mode 100644 index 0000000..f4e2e71 --- /dev/null +++ b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace ERPCore.Dtos.Config; + +/// +/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton. +/// is advisory (frontend-honoured) — see the entity docs. +/// +public sealed record ProductConfigDto( + bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled, + DateTime? UpdatedAt, int? UpdatedBy); + +/// +/// Full replacement of the flags. UpdatedBy is derived from the token, never posted. +/// +/// The flags are ? deliberately: [Required] on a non-nullable bool +/// is a no-op (it always has a value), so a body of {} would bind every flag to +/// false and silently switch all three features off. Nullable makes the requirement +/// actually bind — an omitted flag is a 400, not an accidental disable. +/// +/// +public sealed class UpdateProductConfigRequest +{ + [Required] public bool? SubcategoriesEnabled { get; set; } + [Required] public bool? BrandsEnabled { get; set; } + [Required] public bool? ItemTypesEnabled { get; set; } +} diff --git a/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs b/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs new file mode 100644 index 0000000..35d2e92 --- /dev/null +++ b/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.ItemTypes; + +/// +/// Item type resource (docs/11-BACKEND-PHASE1.md §2.7) — a dimension name such as Color +/// or Size. Carries no values and no item linkage: GET /item-types exists to +/// populate the frontend builder's dropdown, and the chosen values are encoded into the +/// client-generated SKU rather than stored (docs/10 Part C.9). +/// +public sealed record ItemTypeDto( + int ItemTypeId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +// Request DTOs — narrow: server-controlled fields (status, ids, timestamps) +// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ---- + +public sealed class CreateItemTypeRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateItemTypeRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateItemTypeStatusRequest +{ + [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs index 7bb11e7..7720597 100644 --- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs +++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs @@ -7,8 +7,8 @@ namespace ERPCore.Dtos.Items; /// Row shape for GET /items. public sealed record ItemListItemDto( - int ItemId, string Sku, string Name, int CategoryId, int BaseUomId, - int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode, + int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId, + int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode, string? TaxClass, EntityStatus Status); /// A single per-warehouse reorder policy row. @@ -17,7 +17,8 @@ public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decim /// Full item resource for GET /items/{id} and create/update responses. public sealed record ItemDetailDto( int ItemId, string Sku, string Name, string? Description, int CategoryId, - int BaseUomId, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode, + int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId, + StockNature StockNature, TrackingMode TrackingMode, string? TaxClass, EntityStatus Status, IReadOnlyList Reorder, DateTime CreatedAt, DateTime? UpdatedAt); @@ -33,15 +34,23 @@ public sealed record ItemReorderSettingsDto(IReadOnlyList Settin // Request DTOs — narrow: server-controlled fields (status, ids, timestamps) // are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ---- +// Note: the SKU is generated client-side (it encodes the chosen item-type values, e.g. +// "BL-100-0003"); the server only enforces uniqueness. There is no item-type field here +// by design — items carry no item-type reference (docs/10 Part C.9). + public sealed class CreateItemRequest { [Required, StringLength(50)] public string Sku { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty; [StringLength(1000)] public string? Description { get; set; } [Required] public int CategoryId { get; set; } + /// Optional; must belong to . Rejected when subcategories are disabled. + public int? SubCategoryId { get; set; } + /// Optional. Rejected when brands are disabled. + public int? BrandId { get; set; } [Required] public int BaseUomId { get; set; } public int? DefaultVendorId { get; set; } - [Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; } + [Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; } [EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None; [StringLength(20)] public string? TaxClass { get; set; } } @@ -52,9 +61,13 @@ public sealed class UpdateItemRequest [Required, StringLength(200)] public string Name { get; set; } = string.Empty; [StringLength(1000)] public string? Description { get; set; } [Required] public int CategoryId { get; set; } + /// Optional; must belong to . Rejected when subcategories are disabled. + public int? SubCategoryId { get; set; } + /// Optional. Rejected when brands are disabled. + public int? BrandId { get; set; } [Required] public int BaseUomId { get; set; } public int? DefaultVendorId { get; set; } - [Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; } + [Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; } [EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None; [StringLength(20)] public string? TaxClass { get; set; } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs new file mode 100644 index 0000000..52724a8 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs @@ -0,0 +1,29 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class BrandConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("brands"); + builder.HasKey(b => b.BrandId); + + builder.Property(b => b.Name).IsRequired().HasMaxLength(200); + builder.HasIndex(b => b.Name).IsUnique(); + + builder.Property(b => b.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(b => b.CreatedAt).IsRequired(); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(b => b.RowVersion).IsRowVersion(); + + builder.HasIndex(b => b.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs index a0565cb..8cc8732 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs @@ -1,4 +1,5 @@ using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -12,12 +13,17 @@ public sealed class CategoryConfiguration : IEntityTypeConfiguration builder.HasKey(c => c.CategoryId); builder.Property(c => c.Name).IsRequired().HasMaxLength(200); + builder.HasIndex(c => c.Name).IsUnique(); - builder.HasOne(c => c.Parent) - .WithMany(c => c.Children) - .HasForeignKey(c => c.ParentId) - .OnDelete(DeleteBehavior.Restrict); + builder.Property(c => c.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); - builder.HasIndex(c => c.ParentId); + builder.Property(c => c.CreatedAt).IsRequired(); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(c => c.RowVersion).IsRowVersion(); + + builder.HasIndex(c => c.Status); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs index 4a74f03..dab0f1f 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs @@ -19,7 +19,7 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration builder.Property(i => i.Description).HasMaxLength(1000); builder.Property(i => i.TaxClass).HasMaxLength(20); - builder.Property(i => i.ItemType) + builder.Property(i => i.StockNature) .HasConversion().HasMaxLength(20).IsRequired(); builder.Property(i => i.TrackingMode) .HasConversion().HasMaxLength(20).IsRequired(); @@ -37,6 +37,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration .HasForeignKey(i => i.CategoryId) .OnDelete(DeleteBehavior.Restrict); + builder.HasOne(i => i.SubCategory) + .WithMany() + .HasForeignKey(i => i.SubCategoryId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(i => i.Brand) + .WithMany() + .HasForeignKey(i => i.BrandId) + .OnDelete(DeleteBehavior.Restrict); + builder.HasOne(i => i.BaseUom) .WithMany() .HasForeignKey(i => i.BaseUomId) @@ -49,5 +59,6 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration builder.HasIndex(i => i.Status); builder.HasIndex(i => i.CategoryId); + builder.HasIndex(i => i.BrandId); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs new file mode 100644 index 0000000..c26f97c --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs @@ -0,0 +1,33 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +/// +/// Configures the ItemType master (Color/Size/Material). Note there are deliberately no +/// relationships here — nothing references this table (docs/10 Part C.9). +/// +public sealed class ItemTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("item_types"); + builder.HasKey(t => t.ItemTypeId); + + builder.Property(t => t.Name).IsRequired().HasMaxLength(200); + builder.HasIndex(t => t.Name).IsUnique(); + + builder.Property(t => t.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(t => t.CreatedAt).IsRequired(); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(t => t.RowVersion).IsRowVersion(); + + builder.HasIndex(t => t.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs new file mode 100644 index 0000000..d916d09 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs @@ -0,0 +1,37 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +/// +/// Configures the singleton product-configuration row (FR-MD-11). The check constraint +/// is what makes "singleton" a database guarantee rather than a convention. +/// +public sealed class ProductConfigConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // The column is created as quoted PascalCase ("ConfigId"), so the constraint must + // quote it too — an unquoted config_id would fold to a column that does not exist. + builder.ToTable("product_config", t => + t.HasCheckConstraint("ck_product_config_singleton", $"\"ConfigId\" = {ProductConfig.SingletonId}")); + + builder.HasKey(c => c.ConfigId); + + // The id is fixed, never generated — there is exactly one row, seeded by DataSeeder. + builder.Property(c => c.ConfigId).ValueGeneratedNever(); + + builder.Property(c => c.SubcategoriesEnabled).IsRequired().HasDefaultValue(true); + builder.Property(c => c.BrandsEnabled).IsRequired().HasDefaultValue(true); + builder.Property(c => c.ItemTypesEnabled).IsRequired().HasDefaultValue(true); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(c => c.RowVersion).IsRowVersion(); + + builder.HasOne(c => c.UpdatedByUser) + .WithMany() + .HasForeignKey(c => c.UpdatedBy) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs new file mode 100644 index 0000000..9fadd72 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs @@ -0,0 +1,35 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class SubCategoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("subcategories"); + builder.HasKey(s => s.SubCategoryId); + + builder.Property(s => s.Name).IsRequired().HasMaxLength(200); + + builder.Property(s => s.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(s => s.CreatedAt).IsRequired(); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(s => s.RowVersion).IsRowVersion(); + + builder.HasOne(s => s.Category) + .WithMany(c => c.SubCategories) + .HasForeignKey(s => s.CategoryId) + .OnDelete(DeleteBehavior.Restrict); + + // Names need only be unique within their parent category. + builder.HasIndex(s => new { s.CategoryId, s.Name }).IsUnique(); + builder.HasIndex(s => s.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs index 59a8803..79a2352 100644 --- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs +++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs @@ -12,6 +12,13 @@ namespace ERPCore.Infra.Persistence; /// public static class DataSeeder { + /// + /// Item type names the frontend builder has always assumed exist (they were hardcoded + /// while it ran on mock data). Seeded so the dropdown is not empty on a fresh database; + /// users add their own (e.g. Material) from the admin screen. + /// + private static readonly string[] StandardItemTypes = ["Color", "Size"]; + private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes = [ ("DMG", "Damage", ReasonContext.Adjustment), @@ -26,6 +33,15 @@ public static class DataSeeder ]; public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default) + { + var dirty = await SeedReasonCodesAsync(db, ct); + dirty |= await SeedItemTypesAsync(db, ct); + dirty |= await SeedProductConfigAsync(db, ct); + + if (dirty) await db.SaveChangesAsync(ct); + } + + private static async Task SeedReasonCodesAsync(ErpDbContext db, CancellationToken ct) { var existing = await db.ReasonCodes .Select(r => new { r.Context, r.Code }) @@ -37,9 +53,44 @@ public static class DataSeeder .Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context }) .ToList(); - if (toAdd.Count == 0) return; + if (toAdd.Count == 0) return false; db.ReasonCodes.AddRange(toAdd); - await db.SaveChangesAsync(ct); + return true; + } + + private static async Task SeedItemTypesAsync(ErpDbContext db, CancellationToken ct) + { + var have = await db.ItemTypes.Select(t => t.Name).ToListAsync(ct); + + var toAdd = StandardItemTypes + .Where(name => !have.Contains(name, StringComparer.OrdinalIgnoreCase)) + .Select(name => new ItemType { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }) + .ToList(); + + if (toAdd.Count == 0) return false; + + db.ItemTypes.AddRange(toAdd); + return true; + } + + /// + /// Ensures the singleton product-config row exists (FR-MD-11). Migration #2 inserts it, + /// so this only fires for a database built some other way — but without it every Item + /// write would 404 on the missing config, so it is worth the one query at startup. + /// New deployments start with all features on. + /// + private static async Task SeedProductConfigAsync(ErpDbContext db, CancellationToken ct) + { + if (await db.ProductConfig.AnyAsync(c => c.ConfigId == ProductConfig.SingletonId, ct)) return false; + + db.ProductConfig.Add(new ProductConfig + { + ConfigId = ProductConfig.SingletonId, + SubcategoriesEnabled = true, + BrandsEnabled = true, + ItemTypesEnabled = true + }); + return true; } } diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index 6a7d7e6..7305983 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore; namespace ERPCore.Infra.Persistence; /// -/// EF Core context for the ERP database. The 38 Phase 1 entities and their +/// EF Core context for the ERP database. The 42 Phase 1 entities and their /// configurations are added under /// Domain/Entities and Infra/Persistence/Configurations as they are implemented. /// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here. @@ -23,6 +23,10 @@ public class ErpDbContext : DbContext // --- Master Data (docs/10 Part C.1) --- public DbSet Categories => Set(); + public DbSet SubCategories => Set(); + public DbSet Brands => Set(); + /// Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9). + public DbSet ItemTypes => Set(); public DbSet Uoms => Set(); public DbSet UomConversions => Set(); public DbSet Items => Set(); @@ -30,6 +34,8 @@ public class ErpDbContext : DbContext public DbSet Vendors => Set(); public DbSet Warehouses => Set(); public DbSet Bins => Set(); + /// Singleton row (FR-MD-11). + public DbSet ProductConfig => Set(); // --- Cross-cutting (docs/10 Part C.7) --- public DbSet Users => Set(); diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs new file mode 100644 index 0000000..829393d --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs @@ -0,0 +1,2454 @@ +// +using System; +using ERPCore.Infra.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + [DbContext(typeof(ErpDbContext))] + [Migration("20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig")] + partial class AddBrandsSubcategoriesItemTypesAndProductConfig + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("ChangeSet") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("integer"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("AuditId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.Property("BatchId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.HasKey("BatchId"); + + b.HasIndex("ItemId", "BatchNo") + .IsUnique(); + + b.ToTable("batches", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.Property("BinId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); + + b.Property("BinType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("BinId"); + + b.HasIndex("WarehouseId", "Code") + .IsUnique(); + + b.ToTable("bins", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => + { + b.Property("BrandId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BrandId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("brands", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CategoryId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("GrnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("PoId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("grns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Property("GrnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("GrnId") + .HasColumnType("integer"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoLineId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceivedValue") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.HasKey("GrnLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("GrnId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoLineId"); + + b.HasIndex("UomId"); + + b.ToTable("grn_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("integer"); + + b.Property("BrandId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("StockNature") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + + b.Property("TaxClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemId"); + + b.HasIndex("BaseUomId"); + + b.HasIndex("BrandId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DefaultVendorId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("SubCategoryId"); + + b.ToTable("items", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.Property("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReorderId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId") + .IsUnique(); + + b.ToTable("item_reorders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => + { + b.Property("ItemTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemTypeId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("item_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => + { + b.Property("JournalId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreditAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DebitAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("JournalId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("journal_entry_stubs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("integer") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("DocType", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("UomId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.Property("ConfigId") + .HasColumnType("integer"); + + b.Property("BrandsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ItemTypesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SubcategoriesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("integer"); + + b.HasKey("ConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("product_config", null, t => + { + t.HasCheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Property("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("PoId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReturnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("purchase_returns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.Property("ReturnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReturnId") + .HasColumnType("integer"); + + b.HasKey("ReturnLineId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("ReturnId"); + + b.ToTable("purchase_return_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => + { + b.Property("ReasonCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("ReasonCodeId"); + + b.HasIndex("Context", "Code") + .IsUnique(); + + b.ToTable("reason_codes", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Property("RequisitionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequestedBy") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("SerialId"); + + b.HasIndex("ItemId", "SerialNo") + .IsUnique(); + + b.ToTable("serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Property("AdjustmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("AdjustmentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_adjustments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.Property("AdjLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("integer"); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.HasKey("AdjLineId"); + + b.HasIndex("AdjustmentId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_adjustment_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Property("CountId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); + + b.Property("CountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("CountId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_counts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.Property("CountLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CountId") + .HasColumnType("integer"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SystemQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Variance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("CountLineId"); + + b.HasIndex("BinId"); + + b.HasIndex("CountId"); + + b.HasIndex("ItemId"); + + b.ToTable("stock_count_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyRemaining") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceiptDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LayerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); + + b.ToTable("stock_layers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.Property("LedgerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LedgerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("SerialId"); + + b.HasIndex("UserId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); + + b.HasIndex("ItemId", "WarehouseId", "LedgerId"); + + b.ToTable("stock_ledger", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Property("TransferId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DestWarehouseId") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("TransferId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DestWarehouseId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("SrcWarehouseId"); + + b.HasIndex("Status"); + + b.ToTable("stock_transfers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Property("TransferLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("DestBinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SrcBinId") + .HasColumnType("integer"); + + b.Property("TransferId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("TransferLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("DestBinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SrcBinId"); + + b.HasIndex("TransferId"); + + b.ToTable("stock_transfer_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("SubCategoryId"); + + b.HasIndex("Status"); + + b.HasIndex("CategoryId", "Name") + .IsUnique(); + + b.ToTable("subcategories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => + { + b.Property("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.Property("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ToUomId") + .HasColumnType("integer"); + + b.HasKey("ConversionId"); + + b.HasIndex("FromUomId"); + + b.HasIndex("ToUomId"); + + b.HasIndex("ItemId", "FromUomId", "ToUomId") + .IsUnique(); + + b.ToTable("uom_conversions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("AuthUserId") + .HasColumnType("uuid") + .HasColumnName("auth_user_id"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("AuthUserId") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("VendorId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("WarehouseId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany("Bins") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany("Lines") + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") + .WithMany() + .HasForeignKey("PoLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") + .WithMany() + .HasForeignKey("BaseUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") + .WithMany() + .HasForeignKey("DefaultVendorId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("BaseUom"); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("DefaultVendor"); + + b.Navigation("SubCategory"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("ReorderSettings") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Uom"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Requisition"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Return"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany("Lines") + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Lines") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Rfq"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .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("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") + .WithMany("Lines") + .HasForeignKey("AdjustmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") + .WithMany("Lines") + .HasForeignKey("CountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Count"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", null) + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") + .WithMany() + .HasForeignKey("DestWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") + .WithMany() + .HasForeignKey("SrcWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("DestWarehouse"); + + b.Navigation("SrcWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("DestBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("SrcBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") + .WithMany("Lines") + .HasForeignKey("TransferId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Transfer"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany("SubCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") + .WithMany() + .HasForeignKey("FromUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("UomConversions") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom") + .WithMany() + .HasForeignKey("ToUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + b.Navigation("ToUom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Quotations") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rfq"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") + .WithMany("Lines") + .HasForeignKey("QuotationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Quotation"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Navigation("ReorderSettings"); + + b.Navigation("UomConversions"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Navigation("Bins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs new file mode 100644 index 0000000..a7e0ac5 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs @@ -0,0 +1,442 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + /// Adds the Brand / SubCategory / ItemType masters and the singleton product config, + /// and converts CATEGORY from a self-nesting tree into a fixed two-level + /// Category → SubCategory hierarchy (docs/10 Part C.1). + /// + /// This migration carries data, not just DDL. The scaffolded version dropped + /// categories.ParentId outright, which would have silently flattened every + /// child category into a root and left items pointing at what is now a top-level + /// category — losing the parent entirely. The hand-written steps below (marked + /// "data migration") move child categories into subcategories and repoint items + /// onto the correct (category, subcategory) pair before the column goes away. + /// + /// + public partial class AddBrandsSubcategoriesItemTypesAndProductConfig : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // NOTE: the ParentId drop is deliberately deferred to the bottom of this method — + // the data migration reads it. Order here is load-bearing. + migrationBuilder.RenameColumn( + name: "ItemType", + table: "items", + newName: "StockNature"); + + migrationBuilder.AddColumn( + name: "BrandId", + table: "items", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "SubCategoryId", + table: "items", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "categories", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "Status", + table: "categories", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValue: "Active"); + + migrationBuilder.AddColumn( + name: "UpdatedAt", + table: "categories", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "xmin", + table: "categories", + type: "xid", + rowVersion: true, + nullable: false, + defaultValue: 0u); + + migrationBuilder.CreateTable( + name: "brands", + columns: table => new + { + BrandId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_brands", x => x.BrandId); + }); + + migrationBuilder.CreateTable( + name: "item_types", + columns: table => new + { + ItemTypeId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_item_types", x => x.ItemTypeId); + }); + + migrationBuilder.CreateTable( + name: "product_config", + columns: table => new + { + ConfigId = table.Column(type: "integer", nullable: false), + SubcategoriesEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + BrandsEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + ItemTypesEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedBy = table.Column(type: "integer", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_product_config", x => x.ConfigId); + table.CheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1"); + table.ForeignKey( + name: "FK_product_config_users_UpdatedBy", + column: x => x.UpdatedBy, + principalTable: "users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "subcategories", + columns: table => new + { + SubCategoryId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CategoryId = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_subcategories", x => x.SubCategoryId); + table.ForeignKey( + name: "FK_subcategories_categories_CategoryId", + column: x => x.CategoryId, + principalTable: "categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Restrict); + }); + + // --------------------------------------------------------------------------- + // DATA MIGRATION — must run before ParentId is dropped. + // --------------------------------------------------------------------------- + + // Existing categories predate CreatedAt; the added column defaulted them to + // 0001-01-01. Stamp them with the migration time instead of a sentinel date. + migrationBuilder.Sql(@" + UPDATE categories SET ""CreatedAt"" = NOW() AT TIME ZONE 'utc'; + "); + + // Carry the old category id alongside each new subcategory so items can be + // repointed by join below. Dropped again once the repoint is done. + migrationBuilder.Sql(@" + ALTER TABLE subcategories ADD COLUMN legacy_category_id integer; + "); + + // Walk the old tree to its roots. The previous model allowed unlimited nesting, + // but the new one is exactly two levels — so a category at any depth below the + // root collapses into a subcategory of its ROOT ancestor (a grandchild cannot + // become a subcategory of its immediate parent, since that parent is itself + // ceasing to be a category). + migrationBuilder.Sql(@" + WITH RECURSIVE tree AS ( + SELECT ""CategoryId"", ""ParentId"", ""Name"", ""CategoryId"" AS root_id + FROM categories + WHERE ""ParentId"" IS NULL + UNION ALL + SELECT c.""CategoryId"", c.""ParentId"", c.""Name"", t.root_id + FROM categories c + JOIN tree t ON c.""ParentId"" = t.""CategoryId"" + ) + INSERT INTO subcategories (""Name"", ""CategoryId"", ""Status"", ""CreatedAt"", legacy_category_id) + SELECT t.""Name"", t.root_id, 'Active', NOW() AT TIME ZONE 'utc', t.""CategoryId"" + FROM tree t + WHERE t.""ParentId"" IS NOT NULL; + "); + + // Repoint items: an item that pointed at a child category now carries the root + // category plus the subcategory it actually meant. + migrationBuilder.Sql(@" + UPDATE items i + SET ""SubCategoryId"" = s.""SubCategoryId"", + ""CategoryId"" = s.""CategoryId"" + FROM subcategories s + WHERE s.legacy_category_id = i.""CategoryId""; + "); + + // The self-FK must go before the delete, or RESTRICT rejects removing a parent + // whose own child row is still present. + migrationBuilder.DropForeignKey( + name: "FK_categories_categories_ParentId", + table: "categories"); + + // Every non-root category now lives in `subcategories`, and no item references + // one any more (repointed above), so the rows can go. + migrationBuilder.Sql(@" + DELETE FROM categories WHERE ""ParentId"" IS NOT NULL; + ALTER TABLE subcategories DROP COLUMN legacy_category_id; + "); + + migrationBuilder.DropIndex( + name: "IX_categories_ParentId", + table: "categories"); + + migrationBuilder.DropColumn( + name: "ParentId", + table: "categories"); + + // Seed the singleton config (FR-MD-11) — all features on. Item writes read this + // row, so it must exist before the app serves a single request. + migrationBuilder.Sql(@" + INSERT INTO product_config (""ConfigId"", ""SubcategoriesEnabled"", ""BrandsEnabled"", ""ItemTypesEnabled"") + VALUES (1, TRUE, TRUE, TRUE) + ON CONFLICT (""ConfigId"") DO NOTHING; + "); + + // --------------------------------------------------------------------------- + + migrationBuilder.CreateIndex( + name: "IX_items_BrandId", + table: "items", + column: "BrandId"); + + migrationBuilder.CreateIndex( + name: "IX_items_SubCategoryId", + table: "items", + column: "SubCategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_categories_Name", + table: "categories", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_categories_Status", + table: "categories", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_brands_Name", + table: "brands", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_brands_Status", + table: "brands", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_item_types_Name", + table: "item_types", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_item_types_Status", + table: "item_types", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_product_config_UpdatedBy", + table: "product_config", + column: "UpdatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_subcategories_CategoryId_Name", + table: "subcategories", + columns: new[] { "CategoryId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_subcategories_Status", + table: "subcategories", + column: "Status"); + + migrationBuilder.AddForeignKey( + name: "FK_items_brands_BrandId", + table: "items", + column: "BrandId", + principalTable: "brands", + principalColumn: "BrandId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_items_subcategories_SubCategoryId", + table: "items", + column: "SubCategoryId", + principalTable: "subcategories", + principalColumn: "SubCategoryId", + onDelete: ReferentialAction.Restrict); + } + + /// + /// Reverses the schema change and puts the subcategory data back where it came from. + /// + /// The scaffolded version simply dropped subcategories, which would have + /// discarded exactly what preserved. Instead each subcategory is + /// restored as a child category and its items are repointed back onto it. This is + /// not perfectly lossless: the old tree's depth is gone (a former grandchild comes + /// back as a direct child of its root), and Brand data cannot survive a schema that + /// has nowhere to put it. + /// + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_items_brands_BrandId", + table: "items"); + + migrationBuilder.DropForeignKey( + name: "FK_items_subcategories_SubCategoryId", + table: "items"); + + // Restore the parent column + self-FK first so subcategories have somewhere to + // land, then move them back before the table is dropped. + migrationBuilder.AddColumn( + name: "ParentId", + table: "categories", + type: "integer", + nullable: true); + + // --------------------------------------------------------------------------- + // DATA MIGRATION (reverse) — must run before `subcategories` is dropped. + // --------------------------------------------------------------------------- + + migrationBuilder.Sql(@" + ALTER TABLE categories ADD COLUMN legacy_subcategory_id integer; + "); + + // Each subcategory becomes a child category again under the same parent. + migrationBuilder.Sql(@" + INSERT INTO categories (""Name"", ""ParentId"", ""CreatedAt"", ""Status"", legacy_subcategory_id) + SELECT s.""Name"", s.""CategoryId"", s.""CreatedAt"", s.""Status"", s.""SubCategoryId"" + FROM subcategories s; + "); + + // Items that carried a subcategory point back at the restored child category. + migrationBuilder.Sql(@" + UPDATE items i + SET ""CategoryId"" = c.""CategoryId"" + FROM categories c + WHERE c.legacy_subcategory_id = i.""SubCategoryId""; + "); + + migrationBuilder.Sql(@" + ALTER TABLE categories DROP COLUMN legacy_subcategory_id; + "); + + // --------------------------------------------------------------------------- + + migrationBuilder.DropTable( + name: "brands"); + + migrationBuilder.DropTable( + name: "item_types"); + + migrationBuilder.DropTable( + name: "product_config"); + + migrationBuilder.DropTable( + name: "subcategories"); + + migrationBuilder.DropIndex( + name: "IX_items_BrandId", + table: "items"); + + migrationBuilder.DropIndex( + name: "IX_items_SubCategoryId", + table: "items"); + + migrationBuilder.DropIndex( + name: "IX_categories_Name", + table: "categories"); + + migrationBuilder.DropIndex( + name: "IX_categories_Status", + table: "categories"); + + migrationBuilder.DropColumn( + name: "BrandId", + table: "items"); + + migrationBuilder.DropColumn( + name: "SubCategoryId", + table: "items"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "categories"); + + migrationBuilder.DropColumn( + name: "Status", + table: "categories"); + + migrationBuilder.DropColumn( + name: "UpdatedAt", + table: "categories"); + + migrationBuilder.DropColumn( + name: "xmin", + table: "categories"); + + migrationBuilder.RenameColumn( + name: "StockNature", + table: "items", + newName: "ItemType"); + + // ParentId itself was re-added at the top of this method, ahead of the reverse + // data migration that populates it. + migrationBuilder.CreateIndex( + name: "IX_categories_ParentId", + table: "categories", + column: "ParentId"); + + migrationBuilder.AddForeignKey( + name: "FK_categories_categories_ParentId", + table: "categories", + column: "ParentId", + principalTable: "categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs index 92abc54..88ab528 100644 --- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs @@ -119,6 +119,48 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("bins", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => + { + b.Property("BrandId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BrandId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("brands", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => { b.Property("CategoryId") @@ -127,17 +169,36 @@ namespace ERPCore.Infra.Persistence.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); - b.Property("ParentId") - .HasColumnType("integer"); + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); b.HasKey("CategoryId"); - b.HasIndex("ParentId"); + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); b.ToTable("categories", (string)null); }); @@ -273,6 +334,9 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("BaseUomId") .HasColumnType("integer"); + b.Property("BrandId") + .HasColumnType("integer"); + b.Property("CategoryId") .HasColumnType("integer"); @@ -286,11 +350,6 @@ namespace ERPCore.Infra.Persistence.Migrations .HasMaxLength(1000) .HasColumnType("character varying(1000)"); - b.Property("ItemType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -314,6 +373,14 @@ namespace ERPCore.Infra.Persistence.Migrations .HasColumnType("character varying(20)") .HasDefaultValue("Active"); + b.Property("StockNature") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + b.Property("TaxClass") .HasMaxLength(20) .HasColumnType("character varying(20)"); @@ -330,6 +397,8 @@ namespace ERPCore.Infra.Persistence.Migrations b.HasIndex("BaseUomId"); + b.HasIndex("BrandId"); + b.HasIndex("CategoryId"); b.HasIndex("DefaultVendorId"); @@ -339,6 +408,8 @@ namespace ERPCore.Infra.Persistence.Migrations b.HasIndex("Status"); + b.HasIndex("SubCategoryId"); + b.ToTable("items", (string)null); }); @@ -374,6 +445,48 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("item_reorders", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => + { + b.Property("ItemTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemTypeId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("item_types", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => { b.Property("JournalId") @@ -490,6 +603,48 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("po_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.Property("ConfigId") + .HasColumnType("integer"); + + b.Property("BrandsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ItemTypesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SubcategoriesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("integer"); + + b.HasKey("ConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("product_config", null, t => + { + t.HasCheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1"); + }); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => { b.Property("PoId") @@ -1239,6 +1394,51 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("stock_transfer_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("SubCategoryId"); + + b.HasIndex("Status"); + + b.HasIndex("CategoryId", "Name") + .IsUnique(); + + b.ToTable("subcategories", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => { b.Property("UomId") @@ -1516,16 +1716,6 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Warehouse"); }); - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.HasOne("ERPCore.Domain.Entities.Category", "Parent") - .WithMany("Children") - .HasForeignKey("ParentId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Parent"); - }); - modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => { b.HasOne("ERPCore.Domain.Entities.User", "Creator") @@ -1616,6 +1806,11 @@ namespace ERPCore.Infra.Persistence.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("ERPCore.Domain.Entities.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("ERPCore.Domain.Entities.Category", "Category") .WithMany() .HasForeignKey("CategoryId") @@ -1627,11 +1822,20 @@ namespace ERPCore.Infra.Persistence.Migrations .HasForeignKey("DefaultVendorId") .OnDelete(DeleteBehavior.Restrict); + b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict); + b.Navigation("BaseUom"); + b.Navigation("Brand"); + b.Navigation("Category"); b.Navigation("DefaultVendor"); + + b.Navigation("SubCategory"); }); modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => @@ -1688,6 +1892,16 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Warehouse"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => { b.HasOne("ERPCore.Domain.Entities.User", "Creator") @@ -2092,6 +2306,17 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Transfer"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany("SubCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => { b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") @@ -2159,7 +2384,7 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => { - b.Navigation("Children"); + b.Navigation("SubCategories"); }); modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 75b3156..d06df6e 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -62,6 +62,9 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/BrandService.cs b/Backend/ERPCore/Services/BrandService.cs new file mode 100644 index 0000000..dce5fd3 --- /dev/null +++ b/Backend/ERPCore/Services/BrandService.cs @@ -0,0 +1,114 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Brands; +using ERPCore.Dtos.Common; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +/// +/// Brand master service (FR-MD-09). Enforces name uniqueness and optimistic concurrency +/// per docs/11-BACKEND-PHASE1.md §2.6. +/// +public sealed class BrandService : IBrandService +{ + private readonly IRepository _brands; + private readonly IUnitOfWork _uow; + + public BrandService(IRepository brands, IUnitOfWork uow) + { + _brands = brands; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _brands.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(b => EF.Functions.ILike(b.Name, $"%{term}%")); + } + if (status is not null) q = q.Where(b => b.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(b => b.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(b => new BrandDto(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int brandId, CancellationToken ct = default) + { + var brand = await _brands.Query().AsNoTracking() + .FirstOrDefaultAsync(b => b.BrandId == brandId, ct); + return brand is null ? null : new ETagged(Map(brand), brand.RowVersion); + } + + public async Task> CreateAsync(CreateBrandRequest request, CancellationToken ct = default) + { + var name = request.Name.Trim(); + if (await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower(), ct)) + throw new ConflictException($"A brand named '{name}' already exists."); + + var brand = new Brand + { + Name = name, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _brands.AddAsync(brand, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(brand), brand.RowVersion); + } + + public async Task> UpdateAsync( + int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var brand = await _brands.GetByIdAsync(brandId, ct) + ?? throw new NotFoundException($"Brand {brandId} was not found."); + + if (brand.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412); + + var name = request.Name.Trim(); + if (!string.Equals(brand.Name, name, StringComparison.Ordinal) + && await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower() && b.BrandId != brandId, ct)) + throw new ConflictException($"A brand named '{name}' already exists."); + + brand.Name = name; + brand.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412); + } + + return new ETagged(Map(brand), brand.RowVersion); + } + + public async Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default) + { + var brand = await _brands.GetByIdAsync(brandId, ct) + ?? throw new NotFoundException($"Brand {brandId} was not found."); + + brand.Status = status; + brand.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static BrandDto Map(Brand b) => new(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/CategoryService.cs b/Backend/ERPCore/Services/CategoryService.cs index ca0f05d..a84e40a 100644 --- a/Backend/ERPCore/Services/CategoryService.cs +++ b/Backend/ERPCore/Services/CategoryService.cs @@ -1,4 +1,6 @@ +using ERPCore.Common.Http; using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; using ERPCore.Dtos.Categories; using ERPCore.Dtos.Common; using ERPCore.Infra.UoW; @@ -9,18 +11,31 @@ using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; +/// +/// Category + subcategory master service (FR-MD-04). The hierarchy is exactly two levels: +/// categories no longer self-nest, so there is no cycle to detect and no tree to build +/// (docs/11-BACKEND-PHASE1.md §2.3). +/// public sealed class CategoryService : ICategoryService { private readonly IRepository _categories; + private readonly IRepository _subCategories; private readonly IUnitOfWork _uow; - public CategoryService(IRepository categories, IUnitOfWork uow) + public CategoryService( + IRepository categories, + IRepository subCategories, + IUnitOfWork uow) { _categories = categories; + _subCategories = subCategories; _uow = uow; } - public async Task> ListAsync(PageQuery query, CancellationToken ct = default) + // Categories --------------------------------------------------------------- + + public async Task> ListAsync( + PageQuery query, EntityStatus? status, CancellationToken ct = default) { var q = _categories.Query().AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Q)) @@ -28,43 +43,186 @@ public sealed class CategoryService : ICategoryService var term = query.Q.Trim(); q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%")); } + if (status is not null) q = q.Where(c => c.Status == status); var total = await q.CountAsync(ct); var rows = await q.OrderBy(c => c.Name) .Skip(query.Skip).Take(query.PageSize) - .Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId)) + .Select(c => new CategoryDto(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); } - public async Task> GetTreeAsync(CancellationToken ct = default) + public async Task?> GetAsync(int categoryId, CancellationToken ct = default) { - var all = await _categories.Query().AsNoTracking() - .OrderBy(c => c.Name) - .Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId)) - .ToListAsync(ct); - - var byParent = all.ToLookup(c => c.ParentId); - - List Build(int? parentId) => - byParent[parentId] - .Select(c => new CategoryTreeDto(c.CategoryId, c.Name, c.ParentId, Build(c.CategoryId))) - .ToList(); - - return Build(null); + var category = await _categories.Query().AsNoTracking() + .FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct); + return category is null ? null : new ETagged(Map(category), category.RowVersion); } - public async Task CreateAsync(CreateCategoryRequest request, CancellationToken ct = default) + public async Task> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default) { - if (request.ParentId is not null - && !await _categories.Query().AnyAsync(c => c.CategoryId == request.ParentId, ct)) - throw new DomainException(ErrorCodes.Validation, $"Parent category {request.ParentId} does not exist.", 422); + var name = request.Name.Trim(); + if (await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower(), ct)) + throw new ConflictException($"A category named '{name}' already exists."); + + var category = new Category + { + Name = name, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; - var category = new Category { Name = request.Name.Trim(), ParentId = request.ParentId }; await _categories.AddAsync(category, ct); await _uow.SaveChangesAsync(ct); - return new CategoryDto(category.CategoryId, category.Name, category.ParentId); + return new ETagged(Map(category), category.RowVersion); } + + public async Task> UpdateAsync( + int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var category = await _categories.GetByIdAsync(categoryId, ct) + ?? throw new NotFoundException($"Category {categoryId} was not found."); + + if (category.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The category was modified by another request.", 412); + + var name = request.Name.Trim(); + if (!string.Equals(category.Name, name, StringComparison.Ordinal) + && await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower() && c.CategoryId != categoryId, ct)) + throw new ConflictException($"A category named '{name}' already exists."); + + category.Name = name; + category.UpdatedAt = DateTime.UtcNow; + + await SaveGuardingConcurrencyAsync("category", ct); + return new ETagged(Map(category), category.RowVersion); + } + + public async Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default) + { + var category = await _categories.GetByIdAsync(categoryId, ct) + ?? throw new NotFoundException($"Category {categoryId} was not found."); + + category.Status = status; + category.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + // Subcategories ------------------------------------------------------------ + + public async Task> ListSubCategoriesAsync( + int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct)) + throw new NotFoundException($"Category {categoryId} was not found."); + + var q = _subCategories.Query().AsNoTracking().Where(s => s.CategoryId == categoryId); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(s => EF.Functions.ILike(s.Name, $"%{term}%")); + } + if (status is not null) q = q.Where(s => s.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(s => s.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(s => new SubCategoryDto( + s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default) + { + var sub = await _subCategories.Query().AsNoTracking() + .FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct); + return sub is null ? null : new ETagged(MapSub(sub), sub.RowVersion); + } + + public async Task> CreateSubCategoryAsync( + int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default) + { + var category = await _categories.Query().AsNoTracking() + .FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct) + ?? throw new NotFoundException($"Category {categoryId} was not found."); + + if (category.Status != EntityStatus.Active) + throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} is inactive.", 422); + + var name = request.Name.Trim(); + if (await _subCategories.Query().AnyAsync( + s => s.CategoryId == categoryId && s.Name.ToLower() == name.ToLower(), ct)) + throw new ConflictException($"A subcategory named '{name}' already exists under category {categoryId}."); + + var sub = new SubCategory + { + CategoryId = categoryId, + Name = name, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _subCategories.AddAsync(sub, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(MapSub(sub), sub.RowVersion); + } + + public async Task> UpdateSubCategoryAsync( + int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var sub = await _subCategories.GetByIdAsync(subCategoryId, ct) + ?? throw new NotFoundException($"Subcategory {subCategoryId} was not found."); + + if (sub.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The subcategory was modified by another request.", 412); + + var name = request.Name.Trim(); + if (!string.Equals(sub.Name, name, StringComparison.Ordinal) + && await _subCategories.Query().AnyAsync( + s => s.CategoryId == sub.CategoryId && s.Name.ToLower() == name.ToLower() && s.SubCategoryId != subCategoryId, ct)) + throw new ConflictException($"A subcategory named '{name}' already exists under category {sub.CategoryId}."); + + // Name only — reparenting is not offered, since it would silently invalidate the + // category of every item pointing at this subcategory. + sub.Name = name; + sub.UpdatedAt = DateTime.UtcNow; + + await SaveGuardingConcurrencyAsync("subcategory", ct); + return new ETagged(MapSub(sub), sub.RowVersion); + } + + public async Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default) + { + var sub = await _subCategories.GetByIdAsync(subCategoryId, ct) + ?? throw new NotFoundException($"Subcategory {subCategoryId} was not found."); + + sub.Status = status; + sub.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + // --------------------------------------------------------------------------- + + private async Task SaveGuardingConcurrencyAsync(string label, CancellationToken ct) + { + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, $"The {label} was modified by another request.", 412); + } + } + + private static CategoryDto Map(Category c) => new(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt); + + private static SubCategoryDto MapSub(SubCategory s) => new( + s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt); } diff --git a/Backend/ERPCore/Services/Interfaces/IBrandService.cs b/Backend/ERPCore/Services/Interfaces/IBrandService.cs new file mode 100644 index 0000000..33b4a80 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IBrandService.cs @@ -0,0 +1,16 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Brands; +using ERPCore.Dtos.Common; + +namespace ERPCore.Services.Interfaces; + +/// Brand master business logic (docs/11-BACKEND-PHASE1.md §2.6). +public interface IBrandService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int brandId, CancellationToken ct = default); + Task> CreateAsync(CreateBrandRequest request, CancellationToken ct = default); + Task> UpdateAsync(int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ICategoryService.cs b/Backend/ERPCore/Services/Interfaces/ICategoryService.cs index c318ef6..a96116e 100644 --- a/Backend/ERPCore/Services/Interfaces/ICategoryService.cs +++ b/Backend/ERPCore/Services/Interfaces/ICategoryService.cs @@ -1,12 +1,28 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; using ERPCore.Dtos.Categories; using ERPCore.Dtos.Common; namespace ERPCore.Services.Interfaces; -/// Category master business logic (docs/11-BACKEND-PHASE1.md §2.3). +/// +/// Category + subcategory master business logic (docs/11-BACKEND-PHASE1.md §2.3). +/// The hierarchy is exactly two levels deep; there is no tree endpoint any more. +/// public interface ICategoryService { - Task> ListAsync(PageQuery query, CancellationToken ct = default); - Task> GetTreeAsync(CancellationToken ct = default); - Task CreateAsync(CreateCategoryRequest request, CancellationToken ct = default); + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int categoryId, CancellationToken ct = default); + Task> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default); + Task> UpdateAsync(int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default); + + /// Subcategories of one category. 404s when the category itself does not exist. + Task> ListSubCategoriesAsync( + int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default); + + Task?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default); + Task> CreateSubCategoryAsync(int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default); + Task> UpdateSubCategoryAsync(int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IItemService.cs b/Backend/ERPCore/Services/Interfaces/IItemService.cs index b405f27..f73ee08 100644 --- a/Backend/ERPCore/Services/Interfaces/IItemService.cs +++ b/Backend/ERPCore/Services/Interfaces/IItemService.cs @@ -12,7 +12,8 @@ namespace ERPCore.Services.Interfaces; public interface IItemService { Task> ListAsync( - PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default); + PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId, + TrackingMode? trackingMode, CancellationToken ct = default); Task?> GetAsync(int itemId, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IItemTypeService.cs b/Backend/ERPCore/Services/Interfaces/IItemTypeService.cs new file mode 100644 index 0000000..e03969e --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IItemTypeService.cs @@ -0,0 +1,20 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.ItemTypes; + +namespace ERPCore.Services.Interfaces; + +/// +/// Item type master business logic (docs/11-BACKEND-PHASE1.md §2.7). Plain CRUD over an +/// unlinked list — no item ever references an item type (docs/10 Part C.9), so there is +/// nothing here beyond maintaining the names the builder's dropdown reads. +/// +public interface IItemTypeService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int itemTypeId, CancellationToken ct = default); + Task> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default); + Task> UpdateAsync(int itemTypeId, UpdateItemTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int itemTypeId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IProductConfigService.cs b/Backend/ERPCore/Services/Interfaces/IProductConfigService.cs new file mode 100644 index 0000000..468eb29 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IProductConfigService.cs @@ -0,0 +1,13 @@ +using ERPCore.Common.Http; +using ERPCore.Dtos.Config; + +namespace ERPCore.Services.Interfaces; + +/// Product configuration business logic (docs/11-BACKEND-PHASE1.md §2.8). Singleton. +public interface IProductConfigService +{ + Task> GetAsync(CancellationToken ct = default); + + Task> UpdateAsync( + UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index 3a54e1c..051d4e0 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -13,36 +13,52 @@ namespace ERPCore.Services; /// /// Item master service. Enforces SKU uniqueness (SKU_DUPLICATE), reference -/// integrity, and optimistic concurrency (CONCURRENCY_CONFLICT) per -/// docs/11-BACKEND-PHASE1.md §2.1–2.2 and 02-SECURITY C.1. +/// integrity, product-configuration gating (CONFIG_DISABLED), and optimistic +/// concurrency (CONCURRENCY_CONFLICT) per docs/11-BACKEND-PHASE1.md §2.1–2.2 +/// and 02-SECURITY C.1. +/// +/// The SKU arrives generated by the client (it encodes the chosen item-type values, +/// e.g. "BL-100-0003"); this service only checks that it is unique. Items hold no +/// item-type reference at all — see docs/10 Part C.9. +/// /// public sealed class ItemService : IItemService { private readonly IRepository _items; private readonly IRepository _categories; + private readonly IRepository _subCategories; + private readonly IRepository _brands; private readonly IRepository _uoms; private readonly IRepository _vendors; private readonly IRepository _warehouses; + private readonly IProductConfigService _config; private readonly IUnitOfWork _uow; public ItemService( IRepository items, IRepository categories, + IRepository subCategories, + IRepository brands, IRepository uoms, IRepository vendors, IRepository warehouses, + IProductConfigService config, IUnitOfWork uow) { _items = items; _categories = categories; + _subCategories = subCategories; + _brands = brands; _uoms = uoms; _vendors = vendors; _warehouses = warehouses; + _config = config; _uow = uow; } public async Task> ListAsync( - PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default) + PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId, + TrackingMode? trackingMode, CancellationToken ct = default) { var q = _items.Query().AsNoTracking(); @@ -53,14 +69,17 @@ public sealed class ItemService : IItemService } if (status is not null) q = q.Where(i => i.Status == status); if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId); + if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId); + if (brandId is not null) q = q.Where(i => i.BrandId == brandId); if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode); var total = await q.CountAsync(ct); var rows = await q.OrderBy(i => i.Sku) .Skip(query.Skip).Take(query.PageSize) .Select(i => new ItemListItemDto( - i.ItemId, i.Sku, i.Name, i.CategoryId, i.BaseUomId, i.DefaultVendorId, - i.ItemType, i.TrackingMode, i.TaxClass, i.Status)) + i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, + i.BaseUomId, i.DefaultVendorId, + i.StockNature, i.TrackingMode, i.TaxClass, i.Status)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); @@ -80,7 +99,9 @@ public sealed class ItemService : IItemService if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct)) throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400); - await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct); + await ValidateReferencesAsync( + request.CategoryId, request.SubCategoryId, request.BrandId, + request.BaseUomId, request.DefaultVendorId, ct); var item = new Item { @@ -88,9 +109,11 @@ public sealed class ItemService : IItemService Name = request.Name.Trim(), Description = request.Description, CategoryId = request.CategoryId, + SubCategoryId = request.SubCategoryId, + BrandId = request.BrandId, BaseUomId = request.BaseUomId, DefaultVendorId = request.DefaultVendorId, - ItemType = request.ItemType, + StockNature = request.StockNature, TrackingMode = request.TrackingMode, TaxClass = request.TaxClass, Status = EntityStatus.Active, @@ -118,15 +141,19 @@ public sealed class ItemService : IItemService && await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct)) throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400); - await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct); + await ValidateReferencesAsync( + request.CategoryId, request.SubCategoryId, request.BrandId, + request.BaseUomId, request.DefaultVendorId, ct); item.Sku = request.Sku.Trim(); item.Name = request.Name.Trim(); item.Description = request.Description; item.CategoryId = request.CategoryId; + item.SubCategoryId = request.SubCategoryId; + item.BrandId = request.BrandId; item.BaseUomId = request.BaseUomId; item.DefaultVendorId = request.DefaultVendorId; - item.ItemType = request.ItemType; + item.StockNature = request.StockNature; item.TrackingMode = request.TrackingMode; item.TaxClass = request.TaxClass; item.UpdatedAt = DateTime.UtcNow; @@ -240,11 +267,56 @@ public sealed class ItemService : IItemService return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions); } - private async Task ValidateReferencesAsync(int categoryId, int baseUomId, int? defaultVendorId, CancellationToken ct) + /// + /// Validates every FK on an item write, and gates the optional ones on the product + /// configuration (FR-MD-11). Note there is no item-type check: nothing on an item + /// references an item type, so itemTypesEnabled has nothing to reject here — + /// it is advisory and honoured by the frontend only (docs/11 §2.8). + /// + private async Task ValidateReferencesAsync( + int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, CancellationToken ct) { + var config = (await _config.GetAsync(ct)).Value; + if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct)) throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422); + if (subCategoryId is not null) + { + if (!config.SubcategoriesEnabled) + throw new DomainException( + ErrorCodes.ConfigDisabled, + "Subcategories are disabled in the product configuration; subCategoryId must be null.", 422); + + var sub = await _subCategories.Query().AsNoTracking() + .FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct); + if (sub is null) + throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422); + if (sub.Status != EntityStatus.Active) + throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} is inactive.", 422); + // The two FKs must agree, or the item would claim a category its subcategory + // does not belong to. + if (sub.CategoryId != categoryId) + throw new DomainException( + ErrorCodes.Validation, + $"Subcategory {subCategoryId} belongs to category {sub.CategoryId}, not {categoryId}.", 422); + } + + if (brandId is not null) + { + if (!config.BrandsEnabled) + throw new DomainException( + ErrorCodes.ConfigDisabled, + "Brands are disabled in the product configuration; brandId must be null.", 422); + + var brand = await _brands.Query().AsNoTracking() + .FirstOrDefaultAsync(b => b.BrandId == brandId, ct); + if (brand is null) + throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422); + if (brand.Status != EntityStatus.Active) + throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} is inactive.", 422); + } + if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct)) throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422); @@ -272,8 +344,9 @@ public sealed class ItemService : IItemService } private static ItemDetailDto ToDetail(Item i) => new( - i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.BaseUomId, i.DefaultVendorId, - i.ItemType, i.TrackingMode, i.TaxClass, i.Status, + 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.Status, i.ReorderSettings .OrderBy(r => r.WarehouseId) .Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty)) diff --git a/Backend/ERPCore/Services/ItemTypeService.cs b/Backend/ERPCore/Services/ItemTypeService.cs new file mode 100644 index 0000000..6297a54 --- /dev/null +++ b/Backend/ERPCore/Services/ItemTypeService.cs @@ -0,0 +1,118 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.ItemTypes; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +/// +/// Item type master service (FR-MD-10) — maintains the Color/Size/Material list that +/// GET /item-types serves to the frontend builder's dropdown. Nothing references +/// these rows, so there is no in-use check to make and no cascade to worry about +/// (docs/11-BACKEND-PHASE1.md §2.7, docs/10 Part C.9). +/// +public sealed class ItemTypeService : IItemTypeService +{ + private readonly IRepository _itemTypes; + private readonly IUnitOfWork _uow; + + public ItemTypeService(IRepository itemTypes, IUnitOfWork uow) + { + _itemTypes = itemTypes; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _itemTypes.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%")); + } + if (status is not null) q = q.Where(t => t.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(t => t.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int itemTypeId, CancellationToken ct = default) + { + var itemType = await _itemTypes.Query().AsNoTracking() + .FirstOrDefaultAsync(t => t.ItemTypeId == itemTypeId, ct); + return itemType is null ? null : new ETagged(Map(itemType), itemType.RowVersion); + } + + public async Task> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default) + { + var name = request.Name.Trim(); + if (await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower(), ct)) + throw new ConflictException($"An item type named '{name}' already exists."); + + var itemType = new ItemType + { + Name = name, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _itemTypes.AddAsync(itemType, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(itemType), itemType.RowVersion); + } + + public async Task> UpdateAsync( + int itemTypeId, UpdateItemTypeRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var itemType = await _itemTypes.GetByIdAsync(itemTypeId, ct) + ?? throw new NotFoundException($"Item type {itemTypeId} was not found."); + + if (itemType.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item type was modified by another request.", 412); + + var name = request.Name.Trim(); + if (!string.Equals(itemType.Name, name, StringComparison.Ordinal) + && await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower() && t.ItemTypeId != itemTypeId, ct)) + throw new ConflictException($"An item type named '{name}' already exists."); + + // Renaming does not touch existing items: their SKUs already encode the values that + // were chosen, and nothing joins back to this row (docs/10 Part C.9). + itemType.Name = name; + itemType.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item type was modified by another request.", 412); + } + + return new ETagged(Map(itemType), itemType.RowVersion); + } + + public async Task SetStatusAsync(int itemTypeId, EntityStatus status, CancellationToken ct = default) + { + var itemType = await _itemTypes.GetByIdAsync(itemTypeId, ct) + ?? throw new NotFoundException($"Item type {itemTypeId} was not found."); + + itemType.Status = status; + itemType.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/ProductConfigService.cs b/Backend/ERPCore/Services/ProductConfigService.cs new file mode 100644 index 0000000..ef8d0c4 --- /dev/null +++ b/Backend/ERPCore/Services/ProductConfigService.cs @@ -0,0 +1,71 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Config; +using ERPCore.Infra.Auth; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +/// +/// Product configuration service (FR-MD-11) over the singleton row seeded by DataSeeder. +/// Reads are never gated — only writes consult the flags — so switching a feature off +/// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8). +/// +public sealed class ProductConfigService : IProductConfigService +{ + private readonly IRepository _config; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public ProductConfigService(IRepository config, ICurrentUser currentUser, IUnitOfWork uow) + { + _config = config; + _currentUser = currentUser; + _uow = uow; + } + + public async Task> GetAsync(CancellationToken ct = default) + { + var config = await _config.Query().AsNoTracking() + .FirstOrDefaultAsync(c => c.ConfigId == ProductConfig.SingletonId, ct) + ?? throw new NotFoundException("Product configuration has not been seeded."); + + return new ETagged(Map(config), config.RowVersion); + } + + public async Task> UpdateAsync( + UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var config = await _config.GetByIdAsync(ProductConfig.SingletonId, ct) + ?? throw new NotFoundException("Product configuration has not been seeded."); + + if (config.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); + + // Non-null by [Required] on the nullable bools — an omitted flag is a 400, so the + // model binder has already rejected anything that would reach here with a null. + config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value; + config.BrandsEnabled = request.BrandsEnabled!.Value; + config.ItemTypesEnabled = request.ItemTypesEnabled!.Value; + config.UpdatedAt = DateTime.UtcNow; + config.UpdatedBy = _currentUser.AuditUserId; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); + } + + return new ETagged(Map(config), config.RowVersion); + } + + private static ProductConfigDto Map(ProductConfig c) => new( + c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled, c.UpdatedAt, c.UpdatedBy); +} diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index a86e142..1316272 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -24,6 +24,7 @@ public static class ErrorCodes public const string ReasonCodeRequired = "REASON_CODE_REQUIRED"; public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT"; public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY"; + public const string ConfigDisabled = "CONFIG_DISABLED"; // Auth proxy (AuthController → AuthHex, docs/11 §2.0) public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR"; diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index fa8ccd2..e1c80ae 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -15,14 +15,41 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [x] ProblemDetails middleware + domain exception → `code` mapping (System/Errors; full §7 catalog added to `ErrorCodes`) ## 1. Master Data -> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting; `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical. -- [x] Item: entity + config + enums (ItemType, TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU +> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting (**that endpoint was removed on 2026-07-16** — categories no longer nest; see the entry at the end of this section); `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical. +- [x] Item: entity + config + enums (StockNature [ex-ItemType], TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU - [x] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1) - [x] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert) -- [x] Category (hierarchy, `GET /categories?tree=true` nested build, parent-exists validation) +- [x] Category — **rebuilt 2026-07-16 as a two-level Category/SubCategory model** (was a self-nesting tree); full CRUD + status + ETag, which it previously lacked entirely - [x] Vendor (CRUD, ETag/If-Match, unique code, deactivate via `PATCH /vendors/{id}/status`) - [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse) - [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation) +- [x] Brand master (FR-MD-09) — CRUD + status + ETag; `Item.brandId` nullable FK +- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only +- [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId` +- [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes + +> ### 2026-07-16 — Brands, Subcategories, Item Types, Product Config (migration #2) +> Makes real three concepts the frontend had been faking on mock data (`Frontend/erp-system/lib/api/mock-data.ts`), per docs/10 §B.3.1 FR-MD-09/10/11 and docs/11 §2.3/2.6/2.7/2.8. +> +> **Deviations / decisions (all recorded in docs/10 §B.8.4 #11–13):** +> - **`ItemType` enum → `StockNature`.** The Stocked/NonStocked/Service enum was renamed to free the name `ItemType` for the new master entity. FR-MD-01 stays satisfied; the DB column was renamed in-place (`RenameColumn`, data preserved). Blast radius was 4 files — nothing in stock/GRN/costing branches on it. +> - **`CATEGORY.parent_id` removed.** Arbitrary nesting is gone, replaced by a dedicated `SUBCATEGORY` table (exactly two levels). Items now carry **both** FKs; previously the frontend collapsed them (`effectiveCategoryId = subCategoryId ?? categoryId`), losing the parent. +> - **Item types are unlinked to items, deliberately.** No value table, no join — values live only in the client-generated SKU (`BL-100-0003`) and are never parsed server-side. Accepted trade-off (no query-by-colour/size; renaming a type doesn't touch existing SKUs) written up in docs/10 Part C.9. This is *not* a product-variation model; none was requested. +> - **`itemTypesEnabled` is advisory, not enforced.** With no item-type reference on an item there is nothing on a write to reject; only `subcategoriesEnabled`/`brandsEnabled` produce `CONFIG_DISABLED`. Stated plainly in docs/11 §2.8 so it isn't mistaken for a backend guarantee. +> - **`PUT /product-config` is door-policy-gated only** — any ERP-admitted user can flip the flags. A `CONFIG_MANAGE` permission is reserved for when RBAC lands (open decision #13). +> - **`brandId` is now a documented field**, no longer the undocumented frontend-only extra it was. +> +> **Migration #2 (`AddBrandsSubcategoriesItemTypesAndProductConfig`) carries data, not just DDL.** The scaffolded version dropped `parent_id` outright, which would have silently flattened every child category into a root and stranded items on the wrong one. Hand-added: backfill of child categories into `subcategories`, repoint of items onto the correct (category, subcategory) pair, delete of the migrated rows, and the config singleton insert. A recursive CTE maps categories at **any** depth to their root ancestor, since the old model allowed unlimited nesting but the new one is two levels — a grandchild becomes a subcategory of its *root*, not of its (now-nonexistent) parent category. `Down()` was likewise hand-written to restore the tree instead of dropping `subcategories` and losing it. +> +> Also fixed while writing it: the `ck_product_config_singleton` check constraint was scaffolded as `config_id = 1`, but the column is created quoted-PascalCase (`"ConfigId"`) — unquoted, Postgres folds it to a column that doesn't exist. And `UpdateProductConfigRequest`'s flags are `bool?` on purpose: `[Required]` on a non-nullable `bool` is a no-op, so a body of `{}` would have bound all three to `false` and silently switched every feature off. +> +> **Schema/migration verified:** `dotnet build` clean. Migration `Up` **and** `Down` exercised against a purpose-seeded 3-level tree (Hardware → Fasteners → Bolts, plus items on each level and a childless root) — 9/9 forward assertions and 7/7 rollback assertions passed, including the grandchild depth-collapse and `StockNature` data preservation; the fixture was then removed. `DataSeeder` seeds `Color`/`Size` + the config singleton idempotently (it needed restructuring — an early `return` in the reason-code path would otherwise have skipped the new seeds on every start after the first). +> +> **Live smoke test PASSED (2026-07-16), all 24 checks, against Postgres + a real AuthHex session.** Auth note: a token *is* obtainable despite the `loginUser` blocker — **`POST /api/v1/auth/register` succeeds and issues the `erp_at` session cookie directly**, and the JWT handler's cookie fallback means that session authenticates every other controller. (`loginUser` still `500`s "Invalid credentials" for that same freshly-registered user, by username *or* email, with *or* without `userTypeId` — the §6 blocker is real and reproduces, but it is not a barrier to testing.) Registration needs AuthHex-internal `roleId`/`userTypeId` GUIDs, supplied by the user; Admin = role `08de6a11-9e9f-4401-8a10-6859860b41ec` / userType `00000000-0000-0000-0000-000000000004`. +> +> Covered: brand/category/subcategory/item-type create; **case-insensitive duplicate name → 409** (brand, and subcategory scoped per-parent); subcategory under a missing category → 404; item create carrying all three new FKs with SKU `BL-100-0003` → 201 and full round-trip on `GET /items/{id}`; new `brandId`/`subCategoryId` list filters; **cross-FK guard → 422** ("Subcategory 3 belongs to category 7, not 8"); missing/inactive brand → 422; `PUT /product-config {}` → **400** (proving the `bool?` fix — an empty body no longer silently disables everything); `subcategoriesEnabled:false` + `subCategoryId` → **422 CONFIG_DISABLED**, same item without it → 201, and **pre-existing items with a subcategory still read back fine**; `brandsEnabled:false` + `brandId` → 422; **`itemTypesEnabled:false` correctly does NOT block item writes** (advisory, as documented); ETag round-trip 200 / stale-but-well-formed → **412 CONCURRENCY_CONFLICT** (brand + subcategory) / absent → 428; `PATCH /status` → 204 then inactive-brand reference → 422; and **renaming an item type left existing SKUs untouched**, confirming the intended decoupling. Audit stamp confirmed live: `product_config.updatedBy` resolved to a JIT-provisioned shadow user (`SMOKE001`) from the AuthHex `UserId`/`NIC` claims. +> +> Test data was removed afterwards (masters back to empty, config flags restored to all-true with the audit stamp cleared). **Two artifacts left behind on purpose:** the AuthHex user `smoketest_admin` / NIC `SMOKE001` in AuthHex's own MySQL store, and its ERPCore shadow user (`users.UserId = 3`) — referenced by nothing, kept so the session can be reused for future testing. Delete both if unwanted. ## 2. Procurement > Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired. @@ -147,3 +174,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Live-verified against the running AuthHex instance (`:5011`) and ERPCore (`:5224`, dev):** `register` → `200` with `Set-Cookie: erp_at`(httpOnly/Secure/Strict/maxAge=3600) + `erp_rt`(httpOnly/Secure/Strict/Path=`/api/v1/auth/refresh-token`/30d) + `XSRF-TOKEN`(Secure/Strict, JS-readable), body carries `user`+`expiresIn` only, **no tokens**; the `erp_at` cookie alone (zero `Authorization` header) authenticated `GET /api/v1/items` — confirms the `OnMessageReceived` cookie fallback works for every existing v1 controller unchanged; `GET /api/v1/auth/sessions` (protected, cookie-authenticated) → `200`; mutating `POST /api/v1/auth/change-password` without `X-XSRF-TOKEN` → `403 CSRF_TOKEN_MISMATCH`, with the matching header → `204` + all three cookies cleared, exactly as designed. - **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing. - **Login/refresh/logout left unverified live** — `loginUser` currently fails with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` on AuthHex **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — this is a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. Blocked on an AuthHex-side fix; re-run the register→login→cookie→refresh→logout pass once that's resolved. + - **ROOT CAUSE FOUND (2026-07-16), in `ERP_Auth_Service` — two independent bugs, both one-liners. Not fixed here: different project, outside this repo's scope.** Re-confirmed the failure against a user registered this session (`smoketest_admin`/`SMOKE001`), by username *and* email, with *and* without `userTypeId`. + 1. **The password is never stored.** `Services/UserManager/UserManagerService.cs:97` computes `var PasswordHash = PasswordHasher.Hash(...)`, but the assignment in the `new User { … }` initializer at **line 116 is commented out** (`//PasswordHash = PasswordHash`). Every registered user lands in MySQL with a null `PasswordHash`, so `loginUser`'s `if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(...))` (line 230) always throws `"Invalid credentials"`. Uncommenting line 116 should fix login outright. Note existing users are unrecoverable — their hashes were never written — so they need re-registration or a password reset (`ChangeUserPassword`/`UpdateUser` do persist the hash correctly, and `UpdateUser` even handles the null-hash case at line 836). + 2. **Username is not a valid login identifier.** `Repos/UserManageRepository.cs:46` `GetUserByIdentifierAndType` matches only `Email`/`MobileNumber`/`Nic` — **not `UserName`** — and ignores its `userTypeId` argument entirely (that filtering sits commented out at lines 56–65, so the "AndType" half of the method name is currently a lie). Even with bug 1 fixed, `identifier: ""` will not resolve a user; only email/mobile/NIC will. + - **Workaround meanwhile: `POST /api/v1/auth/register` issues a working `erp_at` session cookie directly**, which authenticates every v1 controller via the handler's cookie fallback. That is how this session's Master-Data smoke test (§1) was run — no login needed. diff --git a/docs/00-CORE.md b/docs/00-CORE.md index c878db5..9277b56 100644 --- a/docs/00-CORE.md +++ b/docs/00-CORE.md @@ -311,7 +311,7 @@ dotnet run - Swagger UI at `https://localhost:/swagger` - Health at `https://localhost:/health` → `Healthy` -Entity/DbContext modeling (the 38 entities, configurations, enums) is specified in `10-BACKEND-PHASE1.md`. Do not invent the schema here — follow that file. +Entity/DbContext modeling (the 42 entities, configurations, enums) is specified in `10-BACKEND-PHASE1.md`. Do not invent the schema here — follow that file. --- diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md index 089f757..b181247 100644 --- a/docs/10-BACKEND-PHASE1.md +++ b/docs/10-BACKEND-PHASE1.md @@ -1,6 +1,6 @@ # 10 · BACKEND — Phase 1 Spec (Inventory & Supply Chain) -> **Authoritative for:** backend architecture, business rules, and the data model (the 38-entity schema). +> **Authoritative for:** backend architecture, business rules, and the data model (the 42-entity schema). > **Navigation:** you arrived here from `00-CORE.md`. API request/response contracts are in `11-BACKEND-PHASE1.md`. Frontend rules are in `20-FRONTEND.md`. Record work in `Backend/PROGRESS.md`. > **Scope basis:** SRS v1.1. Costing = FIFO · Multi-warehouse · Single-tenant · RBAC deferred (user identity stamped) · approvals auto/config-gated · vendor invoice + 3-way match deferred to Accounting. > **Authentication:** identity is owned by the **external AuthHex identity provider** (separate service), but as of 2026-07-16 the frontend no longer calls AuthHex directly — all login/registration/recovery/2FA/session traffic is proxied through ERPCore's own `AuthController` (`Controllers/AuthController.cs`, `Services/Auth/*`), which forwards to AuthHex and delivers the resulting session as httpOnly Secure cookies (docs/02-SECURITY.md §B.2). ERPCore still does not mint or sign tokens itself — it only forwards to and validates AuthHex's RS256 JWTs. See A.4 (Authentication / Audit actor) and `11-BACKEND-PHASE1.md §2.0` for the endpoint list. RBAC (per-endpoint) still deferred. @@ -118,14 +118,17 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users ### B.3.1 Master Data (FR-MD) | ID | Requirement | Pri | |---|---|---| -| FR-MD-01 | Maintain **Item master**: SKU (unique), name, description, category, item type (Stocked/NonStocked/Service), tracking mode (None/Batch/Serial), status, tax class, default vendor. | M | +| FR-MD-01 | Maintain **Item master**: SKU (unique), name, description, category (+ optional subcategory), optional brand, **stock nature** (Stocked/NonStocked/Service), tracking mode (None/Batch/Serial), status, tax class, default vendor. | M | | FR-MD-02 | Maintain **UOM master** with base UOM per item and **conversion factors** (purchase→stock→base). | M | | FR-MD-03 | Convert quantities between UOMs on every transaction; store base-UOM quantity in the ledger. | M | -| FR-MD-04 | Maintain **hierarchical item categories**. | S | +| FR-MD-04 | Maintain **item categories with one optional subcategory level**. An item references a category (required) and a subcategory (optional) that must belong to it. Deeper nesting is not supported. | S | | FR-MD-05 | Hold **reorder point** and **reorder quantity** per item, optionally per warehouse. | M | | FR-MD-06 | Maintain **Vendor master**: code, name, contact, terms, tax reg, status, currency. | M | | FR-MD-07 | Maintain **Warehouse master** and, within each, a **bin/location** structure. | M | | FR-MD-08 | Prevent deletion of any master referenced by a transaction; deactivate instead. | M | +| FR-MD-09 | Maintain **Brand master** (name, status); optionally referenced by an item. | S | +| FR-MD-10 | Maintain **Item Type master** (name, status — e.g. Color, Size, Material) as a **selectable list only**: it feeds the item builder's dropdown and is **not** referenced by any item. Chosen values are encoded into the client-generated SKU, not stored (Part C.9). No product-variation model. | S | +| FR-MD-11 | Maintain a singleton **Product Configuration** gating optional features. `subcategoriesEnabled`/`brandsEnabled` are **enforced server-side** — an item write carrying a gated field while its flag is off is rejected (`CONFIG_DISABLED`). `itemTypesEnabled` is **advisory** (frontend-honoured) since items hold no item-type reference. Reads are never gated. | S | ### B.3.2 Procurement (FR-PROC) | ID | Requirement | Pri | @@ -191,7 +194,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users | FR-X-05 | No editing/deleting confirmed/posted transactions; corrections via reversing entries. | M | ## B.4 Data Model (summary) -Key entities and relationships are enumerated in **Part C**. The commitment: Item, UOM/UOMConversion, Category, Vendor, Warehouse/Bin, ItemReorder, Requisition(+Line), RFQ(+Line)/VendorQuotation, PurchaseOrder(+Line), GRN(+Line), PurchaseReturn(+Line), StockLayer (FIFO), StockLedger (immutable), Batch, Serial, StockTransfer(+Line), StockAdjustment(+Line), StockCount(+Line), User, ReasonCode, NumberSequence, AuditLog, JournalEntryStub, and reserved RBAC (Role, Permission, UserRole, RolePermission). +Key entities and relationships are enumerated in **Part C**. The commitment: Item, UOM/UOMConversion, Category, SubCategory, Brand, ItemType, ProductConfig, Vendor, Warehouse/Bin, ItemReorder, Requisition(+Line), RFQ(+Line)/VendorQuotation, PurchaseOrder(+Line), GRN(+Line), PurchaseReturn(+Line), StockLayer (FIFO), StockLedger (immutable), Batch, Serial, StockTransfer(+Line), StockAdjustment(+Line), StockCount(+Line), User, ReasonCode, NumberSequence, AuditLog, JournalEntryStub, and reserved RBAC (Role, Permission, UserRole, RolePermission). ## B.5 External Interfaces UI: responsive; count/pick screens handheld-friendly; status badges; mandatory-field validation. Hardware: barcode/QR (designed-for). Software: relational DB with transactional integrity + row locking; internal service interfaces/events for Phase-2+ modules. @@ -244,26 +247,41 @@ Adjustment: Damage, Theft/Loss, Count Variance, Expiry Write-off, System Correct | 8 | Costing method | **Resolved:** FIFO | | 9 | Tenancy | **Resolved:** single-tenant | | 10 | Authentication | **Resolved:** external **AuthHex** IdP (RS256; ERPCore validates only), **shadow-user** provisioning (`auth_user_id` GUID → local `int`), door-gated by ERP `UserType`/`Role`; per-endpoint RBAC deferred. *Open sub-item:* exact ERP `UserTypeCode`/`RoleCode` + RSA-key rotation process. | +| 11 | Category hierarchy depth | **Resolved:** dedicated `SUBCATEGORY` table, exactly two levels; `CATEGORY.parent_id` dropped. Item carries both FKs (subcategory nullable). Arbitrary nesting is not coming back. | +| 12 | Item types / variants | **Resolved:** the `ItemType` **enum** was replaced by an **unreferenced master list**; Stocked/NonStocked/Service survives as `stock_nature`. Values are **SKU-encoded only** — no value table, no item link, no product-variation model (Part C.9 records the accepted trade-off). | +| 13 | Product-config authorization | **Open:** `PUT /product-config` is gated by the door policy only, like every other endpoint. A `CONFIG_MANAGE` permission is reserved for when per-endpoint RBAC lands (decision #6). Until then any ERP-admitted user can flip the flags. | --- -# Part C — ER Model (38 entities) +# Part C — ER Model (42 entities) Costing: FIFO · Multi-warehouse · Single-tenant. Legend: **PK** primary key · **FK** foreign key. Companion visual diagrams (Mermaid / draw.io ERD) accompany this repo; this part is the authoritative textual model. ## C.1 Master Data ``` -CATEGORY(category_id PK, parent_id FK→CATEGORY, name) +CATEGORY(category_id PK, name, status) -- top level; no self-nesting +SUBCATEGORY(subcategory_id PK, category_id FK→CATEGORY, name, status) +BRAND(brand_id PK, name, status) +ITEM_TYPE(item_type_id PK, name, status) -- Color, Size, Material — standalone UOM(uom_id PK, name) UOM_CONVERSION(conversion_id PK, item_id FK→ITEM, from_uom FK→UOM, to_uom FK→UOM, factor) -ITEM(item_id PK, sku, name, category_id FK→CATEGORY, base_uom_id FK→UOM, - default_vendor_id FK→VENDOR, item_type, tracking_mode, tax_class, status) +ITEM(item_id PK, sku, name, category_id FK→CATEGORY, subcategory_id FK→SUBCATEGORY [nullable], + brand_id FK→BRAND [nullable], base_uom_id FK→UOM, + default_vendor_id FK→VENDOR, stock_nature, tracking_mode, tax_class, status) ITEM_REORDER(reorder_id PK, item_id FK→ITEM, warehouse_id FK→WAREHOUSE, reorder_point, reorder_qty) VENDOR(vendor_id PK, code, name, terms, tax_reg, currency, status) WAREHOUSE(warehouse_id PK, code, name) BIN(bin_id PK, warehouse_id FK→WAREHOUSE, code, bin_type) +PRODUCT_CONFIG(config_id PK [singleton = 1], subcategories_enabled, brands_enabled, + item_types_enabled, updated_at, updated_by FK→USER) -- FR-MD-11 feature gate ``` +**Category hierarchy is exactly two levels.** `CATEGORY.parent_id` was removed (migration `AddBrandsSubcategoriesItemTypesAndProductConfig`); the optional level below a category is now `SUBCATEGORY`. An item carries **both** FKs — `category_id` required, `subcategory_id` nullable — and the service rejects a subcategory that does not belong to the given category (422). + +**`ITEM_TYPE` is deliberately unreferenced** — see C.9. + +**`stock_nature`** (Stocked/NonStocked/Service) is the former `item_type` column, renamed so the name could be taken by the `ITEM_TYPE` master. The two are unrelated concepts. + ## C.2 Procurement ``` REQUISITION(requisition_id PK, doc_no, requested_by FK→USER, status, created_at) @@ -339,6 +357,10 @@ ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION) ``` ## C.9 Modeling notes (load-bearing) +- **Item types are a dropdown, not a relationship.** `ITEM_TYPE` (Color, Size, Material) exists **only** to populate the frontend item-builder's dropdown via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. The builder cross-products the checked types into **one standalone item per combination**; the chosen values (Red, S, M) are encoded by the **client** into the generated SKU (`BL-0002` for one type, `BL-100-0003` for two) and the server only checks that SKU for uniqueness. **The item list is the record of what was built.** This is not a product-variation model: there is no parent-product entity and no variant hierarchy. + - *Accepted trade-off (a decision, not an oversight):* the backend cannot answer "list all blue items", cannot filter or report by colour/size, and cannot validate that a SKU's segments correspond to real item types. Renaming an item type (`Color` → `Colour`) does **not** touch existing SKUs, which keep their old segments — the two are permanently decoupled the moment an item is created. If value-level querying is ever needed, an `ITEM_TYPE_VALUE` table plus a link table can be added additively, but existing SKUs will not be back-fillable without parsing them by hand. +- **Two-level categories.** `CATEGORY` no longer self-nests; `SUBCATEGORY` is the single optional level below it. An item stores both FKs rather than pointing only at the deepest node, so the parent is never inferred or lost. A subcategory cannot be reparented (it would silently invalidate the category of every item referencing it) — deactivate and recreate instead. +- **Product config is a singleton, and only two of its flags are enforceable.** `subcategories_enabled` / `brands_enabled` gate item writes (`CONFIG_DISABLED`, 422). `item_types_enabled` is **advisory only** — since items carry no item-type reference, there is nothing on a write to reject; the frontend honours it by hiding the builder's type section. Reads are never gated, so existing data stays readable after a flag is switched off. - **FIFO = two structures.** `STOCK_LAYER` answers valuation ("what's on hand and at what cost"); `STOCK_LEDGER` answers history ("what moved, when, by whom"). Layers are keyed per item **per warehouse**. - **Polymorphic source.** `STOCK_LEDGER.source_doc_type/source_doc_id` (and `AUDIT_LOG`, `JOURNAL_ENTRY_STUB`) reference the originating document without a hard FK per type — new transaction types (Sales, Manufacturing) write to the ledger without a schema change. - **In-transit + cost-preserving transfer.** `STOCK_TRANSFER` holds `src`/`dest` warehouse; dispatch consumes source layers into in-transit, receive creates the destination layer at the **inherited** source cost. @@ -348,10 +370,10 @@ ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION) - **Reorder alerts are a query**, not an entity — computed from `ITEM_REORDER` vs available. Add a table only if alert history is required. ## C.10 Entity → implementation mapping -- Entities → `Domain/Entities`; enums (`ItemType`, `TrackingMode`, `HoldStatus`, `Direction`, `*Status`, `CountType`) → `Domain/Enums`. +- Entities → `Domain/Entities`; enums (`StockNature`, `TrackingMode`, `HoldStatus`, `Direction`, `*Status`, `CountType`) → `Domain/Enums`. **Note:** `ItemType` in `Domain/Entities` is the master entity; the old `ItemType` **enum** is now `StockNature` — there is no enum by that name. - EF configurations (`IEntityTypeConfiguration`, one per entity) → `Infra/Persistence/Configurations`. - FIFO logic → `Services/Stock/FifoCostingService` (Part A.2). Ledger writes only via stock services inside the UoW transaction. -- `RowVersion` (concurrency) on mutable aggregates: Item, Vendor, PurchaseOrder, GRN, transfers/adjustments/counts headers. +- `RowVersion` (concurrency) on mutable aggregates: Item, Category, SubCategory, Brand, ItemType, ProductConfig, Vendor, PurchaseOrder, GRN, transfers/adjustments/counts headers. --- diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index a570116..b8a81a5 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -105,38 +105,45 @@ session-issuing responses omit `AccessToken`/`RefreshToken` (cookie-delivered in `erp_rt` cookie rather than the request body. ### 2.1 Items +> **`itemType` → `stockNature` (2026-07-16).** The Stocked/NonStocked/Service field was renamed so the name `itemType` could be taken by the new Item Type master (§2.7) — an unrelated concept. Items gained `subCategoryId` and `brandId` (both nullable). Items carry **no** item-type reference: the values chosen in the builder are encoded into the client-generated SKU (docs/10 Part C.9). + #### `GET /items` -Query: `q`, `status` (`Active|Inactive`), `categoryId`, `trackingMode` (`None|Batch|Serial`), + paging. +Query: `q`, `status` (`Active|Inactive`), `categoryId`, `subCategoryId`, `brandId`, `trackingMode` (`None|Batch|Serial`), + paging. **200 OK** ```json { "items": [ { "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40", - "categoryId": 12, "baseUomId": 1, "defaultVendorId": 5, - "itemType": "Stocked", "trackingMode": "Batch", "taxClass": "STD", "status": "Active" } ], + "categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5, + "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD", "status": "Active" } ], "pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } } ``` #### `GET /items/{itemId}` → **200 OK** (header `ETag: "AAAAAAAAB9E="`) ```json { "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40", - "description": "Grade 8.8 zinc-plated hex bolt", "categoryId": 12, "baseUomId": 1, - "defaultVendorId": 5, "itemType": "Stocked", "trackingMode": "Batch", "taxClass": "STD", + "description": "Grade 8.8 zinc-plated hex bolt", "categoryId": 12, "subCategoryId": 30, + "brandId": 2, "baseUomId": 1, + "defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD", "status": "Active", "reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ], "createdAt": "2026-06-01T08:00:00Z", "updatedAt": "2026-07-01T10:15:00Z" } ``` #### `POST /items` +The `sku` is **generated by the client** (it encodes the chosen item-type values, e.g. `BL-100-0003`); the server only enforces uniqueness. `subCategoryId`/`brandId` are optional. ```json { "sku": "ITM-1002", "name": "Steel Nut M8", "description": "Grade 8 zinc-plated hex nut", - "categoryId": 12, "baseUomId": 1, "defaultVendorId": 5, - "itemType": "Stocked", "trackingMode": "None", "taxClass": "STD" } + "categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5, + "stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD" } ``` **201 Created** — `Location: /api/v1/items/1002` ```json -{ "itemId": 1002, "sku": "ITM-1002", "name": "Steel Nut M8", "categoryId": 12, "baseUomId": 1, - "defaultVendorId": 5, "itemType": "Stocked", "trackingMode": "None", "taxClass": "STD", +{ "itemId": 1002, "sku": "ITM-1002", "name": "Steel Nut M8", "categoryId": 12, + "subCategoryId": 30, "brandId": 2, "baseUomId": 1, + "defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD", "status": "Active", "createdAt": "2026-07-07T09:30:00Z" } ``` `400` → `code: SKU_DUPLICATE` if SKU exists. +`422` → `code: CONFIG_DISABLED` if `subCategoryId` is sent while subcategories are disabled, or `brandId` while brands are disabled (§2.8). +`422` → validation error if the subcategory does not belong to `categoryId`, or if a referenced subcategory/brand/vendor is missing or inactive. #### `PUT /items/{itemId}` Full update; requires `If-Match`. → **200 OK** updated resource; `412` on ETag mismatch. @@ -171,13 +178,58 @@ Full update; requires `If-Match`. → **200 OK** updated resource; `412` on ETag "conversions": [ { "conversionId": 33, "fromUom": 7, "toUom": 1, "factor": 12 } ] } ``` -### 2.3 Categories +### 2.3 Categories & Subcategories +> **Two-level hierarchy (2026-07-16).** Categories no longer self-nest: `parentId` and `GET /categories?tree=true` are **gone**, replaced by a dedicated Subcategory resource one level below. Categories also gained `status` + an `ETag` (they previously had neither, so there was no update path at all). + +#### `GET /categories` +Query: `q`, `status` (`Active|Inactive`), + paging. **200 OK** → list envelope of `CategoryDto`. + +#### `GET /categories/{categoryId}` → **200 OK** (+ `ETag`); `404` if absent. +```json +{ "categoryId": 12, "name": "Fasteners", "status": "Active", + "createdAt": "2026-06-01T08:00:00Z", "updatedAt": null } +``` + #### `POST /categories` ```json -{ "name": "Fasteners", "parentId": 3 } +{ "name": "Fasteners" } ``` -**201 Created** → `{ "categoryId": 12, "name": "Fasteners", "parentId": 3 }` -`GET /categories?tree=true` returns a nested tree. +**201 Created** → `{ "categoryId": 12, "name": "Fasteners", "status": "Active", "createdAt": "...", "updatedAt": null }` +`409` if the name already exists (names are unique, case-insensitive). + +#### `PUT /categories/{categoryId}` +Requires `If-Match`. → **200 OK**; `412` on ETag mismatch; `409` on duplicate name. + +#### `PATCH /categories/{categoryId}/status` +```json +{ "status": "Inactive" } +``` +**204 No Content**. Deactivate, never delete (FR-MD-08). + +#### `GET /categories/{categoryId}/subcategories` +Query: `q`, `status`, + paging. **200 OK** → list envelope of `SubCategoryDto`; `404` if the category itself is absent. + +#### `POST /categories/{categoryId}/subcategories` +The parent comes from the route. +```json +{ "name": "Hex Bolts" } +``` +**201 Created** — `Location: /api/v1/subcategories/30` +```json +{ "subCategoryId": 30, "categoryId": 12, "name": "Hex Bolts", "status": "Active", + "createdAt": "2026-07-16T09:00:00Z", "updatedAt": null } +``` +`404` if the category does not exist · `422` if it is inactive · `409` if the name already exists **within that category** (names need only be unique per parent). + +#### `GET /subcategories/{subCategoryId}` → **200 OK** (+ `ETag`); `404` if absent. + +#### `PUT /subcategories/{subCategoryId}` +Requires `If-Match`. **Name only** — a subcategory cannot be moved to another category, since that would silently invalidate the `categoryId` of every item referencing it. → **200 OK**; `412` on mismatch. +```json +{ "name": "Hex Bolts (metric)" } +``` + +#### `PATCH /subcategories/{subCategoryId}/status` → **204 No Content**. ### 2.4 Vendors #### `POST /vendors` @@ -206,6 +258,88 @@ Full update; requires `If-Match`. → **200 OK** updated resource; `412` on ETag **201 Created** → `{ "binId": 45, "warehouseId": 1, "code": "A-01-01", "binType": "Shelf" }` `GET /warehouses/{warehouseId}/bins` lists bins. +### 2.6 Brands +Referenced optionally by `Item.brandId`. Rejected on item writes when brands are disabled (§2.8). + +#### `GET /brands` +Query: `q`, `status` (`Active|Inactive`), + paging. **200 OK** → list envelope of `BrandDto`. + +#### `GET /brands/{brandId}` → **200 OK** (+ `ETag`); `404` if absent. + +#### `POST /brands` +```json +{ "name": "Bosch" } +``` +**201 Created** — `Location: /api/v1/brands/2` +```json +{ "brandId": 2, "name": "Bosch", "status": "Active", + "createdAt": "2026-07-16T09:00:00Z", "updatedAt": null } +``` +`409` if the name already exists (unique, case-insensitive). + +#### `PUT /brands/{brandId}` +Requires `If-Match`. → **200 OK**; `412` on mismatch; `409` on duplicate name. + +#### `PATCH /brands/{brandId}/status` → **204 No Content**. Deactivate, never delete (FR-MD-08). + +### 2.7 Item Types +> **Read this before assuming a relationship exists.** An item type is a *dimension name* (Color, Size, Material) and nothing more. **No item references an item type**, and there is no value resource: the values chosen in the frontend builder (Red, S, M) are encoded into the **client-generated SKU** — `BL-0002` for one type, `BL-100-0003` for two — and are never stored or parsed server-side. `GET /item-types` exists to populate the builder's dropdown; that is the entire purpose of this master. Consequently the API cannot filter items by colour/size, and renaming an item type does not alter any existing SKU. See docs/10 Part C.9 for the recorded trade-off. Not to be confused with `stockNature` (§2.1), which is what the old `itemType` enum became. + +#### `GET /item-types` +Query: `q`, `status` (`Active|Inactive`), + paging. Pass `status=Active` for selectable rows. +**200 OK** +```json +{ "items": [ { "itemTypeId": 1, "name": "Color", "status": "Active", + "createdAt": "2026-07-16T09:00:00Z", "updatedAt": null }, + { "itemTypeId": 2, "name": "Size", "status": "Active", + "createdAt": "2026-07-16T09:00:00Z", "updatedAt": null } ], + "pagination": { "page": 1, "pageSize": 20, "totalItems": 2, "totalPages": 1 } } +``` +`Color` and `Size` are seeded on first start; users add their own (e.g. `Material`). + +#### `GET /item-types/{itemTypeId}` → **200 OK** (+ `ETag`); `404` if absent. + +#### `POST /item-types` +```json +{ "name": "Material" } +``` +**201 Created** — `Location: /api/v1/item-types/3` → the `ItemTypeDto`. `409` if the name exists. +Callable from the item builder's inline "+" as well as the admin screen. + +#### `PUT /item-types/{itemTypeId}` +Requires `If-Match`. → **200 OK**; `412` on mismatch; `409` on duplicate name. +**Renaming does not touch existing items** — nothing joins back to this row. + +#### `PATCH /item-types/{itemTypeId}/status` → **204 No Content**. Deactivate, never delete (FR-MD-08). + +### 2.8 Product Configuration +A **singleton** feature gate (FR-MD-11), seeded with every flag `true`. + +**Enforcement is not uniform, by design:** + +| Flag | Enforced? | Effect when `false` | +|---|---|---| +| `subcategoriesEnabled` | **Server-side** | `POST`/`PUT /items` with a non-null `subCategoryId` → `422 CONFIG_DISABLED` | +| `brandsEnabled` | **Server-side** | `POST`/`PUT /items` with a non-null `brandId` → `422 CONFIG_DISABLED` | +| `itemTypesEnabled` | **Advisory only** | Nothing server-side. Items carry no item-type reference (§2.7), so there is nothing on a write to reject — the frontend honours it by hiding the builder's type section. | + +Reads are **never** gated: switching a flag off leaves existing items readable with their subcategory/brand intact. + +#### `GET /product-config` → **200 OK** (+ `ETag`) +```json +{ "subcategoriesEnabled": true, "brandsEnabled": true, "itemTypesEnabled": true, + "updatedAt": "2026-07-16T10:00:00Z", "updatedBy": 17 } +``` + +#### `PUT /product-config` +Requires `If-Match`. All three flags are **required** — a partial body is a `400`, so a feature can never be switched off by omission. `updatedBy` is derived from the token, never posted. +```json +{ "subcategoriesEnabled": false, "brandsEnabled": true, "itemTypesEnabled": true } +``` +**200 OK** → the updated resource; `412` on ETag mismatch. + +> **Authorization:** writes are admitted by the ERP door policy only — any authenticated ERP user may flip these flags. A `CONFIG_MANAGE` permission is **reserved** for when per-endpoint RBAC lands (FR-X-01, deferred); no schema or route change will be needed to enable it. Tracked as open decision #13 in docs/10 §B.8.4. + --- ## 3. Procurement @@ -471,6 +605,7 @@ Items at/below ROP (FR-STK-10); computed on read, no stored entity. | `EXPIRED_BATCH_BLOCKED` | 409 | Issue/pick of an expired batch. | | `ONHOLD_NOT_ISSUABLE` | 409 | Issue against on-hold/quarantined stock. | | `REASON_CODE_REQUIRED` | 400 | Adjustment/return without a reason code. | +| `CONFIG_DISABLED` | 422 | An item write carries a field whose feature is switched off in the product configuration (`subCategoryId` with subcategories disabled, `brandId` with brands disabled). See §2.8. | | `CONCURRENCY_CONFLICT` | 412 | ETag / RowVersion mismatch. | | `IDEMPOTENCY_REPLAY` | 200 | Duplicate `Idempotency-Key`; original result returned. | @@ -488,7 +623,7 @@ Example (`409`, `application/problem+json`): ## 8. Enumerations | Enum | Values | |---|---| -| `itemType` | `Stocked`, `NonStocked`, `Service` | +| `stockNature` | `Stocked`, `NonStocked`, `Service` — **renamed from `itemType`** (2026-07-16). Item *types* (Color/Size/Material) are now master **data**, not an enum: see §2.7. | | `trackingMode` | `None`, `Batch`, `Serial` | | `holdStatus` | `Available`, `OnHold`, `Rejected` | | `direction` (ledger) | `In`, `Out` | diff --git a/docs/20-FRONTEND.md b/docs/20-FRONTEND.md index 3c4167c..e77ccda 100644 --- a/docs/20-FRONTEND.md +++ b/docs/20-FRONTEND.md @@ -126,9 +126,22 @@ Each screen calls the endpoints in `11-BACKEND-PHASE1.md`. System steps (blue) a | Count | Count | `POST /stock-counts`, `PUT /stock-counts/{id}/counts`, `POST /stock-counts/{id}/post` | ### 2.2 Master data screens (supporting, outside the core flow) -Vendors, Items, Categories, UOM, Warehouses, Brands, and Variant Categories are supporting master-data CRUD screens the flow above depends on but doesn't itself route through, so they're intentionally absent from the diagram/table. List screens follow one pagination convention: `page`/`pageSize`/`q`/`sortOrder` params, page size 5, debounced search, Previous/Next controls. +Vendors, Items, Categories, Subcategories, UOM, Warehouses, Brands, and Item Types are supporting master-data CRUD screens the flow above depends on but doesn't itself route through, so they're intentionally absent from the diagram/table. List screens follow one pagination convention: `page`/`pageSize`/`q`/`sortOrder` params, page size 5, debounced search, Previous/Next controls. -**Brand** (`app/dashboard/products/brands`) and **Variant Category** (`app/dashboard/products/variants`) are UI-only additions with no corresponding endpoint in `11-BACKEND-PHASE1.md` — Item's `brandId` is built the same way. The Item variant builder on `/dashboard/products/new` reads the Variant Category list live: checking a category (Color, Size, or any custom one added inline from that same page) reveals a value-entry section for it, and one Item is auto-created per combination across however many categories are checked, with an auto-generated SKU. Flag Brand/Variant Category to whoever owns the backend contract if they should become real entities rather than staying frontend-only; see `Frontend/PROGRESS.md` (2026-07-15 entries) for the full rationale and discarded design iterations. +> **2026-07-16 — these are real backend entities now; the UI has NOT caught up.** Brand, Subcategory, Item Type (the frontend's "Variant Categories") and a Product Configuration gate were built on the backend (`docs/11 §2.3/2.6/2.7/2.8`). The screens below still run on `lib/api/mock-data.ts` and do not call any of it. Reconciling them is outstanding frontend work — the contract drift is listed in §2.2.1. + +**Brand** (`app/dashboard/products/brands`) and **Variant Category** (`app/dashboard/products/variants`) began as UI-only additions with no backend. The Item variant builder on `/dashboard/products/new` reads the Variant Category list live: checking a category (Color, Size, or any custom one added inline from that same page) reveals a value-entry section for it, and one Item is auto-created per combination across however many categories are checked, with an auto-generated SKU. See `Frontend/PROGRESS.md` (2026-07-15 entries) for the full rationale and discarded design iterations. + +#### 2.2.1 Contract drift to reconcile (backend is authoritative — §1) +- **`variantCategoriesApi` → `GET /item-types`.** Same shape (name-only list), new name. `variantCategoryId` → `itemTypeId`. +- **`Item.itemType` → `stockNature`.** The `Stocked|NonStocked|Service` field was renamed. `itemType` now means something else entirely (Color/Size), so this rename is not cosmetic — read `docs/11 §2.7` before touching it. +- **Send both category FKs.** `effectiveCategoryId = subCategoryId ?? categoryId` must become `categoryId` **and** `subCategoryId`; the server rejects a subcategory that doesn't belong to the category (422). Subcategories are their own resource now, not `Category.parentId`, and **`GET /categories?tree=true` no longer exists**. +- **Colour hex-packing stays frontend-only.** There is no value table, so `"Red|#EF4444"`, `encodeColorValue`/`decodeColorValue`/`isColorCategory` have nothing to reconcile against — keep them. +- **SKU generation stays client-side** (`buildVariantSku`) and is now the *only* record of which colour/size an item is; the server only uniqueness-checks it. Nothing can query items by colour. +- **`remove()` must become `PATCH /{id}/status`.** There are no `DELETE` endpoints on any master (FR-MD-08) — the mock's unconditional delete has no backend equivalent. +- **`initialQty` remains unbacked** — no Stock Core wiring; still informational-only. +- **New: Product Configuration** (`GET`/`PUT /product-config`) gates subcategories/brands/item-types. This is the backend for the toggle screen; note **only 3 of that design's ~13 toggles exist**, and `itemTypesEnabled` is advisory — the frontend is what honours it (`docs/11 §2.8`). No `Switch` primitive exists in `components/ui/` yet. +- **Non-transactional create loop:** the builder's per-row `itemsApi.create()` has no transaction — a `SKU_DUPLICATE` on row 7 of 12 leaves 6 items created. Real HTTP calls will make this failure mode visible in a way mock data never did. --- @@ -142,7 +155,7 @@ Vendors, Items, Categories, UOM, Warehouses, Brands, and Variant Categories are - Format: SKU pattern, numeric fields numeric, date format, positive integers. - Range/bounds: `qty > 0`, `unitPrice >= 0`, `factor > 0`. - Simple cross-field input rules: transfer `destWarehouseId != srcWarehouseId`. -- Enum membership via constrained dropdowns (`itemType`, `trackingMode`, `countType`, `holdStatus`). +- Enum membership via constrained dropdowns (`stockNature` — ex-`itemType`, `trackingMode`, `countType`, `holdStatus`). Note the **Item Type** dropdown is *not* in this category: it's server data (`GET /item-types`), not an enum. **Server-authoritative (client MUST NOT assume — only the server can judge):** anything depending on current server state. - **Stock availability / negative-stock block** (depends on live ledger) — `STOCK_NEGATIVE_BLOCKED`. From 62a5d857de91604c470134a13c23eb57225151f3 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Fri, 17 Jul 2026 14:27:51 +0530 Subject: [PATCH 04/11] Complete all for Items --- Backend/ERPCore/Controllers/AuthController.cs | 33 +- Backend/ERPCore/Controllers/GrnsController.cs | 10 + .../Controllers/PurchaseReturnsController.cs | 18 + .../Controllers/RequisitionsController.cs | 6 +- Backend/ERPCore/Controllers/RfqsController.cs | 9 + .../Controllers/StockAdjustmentsController.cs | 18 + .../ERPCore/Controllers/StockController.cs | 18 +- .../Controllers/StockCountsController.cs | 9 + .../Controllers/StockTransfersController.cs | 10 + Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs | 6 +- Backend/ERPCore/Dtos/Grn/GrnDtos.cs | 5 + Backend/ERPCore/Dtos/Items/ItemDtos.cs | 11 +- .../Dtos/Procurement/PurchaseReturnDtos.cs | 7 +- .../Dtos/Procurement/RequisitionDtos.cs | 2 +- Backend/ERPCore/Dtos/Procurement/RfqDtos.cs | 4 + Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs | 5 + Backend/ERPCore/Dtos/Stock/CountDtos.cs | 8 +- Backend/ERPCore/Dtos/Stock/TransferDtos.cs | 7 +- Backend/ERPCore/Services/AdjustmentService.cs | 62 +- .../ERPCore/Services/Auth/AuthUserService.cs | 5 +- Backend/ERPCore/Services/CountService.cs | 27 +- Backend/ERPCore/Services/GrnService.cs | 27 + .../Services/Interfaces/IAdjustmentService.cs | 6 + .../Services/Interfaces/ICountService.cs | 5 + .../Services/Interfaces/IGrnService.cs | 5 + .../Interfaces/IPurchaseReturnService.cs | 6 + .../Interfaces/IRequisitionService.cs | 4 +- .../Services/Interfaces/IRfqService.cs | 4 + .../Services/Interfaces/IStockService.cs | 7 +- .../Services/Interfaces/ITransferService.cs | 5 + Backend/ERPCore/Services/ItemService.cs | 6 + .../ERPCore/Services/PurchaseReturnService.cs | 60 +- .../ERPCore/Services/RequisitionService.cs | 7 +- Backend/ERPCore/Services/RfqService.cs | 26 + .../ERPCore/Services/Stock/StockService.cs | 73 +- Backend/ERPCore/Services/TransferService.cs | 28 +- Backend/PROGRESS.md | 20 +- Frontend/PROGRESS.md | 61 +- Frontend/erp-system/.env.local.example | 7 + Frontend/erp-system/.gitignore | 2 + .../procurement/purchase-orders/new/page.tsx | 4 +- .../dashboard/procurement/rfqs/[id]/page.tsx | 32 +- .../dashboard/procurement/rfqs/new/page.tsx | 6 + .../app/dashboard/procurement/rfqs/page.tsx | 27 +- .../app/dashboard/products/[id]/page.tsx | 20 +- .../app/dashboard/products/brands/page.tsx | 55 +- .../products/categories/[id]/page.tsx | 233 +++++ .../dashboard/products/categories/page.tsx | 62 +- .../{variants => item-types}/page.tsx | 134 +-- .../app/dashboard/products/new/page.tsx | 316 ++++--- .../app/dashboard/products/page.tsx | 2 +- .../app/dashboard/products/settings/page.tsx | 168 ++++ .../receiving/grn/[id]/edit/page.tsx | 444 ---------- .../app/dashboard/receiving/grn/[id]/page.tsx | 2 +- .../app/dashboard/receiving/grn/new/page.tsx | 2 +- .../app/dashboard/receiving/grn/page.tsx | 63 +- .../app/dashboard/stock/enquiry/page.tsx | 4 +- .../dashboard/stock/reorder-alerts/page.tsx | 4 +- .../dashboard/stock/transfers/new/page.tsx | 4 +- .../app/dashboard/stock/wastage/new/page.tsx | 7 +- .../app/dashboard/stock/wastage/page.tsx | 5 +- .../app/dashboard/warehouse/[id]/page.tsx | 2 +- .../app/dashboard/warehouse/page.tsx | 2 +- Frontend/erp-system/app/login/page.tsx | 26 +- .../components/Layouts/AppSidebar.tsx | 6 +- .../erp-system/components/Layouts/Header.tsx | 49 +- Frontend/erp-system/components/ui/switch.tsx | 32 + Frontend/erp-system/lib/api-client.ts | 109 +++ Frontend/erp-system/lib/api/auth.ts | 32 + Frontend/erp-system/lib/api/brands.ts | 67 +- Frontend/erp-system/lib/api/categories.ts | 110 ++- Frontend/erp-system/lib/api/grns.ts | 210 +---- Frontend/erp-system/lib/api/item-types.ts | 41 + Frontend/erp-system/lib/api/items.ts | 148 +--- Frontend/erp-system/lib/api/mock-data.ts | 833 ------------------ Frontend/erp-system/lib/api/product-config.ts | 25 + .../erp-system/lib/api/purchase-orders.ts | 137 +-- .../erp-system/lib/api/purchase-returns.ts | 97 +- Frontend/erp-system/lib/api/reason-codes.ts | 18 +- Frontend/erp-system/lib/api/requisitions.ts | 59 +- Frontend/erp-system/lib/api/rfqs.ts | 91 +- .../erp-system/lib/api/stock-adjustments.ts | 118 +-- Frontend/erp-system/lib/api/stock-counts.ts | 145 +-- .../erp-system/lib/api/stock-transfers.ts | 160 +--- Frontend/erp-system/lib/api/stock.ts | 147 +--- Frontend/erp-system/lib/api/uoms.ts | 28 +- Frontend/erp-system/lib/api/variants.ts | 49 -- Frontend/erp-system/lib/api/vendors.ts | 104 +-- Frontend/erp-system/lib/api/warehouses.ts | 59 +- Frontend/erp-system/lib/api/wastage.ts | 122 +-- Frontend/erp-system/lib/auth-session.ts | 44 + Frontend/erp-system/lib/error-map.ts | 12 +- Frontend/erp-system/lib/validations.ts | 17 +- .../erp-system/lib/validations/master-data.ts | 5 +- Frontend/erp-system/next.config.ts | 27 + Frontend/erp-system/proxy.ts | 28 + Frontend/erp-system/types/auth.ts | 46 + Frontend/erp-system/types/grn.ts | 26 +- Frontend/erp-system/types/master-data.ts | 135 ++- Frontend/erp-system/types/procurement.ts | 51 +- Frontend/erp-system/types/stock.ts | 53 +- docs/11-BACKEND-PHASE1.md | 62 +- docs/20-FRONTEND.md | 34 +- 103 files changed, 2540 insertions(+), 3259 deletions(-) create mode 100644 Frontend/erp-system/.env.local.example create mode 100644 Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx rename Frontend/erp-system/app/dashboard/products/{variants => item-types}/page.tsx (53%) create mode 100644 Frontend/erp-system/app/dashboard/products/settings/page.tsx delete mode 100644 Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx create mode 100644 Frontend/erp-system/components/ui/switch.tsx create mode 100644 Frontend/erp-system/lib/api-client.ts create mode 100644 Frontend/erp-system/lib/api/auth.ts create mode 100644 Frontend/erp-system/lib/api/item-types.ts delete mode 100644 Frontend/erp-system/lib/api/mock-data.ts create mode 100644 Frontend/erp-system/lib/api/product-config.ts delete mode 100644 Frontend/erp-system/lib/api/variants.ts create mode 100644 Frontend/erp-system/lib/auth-session.ts create mode 100644 Frontend/erp-system/next.config.ts create mode 100644 Frontend/erp-system/proxy.ts create mode 100644 Frontend/erp-system/types/auth.ts diff --git a/Backend/ERPCore/Controllers/AuthController.cs b/Backend/ERPCore/Controllers/AuthController.cs index 5900a4a..a855e6c 100644 --- a/Backend/ERPCore/Controllers/AuthController.cs +++ b/Backend/ERPCore/Controllers/AuthController.cs @@ -125,16 +125,45 @@ public sealed class AuthController : ControllerBase public async Task> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct) => Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct)); + /// + /// Ends the session: revokes it upstream where possible, and always clears our cookies. + /// + /// userId is optional because callers usually cannot supply it — AuthHex returns + /// user.userId: null in its own login/register response, so a browser has no id + /// to send. It is resolved from the session token's UserId claim instead. + /// + /// + /// The cookies are cleared even if the upstream revoke fails or no user can be + /// resolved: a logout that leaves the caller holding a live session cookie is worse + /// than one that leaves a stale session server-side (which lapses on its own). + /// + /// [HttpPost("logout")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task Logout([FromBody] LogoutRequest request, CancellationToken ct) + public async Task Logout([FromBody] LogoutRequest? request, CancellationToken ct) { - await _users.LogoutUserAsync(request, ct); + var userId = request?.UserId ?? ResolveTokenUserId(); + if (userId is not null) + { + try + { + await _users.LogoutUserAsync(new LogoutRequest { UserId = userId.Value }, ct); + } + catch (DomainException) + { + // Upstream unreachable or already-revoked — fall through and clear anyway. + } + } + AuthCookieWriter.ClearSession(Response); return NoContent(); } + /// AuthHex's identity claim, present when the request carried a valid session. + private Guid? ResolveTokenUserId() + => Guid.TryParse(User.FindFirst(AuthHexClaims.UserId)?.Value, out var id) ? id : null; + [HttpPut("me")] [ValidateCsrf] [ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Controllers/GrnsController.cs b/Backend/ERPCore/Controllers/GrnsController.cs index 20ba722..3ad1f5a 100644 --- a/Backend/ERPCore/Controllers/GrnsController.cs +++ b/Backend/ERPCore/Controllers/GrnsController.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Grn; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,6 +14,14 @@ public sealed class GrnsController : ApiControllerBase public GrnsController(IGrnService grns) => _grns = grns; + /// List GRNs, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId, + [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct) + => Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct)); + [HttpGet("{grnId:int}")] [ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/Backend/ERPCore/Controllers/PurchaseReturnsController.cs b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs index 9f59a56..40ce9a2 100644 --- a/Backend/ERPCore/Controllers/PurchaseReturnsController.cs +++ b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs @@ -1,3 +1,4 @@ +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,6 +13,23 @@ public sealed class PurchaseReturnsController : ApiControllerBase public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns; + /// List posted returns, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct) + => Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct)); + + /// Get one return with its lines and the ledger entries it posted. + [HttpGet("{returnId:int}")] + [ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int returnId, CancellationToken ct) + { + var dto = await _returns.GetAsync(returnId, ct); + return dto is null ? NotFound() : Ok(dto); + } + /// Create + auto-post a return (outbound movement). 409 if return exceeds available stock. [HttpPost] [ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)] diff --git a/Backend/ERPCore/Controllers/RequisitionsController.cs b/Backend/ERPCore/Controllers/RequisitionsController.cs index ab849b9..e16d2e1 100644 --- a/Backend/ERPCore/Controllers/RequisitionsController.cs +++ b/Backend/ERPCore/Controllers/RequisitionsController.cs @@ -1,3 +1,4 @@ +using ERPCore.Domain.Enums; using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; using ERPCore.Services.Interfaces; @@ -15,8 +16,9 @@ public sealed class RequisitionsController : ApiControllerBase [HttpGet] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] - public async Task>> List([FromQuery] PageQuery query, CancellationToken ct) - => Ok(await _requisitions.ListAsync(query, ct)); + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct) + => Ok(await _requisitions.ListAsync(query, status, ct)); [HttpGet("{requisitionId:int}")] [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Controllers/RfqsController.cs b/Backend/ERPCore/Controllers/RfqsController.cs index 7cc0e1f..c917479 100644 --- a/Backend/ERPCore/Controllers/RfqsController.cs +++ b/Backend/ERPCore/Controllers/RfqsController.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,6 +14,13 @@ public sealed class RfqsController : ApiControllerBase public RfqsController(IRfqService rfqs) => _rfqs = rfqs; + /// List RFQs, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct) + => Ok(await _rfqs.ListAsync(query, status, ct)); + [HttpGet("{rfqId:int}")] [ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs index 7952ba6..51b2544 100644 --- a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs +++ b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs @@ -1,3 +1,4 @@ +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,6 +13,23 @@ public sealed class StockAdjustmentsController : ApiControllerBase public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments; + /// List posted adjustments, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct) + => Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct)); + + /// Get one adjustment with its lines and the ledger entries it posted. + [HttpGet("{adjustmentId:int}")] + [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int adjustmentId, CancellationToken ct) + { + var dto = await _adjustments.GetAsync(adjustmentId, ct); + return dto is null ? NotFound() : Ok(dto); + } + /// Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes). [HttpPost] [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)] diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs index 652942a..3189268 100644 --- a/Backend/ERPCore/Controllers/StockController.cs +++ b/Backend/ERPCore/Controllers/StockController.cs @@ -24,12 +24,26 @@ public sealed class StockController : ApiControllerBase public async Task> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct) => Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct)); + /// On-hand across every stocked (item, warehouse) pair; both filters optional. + [HttpGet("on-hand/list")] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> OnHandList( + [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct)); + + /// + /// Immutable movement history. sourceDocType/sourceDocId answer "what did + /// this document post?" — the ledger's document reference is polymorphic, so there is + /// no FK to navigate instead (docs/10 C.9). + /// [HttpGet("ledger")] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] public async Task>> Ledger( [FromQuery] int? itemId, [FromQuery] int? warehouseId, - [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct) - => Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct)); + [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, + [FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId, + [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct)); [HttpGet("valuation")] [ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Controllers/StockCountsController.cs b/Backend/ERPCore/Controllers/StockCountsController.cs index 008ca79..aa8b477 100644 --- a/Backend/ERPCore/Controllers/StockCountsController.cs +++ b/Backend/ERPCore/Controllers/StockCountsController.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,6 +14,13 @@ public sealed class StockCountsController : ApiControllerBase public StockCountsController(ICountService counts) => _counts = counts; + /// List counts, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct) + => Ok(await _counts.ListAsync(query, status, warehouseId, ct)); + [HttpGet("{countId:int}")] [ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/Backend/ERPCore/Controllers/StockTransfersController.cs b/Backend/ERPCore/Controllers/StockTransfersController.cs index fd5f52b..49d1a3f 100644 --- a/Backend/ERPCore/Controllers/StockTransfersController.cs +++ b/Backend/ERPCore/Controllers/StockTransfersController.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,6 +14,14 @@ public sealed class StockTransfersController : ApiControllerBase public StockTransfersController(ITransferService transfers) => _transfers = transfers; + /// List transfers, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] TransferStatus? status, + [FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct) + => Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct)); + [HttpGet("{transferId:int}")] [ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs index f75c193..4b541a2 100644 --- a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs +++ b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs @@ -175,5 +175,9 @@ public sealed class TwoFaStatusResponse public sealed class LogoutRequest { - [Required] public Guid UserId { get; set; } + /// + /// Optional: AuthHex returns no userId on login, so browsers cannot supply one. + /// When omitted, the controller resolves it from the session token's UserId claim. + /// + public Guid? UserId { get; set; } } diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs index bdcff2b..9051017 100644 --- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs +++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs @@ -13,6 +13,11 @@ public sealed record GrnDto( int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList Lines); +/// Row shape for GET /grns — line count instead of the lines themselves. +public sealed record GrnSummaryDto( + int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, + int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount); + public sealed record CreatedLayerDto( int LayerId, int ItemId, int WarehouseId, int? BatchId, decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate); diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs index 7720597..adddcfa 100644 --- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs +++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs @@ -14,12 +14,21 @@ public sealed record ItemListItemDto( /// A single per-warehouse reorder policy row. public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty); -/// Full item resource for GET /items/{id} and create/update responses. +/// +/// Full item resource for GET /items/{id} and create/update responses. +/// +/// is embedded because they are otherwise unreadable: they can +/// only be written via PUT /items/{id}/uom-conversions, which returns them, but no +/// endpoint reads them back — so a detail screen could never show current state before +/// editing. Mirrors how is already inlined. +/// +/// 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, string? TaxClass, EntityStatus Status, IReadOnlyList Reorder, + IReadOnlyList Conversions, DateTime CreatedAt, DateTime? UpdatedAt); /// UOM conversion row (docs/11 §2.2). diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs index f9deb75..5854fbb 100644 --- a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs @@ -9,7 +9,12 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int public sealed record PurchaseReturnDto( int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status, - int CreatedBy, IReadOnlyList Lines, IReadOnlyList LedgerRefs); + int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs); + +/// Row shape for GET /purchase-returns — no lines/ledgerRefs (those need a per-row query). +public sealed record PurchaseReturnSummaryDto( + int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status, + int CreatedBy, DateTime CreatedAt, int LineCount); // Requests ---------------------------------------------------------------------- diff --git a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs index 63b5e0a..ef8cae1 100644 --- a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs @@ -12,7 +12,7 @@ public sealed record RequisitionDto( DateTime CreatedAt, IReadOnlyList Lines); public sealed record RequisitionSummaryDto( - int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt); + int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt, int LineCount); // Requests — server sets docNo, status, requestedBy (audit actor), timestamps ---- diff --git a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs index d54ab90..f9a5635 100644 --- a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs @@ -10,6 +10,10 @@ public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty); public sealed record RfqDto( int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList Lines); +/// Row shape for GET /rfqs — line/quotation counts instead of the lines themselves. +public sealed record RfqSummaryDto( + int RfqId, string DocNo, int RequisitionId, RfqStatus Status, int LineCount, int QuotationCount); + public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays); public sealed record VendorQuotationDto( diff --git a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs index a8e9696..d7b97ce 100644 --- a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs @@ -11,6 +11,11 @@ public sealed record AdjustmentDto( int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs); +/// Row shape for GET /stock-adjustments — no lines/ledgerRefs (those need a per-row query). +public sealed record AdjustmentSummaryDto( + int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status, + int CreatedBy, DateTime CreatedAt, int LineCount); + // Requests ---------------------------------------------------------------------- public sealed class CreateAdjustmentLineInput diff --git a/Backend/ERPCore/Dtos/Stock/CountDtos.cs b/Backend/ERPCore/Dtos/Stock/CountDtos.cs index 44a67ec..a75a262 100644 --- a/Backend/ERPCore/Dtos/Stock/CountDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/CountDtos.cs @@ -8,7 +8,13 @@ namespace ERPCore.Dtos.Stock; public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance); public sealed record CountDto( - int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList Lines); + int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, + int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines); + +/// Row shape for GET /stock-counts — line count instead of the lines themselves. +public sealed record CountSummaryDto( + int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, + int CreatedBy, DateTime CreatedAt, int LineCount); public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList LedgerRefs); diff --git a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs index dd91237..5d4d1d7 100644 --- a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs @@ -10,7 +10,12 @@ public sealed record TransferLineDto( public sealed record TransferDto( int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, - TransferStatus Status, IReadOnlyList Lines); + TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines); + +/// Row shape for GET /stock-transfers — line count instead of the lines themselves. +public sealed record TransferSummaryDto( + int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, + TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount); public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost); diff --git a/Backend/ERPCore/Services/AdjustmentService.cs b/Backend/ERPCore/Services/AdjustmentService.cs index 53604f7..8983eec 100644 --- a/Backend/ERPCore/Services/AdjustmentService.cs +++ b/Backend/ERPCore/Services/AdjustmentService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; @@ -23,6 +24,7 @@ public sealed class AdjustmentService : IAdjustmentService private readonly IRepository _warehouses; private readonly IRepository _items; private readonly IRepository _reasonCodes; + private readonly IRepository _ledger; private readonly IStockMutator _mutator; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; @@ -30,19 +32,62 @@ public sealed class AdjustmentService : IAdjustmentService public AdjustmentService( IRepository adjustments, IRepository warehouses, IRepository items, - IRepository reasonCodes, IStockMutator mutator, INumberSequenceService numbers, - ICurrentUser currentUser, IUnitOfWork uow) + IRepository reasonCodes, IRepository ledger, IStockMutator mutator, + INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) { _adjustments = adjustments; _warehouses = warehouses; _items = items; _reasonCodes = reasonCodes; + _ledger = ledger; _mutator = mutator; _numbers = numbers; _currentUser = currentUser; _uow = uow; } + public async Task> ListAsync( + PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default) + { + var q = _adjustments.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%")); + } + if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId); + if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(a => a.AdjustmentId) + .Skip(query.Skip).Take(query.PageSize) + .Select(a => new AdjustmentSummaryDto( + a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, + a.CreatedBy, a.CreatedAt, a.Lines.Count)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetAsync(int adjustmentId, CancellationToken ct = default) + { + var adjustment = await _adjustments.Query().AsNoTracking() + .Include(a => a.Lines) + .FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct); + if (adjustment is null) return null; + + // The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow, + // so the refs this adjustment posted are recovered by source-doc lookup. + var ledgerRefs = await _ledger.Query().AsNoTracking() + .Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == adjustmentId) + .OrderBy(l => l.LedgerId) + .Select(l => l.LedgerId) + .ToListAsync(ct); + + return ToDto(adjustment, ledgerRefs); + } + public async Task CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default) { if (request.ReasonCodeId is null) @@ -94,11 +139,12 @@ public sealed class AdjustmentService : IAdjustmentService return (entity, refs); }, ct); - return new AdjustmentDto( - adjustment.AdjustmentId, adjustment.DocNo, adjustment.WarehouseId, adjustment.ReasonCodeId, - adjustment.Status, adjustment.CreatedBy, adjustment.CreatedAt, - adjustment.Lines.OrderBy(l => l.AdjLineId) - .Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(), - ledgerRefs.Select(l => l.LedgerId).ToList()); + return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList()); } + + private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList ledgerRefs) => new( + a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt, + a.Lines.OrderBy(l => l.AdjLineId) + .Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(), + ledgerRefs); } diff --git a/Backend/ERPCore/Services/Auth/AuthUserService.cs b/Backend/ERPCore/Services/Auth/AuthUserService.cs index 277d493..a6ce0de 100644 --- a/Backend/ERPCore/Services/Auth/AuthUserService.cs +++ b/Backend/ERPCore/Services/Auth/AuthUserService.cs @@ -55,8 +55,11 @@ public sealed class AuthUserService : IAuthUserService public Task VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default) => _authHex.VerifyPasswordAsync(request, bearerToken, ct); + /// The controller resolves the id (from body or token claim) before calling here. public Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default) - => _authHex.LogoutUserAsync(request.UserId, ct); + => request.UserId is null + ? Task.CompletedTask + : _authHex.LogoutUserAsync(request.UserId.Value, ct); public Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default) => _authHex.UpdateUserAsync(request, bearerToken, ct); diff --git a/Backend/ERPCore/Services/CountService.cs b/Backend/ERPCore/Services/CountService.cs index 1c62ecb..6b26bcf 100644 --- a/Backend/ERPCore/Services/CountService.cs +++ b/Backend/ERPCore/Services/CountService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; @@ -49,6 +50,30 @@ public sealed class CountService : ICountService _uow = uow; } + public async Task> ListAsync( + PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default) + { + var q = _counts.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(c => EF.Functions.ILike(c.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(c => c.Status == status); + if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(c => c.CountId) + .Skip(query.Skip).Take(query.PageSize) + .Select(c => new CountSummaryDto( + c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, + c.CreatedBy, c.CreatedAt, c.Lines.Count)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task GetAsync(int countId, CancellationToken ct = default) { var count = await _counts.Query().AsNoTracking().Include(c => c.Lines) @@ -175,7 +200,7 @@ public sealed class CountService : ICountService } private static CountDto Map(StockCount c) => new( - c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, + c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt, c.Lines.OrderBy(l => l.CountLineId) .Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList()); } diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 891163f..8af5783 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Grn; using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; @@ -65,6 +66,32 @@ public sealed class GrnService : IGrnService _uow = uow; } + public async Task> ListAsync( + PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default) + { + var q = _grns.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(g => EF.Functions.ILike(g.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(g => g.Status == status); + if (poId is not null) q = q.Where(g => g.PoId == poId); + if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId); + if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(g => g.GrnId) + .Skip(query.Skip).Take(query.PageSize) + .Select(g => new GrnSummaryDto( + g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, + g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task GetAsync(int grnId, CancellationToken ct = default) { var grn = await _grns.Query().AsNoTracking() diff --git a/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs b/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs index c2f3c29..0b535e1 100644 --- a/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs +++ b/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs @@ -1,3 +1,4 @@ +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; namespace ERPCore.Services.Interfaces; @@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces; /// Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5). public interface IAdjustmentService { + Task> ListAsync( + PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default); + + Task GetAsync(int adjustmentId, CancellationToken ct = default); + Task CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/ICountService.cs b/Backend/ERPCore/Services/Interfaces/ICountService.cs index 667796e..514e751 100644 --- a/Backend/ERPCore/Services/Interfaces/ICountService.cs +++ b/Backend/ERPCore/Services/Interfaces/ICountService.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; namespace ERPCore.Services.Interfaces; @@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces; /// Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08). public interface ICountService { + Task> ListAsync( + PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default); + Task GetAsync(int countId, CancellationToken ct = default); Task CreateAsync(CreateCountRequest request, CancellationToken ct = default); Task EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IGrnService.cs b/Backend/ERPCore/Services/Interfaces/IGrnService.cs index 92c0dc7..a683c26 100644 --- a/Backend/ERPCore/Services/Interfaces/IGrnService.cs +++ b/Backend/ERPCore/Services/Interfaces/IGrnService.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Grn; namespace ERPCore.Services.Interfaces; @@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces; /// Goods-receipt business logic (docs/11 §4; FR-GRN-01..08). public interface IGrnService { + Task> ListAsync( + PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default); + Task GetAsync(int grnId, CancellationToken ct = default); Task CreateAsync(CreateGrnRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs b/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs index 8dc20b7..6543a4c 100644 --- a/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs +++ b/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs @@ -1,3 +1,4 @@ +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; namespace ERPCore.Services.Interfaces; @@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces; /// Purchase-return business logic (docs/11 §3.4; FR-PROC-08). public interface IPurchaseReturnService { + Task> ListAsync( + PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default); + + Task GetAsync(int returnId, CancellationToken ct = default); + Task CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs b/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs index 309645d..640b3a0 100644 --- a/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs +++ b/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs @@ -1,3 +1,4 @@ +using ERPCore.Domain.Enums; using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; @@ -6,7 +7,8 @@ namespace ERPCore.Services.Interfaces; /// Purchase-requisition business logic (docs/11 §3.1). public interface IRequisitionService { - Task> ListAsync(PageQuery query, CancellationToken ct = default); + Task> ListAsync( + PageQuery query, RequisitionStatus? status, CancellationToken ct = default); Task GetAsync(int requisitionId, CancellationToken ct = default); Task CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default); Task SubmitAsync(int requisitionId, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IRfqService.cs b/Backend/ERPCore/Services/Interfaces/IRfqService.cs index 9074dd1..83c42f5 100644 --- a/Backend/ERPCore/Services/Interfaces/IRfqService.cs +++ b/Backend/ERPCore/Services/Interfaces/IRfqService.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; namespace ERPCore.Services.Interfaces; @@ -5,6 +7,8 @@ namespace ERPCore.Services.Interfaces; /// RFQ & vendor-quotation business logic (docs/11 §3.2). public interface IRfqService { + Task> ListAsync(PageQuery query, RfqStatus? status, CancellationToken ct = default); + Task GetAsync(int rfqId, CancellationToken ct = default); Task CreateAsync(CreateRfqRequest request, CancellationToken ct = default); Task AddQuotationAsync(int rfqId, CreateQuotationRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IStockService.cs b/Backend/ERPCore/Services/Interfaces/IStockService.cs index 3fdaf78..32a720c 100644 --- a/Backend/ERPCore/Services/Interfaces/IStockService.cs +++ b/Backend/ERPCore/Services/Interfaces/IStockService.cs @@ -8,8 +8,13 @@ public interface IStockService { Task GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default); + /// On-hand for every (item, warehouse) pair holding stock — backs the enquiry list. + Task> GetOnHandListAsync( + int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default); + Task> GetLedgerAsync( - int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default); + int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, + string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default); Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/ITransferService.cs b/Backend/ERPCore/Services/Interfaces/ITransferService.cs index 44707e2..8afeda9 100644 --- a/Backend/ERPCore/Services/Interfaces/ITransferService.cs +++ b/Backend/ERPCore/Services/Interfaces/ITransferService.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; namespace ERPCore.Services.Interfaces; @@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces; /// Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06). public interface ITransferService { + Task> ListAsync( + PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default); + Task GetAsync(int transferId, CancellationToken ct = default); Task CreateAsync(CreateTransferRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index 051d4e0..c28808c 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -89,6 +89,7 @@ public sealed class ItemService : IItemService { var item = await _items.Query().AsNoTracking() .Include(i => i.ReorderSettings) + .Include(i => i.UomConversions) .FirstOrDefaultAsync(i => i.ItemId == itemId, ct); return item is null ? null : new ETagged(ToDetail(item), item.RowVersion); @@ -131,6 +132,7 @@ public sealed class ItemService : IItemService { var item = await _items.Query() .Include(i => i.ReorderSettings) + .Include(i => i.UomConversions) .FirstOrDefaultAsync(i => i.ItemId == itemId, ct) ?? throw new NotFoundException($"Item {itemId} was not found."); @@ -351,5 +353,9 @@ public sealed class ItemService : IItemService .OrderBy(r => r.WarehouseId) .Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty)) .ToList(), + i.UomConversions + .OrderBy(c => c.ConversionId) + .Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor)) + .ToList(), i.CreatedAt, i.UpdatedAt); } diff --git a/Backend/ERPCore/Services/PurchaseReturnService.cs b/Backend/ERPCore/Services/PurchaseReturnService.cs index 4e0dd11..da6ed71 100644 --- a/Backend/ERPCore/Services/PurchaseReturnService.cs +++ b/Backend/ERPCore/Services/PurchaseReturnService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; @@ -25,6 +26,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService private readonly IRepository _items; private readonly IRepository _reasonCodes; private readonly IRepository _grnLines; + private readonly IRepository _ledger; private readonly IStockMutator _mutator; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; @@ -33,7 +35,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService public PurchaseReturnService( IRepository returns, IRepository vendors, IRepository warehouses, IRepository items, IRepository reasonCodes, IRepository grnLines, - IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) + IRepository ledger, IStockMutator mutator, INumberSequenceService numbers, + ICurrentUser currentUser, IUnitOfWork uow) { _returns = returns; _vendors = vendors; @@ -41,12 +44,54 @@ public sealed class PurchaseReturnService : IPurchaseReturnService _items = items; _reasonCodes = reasonCodes; _grnLines = grnLines; + _ledger = ledger; _mutator = mutator; _numbers = numbers; _currentUser = currentUser; _uow = uow; } + public async Task> ListAsync( + PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default) + { + var q = _returns.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%")); + } + if (vendorId is not null) q = q.Where(r => r.VendorId == vendorId); + if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.ReturnId) + .Skip(query.Skip).Take(query.PageSize) + .Select(r => new PurchaseReturnSummaryDto( + r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status, + r.CreatedBy, r.CreatedAt, r.Lines.Count)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetAsync(int returnId, CancellationToken ct = default) + { + var ret = await _returns.Query().AsNoTracking() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.ReturnId == returnId, ct); + if (ret is null) return null; + + // Polymorphic ledger reference (docs/10 C.9) — recovered by source-doc lookup. + var ledgerRefs = await _ledger.Query().AsNoTracking() + .Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId) + .OrderBy(l => l.LedgerId) + .Select(l => l.LedgerId) + .ToListAsync(ct); + + return ToDto(ret, ledgerRefs); + } + public async Task CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default) { if (request.ReasonCodeId is null) @@ -105,11 +150,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService }, ct); // Map ledger ids after commit so they are populated. - return new PurchaseReturnDto( - entity.ReturnId, entity.DocNo, entity.VendorId, entity.WarehouseId, entity.ReasonCodeId, entity.Status, - entity.CreatedBy, - entity.Lines.OrderBy(l => l.ReturnLineId) - .Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(), - ledgerEntries.Select(r => r.LedgerId).ToList()); + return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList()); } + + private static PurchaseReturnDto ToDto(PurchaseReturn r, IReadOnlyList ledgerRefs) => new( + r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt, + r.Lines.OrderBy(l => l.ReturnLineId) + .Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(), + ledgerRefs); } diff --git a/Backend/ERPCore/Services/RequisitionService.cs b/Backend/ERPCore/Services/RequisitionService.cs index 8390609..d8086a6 100644 --- a/Backend/ERPCore/Services/RequisitionService.cs +++ b/Backend/ERPCore/Services/RequisitionService.cs @@ -31,7 +31,8 @@ public sealed class RequisitionService : IRequisitionService _uow = uow; } - public async Task> ListAsync(PageQuery query, CancellationToken ct = default) + public async Task> ListAsync( + PageQuery query, RequisitionStatus? status, CancellationToken ct = default) { var q = _requisitions.Query().AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Q)) @@ -39,11 +40,13 @@ public sealed class RequisitionService : IRequisitionService var term = query.Q.Trim(); q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%")); } + if (status is not null) q = q.Where(r => r.Status == status); var total = await q.CountAsync(ct); var rows = await q.OrderByDescending(r => r.RequisitionId) .Skip(query.Skip).Take(query.PageSize) - .Select(r => new RequisitionSummaryDto(r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt)) + .Select(r => new RequisitionSummaryDto( + r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt, r.Lines.Count)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); diff --git a/Backend/ERPCore/Services/RfqService.cs b/Backend/ERPCore/Services/RfqService.cs index b73db6e..2072565 100644 --- a/Backend/ERPCore/Services/RfqService.cs +++ b/Backend/ERPCore/Services/RfqService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; @@ -34,6 +35,31 @@ public sealed class RfqService : IRfqService _uow = uow; } + public async Task> ListAsync( + PageQuery query, RfqStatus? status, CancellationToken ct = default) + { + var q = _rfqs.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(r => r.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.RfqId) + .Skip(query.Skip).Take(query.PageSize) + .Select(r => new RfqSummaryDto( + r.RfqId, r.DocNo, r.RequisitionId, r.Status, + r.Lines.Count, + // Correlated subquery: there is no Rfq.Quotations navigation to count. + _quotations.Query().Count(qt => qt.RfqId == r.RfqId))) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task GetAsync(int rfqId, CancellationToken ct = default) { var rfq = await _rfqs.Query().AsNoTracking() diff --git a/Backend/ERPCore/Services/Stock/StockService.cs b/Backend/ERPCore/Services/Stock/StockService.cs index 2eaedab..1d9e5fb 100644 --- a/Backend/ERPCore/Services/Stock/StockService.cs +++ b/Backend/ERPCore/Services/Stock/StockService.cs @@ -50,14 +50,85 @@ public sealed class StockService : IStockService return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow); } + /// + /// On-hand across every (item, warehouse) pair that holds stock — backs the Stock + /// Enquiry list. Deliberately set-based: four grouped queries regardless of page size, + /// rather than calling per row (which would be N+1). + /// Pairs are sourced from StockLayer, so an item that never had a receipt in a + /// warehouse simply does not appear. + /// + public async Task> GetOnHandListAsync( + int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default) + { + var layers = _layers.Query().AsNoTracking(); + if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId); + if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId); + + var grouped = layers + .GroupBy(l => new { l.ItemId, l.WarehouseId }) + .Select(g => new { g.Key.ItemId, g.Key.WarehouseId, OnHand = g.Sum(x => x.QtyRemaining) }); + + var total = await grouped.CountAsync(ct); + var page = await grouped + .OrderBy(x => x.ItemId).ThenBy(x => x.WarehouseId) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + if (page.Count == 0) + return PagedResponse.Create([], query.Page, query.PageSize, total); + + // Filtering by the page's ids gives a superset (the cross-product of both lists); + // the join below narrows it back to the actual pairs. + var itemIds = page.Select(p => p.ItemId).Distinct().ToList(); + var warehouseIds = page.Select(p => p.WarehouseId).Distinct().ToList(); + + var onHold = (await _layers.Query().AsNoTracking() + .Where(l => itemIds.Contains(l.ItemId) && warehouseIds.Contains(l.WarehouseId) + && l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold) + .GroupBy(l => new { l.ItemId, l.WarehouseId }) + .Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) }) + .ToListAsync(ct)) + .ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty); + + var inTransit = (await _transferLines.Query().AsNoTracking() + .Where(l => itemIds.Contains(l.ItemId) + && l.Transfer != null + && warehouseIds.Contains(l.Transfer.SrcWarehouseId) + && l.Transfer.Status == TransferStatus.InTransit) + .GroupBy(l => new { l.ItemId, WarehouseId = l.Transfer!.SrcWarehouseId }) + .Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.Qty - x.QtyReceived) }) + .ToListAsync(ct)) + .ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty); + + var asOf = DateTime.UtcNow; + var rows = page.Select(p => + { + var key = (p.ItemId, p.WarehouseId); + var hold = onHold.GetValueOrDefault(key); + var transit = inTransit.GetValueOrDefault(key); + const decimal reserved = 0m; + // Same formula as GetOnHandAsync: in-transit is reported, not re-subtracted. + return new StockOnHandDto( + p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf); + }).ToList(); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task> GetLedgerAsync( - int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default) + int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, + string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default) { var q = _ledger.Query().AsNoTracking(); if (itemId is not null) q = q.Where(l => l.ItemId == itemId); if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId); if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue)); if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue)); + // Source-doc filter: the ledger references its originating document polymorphically + // (docs/10 C.9), so this is the only way to ask "what did document X post?" — + // needed by any screen that reports on a document's costed movements. + if (!string.IsNullOrWhiteSpace(sourceDocType)) q = q.Where(l => l.SourceDocType == sourceDocType); + if (sourceDocId is not null) q = q.Where(l => l.SourceDocId == sourceDocId); var total = await q.CountAsync(ct); var rows = await q.OrderByDescending(l => l.LedgerId) diff --git a/Backend/ERPCore/Services/TransferService.cs b/Backend/ERPCore/Services/TransferService.cs index 6429811..a3b4ce1 100644 --- a/Backend/ERPCore/Services/TransferService.cs +++ b/Backend/ERPCore/Services/TransferService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; @@ -40,6 +41,31 @@ public sealed class TransferService : ITransferService _uow = uow; } + public async Task> ListAsync( + PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default) + { + var q = _transfers.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(t => EF.Functions.ILike(t.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(t => t.Status == status); + if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId); + if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(t => t.TransferId) + .Skip(query.Skip).Take(query.PageSize) + .Select(t => new TransferSummaryDto( + t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, + t.CreatedBy, t.CreatedAt, t.Lines.Count)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task GetAsync(int transferId, CancellationToken ct = default) { var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines) @@ -190,7 +216,7 @@ public sealed class TransferService : ITransferService } private static TransferDto Map(StockTransfer t) => new( - t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, + t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, t.CreatedBy, t.CreatedAt, t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto( l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList()); } diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index e1c80ae..b9baf5f 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -59,7 +59,18 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [x] Purchase Return (outbound movement, reason code) — `POST /purchase-returns` auto-posts an outbound FIFO consume via shared `StockMutator`; mandatory Return-context reason (`REASON_CODE_REQUIRED`→400, wrong context→422), references the GRN line for traceability, over-return→`409 STOCK_NEGATIVE_BLOCKED`. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.) > **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match. -> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly. +> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly. **Consequence surfaced 2026-07-17:** no UI can show "invited but not yet quoted" — the RFQ screens now report quotations received instead. Persisting the invite list would need a new table. + +> ### 2026-07-17 — read endpoints added so the frontend could be connected +> The frontend rewire (see `Frontend/PROGRESS.md`) needed reads that did not exist. `stock-adjustments` and `purchase-returns` had **no GET at all** — a UI could not re-display a record it had just created. +> - **New:** `GET /grns`, `GET /rfqs`, `GET /stock-transfers`, `GET /stock-counts`, `GET /stock-adjustments` **+ `/{id}`**, `GET /purchase-returns` **+ `/{id}`**, `GET /stock/on-hand/list`. All follow `ItemService.ListAsync` (ILike on `q`, filters, `PagedResponse.Create`) with matching `*SummaryDto`s carrying a `lineCount`. +> - **`GET /stock/on-hand/list`** is deliberately set-based — four grouped queries regardless of page size — rather than calling `GetOnHandAsync` per row (N+1). It replaces a client-side loop the mock used to do. +> - **`GET /stock/ledger` gained `sourceDocType`/`sourceDocId`.** The ledger's document reference is polymorphic with no FK to follow, so this is the only way to ask "what did document X post?". Needed by the wastage report to cost its lines; also useful for any document's movement history. +> - **`ItemDetailDto` gained `conversions`** (+ `.Include(i => i.UomConversions)`): they could only be *written* (`PUT /items/{id}/uom-conversions` returns them; nothing read them back), so the item detail screen could never show current state before editing. Closes a deviation `Frontend/PROGRESS.md` had flagged. +> - **DTOs gained fields the entities already had** and the UI needed: `createdBy`/`createdAt` on transfers + counts, `createdAt` on purchase returns, `lineCount` + a `status` filter on requisitions. Cheaper and more honest than deleting working columns from the screens. +> - **Bug fixed — `POST /auth/logout` made `userId` optional.** AuthHex returns `user.userId: null` on login, so a browser could never supply the id the endpoint required; the call was skipped and the session cookies survived, making logout cosmetic. The controller now resolves the id from the token's `UserId` claim and **always** clears the cookies, even if the upstream revoke fails. Found by driving the real logout in a browser. +> - **Verified:** `dotnet build` clean; every new endpoint returns a correct `PagedResponse` against a live cookie session; `conversions` round-trips; `CONFIG_DISABLED` (422), `CONCURRENCY_CONFLICT` (412) and the cross-FK 422 (*"Subcategory 5 belongs to category 10, not 11"*) all confirmed through the browser. Test data removed afterwards. +> - **Not done — serial numbers (FR-GRN-04, priority M):** `CreateGrnLineInput` carries `batch` but has no serial field, so serials cannot be captured on receipt as the requirement mandates. The frontend does **not** collect them rather than silently discarding them. `SERIAL`/`StockLayer.serial_id` already exist in the model, so this is a service+DTO gap, not a schema one. Recorded in docs/11 §4. ## 3. Goods Receipt > Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate. @@ -173,8 +184,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Not done this pass (tracked as follow-ups, not silently skipped):** CORS (needed once a browser frontend calls cross-origin — `docs/02-SECURITY.md §B.2` left unticked), rate limiting on the anonymous endpoints (`docs/02-SECURITY.md` AR-08), and the frontend wiring itself (`lib/api/auth.ts` + wiring `app/login/**`'s mock pages to these endpoints) — deliberately out of scope per user decision. - **Live-verified against the running AuthHex instance (`:5011`) and ERPCore (`:5224`, dev):** `register` → `200` with `Set-Cookie: erp_at`(httpOnly/Secure/Strict/maxAge=3600) + `erp_rt`(httpOnly/Secure/Strict/Path=`/api/v1/auth/refresh-token`/30d) + `XSRF-TOKEN`(Secure/Strict, JS-readable), body carries `user`+`expiresIn` only, **no tokens**; the `erp_at` cookie alone (zero `Authorization` header) authenticated `GET /api/v1/items` — confirms the `OnMessageReceived` cookie fallback works for every existing v1 controller unchanged; `GET /api/v1/auth/sessions` (protected, cookie-authenticated) → `200`; mutating `POST /api/v1/auth/change-password` without `X-XSRF-TOKEN` → `403 CSRF_TOKEN_MISMATCH`, with the matching header → `204` + all three cookies cleared, exactly as designed. - **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing. -- **Login/refresh/logout left unverified live** — `loginUser` currently fails with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` on AuthHex **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — this is a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. Blocked on an AuthHex-side fix; re-run the register→login→cookie→refresh→logout pass once that's resolved. - - **ROOT CAUSE FOUND (2026-07-16), in `ERP_Auth_Service` — two independent bugs, both one-liners. Not fixed here: different project, outside this repo's scope.** Re-confirmed the failure against a user registered this session (`smoketest_admin`/`SMOKE001`), by username *and* email, with *and* without `userTypeId`. - 1. **The password is never stored.** `Services/UserManager/UserManagerService.cs:97` computes `var PasswordHash = PasswordHasher.Hash(...)`, but the assignment in the `new User { … }` initializer at **line 116 is commented out** (`//PasswordHash = PasswordHash`). Every registered user lands in MySQL with a null `PasswordHash`, so `loginUser`'s `if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(...))` (line 230) always throws `"Invalid credentials"`. Uncommenting line 116 should fix login outright. Note existing users are unrecoverable — their hashes were never written — so they need re-registration or a password reset (`ChangeUserPassword`/`UpdateUser` do persist the hash correctly, and `UpdateUser` even handles the null-hash case at line 836). +- **✅ RESOLVED 2026-07-17 — login works.** The AuthHex fix below was applied (`ERP_Auth_Service`, uncommenting the `PasswordHash` assignment) and verified: `POST /api/v1/auth/login` now returns **200 + `Set-Cookie: erp_at`** for a freshly-registered user, where it previously returned `500 "Invalid credentials"`. This unblocked the §1–§5 live verification that had been pending for two sessions. **Users registered before the fix have a null hash and can never log in** — they must be re-registered (the session's `smoketest_admin` among them). +- **Historical:** `loginUser` used to fail with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. + - **ROOT CAUSE (2026-07-16), in `ERP_Auth_Service` — two independent bugs, both one-liners.** Bug 1 fixed 2026-07-17; bug 2 left alone (out of scope, and email login is what the UI uses). Re-confirmed the failure against a user registered this session (`smoketest_admin`/`SMOKE001`), by username *and* email, with *and* without `userTypeId`. + 1. **The password was never stored — FIXED 2026-07-17.** `Services/UserManager/UserManagerService.cs:97` computes `var PasswordHash = PasswordHasher.Hash(...)`, but the assignment in the `new User { … }` initializer at **line 116 was commented out** (`//PasswordHash = PasswordHash`). Every registered user landed in MySQL with a null `PasswordHash`, so `loginUser`'s `if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(...))` (line 230) always threw `"Invalid credentials"`. **Uncommenting that line fixed login outright** — verified end-to-end. Pre-fix users are unrecoverable (their hashes were never written) and need re-registration or a password reset (`ChangeUserPassword`/`UpdateUser` do persist the hash correctly, and `UpdateUser` even handles the null-hash case at line 836). 2. **Username is not a valid login identifier.** `Repos/UserManageRepository.cs:46` `GetUserByIdentifierAndType` matches only `Email`/`MobileNumber`/`Nic` — **not `UserName`** — and ignores its `userTypeId` argument entirely (that filtering sits commented out at lines 56–65, so the "AndType" half of the method name is currently a lie). Even with bug 1 fixed, `identifier: ""` will not resolve a user; only email/mobile/NIC will. - **Workaround meanwhile: `POST /api/v1/auth/register` issues a working `erp_at` session cookie directly**, which authenticates every v1 controller via the handler's cookie fallback. That is how this session's Master-Data smoke test (§1) was run — no login needed. diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 709ce4a..859af32 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -5,20 +5,23 @@ Spec: `docs/20-FRONTEND.md` (flows + rules) · `docs/11-BACKEND-PHASE1.md` (API Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation. ## 0. Foundation -- [x] `NEXT_PUBLIC_API_BASE_URL` wired (`.env.local` / `.env.local.example`, alongside the existing `NEXT_PUBLIC_AUTH_API_BASE_URL`) — currently unused now that the fetch client is gone (see 2026-07-15 note below) -- [ ] Typed API client / fetch wrapper — **removed 2026-07-15** (`lib/api-client.ts` + `lib/auth-token.ts` deleted per explicit user request: "delete all api connections"). No live code path in the frontend makes an HTTP call to any backend anymore — every screen is 100% mock data (`lib/api/mock-data.ts`). -- [x] Shared TS types mirroring API DTOs (`types/common.ts`, `types/master-data.ts`, `types/procurement.ts`, `types/grn.ts`) — built only for the subset GRN needs (Item/Warehouse/Bin/Vendor/Uom/PurchaseOrder/Grn); `types/common.ts` now also carries `ApiResult` (moved here 2026-07-15 when `lib/api-client.ts` was deleted, since it's a plain data envelope, not fetch-specific) +- [x] **Transport: same-origin Next `rewrites()` proxy** (`next.config.ts`, `/api/*` → `BACKEND_ORIGIN`, default `http://localhost:5224`). `BACKEND_ORIGIN` in `.env.local` / `.env.local.example` — **not** `NEXT_PUBLIC_*`; the browser never sees the backend URL. Chosen over backend CORS because it makes the API same-origin, so CORS and the Secure/SameSite=Strict session cookies stop being problems at all. (`.gitignore`'s `.env*` was silently swallowing the example file — added a `!.env.local.example` negation.) +- [x] **Typed API client rebuilt** (`lib/api-client.ts`, 2026-07-17) — recovered the pre-deletion version from git (`0e4bcf1^`) and adapted: relative `/api/v1` base, **`credentials: "include"`** (never present before), `ApiResult`/`ProblemDetails` imported from `@/types/common` rather than redeclared, `readCsrfToken()` for the eight `[ValidateCsrf]` auth actions. `ApiError`, `apiRequest`, `apiRequestWithETag`, `buildQuery`, `ifMatch`/`idempotencyKey` all carried over. +- [x] **Route guard** (`proxy.ts` — Next 16's rename of `middleware.ts`; the old name still works but warns). Redirects `/dashboard/*` to `/login?next=…` when the `erp_at` cookie is absent. **Presence check only** — the cookie is httpOnly and the JWT is RS256, so the edge cannot validate it; the API stays the authority. +- [x] **Auth** (`lib/api/auth.ts`, `lib/auth-session.ts`, `types/auth.ts`) — real login/logout. No token is stored: the session is httpOnly cookies. `lib/auth-session.ts` caches the user *profile* in localStorage for the Header, because there is no `GET /auth/me` and the user object only arrives in the login response. It is display data, not a credential. +- [x] Shared TS types mirroring API DTOs (`types/{common,master-data,procurement,grn,stock,auth}.ts`) — **reconciled field-by-field against the live schemas 2026-07-17**; see the entry below for what had drifted. - [~] Client validation helpers (`lib/validations/grn.ts`) — **deviation**: uses `zod` (already a project dependency, used by `lib/validations.ts`/login), not hand-rolled, to stay consistent with the codebase's existing pattern rather than introduce a second validation approach. UX-only; server remains authoritative (docs/20-FRONTEND.md §3) -- [x] `code → message` map (`lib/error-map.ts`) — `errorMessage`/`fieldErrors` duck-type any `{ code, detail, errors }`-shaped rejection (the `ApiError` class they used to check via `instanceof` no longer exists); this also fixed a latent bug where the mock layer's plain `Error`-plus-`.code` rejects never matched the old `instanceof ApiError` check, so `CODE_MESSAGES` silently never applied to any mock error - -> **Scope note:** this foundation was built alongside the GRN feature and only covers the endpoints GRN consumes (items, warehouses/bins, vendors, uoms, purchase-orders, grns). Other master-data/procurement API methods still need their own `lib/api/*.ts` files when those screens are built. +- [x] `code → message` map (`lib/error-map.ts`) — `errorMessage`/`fieldErrors` duck-type any `{ code, detail, errors }`-shaped rejection. **Fixed 2026-07-17:** generic framework codes (`conflict`/`not_found`/`validation_error`) were shadowing the server's specific `detail`, so a duplicate brand showed "This action conflicts with the record's current state." instead of "A brand named 'bosch' already exists." Generic codes now lose to `detail`; specific domain codes still win. +> **⚠️ The 2026-07-15 note below is HISTORY, not current state.** The fetch infrastructure was rebuilt on 2026-07-17 and `lib/api/mock-data.ts` is deleted — see "2026-07-17 — connected to the real API" at the bottom of this file. Its parting advice (re-verify the RFQ contract; expect to rebuild the client from scratch) was followed and proved correct. +> > **2026-07-15 — fetch infrastructure deleted outright (not just reverted to mock).** Following an earlier same-week pass that wired every `lib/api/*.ts` module to real `fetch` calls (then reverted via `git revert --no-commit` at the user's request — see `Backend`-adjacent history if relevant later), the user asked to go further and delete the underlying connection mechanism entirely, not just leave it unused. Deleted `lib/api-client.ts` (`apiRequest`/`apiRequestWithETag`/`buildQuery`/`ApiError`) and `lib/auth-token.ts` (bearer-token storage) as files. Follow-on fixes this required: (1) `ApiResult` — used by `items.ts`/`purchase-orders.ts`/`vendors.ts` for their mock ETag pattern — moved into `types/common.ts`; (2) `lib/error-map.ts` rewritten to duck-type instead of `instanceof ApiError`; (3) three detail pages (`vendors/[id]`, `products/[id]`, `procurement/purchase-orders/[id]`) had their `err instanceof ApiError ? err.code : (err as {code?:string})?.code` conflict-detection simplified to the duck-typed form only. Also stripped the now-dangling commented-out "real implementation" blocks (`// import { apiRequest... } from "@/lib/api-client"` etc.) from all 15 `lib/api/*.ts` files, since they referenced a now-deleted module. `tsc --noEmit`/`eslint` clean (same pre-existing `login/page.tsx` error and established `set-state-in-effect` pattern only — confirmed unchanged by this pass). > > **If real backend integration is attempted again**, note two things found during the reverted pass: the RFQ backend contract had drifted from this file's assumed shapes (`Backend/ERPCore/Dtos/Procurement/RfqDtos.cs`/`RfqService.cs` — no persisted invited-vendor list, `requisitionId` required on create, different comparison DTO field names) — re-verify against the actual backend rather than trusting old assumptions; and a typed fetch client + ETag/error-normalization layer will need to be rebuilt from scratch since `lib/api-client.ts`/`lib/auth-token.ts` no longer exist. ## 1. Auth -- [~] Login screen — UI built (`app/login`); not yet wired to `POST /auth/login` / token storage +- [x] Login screen — **wired 2026-07-17** to `POST /auth/login`. Previously it `console.log`'d the plaintext password and pushed to `/dashboard` unconditionally; any schema-valid input "logged in". Now: real call, session cookies, server errors surfaced, `?next=` honoured (same-origin paths only — an absolute URL there would be an open redirect). +- [x] Route guard (`proxy.ts`) + real logout in `components/Layouts/Header.tsx` — the Header no longer hardcodes `john52martinez@gmail.com`, and "Log out" is a real `POST /auth/logout` rather than a ``. - [~] Forgot password — add email screen — UI built (`app/login/forgot`); not yet wired to API - [~] Forgot password — verify OTP screen — UI built (`app/login/forgot/otp`); not yet wired to API - [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API @@ -46,7 +49,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed` - Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn` -> **`[~]` not `[x]`, by design:** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend exists yet** (this was frontend-only work; see the deviation below). +> **⚠️ The two notes below are HISTORY (2026-07-13).** The GRN backend exists and these screens call it as of 2026-07-17; `GET /grns` + `GET /grns/{id}` are real, and GRN edit/delete were removed because the API has no `PUT`/`DELETE`. The FIFO engine they describe as living in `mock-data.ts` is deleted — the server owns it. +> +> **`[~]` not `[x]`, by design (at the time):** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend existed yet** (this was frontend-only work; see the deviation below). > > **UI-only / mock-data mode (temporary):** `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` currently return **in-memory sample data** (`lib/api/mock-data.ts`) instead of calling the real API, so the three screens are fully browsable/demoable (list → create against a mocked PO or as a direct receipt → detail → confirm → release/reject) without a running backend. Each `lib/api/*.ts` file keeps the real `fetch`-based implementation **commented out directly above** the mock block — switch back by deleting the mock block, uncommenting the real block, and deleting `lib/api/mock-data.ts` once the GRN backend exists. `npm run dev` + `tsc --noEmit` + `eslint` are clean (aside from the pre-existing, unrelated `app/login/page.tsx` resolver-typing error and the two `react-hooks/set-state-in-effect` warnings shared with `hooks/use-mobile.ts`). > @@ -64,7 +69,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Wastage (`.../stock/wastage` report, `/new` record) — **not a documented endpoint or SRS document type**: damage/theft-loss/expiry write-offs are modeled as Stock Adjustments with a loss-type reason code (FR-STK-07); `lib/api/wastage.ts` is a frontend-only lens over `stockAdjustmentsApi` + the shared mock ledger (filters to reason codes `DMG`/`LOSS`/`EXPWO`, flattens adjustment lines to per-item wastage records, sums matching outbound ledger entries for cost). No new backend concept — confirmed with the user before building (asked whether "Wastage" meant this vs. a distinct document type). - Sidebar: added "Stock" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/stock`; top-bar titles mapped per route (`components/Layouts/Header.tsx`) -> **`[~]` not `[x]`, by design — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4). +> **⚠️ HISTORY (2026-07-13).** The Stock Core backend exists and these screens call it as of 2026-07-17; the list endpoints assumed here (`GET /stock-transfers`, `/stock-adjustments`, `/stock-counts`, on-hand list) were all added for real. The in-memory Stock Core described below is deleted. +> +> **`[~]` not `[x]`, by design (at the time) — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4). > > **Deviations (same pattern as GRN, see §4):** `GET`/detail list endpoints for transfers/adjustments/counts (`lib/api/stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`) are assumed extensions beyond `docs/11-BACKEND-PHASE1.md` §5.4-5.6, which document only the transactional POSTs/PUT. `stockApi.onHandList()` (used by the Enquiry screen) is also not a documented endpoint — it's a frontend-only convenience that iterates known item/warehouse pairs and calls the (documented) on-hand computation per pair; a real backend would want a proper list endpoint instead. Flag all of these to whoever implements `Backend/PROGRESS.md` §4/§5 (Stock Core + stock transactions). > @@ -83,6 +90,40 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## Done +### 2026-07-17 — connected to the real API (mock-data.ts deleted) + +**The app now talks to ERPCore.** Every `lib/api/*.ts` module calls the backend; `lib/api/mock-data.ts` is gone. This is the pass the 2026-07-15 note anticipated. + +**Transport + auth** +- Same-origin **Next `rewrites()` proxy** rather than backend CORS (see §0). The backend has no CORS and now needs none. +- Rebuilt `lib/api-client.ts` from `git show 0e4bcf1^`; added `credentials: "include"`. +- New `proxy.ts` route guard, `lib/api/auth.ts`, `lib/auth-session.ts`, `types/auth.ts`. Login/logout are real. +- **Fixed the long-standing `app/login/page.tsx` resolver type error** — `lib/validations.ts` used `z.preprocess`, which widens the schema's *input* type to `unknown`, so `zodResolver` produced a `Resolver<{email: unknown}>` that could not satisfy `useForm`. Form fields always yield strings (RHF defaults them to `""`), so the null-coercion it guarded against cannot happen. **`tsc --noEmit` is now fully clean** — the first time in this file's history. + +**Two real bugs found by driving the browser (both fixed, both invisible to unit-level checks)** +1. **Logout didn't log you out.** AuthHex returns `user.userId: null` on login, so the Header could not supply the `userId` that `POST /auth/logout` required; the call was skipped and `erp_at` survived. Fixed backend-side (`userId` optional, resolved from the token claim, cookies always cleared). Verified: cookies now `[]` after logout. +2. **Generic error codes shadowed the server's message.** `errorMessage()` checked `CODE_MESSAGES[code]` before `detail`, so a duplicate brand showed "This action conflicts with the record's current state." instead of "A brand named 'bosch' already exists." Generic codes (`conflict`/`not_found`/`validation_error`) now lose to `detail`. + +**Contract drift reconciled** (types were rewritten field-by-field against the live OpenAPI, not assumed): +- `itemType` → `stockNature`; `ItemType` is now the Color/Size master. `variants.ts` → `item-types.ts`; the screen moved to `/dashboard/products/item-types`. +- `RfqComparison` was `{lines[].cells[]}` in this app but `{rows[].quotes[]}` on the server, and cells carry `quotationId`. `Rfq` has no `vendorIds`/`createdAt`; `StockTransfer`/`StockCount` had `createdBy`/`createdAt` the DTOs never returned (added server-side rather than dropping the columns); `ReasonCodeContext` had `"CountVariance"` where the server says `"Count"`; `EnterCounts` returns the whole `CountDto`, not `{lines}`; `PostCountResponse.adjustmentId` is nullable; `createReorderRequisition` returns a full `Requisition`, not `{qty}`. +- `remove()` → `updateStatus(id, "Inactive")` on brands/categories/item-types, each with a Status column and Deactivate/Activate (no `DELETE` exists — FR-MD-08). +- New `app/dashboard/products/categories/[id]` for subcategories (their own resource now); new `app/dashboard/products/settings` for Product Configuration (added the shadcn `switch` primitive via the CLI). + +**Features deliberately removed rather than left lying** +- **`initialQty`** and the builder's Quantity column — no such field on the Item contract and no initial-receipt flow; stock arrives via a GRN. It never worked under the mock either. +- **GRN edit/delete** + the `grn/[id]/edit` route — the API has no `PUT`/`DELETE` for a GRN (FR-X-05). +- **RFQ "vendors invited"** — not persisted server-side. The screens show quotations received; the quote form offers any active vendor instead of "invited but pending". +- **Serial capture on GRN** — `CreateGrnLineInput` has no serial field despite FR-GRN-04 (priority M). Not collected rather than silently discarded. Flagged in `Backend/PROGRESS.md` + docs/11 §4. + +**Fixed while rewiring:** the builder hardcoded `baseUomId: 1`, which only worked because the mock seeded that id — against a real DB it is a 422 or, worse, the wrong unit. It now adopts the first real UOM and says so when none exists. The per-row create loop still has no transaction, but the error now reports how many items landed before the failure instead of implying none did. + +**Verified end-to-end in a real browser (Playwright), not just typechecked** — 17/17 then 9/9 on a recheck: guard redirect + `?next=` round-trip; login → cookies (`erp_at` httpOnly) → real user in Header; brand created via the UI; **duplicate → server 409 with its own message**; product-config screen reads the singleton; item-types shows the seeded Color/Size; logout clears cookies. Plus, through the page's own session: **cross-FK guard 422** (*"Subcategory 5 belongs to category 10, not 11"*), item created with **both** `categoryId` and `subCategoryId` + `brandId`, `conversions` present on the detail, **`CONFIG_DISABLED` 422** with the same item succeeding without the gated field and pre-existing items still readable, and a stale `If-Match` → **412 `CONCURRENCY_CONFLICT`**. Test data was removed afterwards; the dev DB is back to empty masters. + +> **The DB is near-empty and that is now visible.** The mock silently supplied warehouses, UOMs, reason codes and sample POs. Screens are blank until that data is created — correct behaviour, not a regression, but a dev seed would make the app pleasant to open. `lib/api/mock-data.ts`'s FIFO engine (`receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) is gone with it: **the browser no longer does inventory maths** — the server does. +> +> **Not yet exercised against real data:** GRN confirm → Stock Enquiry/Ledger/Valuation, transfers, counts and the wastage report. They compile and are wired, but proving the FIFO chain needs a warehouse + PO + receipt seeded first. That is the highest-value next verification. + ### 2026-07-13 — GRN screens + frontend foundation (frontend-only; no backend changes) - Foundation: `lib/api-client.ts`, `lib/error-map.ts`, `lib/auth-token.ts`, `types/{common,master-data,procurement,grn}.ts`, `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` — scoped to what the GRN flow needs, not the full API surface. - Added the shadcn `select` primitive (`npx shadcn add select`) — wasn't in `components/ui/` yet; needed for PO/vendor/warehouse/bin/item/hold-status pickers. @@ -123,7 +164,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Wiring, not just new screens:** `stockApi.createReorderRequisition` (Stock → Reorder Alerts, built in an earlier session) previously fabricated a response with no backing record; it now pushes a real row into `mockRequisitions`, so a reorder-triggered requisition genuinely shows up in the new Requisitions list — same "connect the mock modules together" posture as GRN confirm → Stock Core. - Screens: Requisition (list/new/detail+Submit), RFQ (list/new with checkbox vendor multi-invite reusing the Stock Count's `Checkbox` pattern/new with quotation-recording form + vendor-by-line comparison table/"Create PO from vendor"), Purchase Order (list/new — accepts `?requisitionId=` or `?rfqId=&vendorId=` to prefill lines and pricing/detail with inline edit-while-open using the vendor `[id]` page's ETag+412-conflict-banner pattern, plus a Cancel-with-reason flow client-disabled when any line has receipts), Purchase Return (list/new — pick a Confirmed/Closed GRN, checkbox+qty its lines; also reachable via a new "Create Return" button next to `Rejected` lines on the GRN detail page, matching the SRS flow diagram's Reject→Purchase Return step). New `components/procurement/status-badges.tsx` (same fixed-width badge convention as Receiving/Stock) and `lib/validations/procurement.ts` (zod-free hand-rolled, matching the GRN validation file's style, not its `zod` deviation). - Sidebar: added "Procurement" nav entry between Vendors and Receiving; header title mappings added for all new routes. -- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend exists (`Backend/PROGRESS.md` §2 unchanged). +- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend existed at the time (`Backend/PROGRESS.md` §2 unchanged). **Superseded 2026-07-17** — the Procurement backend exists and these screens now call it; several assumed shapes turned out wrong (see the 2026-07-17 entry). - Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from the same established `set-state-in-effect` pattern used throughout this session (confirmed it also fires on the pre-existing `grn/page.tsx`/`vendors/page.tsx`/`hooks/use-mobile.ts` — not a regression); `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure blocks the full build, unrelated). All 12 new routes plus the GRN-detail "Create Return" link (including three query-param-prefilled variants) confirmed rendering 200 with no error boundary via SSR against the dev server. ### 2026-07-13 — Master Data screens: Items, UOM, Categories, Reorder settings (frontend-only; no backend changes) diff --git a/Frontend/erp-system/.env.local.example b/Frontend/erp-system/.env.local.example new file mode 100644 index 0000000..7c31eb9 --- /dev/null +++ b/Frontend/erp-system/.env.local.example @@ -0,0 +1,7 @@ +# Origin of the ERPCore backend. Used ONLY by the Next rewrite proxy in next.config.ts +# (server-side), so it is intentionally not NEXT_PUBLIC_* — the browser never sees it and +# only ever calls this Next server at same-origin /api/v1. +# +# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the +# proxy will refuse. +BACKEND_ORIGIN=http://localhost:5224 diff --git a/Frontend/erp-system/.gitignore b/Frontend/erp-system/.gitignore index 5ef6a52..2b411dd 100644 --- a/Frontend/erp-system/.gitignore +++ b/Frontend/erp-system/.gitignore @@ -32,6 +32,8 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# ...except the template, which carries no secrets and documents what to set. +!.env.local.example # vercel .vercel diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx index 06d58ae..c7e6592 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx @@ -115,7 +115,9 @@ function NewPurchaseOrderContent() { setVendorId(rfqVendorId) setLines( rfq.lines.map((l): DraftLine => { - const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId) + const cell = comparison.rows + .find((row) => row.itemId === l.itemId) + ?.quotes.find((q) => q.vendorId === rfqVendorId) return { key: newKey(), itemId: l.itemId, diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx index a60d775..ea3de47 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx @@ -69,11 +69,22 @@ export default function RfqDetailPage() { const quotedVendorIds = useMemo(() => { const set = new Set() - for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId) + for (const row of comparison?.rows ?? []) for (const quote of row.quotes) set.add(quote.vendorId) return set }, [comparison]) - const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds]) + /** + * Vendors still available to quote. + * + * This used to be "invited but not yet quoted", but the invited list does not survive: + * `POST /rfqs` validates `vendorIds` and then discards them — there is no RFQ↔vendor + * link in the model (docs/11 §3.2). So any active vendor may be quoted here, and the + * comparison's columns come from who actually quoted rather than who was asked. + */ + const pendingVendors = useMemo( + () => vendors.filter((v) => v.status === "Active" && !quotedVendorIds.has(v.vendorId)).map((v) => v.vendorId), + [vendors, quotedVendorIds], + ) function itemFor(itemId: number) { return items.find((i) => i.itemId === itemId) @@ -155,9 +166,9 @@ export default function RfqDetailPage() {

{rfq.docNo}

+ {/* No "Invited: …" — the invited-vendor list is not persisted (docs/11 §3.2). */}

- {rfq.requisitionId ? `From Requisition #${rfq.requisitionId} — ` : ""} - Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")} + {rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}

@@ -191,7 +202,7 @@ export default function RfqDetailPage() {

Vendor comparison

- {comparison.lines.every((l) => l.cells.length === 0) ? ( + {comparison.rows.every((r) => r.quotes.length === 0) ? (

No quotations recorded yet.

) : (
@@ -199,19 +210,20 @@ export default function RfqDetailPage() { Item - {rfq.vendorIds.map((vid) => ( + {/* Columns are the vendors that actually quoted — the server computes this. */} + {comparison.vendorIds.map((vid) => ( {vendorFor(vid)?.code ?? `#${vid}`} ))} - {comparison.lines.map((line) => { + {comparison.rows.map((line) => { const item = itemFor(line.itemId) return ( {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} - {rfq.vendorIds.map((vid) => { - const cell = line.cells.find((c) => c.vendorId === vid) + {comparison.vendorIds.map((vid) => { + const cell = line.quotes.find((c) => c.vendorId === vid) return ( {cell ? ( @@ -256,7 +268,7 @@ export default function RfqDetailPage() { value={quoteVendorId} onValueChange={selectQuoteVendor}> - + {pendingVendors.map((vid) => ( diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx index ee05136..cfb8bcb 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx @@ -99,6 +99,12 @@ function NewRfqContent() { setHeaderError(null) setSubmitError(null) + // The server requires a requisition — an RFQ is always raised against one + // (docs/11 §3.2). Catch it here rather than letting the POST 400. + if (requisitionId === null) { + setHeaderError("Select the requisition this RFQ is raised against.") + return + } if (vendorIds.size === 0) { setHeaderError("Select at least one vendor to invite.") return diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx index 6cc1f9c..b4d9e79 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx @@ -5,10 +5,8 @@ import Link from "next/link" import { FileText, Plus } from "lucide-react" import { rfqsApi } from "@/lib/api/rfqs" -import { vendorsApi } from "@/lib/api/vendors" import { errorMessage } from "@/lib/error-map" import { RfqSummary } from "@/types/procurement" -import { Vendor } from "@/types/master-data" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" @@ -17,22 +15,17 @@ import { RfqStatusBadge } from "@/components/procurement/status-badges" export default function RfqsListPage() { const [rfqs, setRfqs] = useState(null) - const [vendors, setVendors] = useState([]) const [error, setError] = useState(null) + // Vendors are no longer fetched here: the "invited vendors" column is gone because that + // list is not persisted (docs/11 §3.2), so there is nothing to resolve names for. useEffect(() => { - Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })]) - .then(([r, v]) => { - setRfqs(r.items) - setVendors(v.items) - }) + rfqsApi + .list() + .then((r) => setRfqs(r.items)) .catch((err) => setError(errorMessage(err))) }, []) - function vendorNames(vendorIds: number[]) { - return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ") - } - return (
@@ -75,9 +68,11 @@ export default function RfqsListPage() { Doc No Requisition - Vendors invited + {/* "Vendors invited" is gone: the invite list is validated on create but not + persisted (docs/11 §3.2). Quotations received is the fact that survives. */} + Lines + Quotations Status - Created @@ -89,11 +84,11 @@ export default function RfqsListPage() { {r.requisitionId ? `#${r.requisitionId}` : } - {vendorNames(r.vendorIds)} + {r.lineCount} + {r.quotationCount} - {new Date(r.createdAt).toLocaleString()} ))} diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index 94cd29d..bc2eeff 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -13,7 +13,7 @@ import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage, fieldErrors } from "@/lib/error-map" import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data" +import { Item, ItemReorderSetting, StockNature, TrackingMode, UomConversion } from "@/types/master-data" import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" @@ -63,9 +63,13 @@ export default function ItemDetailPage() { const [name, setName] = useState("") const [description, setDescription] = useState("") const [categoryId, setCategoryId] = useState(null) + // Carried through edits so a save doesn't silently drop the item's subcategory/brand. + // Not editable here — they are chosen on the create screen's builder. + const [subCategoryId, setSubCategoryId] = useState(null) + const [brandId, setBrandId] = useState(null) const [baseUomId, setBaseUomId] = useState(null) const [defaultVendorId, setDefaultVendorId] = useState(null) - const [itemType, setItemType] = useState("Stocked") + const [stockNature, setStockNature] = useState("Stocked") const [trackingMode, setTrackingMode] = useState("None") const [taxClass, setTaxClass] = useState("") @@ -93,9 +97,11 @@ export default function ItemDetailPage() { setName(data.name) setDescription(data.description ?? "") setCategoryId(data.categoryId) + setSubCategoryId(data.subCategoryId) + setBrandId(data.brandId) setBaseUomId(data.baseUomId) setDefaultVendorId(data.defaultVendorId) - setItemType(data.itemType) + setStockNature(data.stockNature) setTrackingMode(data.trackingMode) setTaxClass(data.taxClass ?? "") setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) }))) @@ -139,7 +145,7 @@ export default function ItemDetailPage() { try { const result = await itemsApi.update( item.itemId, - { sku, name, description: description || null, categoryId: categoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null }, + { sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, taxClass: taxClass || null }, etag ) applyItem(result.data) @@ -389,8 +395,10 @@ export default function ItemDetailPage() { setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
- - value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}> + {/* "Item type" now means a Color/Size dimension master — this field is the + stock-nature one it used to be confused with (docs/11 §8). */} + + value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}> diff --git a/Frontend/erp-system/app/dashboard/products/brands/page.tsx b/Frontend/erp-system/app/dashboard/products/brands/page.tsx index df1d92f..0ad8c32 100644 --- a/Frontend/erp-system/app/dashboard/products/brands/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/brands/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag, Trash2 } from "lucide-react" +import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" import { brandsApi } from "@/lib/api/brands" import { errorMessage } from "@/lib/error-map" @@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common" import { Brand } from "@/types/master-data" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" @@ -54,7 +55,7 @@ export default function BrandsPage() { function load() { setError(null) brandsApi - .list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE }) + .list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE }) .then((res) => { setBrands(res.items) setPagination(res.pagination) @@ -87,10 +88,16 @@ export default function BrandsPage() { setSubmitting(true) try { - const brand = editing - ? await brandsApi.update(editing.brandId, { name }) - : await brandsApi.create({ name }) - toast.success(editing ? "Brand updated" : "Brand created", brand.name) + let result + if (editing) { + // The list response carries no ETag, so re-read to get a fresh If-Match token + // rather than guessing one. A concurrent edit surfaces as 412 from the server. + const current = await brandsApi.get(editing.brandId) + result = await brandsApi.update(editing.brandId, { name }, current.etag ?? "") + } else { + result = await brandsApi.create({ name }) + } + toast.success(editing ? "Brand updated" : "Brand created", result.data.name) setOpen(false) setName("") setEditing(null) @@ -104,14 +111,16 @@ export default function BrandsPage() { } } - async function handleDelete(brand: Brand) { + /** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */ + async function handleToggleStatus(brand: Brand) { + const next = brand.status === "Active" ? "Inactive" : "Active" setDeletingId(brand.brandId) try { - await brandsApi.remove(brand.brandId) - toast.success("Brand deleted", brand.name) + await brandsApi.updateStatus(brand.brandId, next) + toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name) load() } catch (err) { - toast.error("Could not delete brand", errorMessage(err)) + toast.error("Could not update brand status", errorMessage(err)) } finally { setDeletingId(null) } @@ -206,6 +215,7 @@ export default function BrandsPage() { ID Name + Status Created At Actions @@ -215,6 +225,9 @@ export default function BrandsPage() { #{b.brandId} {b.name} + + {b.status} + {new Date(b.createdAt).toLocaleDateString()}
@@ -227,26 +240,32 @@ export default function BrandsPage() { + {/* Deactivate, not delete: the API has no DELETE for any master + (FR-MD-08) — records referenced by transactions must survive. */} } > - + {b.status === "Active" ? : } handleDelete(b)} + variant={b.status === "Active" ? "destructive" : "success"} + title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`} + description={ + b.status === "Active" + ? "The brand stays on existing items but cannot be assigned to new ones." + : "The brand becomes selectable again." + } + confirmLabel={b.status === "Active" ? "Deactivate" : "Activate"} + onConfirm={() => handleToggleStatus(b)} />
diff --git a/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx new file mode 100644 index 0000000..c41f9a9 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx @@ -0,0 +1,233 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { useParams } from "next/navigation" +import { ArrowLeft, Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react" + +import { categoriesApi, subCategoriesApi } from "@/lib/api/categories" +import { errorMessage } from "@/lib/error-map" +import { validateCategoryName } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { Category, SubCategory } from "@/types/master-data" + +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Button, buttonVariants } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +/** + * Subcategories of one category — the single optional level below it (FR-MD-04). + * The hierarchy is exactly two deep, so there is no recursion here by design. + */ +export default function CategorySubCategoriesPage() { + const params = useParams<{ id: string }>() + const categoryId = Number(params.id) + + const [category, setCategory] = useState(null) + const [subCategories, setSubCategories] = useState(null) + const [error, setError] = useState(null) + + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [togglingId, setTogglingId] = useState(null) + + function load() { + setError(null) + categoriesApi + .get(categoryId) + .then((res) => setCategory(res.data)) + .catch((err) => setError(errorMessage(err))) + categoriesApi + .listSubCategories(categoryId, { pageSize: 200 }) + .then((res) => setSubCategories(res.items)) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [categoryId]) + + function openCreateDialog() { + setEditing(null) + setName("") + setErrors({}) + setOpen(true) + } + + function openEditDialog(sub: SubCategory) { + setEditing(sub) + setName(sub.name) + setErrors({}) + setOpen(true) + } + + async function handleSubmit() { + const nextErrors = validateCategoryName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + if (editing) { + // Re-read for a fresh If-Match; a concurrent edit surfaces as 412. + const current = await subCategoriesApi.get(editing.subCategoryId) + await subCategoriesApi.update(editing.subCategoryId, { name }, current.etag ?? "") + } else { + await categoriesApi.createSubCategory(categoryId, { name }) + } + toast.success(editing ? "Subcategory updated" : "Subcategory created", name) + setOpen(false) + setName("") + setEditing(null) + load() + } catch (err) { + setErrors({ name: errorMessage(err) }) + toast.error(editing ? "Could not update subcategory" : "Could not create subcategory", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function handleToggleStatus(sub: SubCategory) { + const next = sub.status === "Active" ? "Inactive" : "Active" + setTogglingId(sub.subCategoryId) + try { + await subCategoriesApi.updateStatus(sub.subCategoryId, next) + toast.success(next === "Inactive" ? "Subcategory deactivated" : "Subcategory activated", sub.name) + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } finally { + setTogglingId(null) + } + } + + return ( +
+
+
+ + + +
+

{category ? `${category.name} — Subcategories` : "Subcategories"}

+

+ The one optional level below a category (FR-MD-04). A subcategory cannot be moved to another category. +

+
+
+ + + New Subcategory} /> + + + {editing ? "Edit subcategory" : "New subcategory"} + Give the subcategory a name. + + + + Name + setName(e.target.value)} placeholder="Hex Bolts" aria-invalid={!!errors.name} /> + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && subCategories === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && subCategories !== null && subCategories.length === 0 && ( +
+ +

No subcategories yet — items can attach straight to the category.

+
+ )} + + {!error && subCategories !== null && subCategories.length > 0 && ( + + + + ID + Name + Status + Created At + Actions + + + + {subCategories.map((s) => ( + + #{s.subCategoryId} + {s.name} + + {s.status} + + {new Date(s.createdAt).toLocaleDateString()} + +
+ + + + + } + > + {s.status === "Active" ? : } + + handleToggleStatus(s)} + /> + +
+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx index 18909ec..8c5dac3 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ChevronLeft, ChevronRight, ListTree, Pencil, Plus, Search, Trash2 } from "lucide-react" +import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" import { categoriesApi } from "@/lib/api/categories" import { errorMessage } from "@/lib/error-map" @@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common" import { Category } from "@/types/master-data" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" @@ -54,7 +55,7 @@ export default function CategoriesPage() { function load() { setError(null) categoriesApi - .list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE }) + .list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE }) .then((res) => { setCategories(res.items) setPagination(res.pagination) @@ -87,10 +88,15 @@ export default function CategoriesPage() { setSubmitting(true) try { - const category = editing - ? await categoriesApi.update(editing.categoryId, { name }) - : await categoriesApi.create({ name }) - toast.success(editing ? "Category updated" : "Category created", category.name) + let result + if (editing) { + // The list carries no ETag, so re-read for a fresh If-Match rather than guessing. + const current = await categoriesApi.get(editing.categoryId) + result = await categoriesApi.update(editing.categoryId, { name }, current.etag ?? "") + } else { + result = await categoriesApi.create({ name }) + } + toast.success(editing ? "Category updated" : "Category created", result.data.name) setOpen(false) setName("") setEditing(null) @@ -104,14 +110,16 @@ export default function CategoriesPage() { } } - async function handleDelete(category: Category) { + /** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */ + async function handleToggleStatus(category: Category) { + const next = category.status === "Active" ? "Inactive" : "Active" setDeletingId(category.categoryId) try { - await categoriesApi.remove(category.categoryId) - toast.success("Category deleted", category.name) + await categoriesApi.updateStatus(category.categoryId, next) + toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name) load() } catch (err) { - toast.error("Could not delete category", errorMessage(err)) + toast.error("Could not update category status", errorMessage(err)) } finally { setDeletingId(null) } @@ -206,6 +214,7 @@ export default function CategoriesPage() { ID Name + Status Created At Actions @@ -215,9 +224,21 @@ export default function CategoriesPage() { #{c.categoryId} {c.name} + + {c.status} + {new Date(c.createdAt).toLocaleDateString()}
+ {/* Subcategories are their own resource now, not a nested tree. */} + + + + + {/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */} } > - + {c.status === "Active" ? : } handleDelete(c)} + variant={c.status === "Active" ? "destructive" : "success"} + title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`} + description={ + c.status === "Active" + ? "The category stays on existing items but cannot take new subcategories or items." + : "The category becomes selectable again." + } + confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"} + onConfirm={() => handleToggleStatus(c)} />
diff --git a/Frontend/erp-system/app/dashboard/products/variants/page.tsx b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx similarity index 53% rename from Frontend/erp-system/app/dashboard/products/variants/page.tsx rename to Frontend/erp-system/app/dashboard/products/item-types/page.tsx index 051ab76..71d6d22 100644 --- a/Frontend/erp-system/app/dashboard/products/variants/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx @@ -2,15 +2,16 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, Pencil, Plus, SwatchBook, Trash2 } from "lucide-react" +import { ArrowLeft, Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react" -import { variantCategoriesApi } from "@/lib/api/variants" +import { itemTypesApi } from "@/lib/api/item-types" import { errorMessage } from "@/lib/error-map" -import { validateVariantCategoryName } from "@/lib/validations/master-data" +import { validateItemTypeName } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { VariantCategory } from "@/types/master-data" +import { ItemType } from "@/types/master-data" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" @@ -19,22 +20,30 @@ import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" -export default function VariantsPage() { - const [categories, setCategories] = useState(null) +/** + * Item Types (docs/11 §2.7) — the dimension names (Color, Size, Material) the item + * builder's checkboxes read. Formerly "Variant Categories" in this app. + * + * These are names only. The values (Red, S, M) live in each item's generated SKU and are + * not stored, so nothing here links to an item — renaming a type leaves existing SKUs + * untouched. + */ +export default function ItemTypesPage() { + const [itemTypes, setItemTypes] = useState(null) const [error, setError] = useState(null) const [open, setOpen] = useState(false) - const [editing, setEditing] = useState(null) + const [editing, setEditing] = useState(null) const [name, setName] = useState("") const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) - const [deletingId, setDeletingId] = useState(null) + const [togglingId, setTogglingId] = useState(null) function load() { setError(null) - variantCategoriesApi - .list() - .then((res) => setCategories(res.items)) + itemTypesApi + .list({ pageSize: 200 }) + .then((res) => setItemTypes(res.items)) .catch((err) => setError(errorMessage(err))) } @@ -47,24 +56,28 @@ export default function VariantsPage() { setOpen(true) } - function openEditDialog(category: VariantCategory) { - setEditing(category) - setName(category.name) + function openEditDialog(itemType: ItemType) { + setEditing(itemType) + setName(itemType.name) setErrors({}) setOpen(true) } async function handleSubmit() { - const nextErrors = validateVariantCategoryName(name) + const nextErrors = validateItemTypeName(name) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return setSubmitting(true) try { - const category = editing - ? await variantCategoriesApi.update(editing.variantCategoryId, { name }) - : await variantCategoriesApi.create({ name }) - toast.success(editing ? "Variant category updated" : "Variant category created", category.name) + if (editing) { + // Re-read for a fresh If-Match; a concurrent edit surfaces as 412. + const current = await itemTypesApi.get(editing.itemTypeId) + await itemTypesApi.update(editing.itemTypeId, { name }, current.etag ?? "") + } else { + await itemTypesApi.create({ name }) + } + toast.success(editing ? "Item type updated" : "Item type created", name) setOpen(false) setName("") setEditing(null) @@ -72,22 +85,24 @@ export default function VariantsPage() { load() } catch (err) { setErrors({ name: errorMessage(err) }) - toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err)) + toast.error(editing ? "Could not update item type" : "Could not create item type", errorMessage(err)) } finally { setSubmitting(false) } } - async function handleDelete(category: VariantCategory) { - setDeletingId(category.variantCategoryId) + /** Deactivate, never delete — the API has no DELETE (FR-MD-08). */ + async function handleToggleStatus(itemType: ItemType) { + const next = itemType.status === "Active" ? "Inactive" : "Active" + setTogglingId(itemType.itemTypeId) try { - await variantCategoriesApi.remove(category.variantCategoryId) - toast.success("Variant category deleted", category.name) + await itemTypesApi.updateStatus(itemType.itemTypeId, next) + toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name) load() } catch (err) { - toast.error("Could not delete category", errorMessage(err)) + toast.error("Could not update status", errorMessage(err)) } finally { - setDeletingId(null) + setTogglingId(null) } } @@ -99,23 +114,25 @@ export default function VariantsPage() {
-

Variants

-

Variant categories used by the item variant builder (e.g. Color, Size, Material).

+

Item Types

+

+ Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU. +

- Add Category} /> + Add Item Type} /> - {editing ? "Edit variant category" : "New variant category"} - Give the category a name. + {editing ? "Edit item type" : "New item type"} + Give the item type a name. - Name + Name setName(e.target.value)} placeholder="Material" @@ -140,7 +157,7 @@ export default function VariantsPage() {
{error}
)} - {!error && categories === null && ( + {!error && itemTypes === null && (
{Array.from({ length: 3 }).map((_, i) => ( @@ -148,37 +165,36 @@ export default function VariantsPage() {
)} - {!error && categories !== null && categories.length === 0 && ( + {!error && itemTypes !== null && itemTypes.length === 0 && (
-

No variant categories yet.

+

No item types yet.

)} - {!error && categories !== null && categories.length > 0 && ( + {!error && itemTypes !== null && itemTypes.length > 0 && ( ID Name + Status Created At Actions - {categories.map((c) => ( - - #{c.variantCategoryId} - {c.name} - {new Date(c.createdAt).toLocaleDateString()} + {itemTypes.map((t) => ( + + #{t.itemTypeId} + {t.name} + + {t.status} + + {new Date(t.createdAt).toLocaleDateString()}
- @@ -188,20 +204,24 @@ export default function VariantsPage() {
diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index a5666c7..bc9acc6 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -8,11 +8,13 @@ import { ArrowLeft, Plus, X } from "lucide-react" import { itemsApi } from "@/lib/api/items" import { categoriesApi } from "@/lib/api/categories" import { brandsApi } from "@/lib/api/brands" -import { variantCategoriesApi } from "@/lib/api/variants" +import { itemTypesApi } from "@/lib/api/item-types" +import { productConfig } from "@/lib/api/product-config" +import { uomsApi } from "@/lib/api/uoms" import { errorMessage } from "@/lib/error-map" -import { validateVariantCategoryName, validateVariantItemForm } from "@/lib/validations/master-data" +import { validateItemTypeName, validateVariantItemForm } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { Category, VariantCategory } from "@/types/master-data" +import { Brand, Category, ItemType, ProductConfig, SubCategory } from "@/types/master-data" import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" @@ -34,6 +36,10 @@ function buildVariantSku(categoryLabel: string, values: string[]): string { return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-") } +/** + * Colour is special-cased by name. This stays a frontend concern: item types are names + * only — there is no value table server-side to hang a hex column off (docs/10 Part C.9). + */ function isColorCategory(categoryName: string): boolean { return categoryName.trim().toLowerCase() === "color" } @@ -52,26 +58,31 @@ function partLabel(part: { name: string; value: string }): string { return isColorCategory(part.name) ? decodeColorValue(part.value).name : part.value } -// No Base UOM field on this form — every variant created here uses the base "EA" unit (uomId 1 in the seed data). -const DEFAULT_BASE_UOM_ID = 1 - export default function NewItemPage() { const router = useRouter() const [categories, setCategories] = useState(null) - const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null) - const [variantCategories, setVariantCategories] = useState(null) + const [brands, setBrands] = useState(null) + const [itemTypes, setItemTypes] = useState(null) + const [config, setConfig] = useState(null) + /** + * This form has no Base UOM field by design, so it adopts the first UOM as the base. + * It used to hardcode `uomId: 1`, which only worked because the mock seeded that id — + * against a real database that is a 422 waiting to happen, or worse, silently the wrong + * unit. Null here means "no UOM exists yet" and the form says so rather than guessing. + */ + const [baseUomId, setBaseUomId] = useState(null) const [loadError, setLoadError] = useState(null) const [categoryId, setCategoryId] = useState(null) + const [subCategories, setSubCategories] = useState([]) const [subCategoryId, setSubCategoryId] = useState(null) const [brandId, setBrandId] = useState(null) - const [checkedVariantCategoryIds, setCheckedVariantCategoryIds] = useState([]) + const [checkedItemTypeIds, setCheckedItemTypeIds] = useState([]) const [valuesByCategory, setValuesByCategory] = useState>({}) const [inputByCategory, setInputByCategory] = useState>({}) const [colorNameByCategory, setColorNameByCategory] = useState>({}) - const [quantities, setQuantities] = useState>({}) const [addingCategory, setAddingCategory] = useState(false) const [newCategoryName, setNewCategoryName] = useState("") @@ -83,23 +94,40 @@ export default function NewItemPage() { const [submitting, setSubmitting] = useState(false) useEffect(() => { - Promise.all([categoriesApi.list({ pageSize: 200 }), brandsApi.list({ pageSize: 200 }), variantCategoriesApi.list()]) - .then(([cat, br, vc]) => { + Promise.all([ + categoriesApi.list({ pageSize: 200, status: "Active" }), + brandsApi.list({ pageSize: 200, status: "Active" }), + itemTypesApi.list({ pageSize: 200, status: "Active" }), + productConfig(), + uomsApi.list({ pageSize: 1 }), + ]) + .then(([cat, br, types, cfg, uoms]) => { setCategories(cat.items) setBrands(br.items) - setVariantCategories(vc.items) + setItemTypes(types.items) + setConfig(cfg) + setBaseUomId(uoms.items[0]?.uomId ?? null) }) .catch((err) => setLoadError(errorMessage(err))) }, []) - const topCategories = useMemo(() => (categories ?? []).filter((c) => c.parentId === null), [categories]) - const subCategoryOptions = useMemo( - () => (categories ?? []).filter((c) => c.parentId === categoryId), - [categories, categoryId] - ) - const effectiveCategoryId = subCategoryId ?? categoryId - const effectiveCategoryLabel = - (categories ?? []).find((c) => c.categoryId === effectiveCategoryId)?.name ?? "" + // Subcategories are their own resource now — fetched per category rather than filtered + // out of a flat list by parentId (that column no longer exists). + useEffect(() => { + if (categoryId === null || !config?.subcategoriesEnabled) { + setSubCategories([]) + return + } + categoriesApi + .listSubCategories(categoryId, { pageSize: 200, status: "Active" }) + .then((res) => setSubCategories(res.items)) + .catch(() => setSubCategories([])) + }, [categoryId, config?.subcategoriesEnabled]) + + const categoryLabel = (categories ?? []).find((c) => c.categoryId === categoryId)?.name ?? "" + const subCategoryLabel = subCategories.find((s) => s.subCategoryId === subCategoryId)?.name ?? "" + /** SKU/name read best off the most specific level, but BOTH ids are sent to the server. */ + const effectiveLabel = subCategoryLabel || categoryLabel const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? "" function handleCategoryChange(value: number | null) { @@ -107,28 +135,27 @@ export default function NewItemPage() { setSubCategoryId(null) } - function toggleVariantCategory(variantCategoryId: number) { - setCheckedVariantCategoryIds((prev) => - prev.includes(variantCategoryId) ? prev.filter((id) => id !== variantCategoryId) : [...prev, variantCategoryId] + function toggleItemType(itemTypeId: number) { + setCheckedItemTypeIds((prev) => + prev.includes(itemTypeId) ? prev.filter((id) => id !== itemTypeId) : [...prev, itemTypeId] ) - setQuantities({}) } - async function handleAddVariantCategory() { - const nextErrors = validateVariantCategoryName(newCategoryName) + async function handleAddItemType() { + const nextErrors = validateItemTypeName(newCategoryName) if (nextErrors.name) { setNewCategoryError(nextErrors.name) return } setAddingCategorySubmitting(true) try { - const category = await variantCategoriesApi.create({ name: newCategoryName }) - setVariantCategories((prev) => [...(prev ?? []), category]) - setCheckedVariantCategoryIds((prev) => [...prev, category.variantCategoryId]) + const created = await itemTypesApi.create({ name: newCategoryName }) + setItemTypes((prev) => [...(prev ?? []), created.data]) + setCheckedItemTypeIds((prev) => [...prev, created.data.itemTypeId]) setNewCategoryName("") setNewCategoryError(null) setAddingCategory(false) - toast.success("Variant category created", category.name) + toast.success("Item type created", created.data.name) } catch (err) { setNewCategoryError(errorMessage(err)) } finally { @@ -136,34 +163,32 @@ export default function NewItemPage() { } } - function addValue(variantCategoryId: number, overrideValue?: string) { - const value = (overrideValue ?? inputByCategory[variantCategoryId] ?? "").trim() + function addValue(itemTypeId: number, overrideValue?: string) { + const value = (overrideValue ?? inputByCategory[itemTypeId] ?? "").trim() if (value) { setValuesByCategory((prev) => { - const existing = prev[variantCategoryId] ?? [] + const existing = prev[itemTypeId] ?? [] if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev - return { ...prev, [variantCategoryId]: [...existing, value] } + return { ...prev, [itemTypeId]: [...existing, value] } }) - setQuantities({}) } - setInputByCategory((prev) => ({ ...prev, [variantCategoryId]: "" })) + setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" })) } - function removeValue(variantCategoryId: number, value: string) { + function removeValue(itemTypeId: number, value: string) { setValuesByCategory((prev) => ({ ...prev, - [variantCategoryId]: (prev[variantCategoryId] ?? []).filter((v) => v !== value), + [itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v !== value), })) - setQuantities({}) } const activeCategories = useMemo( () => - (variantCategories ?? []) - .filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId)) - .map((vc) => ({ ...vc, values: valuesByCategory[vc.variantCategoryId] ?? [] })) - .filter((vc) => vc.values.length > 0), - [variantCategories, checkedVariantCategoryIds, valuesByCategory] + (itemTypes ?? []) + .filter((t) => checkedItemTypeIds.includes(t.itemTypeId)) + .map((t) => ({ ...t, values: valuesByCategory[t.itemTypeId] ?? [] })) + .filter((t) => t.values.length > 0), + [itemTypes, checkedItemTypeIds, valuesByCategory] ) const variants = useMemo(() => { @@ -183,44 +208,58 @@ export default function NewItemPage() { } return combinations.map((c) => ({ ...c, - sku: buildVariantSku(effectiveCategoryLabel, c.parts.map(partLabel)), + sku: buildVariantSku(effectiveLabel, c.parts.map(partLabel)), })) - }, [activeCategories, effectiveCategoryLabel]) + }, [activeCategories, effectiveLabel]) async function handleSubmit() { setSubmitError(null) - const nextErrors = validateVariantItemForm({ categoryId: effectiveCategoryId, hasVariants: variants.length > 0 }) + const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 }) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return + if (baseUomId === null) { + setSubmitError("No unit of measure exists yet — create one under Products → UOM before adding items.") + return + } setSubmitting(true) + let created = 0 try { - let created = 0 for (const variant of variants) { - const qty = Number(quantities[variant.key] || 0) await itemsApi.create({ sku: variant.sku, - name: `${brandLabel ? brandLabel + " " : ""}${effectiveCategoryLabel} - ${variant.parts.map(partLabel).join("/")}`, - categoryId: effectiveCategoryId as number, + name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map(partLabel).join("/")}`, + // Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the + // category, which lost the parent entirely. The server rejects a mismatched + // pair with 422. + categoryId: categoryId as number, + subCategoryId, brandId, - baseUomId: DEFAULT_BASE_UOM_ID, - itemType: "Stocked", + baseUomId, + stockNature: "Stocked", trackingMode: "None", - initialQty: Number.isFinite(qty) ? qty : 0, }) created += 1 } toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`) router.push("/dashboard/products") } catch (err) { - setSubmitError(errorMessage(err)) - toast.error("Could not create variants", errorMessage(err)) + // Each row is its own POST with no transaction, so a failure partway (e.g. a + // duplicate SKU) leaves the earlier rows created. Say so rather than implying + // nothing happened. + const detail = errorMessage(err) + setSubmitError( + created > 0 + ? `${detail} — ${created} item${created === 1 ? "" : "s"} were already created before this failed.` + : detail, + ) + toast.error("Could not create all variants", detail) } finally { setSubmitting(false) } } - const loading = !categories || !brands || !variantCategories + const loading = !categories || !brands || !itemTypes || !config return (
@@ -230,7 +269,7 @@ export default function NewItemPage() {

New Item

-

Category, subcategory, brand, and variant categories (FR-MD-01).

+

Category, subcategory, brand, and item types (FR-MD-01).

@@ -240,6 +279,16 @@ export default function NewItemPage() { {loading && !loadError && } + {!loading && baseUomId === null && ( +
+ No unit of measure exists yet. Items need a base UOM —{" "} + + create one first + + . +
+ )} + {!loading && ( <>
@@ -250,7 +299,7 @@ export default function NewItemPage() { - {topCategories.map((c) => ( + {(categories ?? []).map((c) => ( {c.name} @@ -259,54 +308,63 @@ export default function NewItemPage() {
-
- - value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}> - - - - - {subCategoryOptions.map((c) => ( - - {c.name} - - ))} - - -
-
- - value={brandId} onValueChange={setBrandId}> - - - - - {(brands ?? []).map((b) => ( - - {b.name} - - ))} - - -
+ {/* Config flags are honoured by hiding the field: sending a gated value would + just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */} + {config?.subcategoriesEnabled && ( +
+ + value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategories.length === 0}> + + + + + {subCategories.map((s) => ( + + {s.name} + + ))} + + +
+ )} + {config?.brandsEnabled && ( +
+ + value={brandId} onValueChange={setBrandId}> + + + + + {(brands ?? []).map((b) => ( + + {b.name} + + ))} + + +
+ )} + {/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no + item-type reference), so this section IS the enforcement. */} + {config?.itemTypesEnabled && (
-

Variants

+

Item types

- Check the variant categories that apply, then add their values to generate a SKU per combination. + Check the item types that apply, then add their values to generate a SKU per combination.

- {(variantCategories ?? []).map((vc) => ( -
- - - Item - UOM - Bin - Qty - Unit cost - Hold status - Batch / Serial - - - - - {lines.map((line) => { - const item = itemFor(line.itemId) - const errors = lineErrors[line.key] ?? {} - return ( - - - value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> - - - - - {(items ?? []).map((i) => ( - - {i.sku} — {i.name} - - ))} - - - - - - value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> - - - - - {(uoms ?? []).map((u) => ( - - {u.name} - - ))} - - - - - - value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}> - - - - - {bins.map((b) => ( - - {b.code} - - ))} - - - - - updateLine(line.key, { qty: e.target.value })} - className="h-11 text-base" - /> - - - - updateLine(line.key, { unitCost: e.target.value })} - className="h-11 text-base" - /> - - - - value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}> - - - - - Available - On hold (inspection) - - - - - {item?.trackingMode === "Batch" && ( -
- updateLine(line.key, { batchNo: e.target.value })} - className="h-9 text-sm" - /> - updateLine(line.key, { expiryDate: e.target.value })} - className="h-9 text-sm" - /> - -
- )} - {item?.trackingMode === "Serial" && ( -
-