price adjustment and stock adjustments

This commit is contained in:
2026-10-12 14:08:23 +05:30
committed by ImanThiyanga
parent a788349138
commit d0354ed169
166 changed files with 98818 additions and 252 deletions
+62
View File
@@ -0,0 +1,62 @@
// Approvals inbox and settings (docs/DUAL_APPROVAL_PLAN.md §7, §10), plus price adjustments (§6).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
ApprovalConfig,
CreatePriceAdjustmentRequest,
ItemPriceHistory,
PendingApprovals,
PriceAdjustment,
PriceAdjustmentStatus,
PriceAdjustmentSummary,
UpdateApprovalConfigRequest,
} from "@/types/approvals"
export const approvalsApi = {
/** What you can approve, plus your own requests that are waiting for others. */
pending(): Promise<PendingApprovals> {
return apiRequest<PendingApprovals>("/approvals/pending")
},
getConfig(): Promise<ApprovalConfig> {
return apiRequest<ApprovalConfig>("/approvals/config")
},
/** Needs settings.approvals. */
updateConfig(request: UpdateApprovalConfigRequest): Promise<ApprovalConfig> {
return apiRequest<ApprovalConfig>("/approvals/config", { method: "PUT", body: request })
},
}
export const priceAdjustmentsApi = {
list(params: { page?: number; pageSize?: number; q?: string; status?: PriceAdjustmentStatus; itemId?: number } = {}): Promise<PagedResponse<PriceAdjustmentSummary>> {
return apiRequest<PagedResponse<PriceAdjustmentSummary>>(`/price-adjustments${buildQuery(params)}`)
},
get(priceAdjustmentId: number): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}`)
},
/** 409 PRICE_ADJUSTMENT_ITEM_PENDING when an item already has a pending request. */
create(request: CreatePriceAdjustmentRequest): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>("/price-adjustments", { method: "POST", body: request })
},
/** The final approval applies the prices; 409 PRICE_ADJUSTMENT_STALE if an item was repriced meanwhile. */
approve(priceAdjustmentId: number, comment?: string): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}/approve`, { method: "POST", body: { comment } })
},
reject(priceAdjustmentId: number, reason: string): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}/reject`, { method: "POST", body: { reason } })
},
/** Requester only, while pending. */
cancel(priceAdjustmentId: number): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}/cancel`, { method: "POST" })
},
itemHistory(itemId: number): Promise<ItemPriceHistory[]> {
return apiRequest<ItemPriceHistory[]>(`/items/${itemId}/price-history`)
},
}
@@ -0,0 +1,43 @@
// Bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.3). create() posts GL (Debit bank /
// Credit cash) and is rejected above the cash in hand; cancel() posts the reversing entry.
import { apiRequest, buildQuery } from "@/lib/api-client"
import {
BankDeposit,
BankDepositList,
BankDepositListFilterParams,
CashInHand,
CashInHandDaily,
CreateBankDepositRequest,
} from "@/types/bank-deposits"
export const bankDepositsApi = {
list(params: BankDepositListFilterParams = {}): Promise<BankDepositList> {
return apiRequest<BankDepositList>(`/bank-deposits${buildQuery(params)}`)
},
get(bankDepositId: number): Promise<BankDeposit> {
return apiRequest<BankDeposit>(`/bank-deposits/${bankDepositId}`)
},
/** 400 BANK_DEPOSIT_EXCEEDS_CASH_IN_HAND when the amount is more than can still be deposited for that day. */
create(request: CreateBankDepositRequest): Promise<BankDeposit> {
return apiRequest<BankDeposit>("/bank-deposits", { method: "POST", body: request })
},
/** Posted only; puts the cash back in hand. */
cancel(bankDepositId: number, reason: string): Promise<BankDeposit> {
return apiRequest<BankDeposit>(`/bank-deposits/${bankDepositId}/cancel`, { method: "POST", body: { reason } })
},
/** `date` omitted ⇒ today in the business time zone. `branchId` is ignored for branch users. */
cashInHand(params: { date?: string; branchId?: number | null } = {}): Promise<CashInHand> {
return apiRequest<CashInHand>(`/bank-deposits/cash-in-hand${buildQuery({ date: params.date, branchId: params.branchId ?? undefined })}`)
},
/** At most 92 days. */
cashInHandDaily(params: { from: string; to: string; branchId?: number | null }): Promise<CashInHandDaily> {
return apiRequest<CashInHandDaily>(
`/bank-deposits/cash-in-hand/daily${buildQuery({ from: params.from, to: params.to, branchId: params.branchId ?? undefined })}`
)
},
}
@@ -0,0 +1,17 @@
// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §4). Read-only: one day's sales,
// payments by method, expenses and income, and the same day-wise over a range (≤ 92 days).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { DayEndDailyReport, DayEndReport } from "@/types/sales"
export const dayEndReportApi = {
/** `date` omitted ⇒ today in the report's time zone. `branchId` is ignored for non-admins. */
getDay(params: { date?: string; branchId?: number | null } = {}): Promise<DayEndReport> {
return apiRequest<DayEndReport>(`/day-end-report${buildQuery({ date: params.date, branchId: params.branchId ?? undefined })}`)
},
getDaily(params: { from: string; to: string; branchId?: number | null }): Promise<DayEndDailyReport> {
return apiRequest<DayEndDailyReport>(
`/day-end-report/daily${buildQuery({ from: params.from, to: params.to, branchId: params.branchId ?? undefined })}`
)
},
}
+93
View File
@@ -0,0 +1,93 @@
// One typed client method per Expense endpoint (docs/EXPENSE_MODULE_PLAN.md §6). Draft
// expenses are editable via update(); post() accrues to GL, pay()/postAndPay() settle it.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
CreateExpensePaymentRequest,
CreateExpenseRequest,
CreateExpenseTitleRequest,
Expense,
ExpenseCategory,
ExpenseListFilterParams,
ExpensePayment,
ExpenseSummary,
ExpenseSummaryReport,
ExpenseTitle,
UpdateExpenseCategoryRequest,
UpdateExpenseRequest,
UpdateExpenseTitleRequest,
} from "@/types/expenses"
export const expenseSetupApi = {
/** Categories with their titles, in display order. Inactive rows only when asked for. */
list(includeInactive = false): Promise<ExpenseCategory[]> {
return apiRequest<ExpenseCategory[]>(`/expense-categories${buildQuery({ includeInactive: includeInactive || undefined })}`)
},
/** Categories are fixed (no create); in practice only the active flag changes — they're all built-in. */
updateCategory(expenseCategoryId: number, request: UpdateExpenseCategoryRequest): Promise<ExpenseCategory> {
return apiRequest<ExpenseCategory>(`/expense-categories/${expenseCategoryId}`, { method: "PUT", body: request })
},
createTitle(expenseCategoryId: number, request: CreateExpenseTitleRequest): Promise<ExpenseTitle> {
return apiRequest<ExpenseTitle>(`/expense-categories/${expenseCategoryId}/titles`, { method: "POST", body: request })
},
/** 409 EXPENSE_SYSTEM_ROW_READONLY when renaming a built-in title (GL code and status stay editable). */
updateTitle(expenseTitleId: number, request: UpdateExpenseTitleRequest): Promise<ExpenseTitle> {
return apiRequest<ExpenseTitle>(`/expense-titles/${expenseTitleId}`, { method: "PUT", body: request })
},
}
export const expensesApi = {
list(params: ExpenseListFilterParams = {}): Promise<PagedResponse<ExpenseSummary>> {
return apiRequest<PagedResponse<ExpenseSummary>>(`/expenses${buildQuery(params)}`)
},
/** Posted expenses dated in [from, to] (default: this month), plus the unpaid/overdue position. */
summary(params: { from?: string; to?: string } = {}): Promise<ExpenseSummaryReport> {
return apiRequest<ExpenseSummaryReport>(`/expenses/summary${buildQuery(params)}`)
},
get(expenseId: number): Promise<ApiResult<Expense>> {
return apiRequestWithETag<Expense>(`/expenses/${expenseId}`)
},
create(request: CreateExpenseRequest): Promise<ApiResult<Expense>> {
return apiRequestWithETag<Expense>("/expenses", { method: "POST", body: request })
},
/** Draft only (409 EXPENSE_NOT_DRAFT otherwise). Echo the ETag from get()/create() as ifMatch. */
update(expenseId: number, request: UpdateExpenseRequest, ifMatch: string): Promise<ApiResult<Expense>> {
return apiRequestWithETag<Expense>(`/expenses/${expenseId}`, { method: "PUT", body: request, ifMatch })
},
/** Draft only. */
remove(expenseId: number): Promise<void> {
return apiRequest<void>(`/expenses/${expenseId}`, { method: "DELETE" })
},
/** Accrue to GL: Debit the title's expense account / Credit Accrued Expenses. */
post(expenseId: number): Promise<Expense> {
return apiRequest<Expense>(`/expenses/${expenseId}/post`, { method: "POST" })
},
/** Draft only; posts and pays in one transaction — full or partial (the rest stays as the balance). */
postAndPay(expenseId: number, request: CreateExpensePaymentRequest): Promise<Expense> {
return apiRequest<Expense>(`/expenses/${expenseId}/post-and-pay`, { method: "POST", body: request })
},
/** Draft, or Posted with no payments (posts a reversing GL entry). */
cancel(expenseId: number, reason: string): Promise<Expense> {
return apiRequest<Expense>(`/expenses/${expenseId}/cancel`, { method: "POST", body: { reason } })
},
/** Posted only; total capped at the balance. Returns one row per payment line. */
pay(expenseId: number, request: CreateExpensePaymentRequest): Promise<ExpensePayment[]> {
return apiRequest<ExpensePayment[]>(`/expenses/${expenseId}/payments`, { method: "POST", body: request })
},
listPayments(expenseId: number): Promise<ExpensePayment[]> {
return apiRequest<ExpensePayment[]>(`/expenses/${expenseId}/payments`)
},
}
+6
View File
@@ -8,6 +8,7 @@ import {
ItemImage,
ItemListItem,
ItemOverview,
ItemPurchasePrice,
ItemReorderSetting,
ItemScanResult,
StockNature,
@@ -95,6 +96,11 @@ export const itemsApi = {
return apiRequest<void>(`/items/${itemId}/status`, { method: "PATCH", body: { status } })
},
/** Latest posted GRN receipts of the item (newest first) — what it was bought at. */
purchasePrices(itemId: number, take = 5): Promise<ItemPurchasePrice[]> {
return apiRequest<ItemPurchasePrice[]>(`/items/${itemId}/purchase-prices${buildQuery({ take })}`)
},
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: ItemReorderSetting[] }> {
return apiRequest<{ settings: ItemReorderSetting[] }>(`/items/${itemId}/reorder`, {
method: "PUT",
@@ -1,8 +1,9 @@
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5).
// Auto-posts on create with a mandatory reason code (FR-STK-07).
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5). Creating
// submits a request with a mandatory reason code; it moves stock once approved
// (docs/DUAL_APPROVAL_PLAN.md §5), or at once when approvals are switched off.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
import { AdjustmentStatus, CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
export interface ListAdjustmentsParams {
page?: number
@@ -11,6 +12,7 @@ export interface ListAdjustmentsParams {
warehouseId?: number
branchId?: number
reasonCodeId?: number
status?: AdjustmentStatus
sort?: string
}
@@ -30,4 +32,18 @@ export const stockAdjustmentsApi = {
create(request: CreateAdjustmentRequest): Promise<StockAdjustment> {
return apiRequest<StockAdjustment>("/stock-adjustments", { method: "POST", body: request })
},
/** Next approval; the final one posts (409 STOCK_NEGATIVE_BLOCKED if stock no longer allows it). */
approve(adjustmentId: number, comment?: string): Promise<StockAdjustment> {
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}/approve`, { method: "POST", body: { comment } })
},
reject(adjustmentId: number, reason: string): Promise<StockAdjustment> {
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}/reject`, { method: "POST", body: { reason } })
},
/** Requester only, while pending. */
cancel(adjustmentId: number): Promise<StockAdjustment> {
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}/cancel`, { method: "POST" })
},
}
+7 -3
View File
@@ -7,7 +7,7 @@
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { stockApi } from "@/lib/api/stock"
import { StockAdjustment } from "@/types/stock"
import { AdjustmentStatus, StockAdjustment } from "@/types/stock"
/**
* Reason codes treated as "wastage" (loss-type) causes. These match the seeded codes in
@@ -37,8 +37,10 @@ export interface WastageRecord {
reasonCodeId: number
/** Positive quantity wasted (the underlying adjustment line is a negative qtyDelta). */
qty: number
/** FIFO cost of the wasted quantity, summed from the ledger entries this adjustment posted. */
/** FIFO cost of the wasted quantity, summed from the ledger entries this adjustment posted (0 until posted). */
value: number
/** Posted, or still waiting for approval (rejected/withdrawn requests are left out). */
status: AdjustmentStatus
}
export interface ListWastageParams {
@@ -81,7 +83,8 @@ export const wastageApi = {
page: params.page,
pageSize: params.pageSize ?? 50,
})
const summaries = res.items.filter((a) => wastageIds.has(a.reasonCodeId))
// A rejected or withdrawn request never wrote anything off.
const summaries = res.items.filter((a) => wastageIds.has(a.reasonCodeId) && a.status !== "Rejected" && a.status !== "Cancelled")
const detailed = await Promise.all(
summaries.map(async (summary) => {
@@ -107,6 +110,7 @@ export const wastageApi = {
reasonCodeId: adjustment.reasonCodeId,
qty: Math.abs(line.qtyDelta),
value: Math.round(value * 100) / 100,
status: adjustment.status,
}
})
}),