From b137176c40ce5f228d40078d9841272e71cb393f Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Fri, 4 Sep 2026 10:35:04 +0200 Subject: [PATCH] Add Stock catalog (items/warehouses) and fix session persistence Stock is scoped to a catalog + cost centers only, per direction that physical GRV/stock-take/recipe transactions belong in an external system (Matrix), not this accounting app. Stock Control/GRN/Variance/ PPV GL accounts are now plain postable accounts rather than locked control accounts. Also fixes session loss on page refresh: auth stores now persist to localStorage, and ProtectedRoute waits for rehydration before deciding to redirect to login. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JWqiUSojMLUoN7mrJqdEbN --- PHASE_4_STOCK_TEST_PLAN.md | 44 +++ client/src/api/stockItems.api.ts | 33 ++ client/src/api/warehouses.api.ts | 26 ++ .../features/admin/coa/GLAccountDialog.tsx | 2 +- .../admin/warehouses/AdminWarehousesPage.tsx | 42 +++ .../admin/warehouses/WarehouseDialog.tsx | 63 ++++ .../features/stock/items/StockItemDialog.tsx | 45 +++ .../stock/items/StockItemsListPage.tsx | 78 +++++ client/src/hooks/useStockItems.ts | 41 +++ client/src/hooks/useWarehouses.ts | 23 ++ client/src/layouts/MainLayout.tsx | 12 + client/src/routes/AppRouter.tsx | 7 + client/src/routes/ProtectedRoute.tsx | 18 +- client/src/store/authStore.ts | 27 +- client/src/types/api.ts | 33 +- manager/client/src/routes/ProtectedRoute.tsx | 19 ++ manager/client/src/store/authStore.ts | 25 +- .../migration.sql | 285 ++++++++++++++++++ .../migration.sql | 11 + .../migration.sql | 96 ++++++ prisma/schema.prisma | 52 +++- prisma/seed.ts | 39 ++- src/app.ts | 4 + src/middleware/errorHandler.ts | 6 +- src/modules/ap/apPosting.service.ts | 8 +- src/modules/gl/glAccounts.controller.ts | 2 +- src/modules/gl/journalEntries.service.ts | 24 +- src/modules/gl/posting.service.ts | 2 - src/modules/gl/posting.types.ts | 1 - src/modules/stock/stockItems.controller.ts | 39 +++ src/modules/stock/stockItems.routes.ts | 34 +++ src/modules/stock/stockItems.service.ts | 34 +++ src/modules/stock/warehouses.controller.ts | 25 ++ src/modules/stock/warehouses.routes.ts | 25 ++ src/modules/stock/warehouses.service.ts | 20 ++ 35 files changed, 1199 insertions(+), 46 deletions(-) create mode 100644 PHASE_4_STOCK_TEST_PLAN.md create mode 100644 client/src/api/stockItems.api.ts create mode 100644 client/src/api/warehouses.api.ts create mode 100644 client/src/features/admin/warehouses/AdminWarehousesPage.tsx create mode 100644 client/src/features/admin/warehouses/WarehouseDialog.tsx create mode 100644 client/src/features/stock/items/StockItemDialog.tsx create mode 100644 client/src/features/stock/items/StockItemsListPage.tsx create mode 100644 client/src/hooks/useStockItems.ts create mode 100644 client/src/hooks/useWarehouses.ts create mode 100644 prisma/migrations/20260903121005_add_stock_module/migration.sql create mode 100644 prisma/migrations/20260903121436_add_variance_control_types/migration.sql create mode 100644 prisma/migrations/20260903131116_simplify_stock_module/migration.sql create mode 100644 src/modules/stock/stockItems.controller.ts create mode 100644 src/modules/stock/stockItems.routes.ts create mode 100644 src/modules/stock/stockItems.service.ts create mode 100644 src/modules/stock/warehouses.controller.ts create mode 100644 src/modules/stock/warehouses.routes.ts create mode 100644 src/modules/stock/warehouses.service.ts diff --git a/PHASE_4_STOCK_TEST_PLAN.md b/PHASE_4_STOCK_TEST_PLAN.md new file mode 100644 index 0000000..21601da --- /dev/null +++ b/PHASE_4_STOCK_TEST_PLAN.md @@ -0,0 +1,44 @@ +# Phase 4 — Stock Simplification + Session Persistence: Manual Test Checklist + +This replaces the earlier version of this checklist. On review, the full transactional Stock +module (GRV, Stock Takes, Recipe production) was removed — this is accounting software, not a +stock/warehouse system, and those physical stock transactions belong in an external system +(Matrix) instead. What remains: a Stock Item catalog, Warehouses as cost centers, and a +Stock-on-Hand view that a future external feed will populate (currently empty — nothing in this +app writes to it). This checklist also covers the session-persistence fix. + +## 1. Session persistence (the "logged out on refresh" fix) + +- [ ] Sign in through the Manager, land in the company app +- [ ] Refresh the browser tab (F5) — you should **stay logged in**, not bounce back to the + Manager +- [ ] Close the browser tab entirely and reopen `http://localhost:5173` — you should still be + signed in +- [ ] Sign out — refreshing afterward should correctly show you signed out (not silently + restore the old session) +- [ ] Same checks on the Manager itself (`http://localhost:5174`): refresh keeps you logged in, + sign out actually signs out + +## 2. Simplified Stock module + +- [ ] Sidebar's **Stock** section now shows only **Stock Items** (Recipes, Goods Received, + Stock Takes are gone) +- [ ] Administration still has **Warehouses** +- [ ] "New Stock Item" only asks for code, name, and unit of measure — no costing method +- [ ] "New Warehouse" is unchanged (code, name, branch) +- [ ] Stock Items page still has an "Items" tab and a "Stock Levels" tab; the Stock Levels tab + shows a note that quantities come from an external system, and the grid is empty (that's + expected — nothing populates it yet) +- [ ] No "Match to GRV" button appears anywhere on a supplier's page +- [ ] AP invoice lines only ever offer a plain expense account — no GRV-matching option +- [ ] `GET /api/stock/grv`, `/api/stock/adjustments`, `/api/stock/recipes` all now 404 (removed) + +## 3. Regression: everything else unaffected + +- [ ] GL, AR, and AP all work exactly as before — Stock Control, Stock Variance, and the + account previously called "Goods Received Not Invoiced" / "Purchase Price Variance" are + now plain, normal GL accounts (no longer locked as control accounts), so they can be + posted to like any other account via a manual journal entry if you want to record a + stock-value adjustment by hand +- [ ] The manual-journal control-account lockout still correctly blocks direct postings to AR + Control, AP Control, Tax accounts, and Bank accounts diff --git a/client/src/api/stockItems.api.ts b/client/src/api/stockItems.api.ts new file mode 100644 index 0000000..8f2e36c --- /dev/null +++ b/client/src/api/stockItems.api.ts @@ -0,0 +1,33 @@ +import { apiClient } from './client'; +import type { StockItem, StockLevel } from '../types/api'; + +export async function fetchStockItems(): Promise { + const response = await apiClient.get('/stock/items'); + return response.data; +} + +export async function fetchStockItem(id: number): Promise { + const response = await apiClient.get(`/stock/items/${id}`); + return response.data; +} + +export async function fetchStockLevels(warehouseId?: number): Promise { + const response = await apiClient.get('/stock/items/levels', { params: { warehouseId } }); + return response.data; +} + +export interface StockItemInput { + code: string; + name: string; + unitOfMeasure: string; +} + +export async function createStockItem(input: StockItemInput): Promise { + const response = await apiClient.post('/stock/items', input); + return response.data; +} + +export async function updateStockItem(id: number, input: Partial & { isActive?: boolean }): Promise { + const response = await apiClient.put(`/stock/items/${id}`, input); + return response.data; +} diff --git a/client/src/api/warehouses.api.ts b/client/src/api/warehouses.api.ts new file mode 100644 index 0000000..59245d6 --- /dev/null +++ b/client/src/api/warehouses.api.ts @@ -0,0 +1,26 @@ +import { apiClient } from './client'; +import type { Warehouse } from '../types/api'; +import type { BranchSelection } from '../store/branchStore'; + +export async function fetchWarehouses(branchScope: BranchSelection): Promise { + const response = await apiClient.get('/stock/warehouses', { + params: { branchScope: branchScope === 'MAIN' ? 'MAIN' : String(branchScope) }, + }); + return response.data; +} + +export interface WarehouseInput { + code: string; + name: string; + branchSegmentValueId: number; +} + +export async function createWarehouse(input: WarehouseInput): Promise { + const response = await apiClient.post('/stock/warehouses', input); + return response.data; +} + +export async function updateWarehouse(id: number, input: Partial & { isActive?: boolean }): Promise { + const response = await apiClient.put(`/stock/warehouses/${id}`, input); + return response.data; +} diff --git a/client/src/features/admin/coa/GLAccountDialog.tsx b/client/src/features/admin/coa/GLAccountDialog.tsx index 950428f..fca1699 100644 --- a/client/src/features/admin/coa/GLAccountDialog.tsx +++ b/client/src/features/admin/coa/GLAccountDialog.tsx @@ -17,7 +17,7 @@ import type { GLAccountType, DebitCredit, ControlAccountType } from '../../../ty const ACCOUNT_TYPES: GLAccountType[] = ['ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE']; const DEBIT_CREDIT: DebitCredit[] = ['DEBIT', 'CREDIT']; -const CONTROL_TYPES: ControlAccountType[] = ['AR', 'AP', 'STOCK', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS']; +const CONTROL_TYPES: ControlAccountType[] = ['AR', 'AP', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS']; export function GLAccountDialog({ open, onClose }: { open: boolean; onClose: () => void }) { const { data: definitions } = useSegmentDefinitions(); diff --git a/client/src/features/admin/warehouses/AdminWarehousesPage.tsx b/client/src/features/admin/warehouses/AdminWarehousesPage.tsx new file mode 100644 index 0000000..c048df3 --- /dev/null +++ b/client/src/features/admin/warehouses/AdminWarehousesPage.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { Box, Button, Card, Stack, Typography } from '@mui/material'; +import { DataGrid, type GridColDef } from '@mui/x-data-grid'; +import { useWarehouses } from '../../../hooks/useWarehouses'; +import { useAuth } from '../../../hooks/useAuth'; +import { StatusChip } from '../../../components/common/StatusChip'; +import type { Warehouse } from '../../../types/api'; +import { WarehouseDialog } from './WarehouseDialog'; + +export function AdminWarehousesPage() { + const { data: warehouses, isLoading } = useWarehouses('MAIN'); + const { hasPermission } = useAuth(); + const [dialogOpen, setDialogOpen] = useState(false); + + const columns: GridColDef[] = [ + { field: 'code', headerName: 'Code', width: 140 }, + { field: 'name', headerName: 'Name', flex: 1 }, + { + field: 'isActive', + headerName: 'Status', + width: 100, + renderCell: (params) => , + }, + ]; + + return ( + + + Warehouses + {hasPermission('stock.warehouse.manage') && ( + + )} + + + + + setDialogOpen(false)} /> + + ); +} diff --git a/client/src/features/admin/warehouses/WarehouseDialog.tsx b/client/src/features/admin/warehouses/WarehouseDialog.tsx new file mode 100644 index 0000000..9206524 --- /dev/null +++ b/client/src/features/admin/warehouses/WarehouseDialog.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField } from '@mui/material'; +import { useCreateWarehouse } from '../../../hooks/useWarehouses'; +import { useSegmentDefinitions } from '../../../hooks/useSegments'; + +export function WarehouseDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const createWarehouse = useCreateWarehouse(); + const { data: definitions } = useSegmentDefinitions(); + const branchDef = (definitions ?? []).find((def) => def.isBranchSegment); + + const [code, setCode] = useState(''); + const [name, setName] = useState(''); + const [branchSegmentValueId, setBranchSegmentValueId] = useState(''); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + setError(null); + if (!branchSegmentValueId) { + setError('Select a branch'); + return; + } + try { + await createWarehouse.mutateAsync({ code, name, branchSegmentValueId }); + onClose(); + setCode(''); + setName(''); + setBranchSegmentValueId(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create warehouse'); + } + }; + + return ( + + New Warehouse + + + setCode(e.target.value)} /> + setName(e.target.value)} /> + setBranchSegmentValueId(Number(e.target.value))} + > + {(branchDef?.values ?? []).map((value) => ( + + {value.code} — {value.description} + + ))} + + {error && {error}} + + + + + + + + ); +} diff --git a/client/src/features/stock/items/StockItemDialog.tsx b/client/src/features/stock/items/StockItemDialog.tsx new file mode 100644 index 0000000..6b8d34e --- /dev/null +++ b/client/src/features/stock/items/StockItemDialog.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from '@mui/material'; +import { useCreateStockItem } from '../../../hooks/useStockItems'; + +export function StockItemDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const createStockItem = useCreateStockItem(); + + const [code, setCode] = useState(''); + const [name, setName] = useState(''); + const [unitOfMeasure, setUnitOfMeasure] = useState(''); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + setError(null); + try { + await createStockItem.mutateAsync({ code, name, unitOfMeasure }); + onClose(); + setCode(''); + setName(''); + setUnitOfMeasure(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create stock item'); + } + }; + + return ( + + New Stock Item + + + setCode(e.target.value)} /> + setName(e.target.value)} /> + setUnitOfMeasure(e.target.value)} helperText="e.g. EACH, KG, LOAF" /> + {error && {error}} + + + + + + + + ); +} diff --git a/client/src/features/stock/items/StockItemsListPage.tsx b/client/src/features/stock/items/StockItemsListPage.tsx new file mode 100644 index 0000000..52d9593 --- /dev/null +++ b/client/src/features/stock/items/StockItemsListPage.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { Box, Button, Card, Stack, Tab, Tabs, Typography } from '@mui/material'; +import { DataGrid, type GridColDef } from '@mui/x-data-grid'; +import { useStockItems, useStockLevels } from '../../../hooks/useStockItems'; +import { useAuth } from '../../../hooks/useAuth'; +import { StatusChip } from '../../../components/common/StatusChip'; +import { MoneyCell } from '../../../components/common/MoneyCell'; +import type { StockItem, StockLevel } from '../../../types/api'; +import { StockItemDialog } from './StockItemDialog'; + +export function StockItemsListPage() { + const { data: items, isLoading: itemsLoading } = useStockItems(); + const { data: levels, isLoading: levelsLoading } = useStockLevels(); + const { hasPermission } = useAuth(); + const [dialogOpen, setDialogOpen] = useState(false); + const [tab, setTab] = useState(0); + + const itemColumns: GridColDef[] = [ + { field: 'code', headerName: 'Code', width: 140 }, + { field: 'name', headerName: 'Name', flex: 1 }, + { field: 'unitOfMeasure', headerName: 'UoM', width: 100 }, + { + field: 'isActive', + headerName: 'Status', + width: 100, + renderCell: (params) => , + }, + ]; + + const levelColumns: GridColDef[] = [ + { field: 'item', headerName: 'Item', flex: 1, valueGetter: (_v, row) => row.item?.name }, + { field: 'warehouse', headerName: 'Warehouse', width: 200, valueGetter: (_v, row) => row.warehouse?.name }, + { + field: 'quantityOnHand', + headerName: 'Qty On Hand', + width: 140, + align: 'right', + headerAlign: 'right', + renderCell: (params) => , + }, + ]; + + return ( + + + Stock Items + {hasPermission('stock.item.manage') && ( + + )} + + + setTab(value)} sx={{ mb: 2 }}> + + + + + {tab === 0 && ( + + + + )} + {tab === 1 && ( + + + Quantities on hand are reported by your external stock system — nothing is entered here. + + + + + + )} + + setDialogOpen(false)} /> + + ); +} diff --git a/client/src/hooks/useStockItems.ts b/client/src/hooks/useStockItems.ts new file mode 100644 index 0000000..cf73ab5 --- /dev/null +++ b/client/src/hooks/useStockItems.ts @@ -0,0 +1,41 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + createStockItem, + fetchStockItem, + fetchStockItems, + fetchStockLevels, + updateStockItem, + type StockItemInput, +} from '../api/stockItems.api'; + +export function useStockItems() { + return useQuery({ queryKey: ['stockItems'], queryFn: fetchStockItems }); +} + +export function useStockItem(id: number | undefined) { + return useQuery({ + queryKey: ['stockItem', id], + queryFn: () => fetchStockItem(id as number), + enabled: id !== undefined, + }); +} + +export function useStockLevels(warehouseId?: number) { + return useQuery({ queryKey: ['stockLevels', warehouseId], queryFn: () => fetchStockLevels(warehouseId) }); +} + +export function useCreateStockItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createStockItem, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['stockItems'] }), + }); +} + +export function useUpdateStockItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: Partial & { isActive?: boolean } }) => updateStockItem(id, input), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['stockItems'] }), + }); +} diff --git a/client/src/hooks/useWarehouses.ts b/client/src/hooks/useWarehouses.ts new file mode 100644 index 0000000..d6bf5b7 --- /dev/null +++ b/client/src/hooks/useWarehouses.ts @@ -0,0 +1,23 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { createWarehouse, fetchWarehouses, updateWarehouse, type WarehouseInput } from '../api/warehouses.api'; +import type { BranchSelection } from '../store/branchStore'; + +export function useWarehouses(branchScope: BranchSelection) { + return useQuery({ queryKey: ['warehouses', branchScope], queryFn: () => fetchWarehouses(branchScope) }); +} + +export function useCreateWarehouse() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createWarehouse, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['warehouses'] }), + }); +} + +export function useUpdateWarehouse() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: Partial & { isActive?: boolean } }) => updateWarehouse(id, input), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['warehouses'] }), + }); +} diff --git a/client/src/layouts/MainLayout.tsx b/client/src/layouts/MainLayout.tsx index ac58548..767665d 100644 --- a/client/src/layouts/MainLayout.tsx +++ b/client/src/layouts/MainLayout.tsx @@ -33,6 +33,8 @@ import PercentOutlinedIcon from '@mui/icons-material/PercentOutlined'; import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'; import RequestQuoteOutlinedIcon from '@mui/icons-material/RequestQuoteOutlined'; import PriceCheckOutlinedIcon from '@mui/icons-material/PriceCheckOutlined'; +import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; +import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined'; import { BranchSwitcher } from '../components/common/BranchSwitcher'; import { useAuth } from '../hooks/useAuth'; import { NAVY, NAVY_SURFACE, ACCENT_BLUE } from '../theme'; @@ -68,6 +70,10 @@ const NAV_SECTIONS: { title: string; items: NavItem[] }[] = [ { label: 'Payments', path: '/ap/payments', icon: }, ], }, + { + title: 'Stock', + items: [{ label: 'Stock Items', path: '/stock/items', icon: }], + }, { title: 'General Ledger', items: [ @@ -108,6 +114,12 @@ const NAV_SECTIONS: { title: string; items: NavItem[] }[] = [ }, { label: 'Users & Roles', path: '/admin/users', icon: , permission: 'admin.users.manage' }, { label: 'Tax Codes', path: '/admin/tax-codes', icon: , permission: 'admin.tax.manage' }, + { + label: 'Warehouses', + path: '/admin/warehouses', + icon: , + permission: 'stock.warehouse.manage', + }, ], }, ]; diff --git a/client/src/routes/AppRouter.tsx b/client/src/routes/AppRouter.tsx index ae5f513..19c2c71 100644 --- a/client/src/routes/AppRouter.tsx +++ b/client/src/routes/AppRouter.tsx @@ -33,6 +33,8 @@ import { DebitNoteDetailPage } from '../features/ap/debitNotes/DebitNoteDetailPa import { PaymentsListPage } from '../features/ap/payments/PaymentsListPage'; import { PaymentFormPage } from '../features/ap/payments/PaymentFormPage'; import { PaymentDetailPage } from '../features/ap/payments/PaymentDetailPage'; +import { AdminWarehousesPage } from '../features/admin/warehouses/AdminWarehousesPage'; +import { StockItemsListPage } from '../features/stock/items/StockItemsListPage'; export function AppRouter() { return ( @@ -68,6 +70,9 @@ export function AppRouter() { }> } /> + }> + } /> + } /> } /> @@ -102,6 +107,8 @@ export function AppRouter() { }> } /> + + } /> diff --git a/client/src/routes/ProtectedRoute.tsx b/client/src/routes/ProtectedRoute.tsx index f9e71cf..4b8b806 100644 --- a/client/src/routes/ProtectedRoute.tsx +++ b/client/src/routes/ProtectedRoute.tsx @@ -1,7 +1,8 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Outlet } from 'react-router-dom'; import { Alert, Box, CircularProgress } from '@mui/material'; import { useAuth } from '../hooks/useAuth'; +import { useAuthStore } from '../store/authStore'; interface ProtectedRouteProps { permission?: string; @@ -11,14 +12,23 @@ const managerUrl = import.meta.env.VITE_MANAGER_URL ?? 'http://localhost:5174'; export function ProtectedRoute({ permission }: ProtectedRouteProps) { const { isAuthenticated, hasPermission } = useAuth(); + // The auth store rehydrates from localStorage asynchronously — without waiting for that, + // isAuthenticated reads false on the very first render after a page reload and this would + // bounce straight to the Manager before the persisted session ever gets a chance to load. + const [hasHydrated, setHasHydrated] = useState(useAuthStore.persist.hasHydrated()); useEffect(() => { - if (!isAuthenticated) { + if (hasHydrated) return; + return useAuthStore.persist.onFinishHydration(() => setHasHydrated(true)); + }, [hasHydrated]); + + useEffect(() => { + if (hasHydrated && !isAuthenticated) { window.location.href = managerUrl; } - }, [isAuthenticated]); + }, [hasHydrated, isAuthenticated]); - if (!isAuthenticated) { + if (!hasHydrated || !isAuthenticated) { return ( diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 41eb3df..f92387b 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; export interface AuthUser { id: number; @@ -19,14 +20,18 @@ interface AuthState { hasPermission: (permissionKey: string) => boolean; } -// Tokens are kept in memory only (not localStorage) to limit XSS exposure — a page reload -// requires signing in again in Phase 1. See the implementation plan §4.5. -export const useAuthStore = create((set, get) => ({ - accessToken: null, - refreshToken: null, - user: null, - setSession: ({ accessToken, refreshToken, user }) => set({ accessToken, refreshToken, user }), - setAccessToken: (accessToken) => set({ accessToken }), - clearSession: () => set({ accessToken: null, refreshToken: null, user: null }), - hasPermission: (permissionKey) => get().user?.permissions.includes(permissionKey) ?? false, -})); +// Persisted to localStorage so a page reload doesn't force signing in again via the Manager. +export const useAuthStore = create()( + persist( + (set, get) => ({ + accessToken: null, + refreshToken: null, + user: null, + setSession: ({ accessToken, refreshToken, user }) => set({ accessToken, refreshToken, user }), + setAccessToken: (accessToken) => set({ accessToken }), + clearSession: () => set({ accessToken: null, refreshToken: null, user: null }), + hasPermission: (permissionKey) => get().user?.permissions.includes(permissionKey) ?? false, + }), + { name: 'cfm-auth-session' }, + ), +); diff --git a/client/src/types/api.ts b/client/src/types/api.ts index fd2cd24..53304b9 100644 --- a/client/src/types/api.ts +++ b/client/src/types/api.ts @@ -1,6 +1,6 @@ export type GLAccountType = 'ASSET' | 'LIABILITY' | 'EQUITY' | 'INCOME' | 'EXPENSE'; export type DebitCredit = 'DEBIT' | 'CREDIT'; -export type ControlAccountType = 'AR' | 'AP' | 'STOCK' | 'TAX_OUTPUT' | 'TAX_INPUT' | 'BANK' | 'RETAINED_EARNINGS'; +export type ControlAccountType = 'AR' | 'AP' | 'TAX_OUTPUT' | 'TAX_INPUT' | 'BANK' | 'RETAINED_EARNINGS'; export type SegmentFormat = 'NUMERIC' | 'ALPHA' | 'ALPHANUMERIC'; export type JournalType = 'GENERAL' | 'SALES' | 'PURCHASES' | 'CASH_RECEIPT' | 'CASH_PAYMENT' | 'STOCK' | 'BANK' | 'CUSTOM'; export type JournalEntryStatus = 'DRAFT' | 'POSTED' | 'REVERSED'; @@ -377,3 +377,34 @@ export interface AppUser { roles: { role: Role }[]; branchRestrictions: { branchSegmentValueId: number }[]; } + +// Deliberately minimal: a catalog (StockItem) + cost centers (Warehouse) + a SOH view +// (StockLevel) for a future external stock system to populate — no transactional stock +// documents (GRV, stock takes, recipes) live in this app. See prisma/schema.prisma. +export interface Warehouse { + id: number; + code: string; + name: string; + branchSegmentValueId: number; + isActive: boolean; +} + +export interface StockLevel { + id: number; + itemId: number; + item?: StockItem; + warehouseId: number; + warehouse?: Warehouse; + quantityOnHand: string; + updatedAt: string; +} + +export interface StockItem { + id: number; + code: string; + name: string; + unitOfMeasure: string; + isActive: boolean; + stockLevels?: StockLevel[]; + createdAt: string; +} diff --git a/manager/client/src/routes/ProtectedRoute.tsx b/manager/client/src/routes/ProtectedRoute.tsx index e6c1cbf..f735d2a 100644 --- a/manager/client/src/routes/ProtectedRoute.tsx +++ b/manager/client/src/routes/ProtectedRoute.tsx @@ -1,8 +1,27 @@ +import { useEffect, useState } from 'react'; import { Navigate, Outlet } from 'react-router-dom'; +import { Box, CircularProgress } from '@mui/material'; import { useAuth } from '../hooks/useAuth'; +import { useAuthStore } from '../store/authStore'; export function ProtectedRoute() { const { isAuthenticated } = useAuth(); + // Wait for the persisted session to rehydrate from localStorage before deciding to redirect — + // otherwise a page reload always bounces to /login for a frame before the session loads. + const [hasHydrated, setHasHydrated] = useState(useAuthStore.persist.hasHydrated()); + + useEffect(() => { + if (hasHydrated) return; + return useAuthStore.persist.onFinishHydration(() => setHasHydrated(true)); + }, [hasHydrated]); + + if (!hasHydrated) { + return ( + + + + ); + } if (!isAuthenticated) { return ; } diff --git a/manager/client/src/store/authStore.ts b/manager/client/src/store/authStore.ts index 9e139fa..66061df 100644 --- a/manager/client/src/store/authStore.ts +++ b/manager/client/src/store/authStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; export interface AuthPerson { id: number; @@ -16,13 +17,17 @@ interface AuthState { clearSession: () => void; } -// Tokens kept in memory only, same rationale as the company app: limits XSS exposure at the -// cost of needing to sign in again on a hard page reload. -export const useAuthStore = create((set) => ({ - accessToken: null, - refreshToken: null, - person: null, - setSession: ({ accessToken, refreshToken, person }) => set({ accessToken, refreshToken, person }), - setAccessToken: (accessToken) => set({ accessToken }), - clearSession: () => set({ accessToken: null, refreshToken: null, person: null }), -})); +// Persisted to localStorage so a page reload doesn't force signing in again. +export const useAuthStore = create()( + persist( + (set) => ({ + accessToken: null, + refreshToken: null, + person: null, + setSession: ({ accessToken, refreshToken, person }) => set({ accessToken, refreshToken, person }), + setAccessToken: (accessToken) => set({ accessToken }), + clearSession: () => set({ accessToken: null, refreshToken: null, person: null }), + }), + { name: 'account-manager-auth-session' }, + ), +); diff --git a/prisma/migrations/20260903121005_add_stock_module/migration.sql b/prisma/migrations/20260903121005_add_stock_module/migration.sql new file mode 100644 index 0000000..657880f --- /dev/null +++ b/prisma/migrations/20260903121005_add_stock_module/migration.sql @@ -0,0 +1,285 @@ +-- CreateEnum +CREATE TYPE "CostingMethod" AS ENUM ('MANUAL', 'LATEST', 'MOVING_AVERAGE'); + +-- CreateEnum +CREATE TYPE "StockMovementType" AS ENUM ('RECEIPT', 'ISSUE', 'TRANSFER_IN', 'TRANSFER_OUT', 'CONSUMPTION', 'PRODUCTION_IN', 'PRODUCTION_OUT', 'ADJUSTMENT_IN', 'ADJUSTMENT_OUT'); + +-- CreateEnum +CREATE TYPE "StockDocumentStatus" AS ENUM ('DRAFT', 'POSTED', 'VOID'); + +-- AlterEnum +ALTER TYPE "ControlAccountType" ADD VALUE 'GRN'; + +-- DropForeignKey +ALTER TABLE "APInvoiceLine" DROP CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey"; + +-- AlterTable +ALTER TABLE "APInvoiceLine" ADD COLUMN "grvLineId" INTEGER, +ALTER COLUMN "expenseGLAccountId" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "Warehouse" ( + "id" SERIAL NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "branchSegmentValueId" INTEGER NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Warehouse_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "StockItem" ( + "id" SERIAL NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "unitOfMeasure" TEXT NOT NULL, + "costingMethod" "CostingMethod" NOT NULL DEFAULT 'MOVING_AVERAGE', + "manualCost" DECIMAL(18,4), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StockItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "StockLevel" ( + "id" SERIAL NOT NULL, + "itemId" INTEGER NOT NULL, + "warehouseId" INTEGER NOT NULL, + "quantityOnHand" DECIMAL(18,4) NOT NULL DEFAULT 0, + "averageCost" DECIMAL(18,4) NOT NULL DEFAULT 0, + "lastCost" DECIMAL(18,4) NOT NULL DEFAULT 0, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "StockLevel_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "StockMovement" ( + "id" SERIAL NOT NULL, + "itemId" INTEGER NOT NULL, + "warehouseId" INTEGER NOT NULL, + "movementType" "StockMovementType" NOT NULL, + "quantity" DECIMAL(18,4) NOT NULL, + "unitCost" DECIMAL(18,4) NOT NULL, + "totalCost" DECIMAL(18,2) NOT NULL, + "movementDate" TIMESTAMP(3) NOT NULL, + "description" TEXT, + "branchSegmentValueId" INTEGER NOT NULL, + "sourceType" TEXT, + "sourceId" INTEGER, + "journalEntryId" INTEGER, + "createdById" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Recipe" ( + "id" SERIAL NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "outputItemId" INTEGER NOT NULL, + "yieldQuantity" DECIMAL(18,4) NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Recipe_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RecipeComponent" ( + "id" SERIAL NOT NULL, + "recipeId" INTEGER NOT NULL, + "componentItemId" INTEGER NOT NULL, + "quantity" DECIMAL(18,4) NOT NULL, + + CONSTRAINT "RecipeComponent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "StockAdjustment" ( + "id" SERIAL NOT NULL, + "adjustmentNumber" TEXT NOT NULL, + "warehouseId" INTEGER NOT NULL, + "branchSegmentValueId" INTEGER NOT NULL, + "adjustmentDate" TIMESTAMP(3) NOT NULL, + "reference" TEXT, + "status" "StockDocumentStatus" NOT NULL DEFAULT 'DRAFT', + "journalEntryId" INTEGER, + "createdById" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StockAdjustment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "StockAdjustmentLine" ( + "id" SERIAL NOT NULL, + "adjustmentId" INTEGER NOT NULL, + "itemId" INTEGER NOT NULL, + "systemQuantity" DECIMAL(18,4) NOT NULL, + "countedQuantity" DECIMAL(18,4) NOT NULL, + "unitCost" DECIMAL(18,4) NOT NULL, + "varianceValue" DECIMAL(18,2) NOT NULL, + + CONSTRAINT "StockAdjustmentLine_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GoodsReceivedVoucher" ( + "id" SERIAL NOT NULL, + "grvNumber" TEXT NOT NULL, + "supplierId" INTEGER NOT NULL, + "warehouseId" INTEGER NOT NULL, + "branchSegmentValueId" INTEGER NOT NULL, + "grvDate" TIMESTAMP(3) NOT NULL, + "reference" TEXT, + "status" "StockDocumentStatus" NOT NULL DEFAULT 'DRAFT', + "journalEntryId" INTEGER, + "createdById" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GoodsReceivedVoucher_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GoodsReceivedVoucherLine" ( + "id" SERIAL NOT NULL, + "grvId" INTEGER NOT NULL, + "itemId" INTEGER NOT NULL, + "quantity" DECIMAL(18,4) NOT NULL, + "unitPrice" DECIMAL(18,4) NOT NULL, + "lineTotal" DECIMAL(18,2) NOT NULL, + "matchedQuantity" DECIMAL(18,4) NOT NULL DEFAULT 0, + + CONSTRAINT "GoodsReceivedVoucherLine_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Warehouse_code_key" ON "Warehouse"("code"); + +-- CreateIndex +CREATE INDEX "Warehouse_branchSegmentValueId_idx" ON "Warehouse"("branchSegmentValueId"); + +-- CreateIndex +CREATE UNIQUE INDEX "StockItem_code_key" ON "StockItem"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "StockLevel_itemId_warehouseId_key" ON "StockLevel"("itemId", "warehouseId"); + +-- CreateIndex +CREATE INDEX "StockMovement_itemId_warehouseId_idx" ON "StockMovement"("itemId", "warehouseId"); + +-- CreateIndex +CREATE INDEX "StockMovement_sourceType_sourceId_idx" ON "StockMovement"("sourceType", "sourceId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Recipe_code_key" ON "Recipe"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "Recipe_outputItemId_key" ON "Recipe"("outputItemId"); + +-- CreateIndex +CREATE INDEX "RecipeComponent_recipeId_idx" ON "RecipeComponent"("recipeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "StockAdjustment_adjustmentNumber_key" ON "StockAdjustment"("adjustmentNumber"); + +-- CreateIndex +CREATE INDEX "StockAdjustment_warehouseId_idx" ON "StockAdjustment"("warehouseId"); + +-- CreateIndex +CREATE INDEX "StockAdjustment_branchSegmentValueId_idx" ON "StockAdjustment"("branchSegmentValueId"); + +-- CreateIndex +CREATE INDEX "StockAdjustment_status_idx" ON "StockAdjustment"("status"); + +-- CreateIndex +CREATE INDEX "StockAdjustmentLine_adjustmentId_idx" ON "StockAdjustmentLine"("adjustmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "GoodsReceivedVoucher_grvNumber_key" ON "GoodsReceivedVoucher"("grvNumber"); + +-- CreateIndex +CREATE INDEX "GoodsReceivedVoucher_supplierId_idx" ON "GoodsReceivedVoucher"("supplierId"); + +-- CreateIndex +CREATE INDEX "GoodsReceivedVoucher_warehouseId_idx" ON "GoodsReceivedVoucher"("warehouseId"); + +-- CreateIndex +CREATE INDEX "GoodsReceivedVoucher_branchSegmentValueId_idx" ON "GoodsReceivedVoucher"("branchSegmentValueId"); + +-- CreateIndex +CREATE INDEX "GoodsReceivedVoucher_status_idx" ON "GoodsReceivedVoucher"("status"); + +-- CreateIndex +CREATE INDEX "GoodsReceivedVoucherLine_grvId_idx" ON "GoodsReceivedVoucherLine"("grvId"); + +-- CreateIndex +CREATE INDEX "APInvoiceLine_grvLineId_idx" ON "APInvoiceLine"("grvLineId"); + +-- AddForeignKey +ALTER TABLE "JournalEntryLine" ADD CONSTRAINT "JournalEntryLine_stockMovementId_fkey" FOREIGN KEY ("stockMovementId") REFERENCES "StockMovement"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "APInvoiceLine" ADD CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey" FOREIGN KEY ("expenseGLAccountId") REFERENCES "GLAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "APInvoiceLine" ADD CONSTRAINT "APInvoiceLine_grvLineId_fkey" FOREIGN KEY ("grvLineId") REFERENCES "GoodsReceivedVoucherLine"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Warehouse" ADD CONSTRAINT "Warehouse_branchSegmentValueId_fkey" FOREIGN KEY ("branchSegmentValueId") REFERENCES "AccountSegmentValue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StockLevel" ADD CONSTRAINT "StockLevel_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StockLevel" ADD CONSTRAINT "StockLevel_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Recipe" ADD CONSTRAINT "Recipe_outputItemId_fkey" FOREIGN KEY ("outputItemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RecipeComponent" ADD CONSTRAINT "RecipeComponent_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "Recipe"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RecipeComponent" ADD CONSTRAINT "RecipeComponent_componentItemId_fkey" FOREIGN KEY ("componentItemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StockAdjustment" ADD CONSTRAINT "StockAdjustment_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StockAdjustment" ADD CONSTRAINT "StockAdjustment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StockAdjustmentLine" ADD CONSTRAINT "StockAdjustmentLine_adjustmentId_fkey" FOREIGN KEY ("adjustmentId") REFERENCES "StockAdjustment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StockAdjustmentLine" ADD CONSTRAINT "StockAdjustmentLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GoodsReceivedVoucher" ADD CONSTRAINT "GoodsReceivedVoucher_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Supplier"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GoodsReceivedVoucher" ADD CONSTRAINT "GoodsReceivedVoucher_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GoodsReceivedVoucher" ADD CONSTRAINT "GoodsReceivedVoucher_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GoodsReceivedVoucherLine" ADD CONSTRAINT "GoodsReceivedVoucherLine_grvId_fkey" FOREIGN KEY ("grvId") REFERENCES "GoodsReceivedVoucher"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GoodsReceivedVoucherLine" ADD CONSTRAINT "GoodsReceivedVoucherLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260903121436_add_variance_control_types/migration.sql b/prisma/migrations/20260903121436_add_variance_control_types/migration.sql new file mode 100644 index 0000000..81696ad --- /dev/null +++ b/prisma/migrations/20260903121436_add_variance_control_types/migration.sql @@ -0,0 +1,11 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "ControlAccountType" ADD VALUE 'STOCK_VARIANCE'; +ALTER TYPE "ControlAccountType" ADD VALUE 'PURCHASE_PRICE_VARIANCE'; + diff --git a/prisma/migrations/20260903131116_simplify_stock_module/migration.sql b/prisma/migrations/20260903131116_simplify_stock_module/migration.sql new file mode 100644 index 0000000..e321698 --- /dev/null +++ b/prisma/migrations/20260903131116_simplify_stock_module/migration.sql @@ -0,0 +1,96 @@ +-- Simplifies Stock down to a catalog (StockItem) + cost centers (Warehouse) + a SOH view +-- (StockLevel) for a future Matrix feed to populate. Removes the GRV / Stock Take / Recipe +-- production transactional workflows entirely — physical stock transactions belong in Matrix, +-- not in this accounting system. + +-- Data cleanup before structural changes (dev/test fixtures from the removed GRV-matching flow). +DELETE FROM "APInvoiceLine" WHERE "expenseGLAccountId" IS NULL; + +-- Demote the Stock-related control accounts (1200 Stock, 2050 GRN Accrual, 5200 Stock Variance, +-- 5300 Purchase Price Variance) back to plain, manually-postable accounts rather than deleting +-- them — several already have real posted JournalEntryLine rows against them from testing, and +-- posted ledger history is never deleted. With the GRV/Stock-Take/Recipe workflows gone, nothing +-- else in this app owns these accounts, and leaving them locked as control accounts would make +-- them permanently unpostable by anyone. +UPDATE "GLAccount" SET "isControlAccount" = false, "controlType" = NULL +WHERE "controlType" IN ('STOCK', 'GRN', 'STOCK_VARIANCE', 'PURCHASE_PRICE_VARIANCE'); + +-- AlterEnum +BEGIN; +CREATE TYPE "ControlAccountType_new" AS ENUM ('AR', 'AP', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS'); +ALTER TABLE "GLAccount" ALTER COLUMN "controlType" TYPE "ControlAccountType_new" USING ("controlType"::text::"ControlAccountType_new"); +ALTER TYPE "ControlAccountType" RENAME TO "ControlAccountType_old"; +ALTER TYPE "ControlAccountType_new" RENAME TO "ControlAccountType"; +DROP TYPE "ControlAccountType_old"; +COMMIT; + +-- DropForeignKey +ALTER TABLE "APInvoiceLine" DROP CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey"; +ALTER TABLE "APInvoiceLine" DROP CONSTRAINT "APInvoiceLine_grvLineId_fkey"; +ALTER TABLE "GoodsReceivedVoucher" DROP CONSTRAINT "GoodsReceivedVoucher_createdById_fkey"; +ALTER TABLE "GoodsReceivedVoucher" DROP CONSTRAINT "GoodsReceivedVoucher_supplierId_fkey"; +ALTER TABLE "GoodsReceivedVoucher" DROP CONSTRAINT "GoodsReceivedVoucher_warehouseId_fkey"; +ALTER TABLE "GoodsReceivedVoucherLine" DROP CONSTRAINT "GoodsReceivedVoucherLine_grvId_fkey"; +ALTER TABLE "GoodsReceivedVoucherLine" DROP CONSTRAINT "GoodsReceivedVoucherLine_itemId_fkey"; +ALTER TABLE "JournalEntryLine" DROP CONSTRAINT "JournalEntryLine_stockMovementId_fkey"; +ALTER TABLE "Recipe" DROP CONSTRAINT "Recipe_outputItemId_fkey"; +ALTER TABLE "RecipeComponent" DROP CONSTRAINT "RecipeComponent_componentItemId_fkey"; +ALTER TABLE "RecipeComponent" DROP CONSTRAINT "RecipeComponent_recipeId_fkey"; +ALTER TABLE "StockAdjustment" DROP CONSTRAINT "StockAdjustment_createdById_fkey"; +ALTER TABLE "StockAdjustment" DROP CONSTRAINT "StockAdjustment_warehouseId_fkey"; +ALTER TABLE "StockAdjustmentLine" DROP CONSTRAINT "StockAdjustmentLine_adjustmentId_fkey"; +ALTER TABLE "StockAdjustmentLine" DROP CONSTRAINT "StockAdjustmentLine_itemId_fkey"; +ALTER TABLE "StockMovement" DROP CONSTRAINT "StockMovement_createdById_fkey"; +ALTER TABLE "StockMovement" DROP CONSTRAINT "StockMovement_itemId_fkey"; +ALTER TABLE "StockMovement" DROP CONSTRAINT "StockMovement_warehouseId_fkey"; + +-- DropIndex +DROP INDEX "APInvoiceLine_grvLineId_idx"; + +-- AlterTable +ALTER TABLE "APInvoiceLine" DROP COLUMN "grvLineId", +ALTER COLUMN "expenseGLAccountId" SET NOT NULL; + +-- AlterTable +ALTER TABLE "JournalEntryLine" DROP COLUMN "stockMovementId"; + +-- AlterTable +ALTER TABLE "StockItem" DROP COLUMN "costingMethod", +DROP COLUMN "manualCost"; + +-- AlterTable +ALTER TABLE "StockLevel" DROP COLUMN "averageCost", +DROP COLUMN "lastCost"; + +-- DropTable +DROP TABLE "GoodsReceivedVoucher"; + +-- DropTable +DROP TABLE "GoodsReceivedVoucherLine"; + +-- DropTable +DROP TABLE "Recipe"; + +-- DropTable +DROP TABLE "RecipeComponent"; + +-- DropTable +DROP TABLE "StockAdjustment"; + +-- DropTable +DROP TABLE "StockAdjustmentLine"; + +-- DropTable +DROP TABLE "StockMovement"; + +-- DropEnum +DROP TYPE "CostingMethod"; + +-- DropEnum +DROP TYPE "StockDocumentStatus"; + +-- DropEnum +DROP TYPE "StockMovementType"; + +-- AddForeignKey +ALTER TABLE "APInvoiceLine" ADD CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey" FOREIGN KEY ("expenseGLAccountId") REFERENCES "GLAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e029536..18b938c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -105,6 +105,7 @@ model AccountSegmentValue { glAccountLinks GLAccountSegmentValue[] customers Customer[] suppliers Supplier[] + warehouses Warehouse[] @@unique([segmentDefId, code]) } @@ -134,7 +135,6 @@ enum DebitCredit { enum ControlAccountType { AR AP - STOCK TAX_OUTPUT TAX_INPUT BANK @@ -253,13 +253,11 @@ model JournalEntryLine { credit Decimal @default(0) @db.Decimal(18, 2) description String? branchSegmentValueId Int? - // Sub-ledger cross-references for drill-down. arInvoiceId/apInvoiceId are real FKs now that - // AR/AP exist; stockMovementId stays a plain id until Stock lands in its own phase. + // Sub-ledger cross-references for drill-down. arInvoiceId Int? arInvoice ARInvoice? @relation(fields: [arInvoiceId], references: [id]) apInvoiceId Int? apInvoice APInvoice? @relation(fields: [apInvoiceId], references: [id]) - stockMovementId Int? currencyCode String @default("BASE") exchangeRateToBase Decimal @default(1) @db.Decimal(18, 6) @@ -615,6 +613,52 @@ model APAllocation { @@index([debitNoteId]) } +// --------------------------------------------------------------------------- +// Stock (catalog + cost centers only — see the comment on Warehouse below) +// --------------------------------------------------------------------------- + +// Deliberately minimal: this is accounting software, not a stock/warehouse system. Physical +// stock transactions (goods received, stock takes, recipe production) belong in Matrix (the +// external hospitality/retail stock system this app is designed to eventually integrate with) +// — this app only needs a catalog of items, a set of cost centers to view SOH by, and a place +// for whatever quantities Matrix eventually reports. No costing engine, no movement ledger, no +// document workflow live here. +model Warehouse { + id Int @id @default(autoincrement()) + code String @unique + name String + branchSegmentValueId Int + branchSegmentValue AccountSegmentValue @relation(fields: [branchSegmentValueId], references: [id]) + isActive Boolean @default(true) + stockLevels StockLevel[] + + @@index([branchSegmentValueId]) +} + +model StockItem { + id Int @id @default(autoincrement()) + code String @unique + name String + unitOfMeasure String + isActive Boolean @default(true) + stockLevels StockLevel[] + createdAt DateTime @default(now()) +} + +// Quantity on hand per item/warehouse — a read target for a future Matrix feed, not written to +// from any UI in this app today. +model StockLevel { + id Int @id @default(autoincrement()) + itemId Int + item StockItem @relation(fields: [itemId], references: [id]) + warehouseId Int + warehouse Warehouse @relation(fields: [warehouseId], references: [id]) + quantityOnHand Decimal @default(0) @db.Decimal(18, 4) + updatedAt DateTime @updatedAt + + @@unique([itemId, warehouseId]) +} + // --------------------------------------------------------------------------- // Auth / RBAC // --------------------------------------------------------------------------- diff --git a/prisma/seed.ts b/prisma/seed.ts index 15faabd..91424ac 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -18,6 +18,8 @@ const PERMISSIONS = [ 'ap.invoice.manage', 'ap.debitnote.manage', 'ap.payment.manage', + 'stock.warehouse.manage', + 'stock.item.manage', ] as const; const ROLE_PERMISSIONS: Record = { @@ -35,6 +37,8 @@ const ROLE_PERMISSIONS: Record = { 'ap.invoice.manage', 'ap.debitnote.manage', 'ap.payment.manage', + 'stock.warehouse.manage', + 'stock.item.manage', ], AR_AP_CLERK: [ 'ar.customer.manage', @@ -46,6 +50,7 @@ const ROLE_PERMISSIONS: Record = { 'ap.debitnote.manage', 'ap.payment.manage', ], + STOCK_CLERK: ['stock.warehouse.manage', 'stock.item.manage'], BRANCH_USER: [], READ_ONLY: [], }; @@ -98,14 +103,17 @@ async function main() { { code: '1100', description: 'Accounts Receivable Control' }, { code: '1200', description: 'Inventory / Stock Control' }, { code: '2000', description: 'Accounts Payable Control' }, + { code: '2050', description: 'Goods Received Not Invoiced' }, { code: '2100', description: 'Output Tax Payable' }, { code: '2200', description: 'Input Tax Receivable' }, + { code: '2300', description: 'Accrued Expenses' }, { code: '3000', description: 'Retained Earnings' }, { code: '3100', description: 'Share Capital' }, { code: '4000', description: 'Sales Income' }, { code: '5000', description: 'Cost of Goods Sold' }, { code: '5100', description: 'Operating Expenses' }, { code: '5200', description: 'Stock Variance' }, + { code: '5300', description: 'Purchase Price Variance' }, ]; const masterByCode: Record = {}; for (const value of masterValues) { @@ -157,14 +165,19 @@ async function main() { }> = [ { masterCode: '1000', accountName: 'Bank / Cash', accountType: 'ASSET', normalBalance: 'DEBIT', controlType: 'BANK' }, { masterCode: '1100', accountName: 'Accounts Receivable Control', accountType: 'ASSET', normalBalance: 'DEBIT', isControlAccount: true, controlType: 'AR' }, - { masterCode: '1200', accountName: 'Inventory / Stock Control', accountType: 'ASSET', normalBalance: 'DEBIT', isControlAccount: true, controlType: 'STOCK' }, + // Not a control account: no subledger in this app owns stock value — see the Stock section + // of prisma/schema.prisma. Postable directly via manual journal like any other GL account. + { masterCode: '1200', accountName: 'Inventory / Stock Control', accountType: 'ASSET', normalBalance: 'DEBIT' }, { masterCode: '2000', accountName: 'Accounts Payable Control', accountType: 'LIABILITY', normalBalance: 'CREDIT', isControlAccount: true, controlType: 'AP' }, + { masterCode: '2050', accountName: 'Goods Received Not Invoiced', accountType: 'LIABILITY', normalBalance: 'CREDIT' }, { masterCode: '2100', accountName: 'Output Tax Payable', accountType: 'LIABILITY', normalBalance: 'CREDIT', isControlAccount: true, controlType: 'TAX_OUTPUT' }, { masterCode: '2200', accountName: 'Input Tax Receivable', accountType: 'ASSET', normalBalance: 'DEBIT', isControlAccount: true, controlType: 'TAX_INPUT' }, + { masterCode: '2300', accountName: 'Accrued Expenses', accountType: 'LIABILITY', normalBalance: 'CREDIT' }, { masterCode: '4000', accountName: 'Sales Income', accountType: 'INCOME', normalBalance: 'CREDIT' }, { masterCode: '5000', accountName: 'Cost of Goods Sold', accountType: 'EXPENSE', normalBalance: 'DEBIT' }, { masterCode: '5100', accountName: 'Operating Expenses', accountType: 'EXPENSE', normalBalance: 'DEBIT' }, { masterCode: '5200', accountName: 'Stock Variance', accountType: 'EXPENSE', normalBalance: 'DEBIT' }, + { masterCode: '5300', accountName: 'Purchase Price Variance', accountType: 'EXPENSE', normalBalance: 'DEBIT' }, ]; for (const branchCode of Object.keys(branchByCode)) { @@ -189,7 +202,13 @@ async function main() { ], }, }, - update: {}, + update: { + accountName: `${account.accountName} — ${branchCode}`, + accountType: account.accountType, + normalBalance: account.normalBalance, + isControlAccount: account.isControlAccount ?? false, + controlType: account.controlType ?? null, + }, }); } } @@ -218,7 +237,7 @@ async function main() { ], }, }, - update: {}, + update: { accountName: account.accountName, controlType: account.controlType ?? null }, }); } @@ -258,6 +277,20 @@ async function main() { create: { code: 'CASH_PAYMENT', name: 'Cash Payments Journal', journalType: JournalType.CASH_PAYMENT }, update: {}, }); + await prisma.journal.upsert({ + where: { code: 'STOCK' }, + create: { code: 'STOCK', name: 'Stock Journal', journalType: JournalType.STOCK }, + update: {}, + }); + + console.log('Seeding warehouses...'); + for (const branchCode of Object.keys(branchByCode)) { + await prisma.warehouse.upsert({ + where: { code: `WH-${branchCode}` }, + create: { code: `WH-${branchCode}`, name: `Main Warehouse — ${branchCode}`, branchSegmentValueId: branchByCode[branchCode].id }, + update: {}, + }); + } console.log('Seeding tax codes...'); const taxCodes = [ diff --git a/src/app.ts b/src/app.ts index f014e75..edd9db0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -19,6 +19,8 @@ import { suppliersRouter } from './modules/ap/suppliers.routes'; import { apInvoicesRouter } from './modules/ap/apInvoices.routes'; import { apDebitNotesRouter } from './modules/ap/apDebitNotes.routes'; import { apPaymentsRouter } from './modules/ap/apPayments.routes'; +import { warehousesRouter } from './modules/stock/warehouses.routes'; +import { stockItemsRouter } from './modules/stock/stockItems.routes'; export function createApp(): Express { const app = express(); @@ -46,6 +48,8 @@ export function createApp(): Express { app.use('/api/ap/invoices', apInvoicesRouter); app.use('/api/ap/debit-notes', apDebitNotesRouter); app.use('/api/ap/payments', apPaymentsRouter); + app.use('/api/stock/warehouses', warehousesRouter); + app.use('/api/stock/items', stockItemsRouter); app.use((req, res) => res.status(404).json({ error: 'Not found' })); app.use(errorHandler); diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index 5802250..a1d0aec 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -1,6 +1,6 @@ import { NextFunction, Request, Response } from 'express'; import { Prisma } from '@prisma/client'; -import { UnbalancedEntryError } from '../modules/gl/posting.types'; +import { ClosedPeriodError, InvalidLineError, UnbalancedEntryError } from '../modules/gl/posting.types'; import { ForbiddenBranchScopeError } from '../lib/branchFilter'; import { ControlAccountNotFoundError } from '../lib/controlAccounts'; import { ARDocumentStateError, ARAllocationError } from '../modules/ar/ar.errors'; @@ -21,7 +21,9 @@ export function errorHandler(error: unknown, req: Request, res: Response, next: error instanceof ARDocumentStateError || error instanceof ARAllocationError || error instanceof APDocumentStateError || - error instanceof APAllocationError + error instanceof APAllocationError || + error instanceof InvalidLineError || + error instanceof ClosedPeriodError ) { res.status(422).json({ error: error.message }); return; diff --git a/src/modules/ap/apPosting.service.ts b/src/modules/ap/apPosting.service.ts index 4a9d2db..4c866ea 100644 --- a/src/modules/ap/apPosting.service.ts +++ b/src/modules/ap/apPosting.service.ts @@ -1,6 +1,6 @@ import { APDebitNote, APDebitNoteLine, APInvoice, APInvoiceLine, APPayment, JournalEntry, Supplier } from '@prisma/client'; import { prisma } from '../../lib/prisma'; -import { money, sum, ZERO } from '../../lib/money'; +import { money, sum, ZERO, Money } from '../../lib/money'; import { resolveControlAccount } from '../../lib/controlAccounts'; import { PostingService } from '../gl/posting.service'; import { JournalEntryDraft, JournalLineDraft } from '../gl/posting.types'; @@ -9,8 +9,8 @@ type InvoiceForPosting = APInvoice & { lines: APInvoiceLine[]; supplier: Supplie type DebitNoteForPosting = APDebitNote & { lines: APDebitNoteLine[]; supplier: Supplier }; type PaymentForPosting = APPayment & { supplier: Supplier }; -function groupByAccount(entries: { glAccountId: number; amount: import('../../lib/money').Money }[]) { - const byAccount = new Map(); +function groupByAccount(entries: { glAccountId: number; amount: Money }[]) { + const byAccount = new Map(); for (const entry of entries) { byAccount.set(entry.glAccountId, (byAccount.get(entry.glAccountId) ?? ZERO).plus(entry.amount)); } @@ -156,6 +156,6 @@ export function calcLine(quantity: number, unitPrice: number, ratePercent: numbe return { subtotal, tax, total: subtotal.plus(tax) }; } -export function sumLines(values: import('../../lib/money').Money[]) { +export function sumLines(values: Money[]) { return sum(values); } diff --git a/src/modules/gl/glAccounts.controller.ts b/src/modules/gl/glAccounts.controller.ts index de22b49..a792789 100644 --- a/src/modules/gl/glAccounts.controller.ts +++ b/src/modules/gl/glAccounts.controller.ts @@ -4,7 +4,7 @@ import * as glAccountsService from './glAccounts.service'; const glAccountTypeSchema = z.enum(['ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE']); const debitCreditSchema = z.enum(['DEBIT', 'CREDIT']); -const controlAccountTypeSchema = z.enum(['AR', 'AP', 'STOCK', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS']); +const controlAccountTypeSchema = z.enum(['AR', 'AP', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS']); export const createGLAccountSchema = z.object({ accountName: z.string().min(1), diff --git a/src/modules/gl/journalEntries.service.ts b/src/modules/gl/journalEntries.service.ts index ab1b970..de76313 100644 --- a/src/modules/gl/journalEntries.service.ts +++ b/src/modules/gl/journalEntries.service.ts @@ -1,7 +1,7 @@ import { prisma } from '../../lib/prisma'; import { BranchScope } from '../../lib/branchFilter'; import { PostingService } from './posting.service'; -import { JournalEntryDraft } from './posting.types'; +import { InvalidLineError, JournalEntryDraft } from './posting.types'; export interface ManualJournalLineInput { glAccountId: number; @@ -18,7 +18,27 @@ export interface CreateManualEntryInput { lines: ManualJournalLineInput[]; } -export function createManualEntry(input: CreateManualEntryInput, createdById: number) { +/** + * Control accounts (AR/AP/GRN/Tax/Stock/Bank) are only ever written by their owning module's + * posting service — a manual GL journal can't touch them directly. Otherwise a manual entry + * could silently desync a control account balance from its sub-ledger (e.g. debiting AR Control + * by hand with no corresponding invoice), which the sub-ledger screens would never reflect. + */ +async function assertNoControlAccountLines(lines: ManualJournalLineInput[]): Promise { + const accountIds = [...new Set(lines.map((line) => line.glAccountId))]; + const accounts = await prisma.gLAccount.findMany({ where: { id: { in: accountIds } } }); + const controlAccounts = accounts.filter((account) => account.isControlAccount); + if (controlAccounts.length > 0) { + const names = controlAccounts.map((account) => `${account.accountCode} (${account.controlType})`).join(', '); + throw new InvalidLineError( + `Manual journal entries cannot post directly to a control account: ${names}. Use the AR/AP/Stock module that owns it instead.`, + ); + } +} + +export async function createManualEntry(input: CreateManualEntryInput, createdById: number) { + await assertNoControlAccountLines(input.lines); + const draft: JournalEntryDraft = { journalCode: input.journalCode, entryDate: new Date(input.entryDate), diff --git a/src/modules/gl/posting.service.ts b/src/modules/gl/posting.service.ts index 918311f..7c2c5c2 100644 --- a/src/modules/gl/posting.service.ts +++ b/src/modules/gl/posting.service.ts @@ -73,7 +73,6 @@ export async function postEntry(draft: JournalEntryDraft): Promise branchSegmentValueId: line.branchSegmentValueId, arInvoiceId: line.arInvoiceId, apInvoiceId: line.apInvoiceId, - stockMovementId: line.stockMovementId, currencyCode: line.currencyCode ?? 'BASE', exchangeRateToBase: line.exchangeRateToBase ? money(line.exchangeRateToBase) : money(1), })), @@ -124,7 +123,6 @@ export async function reverseEntry(journalEntryId: number, createdById: number, branchSegmentValueId: line.branchSegmentValueId ?? undefined, arInvoiceId: line.arInvoiceId ?? undefined, apInvoiceId: line.apInvoiceId ?? undefined, - stockMovementId: line.stockMovementId ?? undefined, currencyCode: line.currencyCode, exchangeRateToBase: line.exchangeRateToBase, })), diff --git a/src/modules/gl/posting.types.ts b/src/modules/gl/posting.types.ts index 92c6bb4..64bad5a 100644 --- a/src/modules/gl/posting.types.ts +++ b/src/modules/gl/posting.types.ts @@ -29,7 +29,6 @@ export interface JournalLineDraft { branchSegmentValueId?: number; arInvoiceId?: number; apInvoiceId?: number; - stockMovementId?: number; currencyCode?: string; exchangeRateToBase?: Prisma.Decimal.Value; } diff --git a/src/modules/stock/stockItems.controller.ts b/src/modules/stock/stockItems.controller.ts new file mode 100644 index 0000000..af8bb9c --- /dev/null +++ b/src/modules/stock/stockItems.controller.ts @@ -0,0 +1,39 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import * as stockItemsService from './stockItems.service'; + +export const createStockItemSchema = z.object({ + code: z.string().min(1), + name: z.string().min(1), + unitOfMeasure: z.string().min(1), +}); + +export const updateStockItemSchema = createStockItemSchema.partial().extend({ isActive: z.boolean().optional() }); + +export async function listStockItemsHandler(_req: Request, res: Response): Promise { + res.json(await stockItemsService.listStockItems()); +} + +export async function createStockItemHandler(req: Request, res: Response): Promise { + const input = req.body as z.infer; + res.status(201).json(await stockItemsService.createStockItem(input)); +} + +export async function updateStockItemHandler(req: Request, res: Response): Promise { + const input = req.body as z.infer; + res.json(await stockItemsService.updateStockItem(Number(req.params.id), input)); +} + +export async function getStockItemHandler(req: Request, res: Response): Promise { + const item = await stockItemsService.getStockItem(Number(req.params.id)); + if (!item) { + res.status(404).json({ error: 'Stock item not found' }); + return; + } + res.json(item); +} + +export async function listStockLevelsHandler(req: Request, res: Response): Promise { + const warehouseId = req.query.warehouseId ? Number(req.query.warehouseId) : undefined; + res.json(await stockItemsService.listStockLevels(warehouseId)); +} diff --git a/src/modules/stock/stockItems.routes.ts b/src/modules/stock/stockItems.routes.ts new file mode 100644 index 0000000..047bac2 --- /dev/null +++ b/src/modules/stock/stockItems.routes.ts @@ -0,0 +1,34 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth'; +import { requirePermission } from '../../middleware/rbac'; +import { validate } from '../../middleware/validate'; +import { asyncHandler } from '../../lib/asyncHandler'; +import { + createStockItemHandler, + createStockItemSchema, + getStockItemHandler, + listStockItemsHandler, + listStockLevelsHandler, + updateStockItemHandler, + updateStockItemSchema, +} from './stockItems.controller'; + +export const stockItemsRouter = Router(); + +stockItemsRouter.use(requireAuth); + +stockItemsRouter.get('/', asyncHandler(listStockItemsHandler)); +stockItemsRouter.get('/levels', asyncHandler(listStockLevelsHandler)); +stockItemsRouter.get('/:id', asyncHandler(getStockItemHandler)); +stockItemsRouter.post( + '/', + requirePermission('stock.item.manage'), + validate(createStockItemSchema), + asyncHandler(createStockItemHandler), +); +stockItemsRouter.put( + '/:id', + requirePermission('stock.item.manage'), + validate(updateStockItemSchema), + asyncHandler(updateStockItemHandler), +); diff --git a/src/modules/stock/stockItems.service.ts b/src/modules/stock/stockItems.service.ts new file mode 100644 index 0000000..7779886 --- /dev/null +++ b/src/modules/stock/stockItems.service.ts @@ -0,0 +1,34 @@ +import { prisma } from '../../lib/prisma'; + +export interface StockItemInput { + code: string; + name: string; + unitOfMeasure: string; +} + +export function listStockItems() { + return prisma.stockItem.findMany({ orderBy: { name: 'asc' } }); +} + +export function createStockItem(input: StockItemInput) { + return prisma.stockItem.create({ data: input }); +} + +export function updateStockItem(id: number, input: Partial & { isActive?: boolean }) { + return prisma.stockItem.update({ where: { id }, data: input }); +} + +export function getStockItem(id: number) { + return prisma.stockItem.findUnique({ + where: { id }, + include: { stockLevels: { include: { warehouse: true } } }, + }); +} + +export function listStockLevels(warehouseId?: number) { + return prisma.stockLevel.findMany({ + where: warehouseId ? { warehouseId } : {}, + include: { item: true, warehouse: true }, + orderBy: [{ warehouse: { name: 'asc' } }, { item: { name: 'asc' } }], + }); +} diff --git a/src/modules/stock/warehouses.controller.ts b/src/modules/stock/warehouses.controller.ts new file mode 100644 index 0000000..6bdc68a --- /dev/null +++ b/src/modules/stock/warehouses.controller.ts @@ -0,0 +1,25 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import * as warehousesService from './warehouses.service'; + +export const createWarehouseSchema = z.object({ + code: z.string().min(1), + name: z.string().min(1), + branchSegmentValueId: z.number().int(), +}); + +export const updateWarehouseSchema = createWarehouseSchema.partial().extend({ isActive: z.boolean().optional() }); + +export async function listWarehousesHandler(req: Request, res: Response): Promise { + res.json(await warehousesService.listWarehouses(req.branchScope!)); +} + +export async function createWarehouseHandler(req: Request, res: Response): Promise { + const input = req.body as z.infer; + res.status(201).json(await warehousesService.createWarehouse(input)); +} + +export async function updateWarehouseHandler(req: Request, res: Response): Promise { + const input = req.body as z.infer; + res.json(await warehousesService.updateWarehouse(Number(req.params.id), input)); +} diff --git a/src/modules/stock/warehouses.routes.ts b/src/modules/stock/warehouses.routes.ts new file mode 100644 index 0000000..2c99778 --- /dev/null +++ b/src/modules/stock/warehouses.routes.ts @@ -0,0 +1,25 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth'; +import { requirePermission } from '../../middleware/rbac'; +import { attachBranchScope } from '../../middleware/branchScope'; +import { validate } from '../../middleware/validate'; +import { asyncHandler } from '../../lib/asyncHandler'; +import { createWarehouseHandler, createWarehouseSchema, listWarehousesHandler, updateWarehouseHandler, updateWarehouseSchema } from './warehouses.controller'; + +export const warehousesRouter = Router(); + +warehousesRouter.use(requireAuth); + +warehousesRouter.get('/', attachBranchScope, asyncHandler(listWarehousesHandler)); +warehousesRouter.post( + '/', + requirePermission('stock.warehouse.manage'), + validate(createWarehouseSchema), + asyncHandler(createWarehouseHandler), +); +warehousesRouter.put( + '/:id', + requirePermission('stock.warehouse.manage'), + validate(updateWarehouseSchema), + asyncHandler(updateWarehouseHandler), +); diff --git a/src/modules/stock/warehouses.service.ts b/src/modules/stock/warehouses.service.ts new file mode 100644 index 0000000..a7eb130 --- /dev/null +++ b/src/modules/stock/warehouses.service.ts @@ -0,0 +1,20 @@ +import { prisma } from '../../lib/prisma'; +import { BranchScope, branchWhere } from '../../lib/branchFilter'; + +export interface WarehouseInput { + code: string; + name: string; + branchSegmentValueId: number; +} + +export function listWarehouses(branchScope: BranchScope) { + return prisma.warehouse.findMany({ where: branchWhere(branchScope), orderBy: { name: 'asc' } }); +} + +export function createWarehouse(input: WarehouseInput) { + return prisma.warehouse.create({ data: input }); +} + +export function updateWarehouse(id: number, input: Partial & { isActive?: boolean }) { + return prisma.warehouse.update({ where: { id }, data: input }); +}