Refactor API modules to remove mock implementations and integrate real endpoints

- Updated stock-transfers.ts to replace mock data with API requests for stock transfers.
- Refactored stock.ts to utilize API calls for stock inquiries instead of mock data.
- Modified uoms.ts to implement real API requests for unit of measure operations.
- Transitioned vendors.ts to use actual API endpoints for vendor management, removing mock data handling.
- Updated warehouses.ts to replace mock implementations with real API calls for warehouse and bin management.
- Refactored wastage.ts to utilize stock adjustments and reason codes APIs, removing mock data dependencies.
- Adjusted procurement.ts to align with backend DTO shapes, ensuring consistency with planned backend structures.
This commit is contained in:
2026-07-14 16:01:47 +05:30
parent 7ac30bb454
commit 0415794473
27 changed files with 416 additions and 2811 deletions
+7 -55
View File
@@ -1,64 +1,16 @@
// One typed client method per Category endpoint (docs/11-BACKEND-PHASE1.md §2.3, FR-MD-04).
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens
// can be reviewed without a running backend. Restore the commented block and
// delete the mock block once Backend/PROGRESS.md §1 (Master Data) exists.
import { apiRequest } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { Category, CategoryTreeNode, CreateCategoryRequest } from "@/types/master-data"
import { allocateCategoryId, mockCategories, mockDelay } from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest } from "@/lib/api-client"
//
// export const categoriesApi = {
// list() {
// return apiRequest<PagedResponse<Category>>("/categories")
// },
// tree() {
// return apiRequest<CategoryTreeNode[]>("/categories?tree=true")
// },
// create(request: CreateCategoryRequest) {
// return apiRequest<Category>("/categories", { method: "POST", body: request })
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
function buildTree(categories: Category[]): CategoryTreeNode[] {
const nodes = new Map<number, CategoryTreeNode>(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 const categoriesApi = {
list(): Promise<PagedResponse<Category>> {
const items = [...mockCategories].sort((a, b) => a.name.localeCompare(b.name))
return mockDelay({
items,
pagination: { page: 1, pageSize: 200, totalItems: items.length, totalPages: 1 },
})
list() {
return apiRequest<PagedResponse<Category>>("/categories")
},
tree(): Promise<CategoryTreeNode[]> {
return mockDelay(buildTree(mockCategories))
tree() {
return apiRequest<CategoryTreeNode[]>("/categories?tree=true")
},
create(request: CreateCategoryRequest): Promise<Category> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required."))
const parentId = request.parentId ?? null
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 }
mockCategories.push(category)
return mockDelay(category)
create(request: CreateCategoryRequest) {
return apiRequest<Category>("/categories", { method: "POST", body: request })
},
}
+40 -251
View File
@@ -1,282 +1,71 @@
// One typed client method per GRN endpoint (docs/11-BACKEND-PHASE1.md §4).
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with an in-memory mock store (lib/api/mock-data.ts) so the GRN
// screens (list/create/confirm/release) can be reviewed end-to-end without a
// running backend. Restore the commented block and delete the mock block once
// Backend/PROGRESS.md §3/§4 (GRN + Stock Core) exist. Note GET /grns and
// GET /grns/{id} are not yet in docs/11-BACKEND-PHASE1.md §4 — see the note in
// Frontend/PROGRESS.md §4.
// Note: no GrnsController exists yet (Backend/PROGRESS.md §3 is unstarted).
// These calls will 404 until that backend is built. GET /grns and GET
// /grns/{id} are also not in docs/11-BACKEND-PHASE1.md §4 — see
// Frontend/PROGRESS.md §4 for that assumed-endpoint deviation.
import { PagedResponse } from "@/types/common"
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import {
ConfirmGrnResponse,
CreateGrnRequest,
CreatedLayer,
Grn,
GrnStatus,
GrnSummary,
ReleaseAction,
ReleaseGrnLineResponse,
} from "@/types/grn"
import { allocateGrnId, allocateGrnLineId, mockDelay, mockGrns, mockPurchaseOrders, receiveLayer } from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
//
// export interface ListGrnsParams {
// page?: number
// pageSize?: number
// q?: string
// status?: GrnStatus
// poId?: number
// warehouseId?: number
// }
//
// export const grnsApi = {
// list(params: ListGrnsParams = {}) {
// return apiRequest<PagedResponse<GrnSummary>>(`/grns${buildQuery(params)}`)
// },
//
// get(grnId: number) {
// return apiRequest<Grn>(`/grns/${grnId}`)
// },
//
// async create(request: CreateGrnRequest) {
// const { data } = await apiRequestWithETag<Grn>("/grns", { method: "POST", body: request })
// return data
// },
//
// // Draft-only — a GRN with createdLayers/ledger postings (Confirmed/Closed) is
// // immutable per docs/11 §4.
// async update(grnId: number, request: CreateGrnRequest, ifMatch: string) {
// const { data } = await apiRequestWithETag<Grn>(`/grns/${grnId}`, { method: "PUT", body: request, ifMatch })
// return data
// },
//
// remove(grnId: number) {
// return apiRequest<void>(`/grns/${grnId}`, { method: "DELETE" })
// },
//
// confirm(grnId: number, idempotencyKey?: string) {
// return apiRequest<ConfirmGrnResponse>(`/grns/${grnId}/confirm`, {
// method: "POST",
// body: {},
// idempotencyKey,
// })
// },
//
// releaseLine(grnId: number, grnLineId: number, action: ReleaseAction) {
// return apiRequest<ReleaseGrnLineResponse>(`/grns/${grnId}/lines/${grnLineId}/release`, {
// method: "POST",
// body: { action },
// })
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
export interface ListGrnsParams {
page?: number
pageSize?: number
/** Free-text search over doc no. and vendor/PO/warehouse id (docs/11 §1.5). */
q?: string
status?: GrnStatus
poId?: number
warehouseId?: number
}
function toSummary(grn: Grn): GrnSummary {
return {
grnId: grn.grnId,
docNo: grn.docNo,
poId: grn.poId,
vendorId: grn.vendorId,
warehouseId: grn.warehouseId,
status: grn.status,
createdAt: grn.createdAt,
}
}
export const grnsApi = {
list(params: ListGrnsParams = {}): Promise<PagedResponse<GrnSummary>> {
const term = params.q?.trim().toLowerCase()
list(params: ListGrnsParams = {}) {
return apiRequest<PagedResponse<GrnSummary>>(`/grns${buildQuery(params)}`)
},
const filtered = mockGrns
.filter((g) => !params.status || g.status === params.status)
.filter((g) => !params.poId || g.poId === params.poId)
.filter((g) => !params.warehouseId || g.warehouseId === params.warehouseId)
.filter((g) => {
if (!term) return true
const haystack = [g.docNo, String(g.poId ?? ""), String(g.vendorId), String(g.warehouseId)]
.join(" ")
.toLowerCase()
return haystack.includes(term)
})
.map(toSummary)
.sort((a, b) => b.grnId - a.grnId)
get(grnId: number) {
return apiRequest<Grn>(`/grns/${grnId}`)
},
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
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)
getWithETag(grnId: number) {
return apiRequestWithETag<Grn>(`/grns/${grnId}`)
},
return mockDelay({
items,
pagination: { page, pageSize, totalItems, totalPages },
async create(request: CreateGrnRequest) {
const { data } = await apiRequestWithETag<Grn>("/grns", { method: "POST", body: request })
return data
},
// Draft-only — a GRN with createdLayers/ledger postings (Confirmed/Closed) is
// immutable per docs/11 §4.
async update(grnId: number, request: CreateGrnRequest, ifMatch: string) {
const { data } = await apiRequestWithETag<Grn>(`/grns/${grnId}`, { method: "PUT", body: request, ifMatch })
return data
},
remove(grnId: number) {
return apiRequest<void>(`/grns/${grnId}`, { method: "DELETE" })
},
confirm(grnId: number, idempotencyKey?: string) {
return apiRequest<ConfirmGrnResponse>(`/grns/${grnId}/confirm`, {
method: "POST",
body: {},
idempotencyKey,
})
},
get(grnId: number): Promise<Grn> {
const grn = mockGrns.find((g) => g.grnId === grnId)
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
return mockDelay(grn)
},
create(request: CreateGrnRequest): Promise<Grn> {
const grnId = allocateGrnId()
const referencedPo = request.poId ? mockPurchaseOrders.find((p) => p.poId === request.poId) : undefined
const grn: Grn = {
grnId,
docNo: `GRN-2026-${String(grnId).padStart(5, "0")}`,
poId: request.poId ?? null,
// Vendor is derived from the PO when receiving against one (as the real
// backend does) — request.vendorId is only meaningful for a direct receipt.
vendorId: referencedPo?.vendorId ?? request.vendorId ?? 0,
warehouseId: request.warehouseId,
status: "Draft",
createdBy: 17,
createdAt: new Date().toISOString(),
lines: request.lines.map((line) => ({
grnLineId: allocateGrnLineId(),
poLineId: line.poLineId ?? null,
itemId: line.itemId,
uomId: line.uomId,
binId: line.binId ?? null,
qty: line.qty,
unitCost: line.unitCost,
receivedValue: Math.round(line.qty * line.unitCost * 100) / 100,
holdStatus: line.holdStatus,
batchId: line.batch ? allocateGrnLineId() : null,
})),
}
mockGrns.push(grn)
return mockDelay(grn)
},
confirm(grnId: number, idempotencyKey?: string): Promise<ConfirmGrnResponse> {
void idempotencyKey // real backend dedupes on this; the mock always reprocesses
const grn = mockGrns.find((g) => g.grnId === grnId)
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
if (grn.status === "Confirmed" || grn.status === "Closed") {
return Promise.reject(new Error(`${grn.docNo} has already been confirmed.`))
}
grn.status = "Confirmed"
const createdLayers: CreatedLayer[] = []
const ledgerRefs: number[] = []
for (const line of grn.lines) {
// FR-GRN-06: each line creates a FIFO layer + posts an inbound ledger entry.
const { layer, ledger } = receiveLayer({
itemId: line.itemId,
warehouseId: grn.warehouseId,
binId: line.binId,
batchId: line.batchId,
grnLineId: line.grnLineId,
qty: line.qty,
unitCost: line.unitCost,
userId: grn.createdBy,
sourceDocType: "GRN",
sourceDocId: grn.grnId,
})
createdLayers.push({
layerId: layer.layerId,
itemId: layer.itemId,
warehouseId: layer.warehouseId,
batchId: layer.batchId,
qtyReceived: layer.qtyReceived,
qtyRemaining: layer.qtyRemaining,
unitCost: layer.unitCost,
receiptDate: layer.receiptDate,
})
ledgerRefs.push(ledger.ledgerId)
// FR-PROC-07: accrue the PO line's received quantity as GRNs confirm.
if (line.poLineId && grn.poId) {
const po = mockPurchaseOrders.find((p) => p.poId === grn.poId)
const poLine = po?.lines.find((l) => l.poLineId === line.poLineId)
if (poLine) poLine.qtyReceived = Math.min(poLine.qty, poLine.qtyReceived + line.qty)
}
}
let poStatus: string | null = null
if (grn.poId) {
const po = mockPurchaseOrders.find((p) => p.poId === grn.poId)
if (po) {
const fullyReceived = po.lines.every((l) => l.qtyReceived >= l.qty)
const anyReceived = po.lines.some((l) => l.qtyReceived > 0)
po.status = fullyReceived ? "FullyReceived" : anyReceived ? "PartiallyReceived" : po.status
poStatus = po.status
}
}
const response: ConfirmGrnResponse = {
grnId: grn.grnId,
status: grn.status,
postedAt: new Date().toISOString(),
createdLayers,
ledgerRefs,
poStatus,
}
return mockDelay(response)
},
releaseLine(grnId: number, grnLineId: number, action: ReleaseAction): Promise<ReleaseGrnLineResponse> {
const grn = mockGrns.find((g) => g.grnId === grnId)
const line = grn?.lines.find((l) => l.grnLineId === grnLineId)
if (!grn || !line) return Promise.reject(new Error(`Mock GRN line ${grnLineId} not found`))
line.holdStatus = action === "Release" ? "Available" : "Rejected"
return mockDelay({ grnLineId: line.grnLineId, holdStatus: line.holdStatus })
},
// Draft-only — once confirmed, a GRN has created stock layers/ledger entries
// and is no longer safe to rewrite in place.
update(grnId: number, request: CreateGrnRequest): Promise<Grn> {
const grn = mockGrns.find((g) => g.grnId === grnId)
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
if (grn.status !== "Draft") {
return Promise.reject(new Error(`${grn.docNo} is ${grn.status.toLowerCase()} and can no longer be edited.`))
}
const referencedPo = request.poId ? mockPurchaseOrders.find((p) => p.poId === request.poId) : undefined
grn.poId = request.poId ?? null
grn.vendorId = referencedPo?.vendorId ?? request.vendorId ?? grn.vendorId
grn.warehouseId = request.warehouseId
grn.lines = request.lines.map((line) => ({
grnLineId: allocateGrnLineId(),
poLineId: line.poLineId ?? null,
itemId: line.itemId,
uomId: line.uomId,
binId: line.binId ?? null,
qty: line.qty,
unitCost: line.unitCost,
receivedValue: Math.round(line.qty * line.unitCost * 100) / 100,
holdStatus: line.holdStatus,
batchId: line.batch ? allocateGrnLineId() : null,
}))
return mockDelay(grn)
},
remove(grnId: number): Promise<void> {
const grn = mockGrns.find((g) => g.grnId === grnId)
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
if (grn.status !== "Draft") {
return Promise.reject(new Error(`${grn.docNo} is ${grn.status.toLowerCase()} and can no longer be deleted.`))
}
mockGrns.splice(mockGrns.indexOf(grn), 1)
return mockDelay(undefined)
releaseLine(grnId: number, grnLineId: number, action: ReleaseAction) {
return apiRequest<ReleaseGrnLineResponse>(`/grns/${grnId}/lines/${grnLineId}/release`, {
method: "POST",
body: { action },
})
},
}
+16 -172
View File
@@ -1,11 +1,6 @@
// One typed client method per Item endpoint (docs/11-BACKEND-PHASE1.md §2.1, FR-MD-01/05/08).
// `list` also backs the GRN/PO/Requisition/RFQ item pickers built in earlier sessions.
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens
// can be reviewed without a running backend. Restore the commented block and
// delete the mock block once Backend/PROGRESS.md §1 (Master Data) exists.
import { ApiResult } from "@/lib/api-client"
// `list` also backs the GRN/PO/Requisition/RFQ item pickers.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { EntityStatus, PagedResponse } from "@/types/common"
import {
CreateItemRequest,
@@ -17,52 +12,7 @@ import {
UpdateUomConversionsRequest,
UpdateUomConversionsResponse,
} from "@/types/master-data"
import {
allocateItemId,
bumpItemVersion,
getItemVersion,
initItemVersion,
mockDelay,
mockItems,
} from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
//
// export interface ListItemsParams {
// page?: number
// pageSize?: number
// q?: string
// status?: EntityStatus
// categoryId?: number
// trackingMode?: TrackingMode
// }
//
// export const itemsApi = {
// list(params: ListItemsParams = {}) {
// return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
// },
// get(itemId: number) {
// return apiRequestWithETag<Item>(`/items/${itemId}`)
// },
// create(request: CreateItemRequest) {
// return apiRequestWithETag<Item>("/items", { method: "POST", body: request })
// },
// update(itemId: number, request: UpdateItemRequest, ifMatch: string) {
// return apiRequestWithETag<Item>(`/items/${itemId}`, { method: "PUT", body: request, ifMatch })
// },
// updateStatus(itemId: number, status: EntityStatus) {
// return apiRequest<void>(`/items/${itemId}/status`, { method: "PATCH", body: { status } })
// },
// updateReorder(itemId: number, request: UpdateItemReorderRequest) {
// return apiRequest<{ settings: UpdateItemReorderRequest["settings"] }>(`/items/${itemId}/reorder`, { method: "PUT", body: request })
// },
// updateUomConversions(itemId: number, request: UpdateUomConversionsRequest) {
// return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, { method: "PUT", body: request })
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
export interface ListItemsParams {
page?: number
pageSize?: number
@@ -72,132 +22,26 @@ export interface ListItemsParams {
trackingMode?: TrackingMode
}
function toListItem(item: Item): ItemListItem {
return {
itemId: item.itemId,
sku: item.sku,
name: item.name,
categoryId: item.categoryId,
baseUomId: item.baseUomId,
defaultVendorId: item.defaultVendorId,
itemType: item.itemType,
trackingMode: item.trackingMode,
taxClass: item.taxClass,
status: item.status,
}
}
function skuTaken(sku: string, excludeItemId?: number) {
return mockItems.some((i) => i.itemId !== excludeItemId && i.sku.toLowerCase() === sku.toLowerCase())
}
export const itemsApi = {
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
const term = params.q?.trim().toLowerCase()
const filtered = mockItems
.filter((i) => !params.status || i.status === params.status)
.filter((i) => !params.categoryId || i.categoryId === params.categoryId)
.filter((i) => !params.trackingMode || i.trackingMode === params.trackingMode)
.filter((i) => !term || `${i.sku} ${i.name}`.toLowerCase().includes(term))
.sort((a, b) => a.sku.localeCompare(b.sku))
.map(toListItem)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
list(params: ListItemsParams = {}) {
return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
},
get(itemId: number): Promise<ApiResult<Item>> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
return mockDelay({ data: item, etag: String(getItemVersion(itemId)) })
get(itemId: number) {
return apiRequestWithETag<Item>(`/items/${itemId}`)
},
create(request: CreateItemRequest): Promise<ApiResult<Item>> {
const sku = request.sku.trim()
if (!sku) return Promise.reject(new Error("SKU is required."))
if (skuTaken(sku)) {
return Promise.reject(Object.assign(new Error(`SKU "${sku}" already exists.`), { code: "SKU_DUPLICATE" }))
}
const item: Item = {
itemId: allocateItemId(),
sku,
name: request.name.trim(),
description: request.description?.trim() || null,
categoryId: request.categoryId,
baseUomId: request.baseUomId,
defaultVendorId: request.defaultVendorId ?? null,
itemType: request.itemType,
trackingMode: request.trackingMode,
taxClass: request.taxClass?.trim() || null,
status: "Active",
reorder: [],
conversions: [],
createdAt: new Date().toISOString(),
updatedAt: null,
}
mockItems.push(item)
initItemVersion(item.itemId)
return mockDelay({ data: item, etag: "1" })
create(request: CreateItemRequest) {
return apiRequestWithETag<Item>("/items", { method: "POST", body: request })
},
update(itemId: number, request: UpdateItemRequest, ifMatch: string): Promise<ApiResult<Item>> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
if (String(getItemVersion(itemId)) !== ifMatch) {
return Promise.reject(Object.assign(new Error("The item was modified by another request."), { code: "CONCURRENCY_CONFLICT" }))
}
const sku = request.sku.trim()
if (!sku) return Promise.reject(new Error("SKU is required."))
if (skuTaken(sku, itemId)) {
return Promise.reject(Object.assign(new Error(`SKU "${sku}" already exists.`), { code: "SKU_DUPLICATE" }))
}
item.sku = sku
item.name = request.name.trim()
item.description = request.description?.trim() || null
item.categoryId = request.categoryId
item.baseUomId = request.baseUomId
item.defaultVendorId = request.defaultVendorId ?? null
item.itemType = request.itemType
item.trackingMode = request.trackingMode
item.taxClass = request.taxClass?.trim() || null
item.updatedAt = new Date().toISOString()
const next = bumpItemVersion(itemId)
return mockDelay({ data: item, etag: String(next) })
update(itemId: number, request: UpdateItemRequest, ifMatch: string) {
return apiRequestWithETag<Item>(`/items/${itemId}`, { method: "PUT", body: request, ifMatch })
},
updateStatus(itemId: number, status: EntityStatus): Promise<void> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
item.status = status
item.updatedAt = new Date().toISOString()
bumpItemVersion(itemId)
return mockDelay(undefined)
updateStatus(itemId: number, status: EntityStatus) {
return apiRequest<void>(`/items/${itemId}/status`, { method: "PATCH", body: { status } })
},
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: UpdateItemReorderRequest["settings"] }> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
item.reorder = request.settings
item.updatedAt = new Date().toISOString()
bumpItemVersion(itemId)
return mockDelay({ settings: item.reorder })
updateReorder(itemId: number, request: UpdateItemReorderRequest) {
return apiRequest<{ settings: UpdateItemReorderRequest["settings"] }>(`/items/${itemId}/reorder`, { method: "PUT", body: request })
},
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
item.conversions = request.conversions.map((c, i) => ({ conversionId: 1000 + itemId * 10 + i, fromUom: c.fromUom, toUom: c.toUom, factor: c.factor }))
item.updatedAt = new Date().toISOString()
bumpItemVersion(itemId)
return mockDelay({ itemId: item.itemId, baseUomId: item.baseUomId, conversions: item.conversions })
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest) {
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, { method: "PUT", body: request })
},
}
-814
View File
@@ -1,814 +0,0 @@
// Temporary in-memory sample data so the GRN screens can be reviewed as pure UI
// without a running backend. Shapes mirror docs/11-BACKEND-PHASE1.md exactly.
//
// This file (and the "MOCK" blocks in the sibling lib/api/*.ts files) is meant
// to be deleted once the real GRN backend exists — the original fetch-based
// implementations are left commented out in each file for that switch-back.
import { Bin, Category, Item, Uom, Vendor, Warehouse } from "@/types/master-data"
import { PurchaseOrder, Quotation, Requisition, Rfq, PurchaseReturn } from "@/types/procurement"
import { Grn } from "@/types/grn"
import {
AdjustmentStatus,
CountStatus,
CountType,
LedgerDirection,
LedgerEntry,
ReasonCode,
TransferStatus,
} from "@/types/stock"
export const mockWarehouses: Warehouse[] = [
{ warehouseId: 1, code: "WH-MAIN", name: "Main Warehouse - Negombo" },
{ warehouseId: 2, code: "WH-COLOMBO", name: "Colombo Distribution Center" },
]
export const mockBins: Bin[] = [
{ binId: 1, warehouseId: 1, code: "A-01-01", binType: "Shelf" },
{ binId: 2, warehouseId: 1, code: "A-01-02", binType: "Shelf" },
{ binId: 3, warehouseId: 1, code: "B-02-01", binType: "Pallet" },
{ binId: 4, warehouseId: 2, code: "C-01-01", binType: "Shelf" },
{ binId: 5, warehouseId: 2, code: "C-01-02", binType: "Shelf" },
]
let nextWarehouseId = 3
let nextBinId = 6
export function allocateWarehouseId() {
return nextWarehouseId++
}
export function allocateBinId() {
return nextBinId++
}
export const mockUoms: Uom[] = [
{ uomId: 1, name: "EA" },
{ uomId: 2, name: "Box-12" },
{ uomId: 3, name: "KG" },
]
let nextUomId = 4
export function allocateUomId() {
return nextUomId++
}
export const mockCategories: Category[] = [
{ categoryId: 3, name: "Hardware", parentId: null },
{ categoryId: 12, name: "Fasteners", parentId: 3 },
{ categoryId: 20, name: "Power Tools", parentId: null },
]
let nextCategoryId = 21
export function allocateCategoryId() {
return nextCategoryId++
}
export const mockVendors: Vendor[] = [
{
vendorId: 5,
code: "VN-005",
name: "Lanka Steel Traders (Pvt) Ltd",
terms: "NET30",
taxReg: "134567890-7000",
currency: "LKR",
status: "Active",
createdAt: "2026-06-01T08:00:00Z",
updatedAt: null,
},
{
vendorId: 8,
code: "VN-008",
name: "Ceylon Hardware Supplies",
terms: "NET45",
taxReg: "198765432-1000",
currency: "LKR",
status: "Active",
createdAt: "2026-06-05T08:00:00Z",
updatedAt: null,
},
]
// A handful more so the vendors list has something real to paginate/search through.
const extraVendorNames = [
"Colombo Timber & Plywood Co.",
"Kandy Electrical Distributors",
"Galle Packaging Solutions",
"Jaffna Agro Supplies",
"Negombo Fasteners (Pvt) Ltd",
"Kurunegala Paints & Coatings",
"Trinco Marine Hardware",
"Ratnapura Gems & Tools",
]
for (let i = 0; i < extraVendorNames.length; i++) {
const vendorId = 9 + i
mockVendors.push({
vendorId,
code: `VN-${String(vendorId).padStart(3, "0")}`,
name: extraVendorNames[i],
terms: i % 2 === 0 ? "NET30" : "NET60",
taxReg: `1${String(10000000 + vendorId * 137)}-${7000 + i}`,
currency: "LKR",
status: i % 5 === 0 ? "Inactive" : "Active",
createdAt: new Date(Date.UTC(2026, 6, 1 + i, 9, 0, 0)).toISOString(),
updatedAt: null,
})
}
let nextVendorId = 9 + extraVendorNames.length
export function allocateVendorId() {
return nextVendorId++
}
// Concurrency token per vendor (stands in for the real backend's xmin/RowVersion
// ETag, docs/11 §1.6) — kept out-of-band since the public Vendor type has no
// version field of its own (it travels as an HTTP ETag header, not a body field).
const mockVendorVersions = new Map<number, number>(mockVendors.map((v) => [v.vendorId, 1]))
export function getVendorVersion(vendorId: number): number {
return mockVendorVersions.get(vendorId) ?? 1
}
export function bumpVendorVersion(vendorId: number): number {
const next = getVendorVersion(vendorId) + 1
mockVendorVersions.set(vendorId, next)
return next
}
export function initVendorVersion(vendorId: number) {
mockVendorVersions.set(vendorId, 1)
}
// Full Item records (docs/11 §2.1). ItemListItem (the list/GRN-picker view) is
// derived from these in lib/api/items.ts, same "full record → mapped summary"
// pattern as mockPurchaseOrders → PurchaseOrderSummary.
export const mockItems: Item[] = [
{
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",
status: "Active",
reorder: [{ warehouseId: 1, reorderPoint: 2500, reorderQty: 5000 }],
conversions: [{ conversionId: 33, fromUom: 2, toUom: 1, factor: 12 }],
createdAt: "2026-06-01T08:00:00Z",
updatedAt: null,
},
{
itemId: 1002,
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",
status: "Active",
reorder: [{ warehouseId: 1, reorderPoint: 5000, reorderQty: 8000 }],
conversions: [],
createdAt: "2026-06-01T08:05:00Z",
updatedAt: null,
},
{
itemId: 1003,
sku: "ITM-1003",
name: "Cordless Drill 18V",
description: "18V lithium-ion cordless drill/driver, includes charger",
categoryId: 20,
baseUomId: 1,
defaultVendorId: 8,
itemType: "Stocked",
trackingMode: "Serial",
taxClass: "STD",
status: "Active",
reorder: [{ warehouseId: 2, reorderPoint: 15, reorderQty: 20 }],
conversions: [],
createdAt: "2026-06-05T08:10:00Z",
updatedAt: null,
},
]
let nextItemId = 1004
export function allocateItemId() {
return nextItemId++
}
// Concurrency token per item (same out-of-band ETag pattern as mockVendorVersions).
const mockItemVersions = new Map<number, number>(mockItems.map((i) => [i.itemId, 1]))
export function getItemVersion(itemId: number): number {
return mockItemVersions.get(itemId) ?? 1
}
export function bumpItemVersion(itemId: number): number {
const next = getItemVersion(itemId) + 1
mockItemVersions.set(itemId, next)
return next
}
export function initItemVersion(itemId: number) {
mockItemVersions.set(itemId, 1)
}
export const mockPurchaseOrders: PurchaseOrder[] = [
{
poId: 342,
docNo: "PO-2026-00342",
vendorId: 5,
requisitionId: 210,
status: "Approved",
approvalRequired: false,
createdBy: 17,
createdAt: "2026-07-07T09:40:00Z",
updatedAt: null,
totals: { subTotal: 112100.0, tax: 20178.0, grandTotal: 132278.0, currency: "LKR" },
lines: [
{ poLineId: 900, itemId: 1001, uomId: 1, warehouseId: 1, qty: 5000, unitPrice: 12.5, tax: 0.18, qtyReceived: 0 },
{ poLineId: 901, itemId: 1002, uomId: 1, warehouseId: 1, qty: 8000, unitPrice: 6.2, tax: 0.18, qtyReceived: 0 },
],
},
{
poId: 350,
docNo: "PO-2026-00350",
vendorId: 8,
requisitionId: null,
status: "PartiallyReceived",
approvalRequired: false,
createdBy: 17,
createdAt: "2026-07-09T09:00:00Z",
updatedAt: "2026-07-10T11:00:00Z",
totals: { subTotal: 22500.0, tax: 4050.0, grandTotal: 26550.0, currency: "LKR" },
lines: [
{ poLineId: 910, itemId: 1003, uomId: 1, warehouseId: 2, qty: 50, unitPrice: 450.0, tax: 0.18, qtyReceived: 20 },
],
},
]
let nextPoId = 351
export function allocatePoId() {
return nextPoId++
}
// Concurrency token per PO (same out-of-band ETag pattern as mockVendorVersions,
// docs/11 §1.6) — backs PUT /purchase-orders/{poId}'s If-Match (FR-PROC-05, Option B).
const mockPoVersions = new Map<number, number>(mockPurchaseOrders.map((p) => [p.poId, 1]))
export function getPoVersion(poId: number): number {
return mockPoVersions.get(poId) ?? 1
}
export function bumpPoVersion(poId: number): number {
const next = getPoVersion(poId) + 1
mockPoVersions.set(poId, next)
return next
}
export function initPoVersion(poId: number) {
mockPoVersions.set(poId, 1)
}
export const mockGrns: Grn[] = [
{
grnId: 780,
docNo: "GRN-2026-00780",
poId: 342,
vendorId: 5,
warehouseId: 1,
status: "Draft",
createdBy: 17,
createdAt: "2026-07-11T10:00:00Z",
lines: [
{
grnLineId: 1300,
poLineId: 900,
itemId: 1001,
uomId: 1,
binId: 1,
qty: 5000,
unitCost: 12.5,
receivedValue: 62500.0,
holdStatus: "OnHold",
batchId: 410,
},
],
},
{
grnId: 781,
docNo: "GRN-2026-00781",
poId: null,
vendorId: 8,
warehouseId: 2,
status: "Confirmed",
createdBy: 17,
createdAt: "2026-07-10T14:30:00Z",
lines: [
{
grnLineId: 1310,
poLineId: null,
itemId: 1003,
uomId: 1,
binId: 4,
qty: 5,
unitCost: 450.0,
receivedValue: 2250.0,
holdStatus: "Available",
batchId: null,
},
],
},
]
// A handful more so the list screen's pagination/search/filter controls have
// something real to page through (10 items total across statuses/warehouses).
const extraStatuses: Grn["status"][] = ["Draft", "Confirmed", "Closed", "Confirmed", "Draft", "Confirmed", "Closed", "Draft"]
for (let i = 0; i < extraStatuses.length; i++) {
const grnId = 782 + i
const warehouseId = i % 2 === 0 ? 1 : 2
const vendorId = i % 2 === 0 ? 5 : 8
const itemId = i % 2 === 0 ? 1001 : 1003
const status = extraStatuses[i]
mockGrns.push({
grnId,
docNo: `GRN-2026-${String(grnId).padStart(5, "0")}`,
poId: i % 3 === 0 ? null : 342,
vendorId,
warehouseId,
status,
createdBy: 17,
createdAt: new Date(Date.UTC(2026, 6, 1 + i, 9, 0, 0)).toISOString(),
lines: [
{
grnLineId: 2000 + i,
poLineId: i % 3 === 0 ? null : 900,
itemId,
uomId: 1,
binId: warehouseId === 1 ? 1 : 4,
qty: 100 * (i + 1),
unitCost: 10 + i,
receivedValue: 100 * (i + 1) * (10 + i),
holdStatus: status === "Draft" ? "OnHold" : "Available",
batchId: null,
},
],
})
}
let nextGrnId = 782 + extraStatuses.length
let nextGrnLineId = 2000 + extraStatuses.length
export function allocateGrnId() {
return nextGrnId++
}
export function allocateGrnLineId() {
return nextGrnLineId++
}
/** Small delay so loading states are visible when reviewing the UI. */
export function mockDelay<T>(value: T, ms = 300): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(value), ms))
}
// ============================================================================
// Stock Core (FIFO layers + immutable ledger) — docs/10 Part C.5, FR-STK-01..04.
// GRN confirm and every stock transaction below post through these helpers so
// Stock Enquiry / Ledger / Valuation reflect what actually happened this session.
// ============================================================================
export interface MockStockLayer {
layerId: number
itemId: number
warehouseId: number
batchId: number | null
serialId: number | null
grnLineId: number | null
qtyReceived: number
qtyRemaining: number
unitCost: number
receiptDate: string
}
export const mockStockLayers: MockStockLayer[] = []
export const mockStockLedger: LedgerEntry[] = []
let nextLayerId = 9001
let nextLedgerId = 55010
export function allocateLayerId() {
return nextLayerId++
}
export function allocateLedgerId() {
return nextLedgerId++
}
function round2(n: number) {
return Math.round(n * 100) / 100
}
function latestRunningBalance(itemId: number, warehouseId: number): number {
for (let i = mockStockLedger.length - 1; i >= 0; i--) {
const entry = mockStockLedger[i]
if (entry.itemId === itemId && entry.warehouseId === warehouseId) return entry.runningBalance
}
return 0
}
export function postLedgerEntry(input: {
itemId: number
warehouseId: number
binId?: number | null
batchId?: number | null
serialId?: number | null
userId: number
direction: LedgerDirection
qtyBase: number
unitCost: number
sourceDocType: string
sourceDocId: number
}): LedgerEntry {
const prior = latestRunningBalance(input.itemId, input.warehouseId)
const delta = input.direction === "In" ? input.qtyBase : -input.qtyBase
const entry: LedgerEntry = {
ledgerId: allocateLedgerId(),
itemId: input.itemId,
warehouseId: input.warehouseId,
binId: input.binId ?? null,
batchId: input.batchId ?? null,
serialId: input.serialId ?? null,
direction: input.direction,
qtyBase: input.qtyBase,
unitCost: input.unitCost,
value: round2(input.qtyBase * input.unitCost),
runningBalance: round2(prior + delta),
sourceDocType: input.sourceDocType,
sourceDocId: input.sourceDocId,
userId: input.userId,
createdAt: new Date().toISOString(),
}
mockStockLedger.push(entry)
return entry
}
/** Creates a FIFO layer + posts the matching inbound ledger entry (FR-GRN-06 / FR-STK-02). */
export function receiveLayer(input: {
itemId: number
warehouseId: number
binId?: number | null
batchId?: number | null
serialId?: number | null
grnLineId?: number | null
qty: number
unitCost: number
userId: number
sourceDocType: string
sourceDocId: number
}): { layer: MockStockLayer; ledger: LedgerEntry } {
const layer: MockStockLayer = {
layerId: allocateLayerId(),
itemId: input.itemId,
warehouseId: input.warehouseId,
batchId: input.batchId ?? null,
serialId: input.serialId ?? null,
grnLineId: input.grnLineId ?? null,
qtyReceived: input.qty,
qtyRemaining: input.qty,
unitCost: input.unitCost,
receiptDate: new Date().toISOString(),
}
mockStockLayers.push(layer)
const ledger = postLedgerEntry({
itemId: input.itemId,
warehouseId: input.warehouseId,
binId: input.binId,
batchId: input.batchId,
serialId: input.serialId,
userId: input.userId,
direction: "In",
qtyBase: input.qty,
unitCost: input.unitCost,
sourceDocType: input.sourceDocType,
sourceDocId: input.sourceDocId,
})
return { layer, ledger }
}
/** 409 STOCK_NEGATIVE_BLOCKED (docs/11 §7) — thrown by consumeFifo when available < requested. */
export class StockNegativeError extends Error {
code = "STOCK_NEGATIVE_BLOCKED"
constructor(itemId: number, warehouseId: number) {
super(`Not enough available stock for item #${itemId} at warehouse #${warehouseId}.`)
}
}
/** A layer is unavailable while its originating GRN line is still on hold/rejected (docs/10 C.9). */
function isLayerOnHold(layer: MockStockLayer): boolean {
if (!layer.grnLineId) return false
for (const grn of mockGrns) {
const line = grn.lines.find((l) => l.grnLineId === layer.grnLineId)
if (line) return line.holdStatus === "OnHold" || line.holdStatus === "Rejected"
}
return false
}
/** Consumes the oldest open (non-held) layers first (FR-STK-03); throws StockNegativeError if insufficient. */
export function consumeFifo(
itemId: number,
warehouseId: number,
qty: number
): { layerId: number; qtyConsumed: number; unitCost: number }[] {
let remaining = qty
const consumed: { layerId: number; qtyConsumed: number; unitCost: number }[] = []
const candidates = mockStockLayers
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId && l.qtyRemaining > 0 && !isLayerOnHold(l))
.sort((a, b) => new Date(a.receiptDate).getTime() - new Date(b.receiptDate).getTime())
for (const layer of candidates) {
if (remaining <= 0) break
const take = Math.min(layer.qtyRemaining, remaining)
layer.qtyRemaining = round2(layer.qtyRemaining - take)
remaining = round2(remaining - take)
consumed.push({ layerId: layer.layerId, qtyConsumed: take, unitCost: layer.unitCost })
}
if (remaining > 0.0001) throw new StockNegativeError(itemId, warehouseId)
return consumed
}
/** "Last cost" for an adjustment increase (FR-STK-07) when no more specific cost is supplied. */
export function lastKnownCost(itemId: number, warehouseId: number): number {
const layers = mockStockLayers
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId)
.sort((a, b) => new Date(b.receiptDate).getTime() - new Date(a.receiptDate).getTime())
return layers[0]?.unitCost ?? 10
}
/**
* Consumes stock for a Purchase Return against the specific layer its GRN line created
* (FR-PROC-08) — deliberately not routed through consumeFifo: a return disposes of the
* exact received batch (often On-hold/Rejected, which consumeFifo's isLayerOnHold filter
* would otherwise skip), not just "the oldest open layer for this item/warehouse".
* Throws StockNegativeError (409 STOCK_NEGATIVE_BLOCKED, docs/11 §3.4) if the return
* qty exceeds what remains on that layer.
*/
export function consumeLayerByGrnLine(
grnLineId: number,
qty: number
): { layerId: number; qtyConsumed: number; unitCost: number; itemId: number; warehouseId: number } {
const layer = mockStockLayers.find((l) => l.grnLineId === grnLineId)
if (!layer || layer.qtyRemaining < qty) {
const itemId = layer?.itemId ?? 0
const warehouseId = layer?.warehouseId ?? 0
throw new StockNegativeError(itemId, warehouseId)
}
layer.qtyRemaining = round2(layer.qtyRemaining - qty)
return { layerId: layer.layerId, qtyConsumed: qty, unitCost: layer.unitCost, itemId: layer.itemId, warehouseId: layer.warehouseId }
}
export function computeOnHand(itemId: number, warehouseId: number) {
const layers = mockStockLayers.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId)
const onHand = round2(layers.reduce((sum, l) => sum + l.qtyRemaining, 0))
const onHold = round2(layers.filter(isLayerOnHold).reduce((sum, l) => sum + l.qtyRemaining, 0))
const inTransit = round2(
mockStockTransfers
.filter((t) => t.status === "InTransit" && t.destWarehouseId === warehouseId)
.flatMap((t) => t.lines)
.filter((l) => l.itemId === itemId)
.reduce((sum, l) => sum + l.qty, 0)
)
const reserved = 0
const available = Math.max(0, round2(onHand - onHold - reserved))
return { onHand, onHold, inTransit, reserved, available }
}
/** Every item/warehouse combination that currently has (or ever had) a layer — drives the Enquiry screen. */
export function knownStockKeys(): { itemId: number; warehouseId: number }[] {
const seen = new Map<string, { itemId: number; warehouseId: number }>()
for (const layer of mockStockLayers) {
seen.set(`${layer.itemId}:${layer.warehouseId}`, { itemId: layer.itemId, warehouseId: layer.warehouseId })
}
return [...seen.values()]
}
// --- Reference data (docs/11 §6) --------------------------------------------------
export interface MockItemReorder {
itemId: number
warehouseId: number
reorderPoint: number
reorderQty: number
}
export const mockItemReorders: MockItemReorder[] = [
{ itemId: 1001, warehouseId: 1, reorderPoint: 2500, reorderQty: 5000 },
{ itemId: 1002, warehouseId: 1, reorderPoint: 5000, reorderQty: 8000 },
{ itemId: 1003, warehouseId: 2, reorderPoint: 15, reorderQty: 20 },
]
export const mockReasonCodes: ReasonCode[] = [
{ reasonCodeId: 1, code: "DMG", description: "Damage", context: "Adjustment" },
{ reasonCodeId: 2, code: "LOSS", description: "Theft/Loss", context: "Adjustment" },
{ reasonCodeId: 3, code: "CNTVAR", description: "Count Variance", context: "Adjustment" },
{ reasonCodeId: 4, code: "EXPWO", description: "Expiry Write-off", context: "Adjustment" },
{ reasonCodeId: 5, code: "SYSCORR", description: "System Correction", context: "Adjustment" },
{ reasonCodeId: 22, code: "QREJ", description: "Quality Reject", context: "Return" },
]
// --- Seed some prior receipts so Enquiry/Ledger/Valuation aren't empty on first load ---
receiveLayer({
itemId: 1001, warehouseId: 1, binId: 1, batchId: 411, qty: 3000, unitCost: 12.5,
userId: 17, sourceDocType: "GRN", sourceDocId: 779,
})
receiveLayer({
itemId: 1002, warehouseId: 1, binId: 2, qty: 6000, unitCost: 6.2,
userId: 17, sourceDocType: "GRN", sourceDocId: 779,
})
receiveLayer({
itemId: 1003, warehouseId: 2, binId: 4, qty: 20, unitCost: 450,
userId: 17, sourceDocType: "GRN", sourceDocId: 781,
})
// ============================================================================
// Transfers (FR-STK-05/06) — create → dispatch (consume src) → receive (create dest).
// ============================================================================
export interface MockTransferLine {
transferLineId: number
itemId: number
srcBinId: number | null
destBinId: number | null
batchId: number | null
qty: number
/** Recorded on dispatch so receive() can create cost-preserving destination layers (FR-STK-06). */
dispatchedChunks: { layerId: number; qtyConsumed: number; unitCost: number }[]
}
export interface MockStockTransfer {
transferId: number
docNo: string
srcWarehouseId: number
destWarehouseId: number
status: TransferStatus
createdBy: number
createdAt: string
lines: MockTransferLine[]
}
export const mockStockTransfers: MockStockTransfer[] = []
let nextTransferId = 55
let nextTransferLineId = 300
export function allocateTransferId() {
return nextTransferId++
}
export function allocateTransferLineId() {
return nextTransferLineId++
}
// ============================================================================
// Adjustments (FR-STK-07) — auto-post on creation.
// ============================================================================
export interface MockAdjustmentLine {
adjLineId: number
itemId: number
binId: number | null
batchId: number | null
qtyDelta: number
}
export interface MockStockAdjustment {
adjustmentId: number
docNo: string
warehouseId: number
reasonCodeId: number
status: AdjustmentStatus
createdBy: number
createdAt: string
lines: MockAdjustmentLine[]
ledgerRefs: number[]
}
export const mockStockAdjustments: MockStockAdjustment[] = []
let nextAdjustmentId = 77
let nextAdjLineId = 210
export function allocateAdjustmentId() {
return nextAdjustmentId++
}
export function allocateAdjLineId() {
return nextAdjLineId++
}
// ============================================================================
// Counts (FR-STK-08) — snapshot system qty → enter counted qty → post variance.
// ============================================================================
export interface MockCountLine {
countLineId: number
itemId: number
binId: number | null
systemQty: number
countedQty: number | null
variance: number | null
}
export interface MockStockCount {
countId: number
docNo: string
warehouseId: number
countType: CountType
status: CountStatus
createdBy: number
createdAt: string
lines: MockCountLine[]
}
export const mockStockCounts: MockStockCount[] = []
let nextCountId = 30
let nextCountLineId = 400
export function allocateCountId() {
return nextCountId++
}
export function allocateCountLineId() {
return nextCountLineId++
}
// ============================================================================
// Procurement (FR-PROC-01..09) — Requisition → RFQ → Quotations → PO → Return.
// No Procurement backend exists yet; same frontend-only posture as GRN/Stock.
// ============================================================================
export const mockRequisitions: Requisition[] = [
// Seeded to match mockPurchaseOrders[0].requisitionId (PO-2026-00342 was raised
// against this requisition) so the two screens cross-reference consistently.
{
requisitionId: 210,
docNo: "PR-2026-00210",
status: "Submitted",
requestedBy: 17,
createdAt: "2026-07-06T08:30:00Z",
lines: [
{ reqLineId: 501, itemId: 1001, qty: 5000, requiredBy: "2026-07-20" },
{ reqLineId: 502, itemId: 1002, qty: 8000, requiredBy: "2026-07-20" },
],
},
]
let nextRequisitionId = 211
let nextReqLineId = 503
export function allocateRequisitionId() {
return nextRequisitionId++
}
export function allocateReqLineId() {
return nextReqLineId++
}
export const mockRfqs: Rfq[] = []
let nextRfqId = 89
let nextRfqLineId = 703
export function allocateRfqId() {
return nextRfqId++
}
export function allocateRfqLineId() {
return nextRfqLineId++
}
export const mockQuotations: Quotation[] = []
let nextQuotationId = 141
export function allocateQuotationId() {
return nextQuotationId++
}
export const mockPurchaseReturns: PurchaseReturn[] = []
let nextPurchaseReturnId = 61
let nextPurchaseReturnLineId = 121
export function allocatePurchaseReturnId() {
return nextPurchaseReturnId++
}
export function allocatePurchaseReturnLineId() {
return nextPurchaseReturnLineId++
}
+13 -168
View File
@@ -1,12 +1,6 @@
// One typed client method per Purchase Order endpoint (docs/11-BACKEND-PHASE1.md §3.3,
// FR-PROC-03..07). `get`/`list` also back the GRN "against a PO" picker.
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with in-memory sample data (lib/api/mock-data.ts) so the GRN and
// Procurement screens can be reviewed without a running backend. Restore the
// commented block and delete the mock block once Backend/PROGRESS.md §2/§3/§4
// (Procurement + GRN + Stock Core) exist.
import { ApiResult } from "@/lib/api-client"
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
CancelPurchaseOrderRequest,
@@ -16,48 +10,7 @@ import {
PurchaseOrderSummary,
UpdatePurchaseOrderRequest,
} from "@/types/procurement"
import {
allocatePoId,
bumpPoVersion,
getPoVersion,
initPoVersion,
mockDelay,
mockPurchaseOrders,
} from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
//
// export interface ListPurchaseOrdersParams {
// page?: number
// pageSize?: number
// q?: string
// status?: PurchaseOrderStatus
// vendorId?: number
// }
//
// export const purchaseOrdersApi = {
// list(params: ListPurchaseOrdersParams = {}) {
// return apiRequest<PagedResponse<PurchaseOrderSummary>>(`/purchase-orders${buildQuery(params)}`)
// },
// get(poId: number) {
// return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}`)
// },
// getWithETag(poId: number) {
// return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`)
// },
// create(request: CreatePurchaseOrderRequest) {
// return apiRequestWithETag<PurchaseOrder>("/purchase-orders", { method: "POST", body: request })
// },
// update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string) {
// return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`, { method: "PUT", body: request, ifMatch })
// },
// cancel(poId: number, request: CancelPurchaseOrderRequest) {
// return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request })
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
export interface ListPurchaseOrdersParams {
page?: number
pageSize?: number
@@ -71,131 +24,23 @@ export function isPoEditable(status: PurchaseOrderStatus): boolean {
return status !== "FullyReceived" && status !== "Closed" && status !== "Cancelled"
}
function computeTotals(lines: CreatePurchaseOrderRequest["lines"], currency = "LKR") {
const subTotal = Math.round(lines.reduce((sum, l) => sum + l.qty * l.unitPrice, 0) * 100) / 100
const tax = Math.round(lines.reduce((sum, l) => sum + l.qty * l.unitPrice * l.tax, 0) * 100) / 100
return { subTotal, tax, grandTotal: Math.round((subTotal + tax) * 100) / 100, currency }
}
export const purchaseOrdersApi = {
list(params: ListPurchaseOrdersParams = {}): Promise<PagedResponse<PurchaseOrderSummary>> {
const term = params.q?.trim().toLowerCase()
const filtered = mockPurchaseOrders
.filter((po) => !params.status || po.status === params.status)
.filter((po) => !params.vendorId || po.vendorId === params.vendorId)
.filter((po) => !term || `${po.docNo} ${po.vendorId}`.toLowerCase().includes(term))
.sort((a, b) => b.poId - a.poId)
.map(
(po): PurchaseOrderSummary => ({
poId: po.poId,
docNo: po.docNo,
vendorId: po.vendorId,
status: po.status,
approvalRequired: po.approvalRequired,
createdAt: po.createdAt,
totals: po.totals,
})
)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
list(params: ListPurchaseOrdersParams = {}) {
return apiRequest<PagedResponse<PurchaseOrderSummary>>(`/purchase-orders${buildQuery(params)}`)
},
get(poId: number): Promise<PurchaseOrder> {
const po = mockPurchaseOrders.find((p) => p.poId === poId)
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
return mockDelay(po)
get(poId: number) {
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}`)
},
getWithETag(poId: number): Promise<ApiResult<PurchaseOrder>> {
const po = mockPurchaseOrders.find((p) => p.poId === poId)
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
return mockDelay({ data: po, etag: String(getPoVersion(poId)) })
getWithETag(poId: number) {
return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`)
},
create(request: CreatePurchaseOrderRequest): Promise<ApiResult<PurchaseOrder>> {
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
const poId = allocatePoId()
// FR-PROC-04: approvalRequired defaults false → auto-approved on creation.
const po: PurchaseOrder = {
poId,
docNo: `PO-2026-${String(poId).padStart(5, "0")}`,
vendorId: request.vendorId,
requisitionId: request.requisitionId ?? null,
status: "Approved",
approvalRequired: false,
createdBy: 17,
createdAt: new Date().toISOString(),
updatedAt: null,
totals: computeTotals(request.lines),
lines: request.lines.map((l, i) => ({
poLineId: 900 + poId * 10 + i,
itemId: l.itemId,
uomId: l.uomId,
warehouseId: l.warehouseId,
qty: l.qty,
unitPrice: l.unitPrice,
tax: l.tax,
qtyReceived: 0,
})),
}
mockPurchaseOrders.push(po)
initPoVersion(poId)
return mockDelay({ data: po, etag: "1" })
create(request: CreatePurchaseOrderRequest) {
return apiRequestWithETag<PurchaseOrder>("/purchase-orders", { method: "POST", body: request })
},
update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string): Promise<ApiResult<PurchaseOrder>> {
const po = mockPurchaseOrders.find((p) => p.poId === poId)
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
if (!isPoEditable(po.status)) {
return Promise.reject(Object.assign(new Error(`${po.docNo} is ${po.status} and can no longer be edited.`), { code: "PO_NOT_EDITABLE" }))
}
if (String(getPoVersion(poId)) !== ifMatch) {
return Promise.reject(Object.assign(new Error("The purchase order was modified by another request."), { code: "CONCURRENCY_CONFLICT" }))
}
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
const priorQtyReceived = new Map(po.lines.map((l) => [l.poLineId, l.qtyReceived]))
po.vendorId = request.vendorId
po.requisitionId = request.requisitionId ?? null
po.totals = computeTotals(request.lines, po.totals.currency)
po.lines = request.lines.map((l, i) => {
// Preserve qtyReceived for lines that already existed (edit-while-open must not erase receipt progress).
const existingLineId = po.lines[i]?.poLineId
return {
poLineId: existingLineId ?? 900 + poId * 10 + i,
itemId: l.itemId,
uomId: l.uomId,
warehouseId: l.warehouseId,
qty: l.qty,
unitPrice: l.unitPrice,
tax: l.tax,
qtyReceived: existingLineId ? (priorQtyReceived.get(existingLineId) ?? 0) : 0,
}
})
po.updatedAt = new Date().toISOString()
const next = bumpPoVersion(poId)
return mockDelay({ data: po, etag: String(next) })
update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string) {
return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`, { method: "PUT", body: request, ifMatch })
},
cancel(poId: number, request: CancelPurchaseOrderRequest): Promise<PurchaseOrder> {
const po = mockPurchaseOrders.find((p) => p.poId === poId)
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
if (po.lines.some((l) => l.qtyReceived > 0)) {
return Promise.reject(new Error(`${po.docNo} has receipts against it and can no longer be cancelled.`))
}
if (!request.reason.trim()) return Promise.reject(new Error("A cancellation reason is required."))
po.status = "Cancelled"
po.updatedAt = new Date().toISOString()
bumpPoVersion(poId)
return mockDelay(po)
cancel(poId: number, request: CancelPurchaseOrderRequest) {
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request })
},
}
+9 -103
View File
@@ -1,113 +1,19 @@
// One typed client method per Purchase Return endpoint (docs/11-BACKEND-PHASE1.md §3.4, FR-PROC-08).
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with in-memory sample data (lib/api/mock-data.ts) so the Procurement
// screens can be reviewed without a running backend. Restore the commented block
// and delete the mock block once Backend/PROGRESS.md §2 (Procurement) exists.
// Note: no PurchaseReturnsController exists yet (Backend/PROGRESS.md §2: "deferred —
// needs GRN lines + stock ledger/FIFO"). These calls will 404 until that's built.
import { apiRequest } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreatePurchaseReturnRequest, PurchaseReturn, PurchaseReturnSummary } from "@/types/procurement"
import {
allocatePurchaseReturnId,
allocatePurchaseReturnLineId,
consumeLayerByGrnLine,
mockDelay,
mockPurchaseReturns,
postLedgerEntry,
} from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest } from "@/lib/api-client"
//
// export const purchaseReturnsApi = {
// list() {
// return apiRequest<PagedResponse<PurchaseReturnSummary>>("/purchase-returns")
// },
// get(returnId: number) {
// return apiRequest<PurchaseReturn>(`/purchase-returns/${returnId}`)
// },
// create(request: CreatePurchaseReturnRequest) {
// return apiRequest<PurchaseReturn>("/purchase-returns", { method: "POST", body: request })
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
function toSummary(r: PurchaseReturn): PurchaseReturnSummary {
return {
returnId: r.returnId,
docNo: r.docNo,
vendorId: r.vendorId,
warehouseId: r.warehouseId,
reasonCodeId: r.reasonCodeId,
status: r.status,
createdAt: r.createdAt,
}
}
export const purchaseReturnsApi = {
list(): Promise<PagedResponse<PurchaseReturnSummary>> {
const items = [...mockPurchaseReturns].sort((a, b) => b.returnId - a.returnId).map(toSummary)
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
list() {
return apiRequest<PagedResponse<PurchaseReturnSummary>>("/purchase-returns")
},
get(returnId: number): Promise<PurchaseReturn> {
const r = mockPurchaseReturns.find((x) => x.returnId === returnId)
if (!r) return Promise.reject(new Error(`Mock purchase return ${returnId} not found`))
return mockDelay(r)
get(returnId: number) {
return apiRequest<PurchaseReturn>(`/purchase-returns/${returnId}`)
},
create(request: CreatePurchaseReturnRequest): Promise<PurchaseReturn> {
if (!request.reasonCodeId) {
return Promise.reject(Object.assign(new Error("A reason code is required for returns."), { code: "REASON_CODE_REQUIRED" }))
}
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
const returnId = allocatePurchaseReturnId()
const ledgerRefs: number[] = []
const lines: PurchaseReturn["lines"] = []
try {
for (const line of request.lines) {
// FR-PROC-08: consumes the exact layer the GRN line created; throws
// StockNegativeError (409 STOCK_NEGATIVE_BLOCKED) if qty exceeds it.
const chunk = consumeLayerByGrnLine(line.grnLineId, line.qty)
const ledger = postLedgerEntry({
itemId: line.itemId,
warehouseId: chunk.warehouseId,
userId: 17,
direction: "Out",
qtyBase: chunk.qtyConsumed,
unitCost: chunk.unitCost,
sourceDocType: "PurchaseReturn",
sourceDocId: returnId,
})
ledgerRefs.push(ledger.ledgerId)
lines.push({
returnLineId: allocatePurchaseReturnLineId(),
grnLineId: line.grnLineId,
itemId: line.itemId,
qty: line.qty,
})
}
} catch (err) {
return Promise.reject(err)
}
const purchaseReturn: PurchaseReturn = {
returnId,
docNo: `PRET-2026-${String(returnId).padStart(5, "0")}`,
vendorId: request.vendorId,
warehouseId: request.warehouseId,
reasonCodeId: request.reasonCodeId,
status: "Posted",
createdBy: 17,
createdAt: new Date().toISOString(),
lines,
ledgerRefs,
}
mockPurchaseReturns.push(purchaseReturn)
return mockDelay(purchaseReturn)
create(request: CreatePurchaseReturnRequest) {
return apiRequest<PurchaseReturn>("/purchase-returns", { method: "POST", body: request })
},
}
+5 -19
View File
@@ -1,27 +1,13 @@
// One typed client method for reference data (docs/11-BACKEND-PHASE1.md §6).
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with in-memory sample data (lib/api/mock-data.ts).
// Note: no ReasonCodesController exists yet — this will 404 until Stock
// Transactions (Backend/PROGRESS.md §5) is built.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { ReasonCode, ReasonCodeContext } from "@/types/stock"
import { mockDelay, mockReasonCodes } from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest, buildQuery } from "@/lib/api-client"
//
// export const reasonCodesApi = {
// list(context?: ReasonCodeContext) {
// return apiRequest<PagedResponse<ReasonCode>>(`/reason-codes${buildQuery({ context })}`)
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
export const reasonCodesApi = {
list(context?: ReasonCodeContext): Promise<PagedResponse<ReasonCode>> {
const items = mockReasonCodes.filter((r) => !context || r.context === context)
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
list(context?: ReasonCodeContext) {
return apiRequest<PagedResponse<ReasonCode>>(`/reason-codes${buildQuery({ context })}`)
},
}
+9 -87
View File
@@ -1,103 +1,25 @@
// One typed client method per Requisition endpoint (docs/11-BACKEND-PHASE1.md §3.1, FR-PROC-01).
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with in-memory sample data (lib/api/mock-data.ts) so the Procurement
// screens can be reviewed without a running backend. Restore the commented block
// and delete the mock block once Backend/PROGRESS.md §2 (Procurement) exists.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreateRequisitionRequest, Requisition, RequisitionStatus, RequisitionSummary } from "@/types/procurement"
import { allocateReqLineId, allocateRequisitionId, mockDelay, mockRequisitions } from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest, buildQuery } from "@/lib/api-client"
//
// export interface ListRequisitionsParams {
// page?: number
// pageSize?: number
// status?: RequisitionStatus
// }
//
// export const requisitionsApi = {
// list(params: ListRequisitionsParams = {}) {
// return apiRequest<PagedResponse<RequisitionSummary>>(`/requisitions${buildQuery(params)}`)
// },
// get(requisitionId: number) {
// return apiRequest<Requisition>(`/requisitions/${requisitionId}`)
// },
// create(request: CreateRequisitionRequest) {
// return apiRequest<Requisition>("/requisitions", { method: "POST", body: request })
// },
// submit(requisitionId: number) {
// return apiRequest<Requisition>(`/requisitions/${requisitionId}/submit`, { method: "POST", body: {} })
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
export interface ListRequisitionsParams {
page?: number
pageSize?: number
status?: RequisitionStatus
}
function toSummary(r: Requisition): RequisitionSummary {
return {
requisitionId: r.requisitionId,
docNo: r.docNo,
status: r.status,
requestedBy: r.requestedBy,
createdAt: r.createdAt,
lineCount: r.lines.length,
}
}
export const requisitionsApi = {
list(params: ListRequisitionsParams = {}): Promise<PagedResponse<RequisitionSummary>> {
const filtered = mockRequisitions
.filter((r) => !params.status || r.status === params.status)
.map(toSummary)
.sort((a, b) => b.requisitionId - a.requisitionId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
list(params: ListRequisitionsParams = {}) {
return apiRequest<PagedResponse<RequisitionSummary>>(`/requisitions${buildQuery(params)}`)
},
get(requisitionId: number): Promise<Requisition> {
const r = mockRequisitions.find((x) => x.requisitionId === requisitionId)
if (!r) return Promise.reject(new Error(`Mock requisition ${requisitionId} not found`))
return mockDelay(r)
get(requisitionId: number) {
return apiRequest<Requisition>(`/requisitions/${requisitionId}`)
},
create(request: CreateRequisitionRequest): Promise<Requisition> {
if (request.lines.length === 0) {
return Promise.reject(new Error("A requisition needs at least one line."))
}
const requisition: Requisition = {
requisitionId: allocateRequisitionId(),
docNo: "",
status: "Draft",
requestedBy: 17,
createdAt: new Date().toISOString(),
lines: request.lines.map((l) => ({ reqLineId: allocateReqLineId(), itemId: l.itemId, qty: l.qty, requiredBy: l.requiredBy })),
}
requisition.docNo = `PR-2026-${String(requisition.requisitionId).padStart(5, "0")}`
mockRequisitions.push(requisition)
return mockDelay(requisition)
create(request: CreateRequisitionRequest) {
return apiRequest<Requisition>("/requisitions", { method: "POST", body: request })
},
submit(requisitionId: number): Promise<Requisition> {
const r = mockRequisitions.find((x) => x.requisitionId === requisitionId)
if (!r) return Promise.reject(new Error(`Mock requisition ${requisitionId} not found`))
if (r.status !== "Draft") {
return Promise.reject(new Error(`${r.docNo} has already been submitted.`))
}
r.status = "Submitted"
return mockDelay(r)
submit(requisitionId: number) {
return apiRequest<Requisition>(`/requisitions/${requisitionId}/submit`, { method: "POST", body: {} })
},
}
+15 -115
View File
@@ -1,126 +1,26 @@
// One typed client method per RFQ/Quotation endpoint (docs/11-BACKEND-PHASE1.md §3.2, FR-PROC-02).
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with in-memory sample data (lib/api/mock-data.ts) so the Procurement
// screens can be reviewed without a running backend. Restore the commented block
// and delete the mock block once Backend/PROGRESS.md §2 (Procurement) exists.
// Note: `list()` calls GET /rfqs, which is not implemented by RfqsController
// (only GET /rfqs/{id} exists) — see Backend/PROGRESS.md §2. This will 404
// until that endpoint is added; flagged here rather than silently faked.
import { apiRequest } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
CreateQuotationRequest,
CreateRfqRequest,
Quotation,
Rfq,
RfqComparison,
RfqComparisonLine,
RfqSummary,
} from "@/types/procurement"
import {
allocateQuotationId,
allocateRfqId,
allocateRfqLineId,
mockDelay,
mockQuotations,
mockRfqs,
} from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest } from "@/lib/api-client"
//
// export const rfqsApi = {
// list() {
// return apiRequest<PagedResponse<RfqSummary>>("/rfqs")
// },
// get(rfqId: number) {
// return apiRequest<Rfq>(`/rfqs/${rfqId}`)
// },
// create(request: CreateRfqRequest) {
// return apiRequest<Rfq>("/rfqs", { method: "POST", body: request })
// },
// addQuotation(rfqId: number, request: CreateQuotationRequest) {
// return apiRequest<Quotation>(`/rfqs/${rfqId}/quotations`, { method: "POST", body: request })
// },
// comparison(rfqId: number) {
// return apiRequest<RfqComparison>(`/rfqs/${rfqId}/comparison`)
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
function toSummary(r: Rfq): RfqSummary {
return {
rfqId: r.rfqId,
docNo: r.docNo,
requisitionId: r.requisitionId,
status: r.status,
vendorIds: r.vendorIds,
createdAt: r.createdAt,
}
}
import { CreateQuotationRequest, CreateRfqRequest, Quotation, Rfq, RfqComparison, RfqSummary } from "@/types/procurement"
export const rfqsApi = {
list(): Promise<PagedResponse<RfqSummary>> {
const items = [...mockRfqs].sort((a, b) => b.rfqId - a.rfqId).map(toSummary)
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
list() {
return apiRequest<PagedResponse<RfqSummary>>("/rfqs")
},
get(rfqId: number): Promise<Rfq> {
const r = mockRfqs.find((x) => x.rfqId === rfqId)
if (!r) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
return mockDelay(r)
get(rfqId: number) {
return apiRequest<Rfq>(`/rfqs/${rfqId}`)
},
create(request: CreateRfqRequest): Promise<Rfq> {
if (request.vendorIds.length === 0) return Promise.reject(new Error("Select at least one vendor."))
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
const rfq: Rfq = {
rfqId: allocateRfqId(),
docNo: "",
requisitionId: request.requisitionId ?? null,
status: "Open",
vendorIds: request.vendorIds,
createdAt: new Date().toISOString(),
lines: request.lines.map((l) => ({ rfqLineId: allocateRfqLineId(), itemId: l.itemId, qty: l.qty })),
}
rfq.docNo = `RFQ-2026-${String(rfq.rfqId).padStart(5, "0")}`
mockRfqs.push(rfq)
return mockDelay(rfq)
create(request: CreateRfqRequest) {
return apiRequest<Rfq>("/rfqs", { method: "POST", body: request })
},
addQuotation(rfqId: number, request: CreateQuotationRequest): Promise<Quotation> {
const rfq = mockRfqs.find((x) => x.rfqId === rfqId)
if (!rfq) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
if (!rfq.vendorIds.includes(request.vendorId)) {
return Promise.reject(new Error("This vendor was not invited to the RFQ."))
}
const quotation: Quotation = {
quotationId: allocateQuotationId(),
rfqId,
vendorId: request.vendorId,
createdAt: new Date().toISOString(),
lines: request.lines,
}
mockQuotations.push(quotation)
return mockDelay(quotation)
addQuotation(rfqId: number, request: CreateQuotationRequest) {
return apiRequest<Quotation>(`/rfqs/${rfqId}/quotations`, { method: "POST", body: request })
},
comparison(rfqId: number): Promise<RfqComparison> {
const rfq = mockRfqs.find((x) => x.rfqId === rfqId)
if (!rfq) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
const quotations = mockQuotations.filter((q) => q.rfqId === rfqId)
const lines: RfqComparisonLine[] = rfq.lines.map((rfqLine) => ({
itemId: rfqLine.itemId,
qty: rfqLine.qty,
cells: quotations
.map((q) => {
const line = q.lines.find((l) => l.itemId === rfqLine.itemId)
return line ? { vendorId: q.vendorId, unitPrice: line.unitPrice, leadDays: line.leadDays } : null
})
.filter((c): c is { vendorId: number; unitPrice: number; leadDays: number } => c !== null),
}))
return mockDelay({ rfqId, vendorIds: rfq.vendorIds, lines })
comparison(rfqId: number) {
return apiRequest<RfqComparison>(`/rfqs/${rfqId}/comparison`)
},
}
+11 -136
View File
@@ -1,152 +1,27 @@
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5).
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with the in-memory Stock Core (lib/api/mock-data.ts). Restore the
// commented block and delete the mock block once Backend/PROGRESS.md §5
// (stock transactions) exists. GET /stock-adjustments and GET
// /stock-adjustments/{id} are not documented in docs/11 §5.5 — same
// Note: no Stock Transactions backend exists yet (Backend/PROGRESS.md §5). These
// calls will 404 until that's built. GET /stock-adjustments and GET
// /stock-adjustments/{id} are also not documented in docs/11 §5.5 — same
// assumed-extension deviation as GRN (see Frontend/PROGRESS.md §5).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { AdjustmentStatus, CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
import {
allocateAdjLineId,
allocateAdjustmentId,
consumeFifo,
lastKnownCost,
mockDelay,
mockStockAdjustments,
postLedgerEntry,
receiveLayer,
} from "@/lib/api/mock-data"
import { CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest, buildQuery } from "@/lib/api-client"
//
// export interface ListAdjustmentsParams {
// page?: number
// pageSize?: number
// warehouseId?: number
// }
//
// export const stockAdjustmentsApi = {
// list(params: ListAdjustmentsParams = {}) {
// return apiRequest<PagedResponse<StockAdjustmentSummary>>(`/stock-adjustments${buildQuery(params)}`)
// },
// get(adjustmentId: number) {
// return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}`)
// },
// create(request: CreateAdjustmentRequest) {
// return apiRequest<StockAdjustment>("/stock-adjustments", { method: "POST", body: request })
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
export interface ListAdjustmentsParams {
page?: number
pageSize?: number
warehouseId?: number
}
function toSummary(a: (typeof mockStockAdjustments)[number]): StockAdjustmentSummary {
return {
adjustmentId: a.adjustmentId,
docNo: a.docNo,
warehouseId: a.warehouseId,
reasonCodeId: a.reasonCodeId,
status: a.status,
createdAt: a.createdAt,
}
}
export const stockAdjustmentsApi = {
list(params: ListAdjustmentsParams = {}): Promise<PagedResponse<StockAdjustmentSummary>> {
const filtered = mockStockAdjustments
.filter((a) => !params.warehouseId || a.warehouseId === params.warehouseId)
.map(toSummary)
.sort((a, b) => b.adjustmentId - a.adjustmentId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
list(params: ListAdjustmentsParams = {}) {
return apiRequest<PagedResponse<StockAdjustmentSummary>>(`/stock-adjustments${buildQuery(params)}`)
},
get(adjustmentId: number): Promise<StockAdjustment> {
const a = mockStockAdjustments.find((x) => x.adjustmentId === adjustmentId)
if (!a) return Promise.reject(new Error(`Mock adjustment ${adjustmentId} not found`))
return mockDelay(a)
get(adjustmentId: number) {
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}`)
},
create(request: CreateAdjustmentRequest): Promise<StockAdjustment> {
if (!request.reasonCodeId) {
return Promise.reject(Object.assign(new Error("A reason code is required for adjustments."), { code: "REASON_CODE_REQUIRED" }))
}
const adjustmentId = allocateAdjustmentId()
const ledgerRefs: number[] = []
const lines: StockAdjustment["lines"] = []
try {
for (const line of request.lines) {
const adjLineId = allocateAdjLineId()
lines.push({ adjLineId, itemId: line.itemId, binId: line.binId ?? null, batchId: line.batchId ?? null, qtyDelta: line.qtyDelta })
if (line.qtyDelta > 0) {
// FR-STK-07: increase creates a layer at the last known cost.
const unitCost = lastKnownCost(line.itemId, request.warehouseId)
const { ledger } = receiveLayer({
itemId: line.itemId,
warehouseId: request.warehouseId,
binId: line.binId,
batchId: line.batchId,
qty: line.qtyDelta,
unitCost,
userId: 17,
sourceDocType: "Adjustment",
sourceDocId: adjustmentId,
})
ledgerRefs.push(ledger.ledgerId)
} else if (line.qtyDelta < 0) {
// Decrease consumes FIFO layers (409 STOCK_NEGATIVE_BLOCKED if insufficient).
const chunks = consumeFifo(line.itemId, request.warehouseId, Math.abs(line.qtyDelta))
for (const chunk of chunks) {
const ledger = postLedgerEntry({
itemId: line.itemId,
warehouseId: request.warehouseId,
binId: line.binId,
batchId: line.batchId,
userId: 17,
direction: "Out",
qtyBase: chunk.qtyConsumed,
unitCost: chunk.unitCost,
sourceDocType: "Adjustment",
sourceDocId: adjustmentId,
})
ledgerRefs.push(ledger.ledgerId)
}
}
}
} catch (err) {
return Promise.reject(err)
}
const adjustment = {
adjustmentId,
docNo: `ADJ-2026-${String(adjustmentId).padStart(5, "0")}`,
warehouseId: request.warehouseId,
reasonCodeId: request.reasonCodeId,
status: "Posted" as AdjustmentStatus,
createdBy: 17,
createdAt: new Date().toISOString(),
lines,
ledgerRefs,
}
mockStockAdjustments.push(adjustment)
return mockDelay(adjustment)
create(request: CreateAdjustmentRequest) {
return apiRequest<StockAdjustment>("/stock-adjustments", { method: "POST", body: request })
},
}
+15 -173
View File
@@ -1,14 +1,12 @@
// One typed client method per count endpoint (docs/11-BACKEND-PHASE1.md §5.6).
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with the in-memory Stock Core (lib/api/mock-data.ts). Restore the
// commented block and delete the mock block once Backend/PROGRESS.md §5
// (stock transactions) exists. GET /stock-counts and GET /stock-counts/{id}
// are not documented in docs/11 §5.6 — same assumed-extension deviation as
// GRN (see Frontend/PROGRESS.md §5).
// Note: no Stock Transactions backend exists yet (Backend/PROGRESS.md §5). These
// calls will 404 until that's built. GET /stock-counts and GET
// /stock-counts/{id} are also not documented in docs/11 §5.6 — same
// assumed-extension deviation as GRN (see Frontend/PROGRESS.md §5).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
CountStatus,
CreateCountRequest,
EnterCountsRequest,
EnterCountsResponse,
@@ -16,183 +14,27 @@ import {
StockCount,
StockCountSummary,
} from "@/types/stock"
import {
allocateAdjLineId,
allocateAdjustmentId,
allocateCountId,
allocateCountLineId,
computeOnHand,
consumeFifo,
lastKnownCost,
mockDelay,
mockStockAdjustments,
mockStockCounts,
postLedgerEntry,
receiveLayer,
} from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest, buildQuery } from "@/lib/api-client"
//
// export interface ListCountsParams {
// page?: number
// pageSize?: number
// warehouseId?: number
// }
//
// export const stockCountsApi = {
// list(params: ListCountsParams = {}) {
// return apiRequest<PagedResponse<StockCountSummary>>(`/stock-counts${buildQuery(params)}`)
// },
// get(countId: number) {
// return apiRequest<StockCount>(`/stock-counts/${countId}`)
// },
// create(request: CreateCountRequest) {
// return apiRequest<StockCount>("/stock-counts", { method: "POST", body: request })
// },
// enterCounts(countId: number, request: EnterCountsRequest) {
// return apiRequest<EnterCountsResponse>(`/stock-counts/${countId}/counts`, { method: "PUT", body: request })
// },
// post(countId: number) {
// return apiRequest<PostCountResponse>(`/stock-counts/${countId}/post`, { method: "POST", body: {} })
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
export interface ListCountsParams {
page?: number
pageSize?: number
warehouseId?: number
}
function toSummary(c: (typeof mockStockCounts)[number]): StockCountSummary {
return {
countId: c.countId,
docNo: c.docNo,
warehouseId: c.warehouseId,
countType: c.countType,
status: c.status,
createdAt: c.createdAt,
}
}
export const stockCountsApi = {
list(params: ListCountsParams = {}): Promise<PagedResponse<StockCountSummary>> {
const filtered = mockStockCounts
.filter((c) => !params.warehouseId || c.warehouseId === params.warehouseId)
.map(toSummary)
.sort((a, b) => b.countId - a.countId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
list(params: ListCountsParams = {}) {
return apiRequest<PagedResponse<StockCountSummary>>(`/stock-counts${buildQuery(params)}`)
},
get(countId: number): Promise<StockCount> {
const c = mockStockCounts.find((x) => x.countId === countId)
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
return mockDelay(c)
get(countId: number) {
return apiRequest<StockCount>(`/stock-counts/${countId}`)
},
create(request: CreateCountRequest): Promise<StockCount> {
const countId = allocateCountId()
const count = {
countId,
docNo: `CNT-2026-${String(countId).padStart(5, "0")}`,
warehouseId: request.warehouseId,
countType: request.countType,
status: "Draft" as CountStatus,
createdBy: 17,
createdAt: new Date().toISOString(),
lines: request.itemIds.map((itemId) => ({
countLineId: allocateCountLineId(),
itemId,
binId: null,
systemQty: computeOnHand(itemId, request.warehouseId).onHand,
countedQty: null,
variance: null,
})),
}
mockStockCounts.push(count)
return mockDelay(count)
create(request: CreateCountRequest) {
return apiRequest<StockCount>("/stock-counts", { method: "POST", body: request })
},
enterCounts(countId: number, request: EnterCountsRequest): Promise<EnterCountsResponse> {
const c = mockStockCounts.find((x) => x.countId === countId)
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
for (const input of request.lines) {
const line = c.lines.find((l) => l.countLineId === input.countLineId)
if (!line) continue
line.countedQty = input.countedQty
line.variance = Math.round((input.countedQty - line.systemQty) * 100) / 100
}
return mockDelay({ lines: c.lines })
enterCounts(countId: number, request: EnterCountsRequest) {
return apiRequest<EnterCountsResponse>(`/stock-counts/${countId}/counts`, { method: "PUT", body: request })
},
post(countId: number): Promise<PostCountResponse> {
const c = mockStockCounts.find((x) => x.countId === countId)
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
if (c.status === "Posted") return Promise.reject(new Error(`${c.docNo} has already been posted.`))
const adjustmentId = allocateAdjustmentId()
const ledgerRefs: number[] = []
const adjLines: { adjLineId: number; itemId: number; binId: number | null; batchId: number | null; qtyDelta: number }[] = []
for (const line of c.lines) {
if (!line.variance) continue
adjLines.push({ adjLineId: allocateAdjLineId(), itemId: line.itemId, binId: line.binId, batchId: null, qtyDelta: line.variance })
if (line.variance > 0) {
const unitCost = lastKnownCost(line.itemId, c.warehouseId)
const { ledger } = receiveLayer({
itemId: line.itemId,
warehouseId: c.warehouseId,
qty: line.variance,
unitCost,
userId: 17,
sourceDocType: "Count",
sourceDocId: c.countId,
})
ledgerRefs.push(ledger.ledgerId)
} else {
const chunks = consumeFifo(line.itemId, c.warehouseId, Math.abs(line.variance))
for (const chunk of chunks) {
const ledger = postLedgerEntry({
itemId: line.itemId,
warehouseId: c.warehouseId,
userId: 17,
direction: "Out",
qtyBase: chunk.qtyConsumed,
unitCost: chunk.unitCost,
sourceDocType: "Count",
sourceDocId: c.countId,
})
ledgerRefs.push(ledger.ledgerId)
}
}
}
// Count Variance reason code (docs/8.3 seed list) — posted as its own adjustment record.
mockStockAdjustments.push({
adjustmentId,
docNo: `ADJ-2026-${String(adjustmentId).padStart(5, "0")}`,
warehouseId: c.warehouseId,
reasonCodeId: 3,
status: "Posted",
createdBy: 17,
createdAt: new Date().toISOString(),
lines: adjLines,
ledgerRefs,
})
c.status = "Posted"
return mockDelay({ countId: c.countId, status: c.status, adjustmentId, ledgerRefs })
post(countId: number) {
return apiRequest<PostCountResponse>(`/stock-counts/${countId}/post`, { method: "POST", body: {} })
},
}
+21 -196
View File
@@ -1,11 +1,10 @@
// One typed client method per transfer endpoint (docs/11-BACKEND-PHASE1.md §5.4).
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with the in-memory Stock Core (lib/api/mock-data.ts). Restore the
// commented block and delete the mock block once Backend/PROGRESS.md §5
// (stock transactions) exists. GET /stock-transfers and GET /stock-transfers/{id}
// are not documented in docs/11 §5.4 — same assumed-extension deviation as GRN
// (see Frontend/PROGRESS.md §5).
// Note: no Stock Transactions backend exists yet (Backend/PROGRESS.md §5). These
// calls will 404 until that's built. GET /stock-transfers and GET
// /stock-transfers/{id} are also not documented in docs/11 §5.4 — same
// assumed-extension deviation as GRN (see Frontend/PROGRESS.md §5).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
CreateTransferRequest,
@@ -16,50 +15,7 @@ import {
StockTransferSummary,
TransferStatus,
} from "@/types/stock"
import {
MockTransferLine,
allocateTransferId,
allocateTransferLineId,
consumeFifo,
mockDelay,
mockStockTransfers,
postLedgerEntry,
receiveLayer,
} from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest, buildQuery } from "@/lib/api-client"
//
// export interface ListTransfersParams {
// page?: number
// pageSize?: number
// status?: TransferStatus
// srcWarehouseId?: number
// destWarehouseId?: number
// }
//
// export const stockTransfersApi = {
// list(params: ListTransfersParams = {}) {
// return apiRequest<PagedResponse<StockTransferSummary>>(`/stock-transfers${buildQuery(params)}`)
// },
// get(transferId: number) {
// return apiRequest<StockTransfer>(`/stock-transfers/${transferId}`)
// },
// create(request: CreateTransferRequest) {
// return apiRequest<StockTransfer>("/stock-transfers", { method: "POST", body: request })
// },
// dispatch(transferId: number) {
// return apiRequest<DispatchTransferResponse>(`/stock-transfers/${transferId}/dispatch`, { method: "POST", body: {} })
// },
// receive(transferId: number, lines: ReceiveTransferLineInput[]) {
// return apiRequest<ReceiveTransferResponse>(`/stock-transfers/${transferId}/receive`, {
// method: "POST",
// body: { lines },
// })
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
export interface ListTransfersParams {
page?: number
pageSize?: number
@@ -68,154 +24,23 @@ export interface ListTransfersParams {
destWarehouseId?: number
}
function toPublicLine(line: MockTransferLine) {
return {
transferLineId: line.transferLineId,
itemId: line.itemId,
srcBinId: line.srcBinId,
destBinId: line.destBinId,
batchId: line.batchId,
qty: line.qty,
}
}
function toSummary(t: (typeof mockStockTransfers)[number]): StockTransferSummary {
return {
transferId: t.transferId,
docNo: t.docNo,
srcWarehouseId: t.srcWarehouseId,
destWarehouseId: t.destWarehouseId,
status: t.status,
createdAt: t.createdAt,
}
}
function toPublic(t: (typeof mockStockTransfers)[number]): StockTransfer {
return { ...toSummary(t), createdBy: t.createdBy, lines: t.lines.map(toPublicLine) }
}
export const stockTransfersApi = {
list(params: ListTransfersParams = {}): Promise<PagedResponse<StockTransferSummary>> {
const filtered = mockStockTransfers
.filter((t) => !params.status || t.status === params.status)
.filter((t) => !params.srcWarehouseId || t.srcWarehouseId === params.srcWarehouseId)
.filter((t) => !params.destWarehouseId || t.destWarehouseId === params.destWarehouseId)
.map(toSummary)
.sort((a, b) => b.transferId - a.transferId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
list(params: ListTransfersParams = {}) {
return apiRequest<PagedResponse<StockTransferSummary>>(`/stock-transfers${buildQuery(params)}`)
},
get(transferId: number) {
return apiRequest<StockTransfer>(`/stock-transfers/${transferId}`)
},
create(request: CreateTransferRequest) {
return apiRequest<StockTransfer>("/stock-transfers", { method: "POST", body: request })
},
dispatch(transferId: number) {
return apiRequest<DispatchTransferResponse>(`/stock-transfers/${transferId}/dispatch`, { method: "POST", body: {} })
},
receive(transferId: number, lines: ReceiveTransferLineInput[]) {
return apiRequest<ReceiveTransferResponse>(`/stock-transfers/${transferId}/receive`, {
method: "POST",
body: { lines },
})
},
get(transferId: number): Promise<StockTransfer> {
const t = mockStockTransfers.find((x) => x.transferId === transferId)
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
return mockDelay(toPublic(t))
},
create(request: CreateTransferRequest): Promise<StockTransfer> {
const transferId = allocateTransferId()
const t = {
transferId,
docNo: `TRF-2026-${String(transferId).padStart(5, "0")}`,
srcWarehouseId: request.srcWarehouseId,
destWarehouseId: request.destWarehouseId,
status: "Draft" as TransferStatus,
createdBy: 17,
createdAt: new Date().toISOString(),
lines: request.lines.map((l) => ({
transferLineId: allocateTransferLineId(),
itemId: l.itemId,
srcBinId: l.srcBinId ?? null,
destBinId: l.destBinId ?? null,
batchId: l.batchId ?? null,
qty: l.qty,
dispatchedChunks: [],
})),
}
mockStockTransfers.push(t)
return mockDelay(toPublic(t))
},
dispatch(transferId: number): Promise<DispatchTransferResponse> {
const t = mockStockTransfers.find((x) => x.transferId === transferId)
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
if (t.status !== "Draft") return Promise.reject(new Error(`${t.docNo} has already been dispatched.`))
const consumedLayers: { layerId: number; qtyConsumed: number; unitCost: number }[] = []
const ledgerRefs: number[] = []
try {
for (const line of t.lines) {
const chunks = consumeFifo(line.itemId, t.srcWarehouseId, line.qty)
line.dispatchedChunks = chunks
for (const chunk of chunks) {
const ledger = postLedgerEntry({
itemId: line.itemId,
warehouseId: t.srcWarehouseId,
binId: line.srcBinId,
batchId: line.batchId,
userId: t.createdBy,
direction: "Out",
qtyBase: chunk.qtyConsumed,
unitCost: chunk.unitCost,
sourceDocType: "Transfer",
sourceDocId: t.transferId,
})
consumedLayers.push(chunk)
ledgerRefs.push(ledger.ledgerId)
}
}
} catch (err) {
return Promise.reject(err)
}
t.status = "InTransit"
return mockDelay({ transferId: t.transferId, status: t.status, consumedLayers, ledgerRefs })
},
receive(transferId: number, lines: ReceiveTransferLineInput[]): Promise<ReceiveTransferResponse> {
const t = mockStockTransfers.find((x) => x.transferId === transferId)
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
if (t.status !== "InTransit") return Promise.reject(new Error(`${t.docNo} is not in transit.`))
const createdLayers: { layerId: number; warehouseId: number; qtyReceived: number; unitCost: number }[] = []
const ledgerRefs: number[] = []
for (const input of lines) {
const line = t.lines.find((l) => l.transferLineId === input.transferLineId)
if (!line) continue
// Cost-preserving (FR-STK-06): one destination layer per dispatched chunk, at its exact source cost.
for (const chunk of line.dispatchedChunks) {
const { layer, ledger } = receiveLayer({
itemId: line.itemId,
warehouseId: t.destWarehouseId,
binId: line.destBinId,
batchId: line.batchId,
qty: chunk.qtyConsumed,
unitCost: chunk.unitCost,
userId: t.createdBy,
sourceDocType: "Transfer",
sourceDocId: t.transferId,
})
createdLayers.push({
layerId: layer.layerId,
warehouseId: layer.warehouseId,
qtyReceived: layer.qtyReceived,
unitCost: layer.unitCost,
})
ledgerRefs.push(ledger.ledgerId)
}
}
t.status = "Received"
return mockDelay({ transferId: t.transferId, status: t.status, createdLayers, ledgerRefs })
},
}
+29 -163
View File
@@ -1,62 +1,15 @@
// One typed client method per stock-enquiry endpoint (docs/11-BACKEND-PHASE1.md §5.1-5.3, §5.7).
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with the in-memory Stock Core (lib/api/mock-data.ts) so the Stock
// Management screens can be reviewed without a running backend. Restore the
// commented block and delete the mock block once Backend/PROGRESS.md §4
// (Stock Core) exists.
// Note: no Stock Core backend exists yet (Backend/PROGRESS.md §4 is unstarted).
// These calls will 404 until that's built. `onHandList`/`createReorderRequisition`
// are frontend-only conveniences, not documented endpoints (same posture as the
// GRN list/detail assumed-extension deviation, see Frontend/PROGRESS.md §5).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { LedgerEntry, OnHand, ReorderAlert, ReorderRequisitionResponse, Valuation } from "@/types/stock"
import {
allocateReqLineId,
allocateRequisitionId,
computeOnHand,
knownStockKeys,
mockDelay,
mockItemReorders,
mockRequisitions,
mockStockLayers,
mockStockLedger,
} from "@/lib/api/mock-data"
import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest, buildQuery } from "@/lib/api-client"
//
// export interface LedgerQuery {
// itemId?: number
// warehouseId?: number
// from?: string
// to?: string
// page?: number
// pageSize?: number
// }
//
// export const stockApi = {
// onHand(itemId: number, warehouseId: number) {
// return apiRequest<OnHand>(`/stock/on-hand${buildQuery({ itemId, warehouseId })}`)
// },
//
// ledger(params: LedgerQuery) {
// return apiRequest<PagedResponse<LedgerEntry>>(`/stock/ledger${buildQuery(params)}`)
// },
//
// valuation(itemId: number, warehouseId: number) {
// return apiRequest<Valuation>(`/stock/valuation${buildQuery({ itemId, warehouseId })}`)
// },
//
// reorderAlerts(warehouseId?: number) {
// return apiRequest<PagedResponse<ReorderAlert>>(`/stock/reorder-alerts${buildQuery({ warehouseId })}`)
// },
//
// createReorderRequisition(itemId: number, warehouseId: number) {
// return apiRequest<ReorderRequisitionResponse>(
// `/stock/reorder-alerts/${itemId}/requisition${buildQuery({ warehouseId })}`,
// { method: "POST", body: {} }
// )
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
export interface LedgerQuery {
itemId?: number
warehouseId?: number
@@ -67,124 +20,37 @@ export interface LedgerQuery {
}
export const stockApi = {
onHand(itemId: number, warehouseId: number): Promise<OnHand> {
const computed = computeOnHand(itemId, warehouseId)
return mockDelay({
itemId,
warehouseId,
...computed,
asOf: new Date().toISOString(),
})
onHand(itemId: number, warehouseId: number) {
return apiRequest<OnHand>(`/stock/on-hand${buildQuery({ itemId, warehouseId })}`)
},
/** Every item/warehouse pair currently on record — the Enquiry screen's row source. */
onHandList(): Promise<OnHand[]> {
const rows = knownStockKeys().map(({ itemId, warehouseId }) => ({
itemId,
warehouseId,
...computeOnHand(itemId, warehouseId),
asOf: new Date().toISOString(),
}))
return mockDelay(rows)
/** Every item x warehouse combination — composed client-side, not a documented endpoint. */
async onHandList(): Promise<OnHand[]> {
const [items, warehouses] = await Promise.all([
itemsApi.list({ pageSize: 200 }),
warehousesApi.list(),
])
const pairs = items.items.flatMap((item) => warehouses.items.map((wh) => ({ item, wh })))
const rows = await Promise.all(pairs.map(({ item, wh }) => stockApi.onHand(item.itemId, wh.warehouseId)))
return rows.filter((r) => r.onHand > 0 || r.onHold > 0 || r.inTransit > 0)
},
ledger(params: LedgerQuery): Promise<PagedResponse<LedgerEntry>> {
const from = params.from ? new Date(params.from).getTime() : null
const to = params.to ? new Date(params.to).getTime() : null
const filtered = mockStockLedger
.filter((e) => !params.itemId || e.itemId === params.itemId)
.filter((e) => !params.warehouseId || e.warehouseId === params.warehouseId)
.filter((e) => from === null || new Date(e.createdAt).getTime() >= from)
.filter((e) => to === null || new Date(e.createdAt).getTime() <= to)
.sort((a, b) => b.ledgerId - a.ledgerId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: {
page,
pageSize,
totalItems: filtered.length,
totalPages: pageSize <= 0 ? 0 : Math.ceil(filtered.length / pageSize),
},
})
ledger(params: LedgerQuery) {
return apiRequest<PagedResponse<LedgerEntry>>(`/stock/ledger${buildQuery(params)}`)
},
valuation(itemId: number, warehouseId: number): Promise<Valuation> {
const layers = mockStockLayers
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId && l.qtyRemaining > 0)
.sort((a, b) => new Date(a.receiptDate).getTime() - new Date(b.receiptDate).getTime())
.map((l) => ({
layerId: l.layerId,
qtyRemaining: l.qtyRemaining,
unitCost: l.unitCost,
value: Math.round(l.qtyRemaining * l.unitCost * 100) / 100,
receiptDate: l.receiptDate,
}))
const totalQty = layers.reduce((sum, l) => sum + l.qtyRemaining, 0)
const totalValue = Math.round(layers.reduce((sum, l) => sum + l.value, 0) * 100) / 100
return mockDelay({
itemId,
warehouseId,
layers,
totalQty,
totalValue,
currency: "LKR",
costingMethod: "FIFO",
})
valuation(itemId: number, warehouseId: number) {
return apiRequest<Valuation>(`/stock/valuation${buildQuery({ itemId, warehouseId })}`)
},
reorderAlerts(warehouseId?: number): Promise<PagedResponse<ReorderAlert>> {
const items = mockItemReorders
.filter((r) => !warehouseId || r.warehouseId === warehouseId)
.map((r) => ({ ...r, available: computeOnHand(r.itemId, r.warehouseId).available }))
.filter((r) => r.available <= r.reorderPoint)
.map((r) => ({
itemId: r.itemId,
warehouseId: r.warehouseId,
available: r.available,
reorderPoint: r.reorderPoint,
reorderQty: r.reorderQty,
suggestedRequisitionQty: r.reorderQty,
}))
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
reorderAlerts(warehouseId?: number) {
return apiRequest<PagedResponse<ReorderAlert>>(`/stock/reorder-alerts${buildQuery({ warehouseId })}`)
},
createReorderRequisition(itemId: number, warehouseId: number): Promise<ReorderRequisitionResponse> {
const setting = mockItemReorders.find((r) => r.itemId === itemId && r.warehouseId === warehouseId)
const qty = setting?.reorderQty ?? 0
const requisitionId = allocateRequisitionId()
const requiredBy = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
// Genuinely lands in the Requisitions list (§3), not a fabricated response —
// same "wire mock modules together" posture as GRN confirm → Stock Core.
mockRequisitions.push({
requisitionId,
docNo: `PR-2026-${String(requisitionId).padStart(5, "0")}`,
status: "Draft",
requestedBy: 17,
createdAt: new Date().toISOString(),
lines: [{ reqLineId: allocateReqLineId(), itemId, qty, requiredBy }],
})
return mockDelay({
requisitionId,
docNo: `PR-2026-${String(requisitionId).padStart(5, "0")}`,
itemId,
warehouseId,
qty,
status: "Draft",
})
createReorderRequisition(itemId: number, warehouseId: number) {
return apiRequest<ReorderRequisitionResponse>(
`/stock/reorder-alerts/${itemId}/requisition${buildQuery({ warehouseId })}`,
{ method: "POST", body: {} }
)
},
}
+6 -35
View File
@@ -1,43 +1,14 @@
// One typed client method per UOM endpoint (docs/11-BACKEND-PHASE1.md §2.2, FR-MD-02).
// `list` also backs the GRN/PO line UOM picker built in earlier sessions.
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens
// can be reviewed without a running backend. Restore the commented block and
// delete the mock block once Backend/PROGRESS.md §1 (Master Data) exists.
// `list` also backs the GRN/PO line UOM picker.
import { apiRequest } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreateUomRequest, Uom } from "@/types/master-data"
import { allocateUomId, mockDelay, mockUoms } from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest } from "@/lib/api-client"
//
// export const uomsApi = {
// list() {
// return apiRequest<PagedResponse<Uom>>("/uoms")
// },
// create(request: CreateUomRequest) {
// return apiRequest<Uom>("/uoms", { method: "POST", body: request })
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
export const uomsApi = {
list(): Promise<PagedResponse<Uom>> {
return mockDelay({
items: mockUoms,
pagination: { page: 1, pageSize: 20, totalItems: mockUoms.length, totalPages: 1 },
})
list() {
return apiRequest<PagedResponse<Uom>>("/uoms")
},
create(request: CreateUomRequest): Promise<Uom> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("UOM name is required."))
if (mockUoms.some((u) => u.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(Object.assign(new Error(`UOM "${name}" already exists.`), { code: "SKU_DUPLICATE" }))
}
const uom: Uom = { uomId: allocateUomId(), name }
mockUoms.push(uom)
return mockDelay(uom)
create(request: CreateUomRequest) {
return apiRequest<Uom>("/uoms", { method: "POST", body: request })
},
}
+13 -137
View File
@@ -1,63 +1,10 @@
// One typed client method per vendor (supplier) endpoint (docs/11-BACKEND-PHASE1.md
// §2.4, FR-MD-06). "GET/PUT/PATCH follow the Item pattern" per the doc — ETag/If-Match
// on update, PATCH status for deactivate (masters are deactivated, not hard-deleted,
// FR-MD-08).
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with in-memory sample data (lib/api/mock-data.ts) so these screens can
// be reviewed without a running backend. Restore the commented block and delete
// the mock block once Backend/PROGRESS.md §1 (Master Data) exists.
import { ApiResult } from "@/lib/api-client"
// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate (masters
// are deactivated, not hard-deleted, FR-MD-08).
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { EntityStatus, PagedResponse } from "@/types/common"
import { Vendor } from "@/types/master-data"
import {
allocateVendorId,
bumpVendorVersion,
getVendorVersion,
initVendorVersion,
mockDelay,
mockVendors,
} from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
//
// export interface ListVendorsParams {
// page?: number
// pageSize?: number
// q?: string
// status?: EntityStatus
// }
//
// export interface CreateVendorRequest {
// code: string
// name: string
// terms?: string | null
// taxReg?: string | null
// currency: string
// }
//
// export type UpdateVendorRequest = CreateVendorRequest
//
// export const vendorsApi = {
// list(params: ListVendorsParams = {}) {
// return apiRequest<PagedResponse<Vendor>>(`/vendors${buildQuery(params)}`)
// },
// get(vendorId: number) {
// return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`)
// },
// create(request: CreateVendorRequest) {
// return apiRequestWithETag<Vendor>("/vendors", { method: "POST", body: request })
// },
// update(vendorId: number, request: UpdateVendorRequest, ifMatch: string) {
// return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`, { method: "PUT", body: request, ifMatch })
// },
// updateStatus(vendorId: number, status: EntityStatus) {
// return apiRequest<void>(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } })
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
export interface ListVendorsParams {
page?: number
pageSize?: number
@@ -75,91 +22,20 @@ export interface CreateVendorRequest {
export type UpdateVendorRequest = CreateVendorRequest
function codeTaken(code: string, excludeVendorId?: number) {
return mockVendors.some((v) => v.vendorId !== excludeVendorId && v.code.toLowerCase() === code.toLowerCase())
}
export const vendorsApi = {
list(params: ListVendorsParams = {}): Promise<PagedResponse<Vendor>> {
const term = params.q?.trim().toLowerCase()
const filtered = mockVendors
.filter((v) => !params.status || v.status === params.status)
.filter((v) => !term || `${v.code} ${v.name}`.toLowerCase().includes(term))
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
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 },
})
list(params: ListVendorsParams = {}) {
return apiRequest<PagedResponse<Vendor>>(`/vendors${buildQuery(params)}`)
},
get(vendorId: number): Promise<ApiResult<Vendor>> {
const v = mockVendors.find((x) => x.vendorId === vendorId)
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
return mockDelay({ data: v, etag: String(getVendorVersion(vendorId)) })
get(vendorId: number) {
return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`)
},
create(request: CreateVendorRequest): Promise<ApiResult<Vendor>> {
const code = request.code.trim()
if (!code) return Promise.reject(new Error("Vendor code is required."))
if (codeTaken(code)) {
return Promise.reject(Object.assign(new Error(`Vendor code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
}
const vendor: Vendor = {
vendorId: allocateVendorId(),
code,
name: request.name.trim(),
terms: request.terms?.trim() || null,
taxReg: request.taxReg?.trim() || null,
currency: request.currency.trim().toUpperCase(),
status: "Active",
createdAt: new Date().toISOString(),
updatedAt: null,
}
mockVendors.push(vendor)
initVendorVersion(vendor.vendorId)
return mockDelay({ data: vendor, etag: "1" })
create(request: CreateVendorRequest) {
return apiRequestWithETag<Vendor>("/vendors", { method: "POST", body: request })
},
update(vendorId: number, request: UpdateVendorRequest, ifMatch: string): Promise<ApiResult<Vendor>> {
const v = mockVendors.find((x) => x.vendorId === vendorId)
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
if (String(getVendorVersion(vendorId)) !== ifMatch) {
return Promise.reject(
Object.assign(new Error("The vendor was modified by another request."), { code: "CONCURRENCY_CONFLICT" })
)
}
const code = request.code.trim()
if (!code) return Promise.reject(new Error("Vendor code is required."))
if (codeTaken(code, vendorId)) {
return Promise.reject(Object.assign(new Error(`Vendor code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
}
v.code = code
v.name = request.name.trim()
v.terms = request.terms?.trim() || null
v.taxReg = request.taxReg?.trim() || null
v.currency = request.currency.trim().toUpperCase()
v.updatedAt = new Date().toISOString()
const next = bumpVendorVersion(vendorId)
return mockDelay({ data: v, etag: String(next) })
update(vendorId: number, request: UpdateVendorRequest, ifMatch: string) {
return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`, { method: "PUT", body: request, ifMatch })
},
updateStatus(vendorId: number, status: EntityStatus): Promise<void> {
const v = mockVendors.find((x) => x.vendorId === vendorId)
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
v.status = status
v.updatedAt = new Date().toISOString()
bumpVendorVersion(vendorId)
return mockDelay(undefined)
updateStatus(vendorId: number, status: EntityStatus) {
return apiRequest<void>(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } })
},
}
+12 -72
View File
@@ -1,47 +1,9 @@
// One typed client method per warehouse/bin endpoint (docs/11-BACKEND-PHASE1.md §2.5,
// FR-MD-07/FR-WH-01).
//
// UI-ONLY MODE: the real fetch-based implementation is commented out below and
// replaced with in-memory sample data (lib/api/mock-data.ts) so the GRN/Stock/
// Warehouse screens can be reviewed without a running backend. Restore the
// commented block and delete the mock block once Backend/PROGRESS.md §1
// (Master Data) exists.
import { apiRequest } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { Bin, Warehouse } from "@/types/master-data"
import { allocateBinId, allocateWarehouseId, mockBins, mockDelay, mockWarehouses } from "@/lib/api/mock-data"
// --- Real implementation (restore when the backend exists) --------------------
// import { apiRequest } from "@/lib/api-client"
//
// export interface CreateWarehouseRequest {
// code: string
// name: string
// }
//
// export interface CreateBinRequest {
// code: string
// binType?: string | null
// }
//
// export const warehousesApi = {
// list() {
// return apiRequest<PagedResponse<Warehouse>>("/warehouses")
// },
//
// create(request: CreateWarehouseRequest) {
// return apiRequest<Warehouse>("/warehouses", { method: "POST", body: request })
// },
//
// listBins(warehouseId: number) {
// return apiRequest<PagedResponse<Bin>>(`/warehouses/${warehouseId}/bins`)
// },
//
// createBin(warehouseId: number, request: CreateBinRequest) {
// return apiRequest<Bin>(`/warehouses/${warehouseId}/bins`, { method: "POST", body: request })
// },
// }
// --- Mock implementation (UI-only review) --------------------------------------
export interface CreateWarehouseRequest {
code: string
name: string
@@ -53,46 +15,24 @@ export interface CreateBinRequest {
}
export const warehousesApi = {
list(): Promise<PagedResponse<Warehouse>> {
return mockDelay({
items: mockWarehouses,
pagination: { page: 1, pageSize: 20, totalItems: mockWarehouses.length, totalPages: 1 },
})
list() {
return apiRequest<PagedResponse<Warehouse>>("/warehouses")
},
get(warehouseId: number): Promise<Warehouse> {
const wh = mockWarehouses.find((w) => w.warehouseId === warehouseId)
if (!wh) return Promise.reject(new Error(`Mock warehouse ${warehouseId} not found`))
return mockDelay(wh)
get(warehouseId: number) {
return apiRequest<Warehouse>(`/warehouses/${warehouseId}`)
},
create(request: CreateWarehouseRequest): Promise<Warehouse> {
const code = request.code.trim()
if (!code) return Promise.reject(new Error("Warehouse code is required."))
if (mockWarehouses.some((w) => w.code.toLowerCase() === code.toLowerCase())) {
return Promise.reject(Object.assign(new Error(`Warehouse code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
}
const warehouse: Warehouse = { warehouseId: allocateWarehouseId(), code, name: request.name.trim() }
mockWarehouses.push(warehouse)
return mockDelay(warehouse)
create(request: CreateWarehouseRequest) {
return apiRequest<Warehouse>("/warehouses", { method: "POST", body: request })
},
listBins(warehouseId: number): Promise<PagedResponse<Bin>> {
const items = mockBins.filter((b) => b.warehouseId === warehouseId)
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
async listBins(warehouseId: number): Promise<PagedResponse<Bin>> {
const items = await apiRequest<Bin[]>(`/warehouses/${warehouseId}/bins`)
return { items, pagination: { page: 1, pageSize: items.length, totalItems: items.length, totalPages: 1 } }
},
createBin(warehouseId: number, request: CreateBinRequest): Promise<Bin> {
const code = request.code.trim()
if (!code) return Promise.reject(new Error("Bin code is required."))
if (mockBins.some((b) => b.warehouseId === warehouseId && b.code.toLowerCase() === code.toLowerCase())) {
return Promise.reject(Object.assign(new Error(`Bin code "${code}" already exists in this warehouse.`), { code: "SKU_DUPLICATE" }))
}
const bin: Bin = { binId: allocateBinId(), warehouseId, code, binType: request.binType?.trim() || null }
mockBins.push(bin)
return mockDelay(bin)
createBin(warehouseId: number, request: CreateBinRequest) {
return apiRequest<Bin>(`/warehouses/${warehouseId}/bins`, { method: "POST", body: request })
},
}
+24 -25
View File
@@ -1,20 +1,22 @@
// "Wastage" is not a distinct document type in docs/10-BACKEND-PHASE1.md or the
// SRS — stock write-offs (damage, theft/loss, expiry) are modeled as Stock
// Adjustments with a mandatory reason code (FR-STK-07), and the reason-code seed
// list (docs/8.3 / docs/11 §6) already includes Damage / Theft-Loss / Expiry
// Write-off. This module is a frontend-only lens: it reuses stockAdjustmentsApi
// and the shared mock Stock Core, filtered to loss-type reason codes and
// flattened to per-line records for a focused "record wastage" flow and report.
// No new backend concept, no new mock store.
// Adjustments with a mandatory reason code (FR-STK-07). This module is a
// frontend-only lens: it composes stockAdjustmentsApi/reasonCodesApi/stockApi
// (real endpoints, though none of §5's backend exists yet — see
// Backend/PROGRESS.md §5), filtered to loss-type reason codes and flattened to
// per-line records for a focused "record wastage" flow and report. No new
// backend concept.
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { mockDelay, mockReasonCodes, mockStockAdjustments, mockStockLedger } from "@/lib/api/mock-data"
import { stockApi } from "@/lib/api/stock"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { StockAdjustment } from "@/types/stock"
/** Reason-code strings treated as "wastage" (loss-type) causes, per docs/8.3. */
const WASTAGE_CODES = new Set(["DMG", "LOSS", "EXPWO"])
export function wastageReasonCodeIds(): number[] {
return mockReasonCodes.filter((r) => WASTAGE_CODES.has(r.code)).map((r) => r.reasonCodeId)
export async function wastageReasonCodeIds(): Promise<number[]> {
const { items } = await reasonCodesApi.list("Adjustment")
return items.filter((r) => WASTAGE_CODES.has(r.code)).map((r) => r.reasonCodeId)
}
export interface WastageRecord {
@@ -46,26 +48,23 @@ export interface RecordWastageInput {
}
export const wastageApi = {
list(params: ListWastageParams = {}): Promise<WastageRecord[]> {
const wastageIds = new Set(wastageReasonCodeIds())
async list(params: ListWastageParams = {}): Promise<WastageRecord[]> {
const wastageIds = new Set(await wastageReasonCodeIds())
const { items: summaries } = await stockAdjustmentsApi.list({ warehouseId: params.warehouseId, pageSize: 200 })
const candidates = summaries.filter(
(a) => wastageIds.has(a.reasonCodeId) && (!params.reasonCodeId || a.reasonCodeId === params.reasonCodeId)
)
const adjustments = await Promise.all(candidates.map((a) => stockAdjustmentsApi.get(a.adjustmentId)))
const records: WastageRecord[] = []
for (const adj of mockStockAdjustments) {
if (!wastageIds.has(adj.reasonCodeId)) continue
if (params.warehouseId && adj.warehouseId !== params.warehouseId) continue
if (params.reasonCodeId && adj.reasonCodeId !== params.reasonCodeId) continue
for (const adj of adjustments) {
for (const line of adj.lines) {
if (line.qtyDelta >= 0) continue // wastage is always a decrease
const value = mockStockLedger
.filter(
(l) =>
l.sourceDocType === "Adjustment" &&
l.sourceDocId === adj.adjustmentId &&
l.itemId === line.itemId &&
l.direction === "Out"
)
const { items: ledger } = await stockApi.ledger({ itemId: line.itemId, warehouseId: adj.warehouseId, pageSize: 200 })
const value = ledger
.filter((l) => l.sourceDocType === "Adjustment" && l.sourceDocId === adj.adjustmentId && l.direction === "Out")
.reduce((sum, l) => sum + l.value, 0)
records.push({
@@ -84,7 +83,7 @@ export const wastageApi = {
}
records.sort((a, b) => b.adjustmentId - a.adjustmentId)
return mockDelay(records)
return records
},
/** Records wastage as a single-line, negative-qtyDelta stock adjustment (FR-STK-07). */