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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWqiUSojMLUoN7mrJqdEbN
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4acbc03b35
commit
b137176c40
@@ -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
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import type { StockItem, StockLevel } from '../types/api';
|
||||||
|
|
||||||
|
export async function fetchStockItems(): Promise<StockItem[]> {
|
||||||
|
const response = await apiClient.get<StockItem[]>('/stock/items');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStockItem(id: number): Promise<StockItem> {
|
||||||
|
const response = await apiClient.get<StockItem>(`/stock/items/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStockLevels(warehouseId?: number): Promise<StockLevel[]> {
|
||||||
|
const response = await apiClient.get<StockLevel[]>('/stock/items/levels', { params: { warehouseId } });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockItemInput {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
unitOfMeasure: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStockItem(input: StockItemInput): Promise<StockItem> {
|
||||||
|
const response = await apiClient.post<StockItem>('/stock/items', input);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStockItem(id: number, input: Partial<StockItemInput> & { isActive?: boolean }): Promise<StockItem> {
|
||||||
|
const response = await apiClient.put<StockItem>(`/stock/items/${id}`, input);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -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<Warehouse[]> {
|
||||||
|
const response = await apiClient.get<Warehouse[]>('/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<Warehouse> {
|
||||||
|
const response = await apiClient.post<Warehouse>('/stock/warehouses', input);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWarehouse(id: number, input: Partial<WarehouseInput> & { isActive?: boolean }): Promise<Warehouse> {
|
||||||
|
const response = await apiClient.put<Warehouse>(`/stock/warehouses/${id}`, input);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import type { GLAccountType, DebitCredit, ControlAccountType } from '../../../ty
|
|||||||
|
|
||||||
const ACCOUNT_TYPES: GLAccountType[] = ['ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE'];
|
const ACCOUNT_TYPES: GLAccountType[] = ['ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE'];
|
||||||
const DEBIT_CREDIT: DebitCredit[] = ['DEBIT', 'CREDIT'];
|
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 }) {
|
export function GLAccountDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
const { data: definitions } = useSegmentDefinitions();
|
const { data: definitions } = useSegmentDefinitions();
|
||||||
|
|||||||
@@ -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<Warehouse>[] = [
|
||||||
|
{ field: 'code', headerName: 'Code', width: 140 },
|
||||||
|
{ field: 'name', headerName: 'Name', flex: 1 },
|
||||||
|
{
|
||||||
|
field: 'isActive',
|
||||||
|
headerName: 'Status',
|
||||||
|
width: 100,
|
||||||
|
renderCell: (params) => <StatusChip status={params.row.isActive ? 'ACTIVE' : 'CLOSED'} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="h5">Warehouses</Typography>
|
||||||
|
{hasPermission('stock.warehouse.manage') && (
|
||||||
|
<Button variant="contained" onClick={() => setDialogOpen(true)}>
|
||||||
|
New Warehouse
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Card sx={{ height: 500 }}>
|
||||||
|
<DataGrid rows={warehouses ?? []} columns={columns} loading={isLoading} density="compact" disableRowSelectionOnClick sx={{ border: 'none' }} />
|
||||||
|
</Card>
|
||||||
|
<WarehouseDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<number | ''>('');
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>New Warehouse</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField label="Code" value={code} onChange={(e) => setCode(e.target.value)} />
|
||||||
|
<TextField label="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Branch"
|
||||||
|
value={branchSegmentValueId}
|
||||||
|
onChange={(e) => setBranchSegmentValueId(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{(branchDef?.values ?? []).map((value) => (
|
||||||
|
<MenuItem key={value.id} value={value.id}>
|
||||||
|
{value.code} — {value.description}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
{error && <span style={{ color: 'red' }}>{error}</span>}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={handleSubmit} disabled={createWarehouse.isPending}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>New Stock Item</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField label="Code" value={code} onChange={(e) => setCode(e.target.value)} />
|
||||||
|
<TextField label="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<TextField label="Unit of measure" value={unitOfMeasure} onChange={(e) => setUnitOfMeasure(e.target.value)} helperText="e.g. EACH, KG, LOAF" />
|
||||||
|
{error && <span style={{ color: 'red' }}>{error}</span>}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={handleSubmit} disabled={createStockItem.isPending}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<StockItem>[] = [
|
||||||
|
{ 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) => <StatusChip status={params.row.isActive ? 'ACTIVE' : 'CLOSED'} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const levelColumns: GridColDef<StockLevel>[] = [
|
||||||
|
{ 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) => <MoneyCell value={params.row.quantityOnHand} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="h5">Stock Items</Typography>
|
||||||
|
{hasPermission('stock.item.manage') && (
|
||||||
|
<Button variant="contained" onClick={() => setDialogOpen(true)}>
|
||||||
|
New Stock Item
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Tabs value={tab} onChange={(_e, value) => setTab(value)} sx={{ mb: 2 }}>
|
||||||
|
<Tab label="Items" />
|
||||||
|
<Tab label="Stock Levels" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{tab === 0 && (
|
||||||
|
<Card sx={{ height: 650 }}>
|
||||||
|
<DataGrid rows={items ?? []} columns={itemColumns} loading={itemsLoading} density="compact" disableRowSelectionOnClick sx={{ border: 'none' }} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{tab === 1 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||||
|
Quantities on hand are reported by your external stock system — nothing is entered here.
|
||||||
|
</Typography>
|
||||||
|
<Card sx={{ height: 600 }}>
|
||||||
|
<DataGrid rows={levels ?? []} columns={levelColumns} loading={levelsLoading} density="compact" disableRowSelectionOnClick sx={{ border: 'none' }} />
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<StockItemDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<StockItemInput> & { isActive?: boolean } }) => updateStockItem(id, input),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['stockItems'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<WarehouseInput> & { isActive?: boolean } }) => updateWarehouse(id, input),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['warehouses'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ import PercentOutlinedIcon from '@mui/icons-material/PercentOutlined';
|
|||||||
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
||||||
import RequestQuoteOutlinedIcon from '@mui/icons-material/RequestQuoteOutlined';
|
import RequestQuoteOutlinedIcon from '@mui/icons-material/RequestQuoteOutlined';
|
||||||
import PriceCheckOutlinedIcon from '@mui/icons-material/PriceCheckOutlined';
|
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 { BranchSwitcher } from '../components/common/BranchSwitcher';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { NAVY, NAVY_SURFACE, ACCENT_BLUE } from '../theme';
|
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: <PriceCheckOutlinedIcon fontSize="small" /> },
|
{ label: 'Payments', path: '/ap/payments', icon: <PriceCheckOutlinedIcon fontSize="small" /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Stock',
|
||||||
|
items: [{ label: 'Stock Items', path: '/stock/items', icon: <Inventory2OutlinedIcon fontSize="small" /> }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'General Ledger',
|
title: 'General Ledger',
|
||||||
items: [
|
items: [
|
||||||
@@ -108,6 +114,12 @@ const NAV_SECTIONS: { title: string; items: NavItem[] }[] = [
|
|||||||
},
|
},
|
||||||
{ label: 'Users & Roles', path: '/admin/users', icon: <GroupOutlinedIcon fontSize="small" />, permission: 'admin.users.manage' },
|
{ label: 'Users & Roles', path: '/admin/users', icon: <GroupOutlinedIcon fontSize="small" />, permission: 'admin.users.manage' },
|
||||||
{ label: 'Tax Codes', path: '/admin/tax-codes', icon: <PercentOutlinedIcon fontSize="small" />, permission: 'admin.tax.manage' },
|
{ label: 'Tax Codes', path: '/admin/tax-codes', icon: <PercentOutlinedIcon fontSize="small" />, permission: 'admin.tax.manage' },
|
||||||
|
{
|
||||||
|
label: 'Warehouses',
|
||||||
|
path: '/admin/warehouses',
|
||||||
|
icon: <WarehouseOutlinedIcon fontSize="small" />,
|
||||||
|
permission: 'stock.warehouse.manage',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { DebitNoteDetailPage } from '../features/ap/debitNotes/DebitNoteDetailPa
|
|||||||
import { PaymentsListPage } from '../features/ap/payments/PaymentsListPage';
|
import { PaymentsListPage } from '../features/ap/payments/PaymentsListPage';
|
||||||
import { PaymentFormPage } from '../features/ap/payments/PaymentFormPage';
|
import { PaymentFormPage } from '../features/ap/payments/PaymentFormPage';
|
||||||
import { PaymentDetailPage } from '../features/ap/payments/PaymentDetailPage';
|
import { PaymentDetailPage } from '../features/ap/payments/PaymentDetailPage';
|
||||||
|
import { AdminWarehousesPage } from '../features/admin/warehouses/AdminWarehousesPage';
|
||||||
|
import { StockItemsListPage } from '../features/stock/items/StockItemsListPage';
|
||||||
|
|
||||||
export function AppRouter() {
|
export function AppRouter() {
|
||||||
return (
|
return (
|
||||||
@@ -68,6 +70,9 @@ export function AppRouter() {
|
|||||||
<Route element={<ProtectedRoute permission="admin.tax.manage" />}>
|
<Route element={<ProtectedRoute permission="admin.tax.manage" />}>
|
||||||
<Route path="/admin/tax-codes" element={<AdminTaxCodesPage />} />
|
<Route path="/admin/tax-codes" element={<AdminTaxCodesPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route element={<ProtectedRoute permission="stock.warehouse.manage" />}>
|
||||||
|
<Route path="/admin/warehouses" element={<AdminWarehousesPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="/ar/customers" element={<CustomersListPage />} />
|
<Route path="/ar/customers" element={<CustomersListPage />} />
|
||||||
<Route path="/ar/customers/:id" element={<CustomerDetailPage />} />
|
<Route path="/ar/customers/:id" element={<CustomerDetailPage />} />
|
||||||
@@ -102,6 +107,8 @@ export function AppRouter() {
|
|||||||
<Route element={<ProtectedRoute permission="ap.payment.manage" />}>
|
<Route element={<ProtectedRoute permission="ap.payment.manage" />}>
|
||||||
<Route path="/ap/payments/new" element={<PaymentFormPage />} />
|
<Route path="/ap/payments/new" element={<PaymentFormPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/stock/items" element={<StockItemsListPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { Alert, Box, CircularProgress } from '@mui/material';
|
import { Alert, Box, CircularProgress } from '@mui/material';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
permission?: string;
|
permission?: string;
|
||||||
@@ -11,14 +12,23 @@ const managerUrl = import.meta.env.VITE_MANAGER_URL ?? 'http://localhost:5174';
|
|||||||
|
|
||||||
export function ProtectedRoute({ permission }: ProtectedRouteProps) {
|
export function ProtectedRoute({ permission }: ProtectedRouteProps) {
|
||||||
const { isAuthenticated, hasPermission } = useAuth();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (hasHydrated) return;
|
||||||
|
return useAuthStore.persist.onFinishHydration(() => setHasHydrated(true));
|
||||||
|
}, [hasHydrated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasHydrated && !isAuthenticated) {
|
||||||
window.location.href = managerUrl;
|
window.location.href = managerUrl;
|
||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [hasHydrated, isAuthenticated]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!hasHydrated || !isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center' }}>
|
<Box sx={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -19,14 +20,18 @@ interface AuthState {
|
|||||||
hasPermission: (permissionKey: string) => boolean;
|
hasPermission: (permissionKey: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tokens are kept in memory only (not localStorage) to limit XSS exposure — a page reload
|
// Persisted to localStorage so a page reload doesn't force signing in again via the Manager.
|
||||||
// requires signing in again in Phase 1. See the implementation plan §4.5.
|
export const useAuthStore = create<AuthState>()(
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
persist(
|
||||||
accessToken: null,
|
(set, get) => ({
|
||||||
refreshToken: null,
|
accessToken: null,
|
||||||
user: null,
|
refreshToken: null,
|
||||||
setSession: ({ accessToken, refreshToken, user }) => set({ accessToken, refreshToken, user }),
|
user: null,
|
||||||
setAccessToken: (accessToken) => set({ accessToken }),
|
setSession: ({ accessToken, refreshToken, user }) => set({ accessToken, refreshToken, user }),
|
||||||
clearSession: () => set({ accessToken: null, refreshToken: null, user: null }),
|
setAccessToken: (accessToken) => set({ accessToken }),
|
||||||
hasPermission: (permissionKey) => get().user?.permissions.includes(permissionKey) ?? false,
|
clearSession: () => set({ accessToken: null, refreshToken: null, user: null }),
|
||||||
}));
|
hasPermission: (permissionKey) => get().user?.permissions.includes(permissionKey) ?? false,
|
||||||
|
}),
|
||||||
|
{ name: 'cfm-auth-session' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
+32
-1
@@ -1,6 +1,6 @@
|
|||||||
export type GLAccountType = 'ASSET' | 'LIABILITY' | 'EQUITY' | 'INCOME' | 'EXPENSE';
|
export type GLAccountType = 'ASSET' | 'LIABILITY' | 'EQUITY' | 'INCOME' | 'EXPENSE';
|
||||||
export type DebitCredit = 'DEBIT' | 'CREDIT';
|
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 SegmentFormat = 'NUMERIC' | 'ALPHA' | 'ALPHANUMERIC';
|
||||||
export type JournalType = 'GENERAL' | 'SALES' | 'PURCHASES' | 'CASH_RECEIPT' | 'CASH_PAYMENT' | 'STOCK' | 'BANK' | 'CUSTOM';
|
export type JournalType = 'GENERAL' | 'SALES' | 'PURCHASES' | 'CASH_RECEIPT' | 'CASH_PAYMENT' | 'STOCK' | 'BANK' | 'CUSTOM';
|
||||||
export type JournalEntryStatus = 'DRAFT' | 'POSTED' | 'REVERSED';
|
export type JournalEntryStatus = 'DRAFT' | 'POSTED' | 'REVERSED';
|
||||||
@@ -377,3 +377,34 @@ export interface AppUser {
|
|||||||
roles: { role: Role }[];
|
roles: { role: Role }[];
|
||||||
branchRestrictions: { branchSegmentValueId: number }[];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Navigate, Outlet } from 'react-router-dom';
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import { Box, CircularProgress } from '@mui/material';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
export function ProtectedRoute() {
|
export function ProtectedRoute() {
|
||||||
const { isAuthenticated } = useAuth();
|
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 (
|
||||||
|
<Box sx={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
export interface AuthPerson {
|
export interface AuthPerson {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,13 +17,17 @@ interface AuthState {
|
|||||||
clearSession: () => void;
|
clearSession: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tokens kept in memory only, same rationale as the company app: limits XSS exposure at the
|
// Persisted to localStorage so a page reload doesn't force signing in again.
|
||||||
// cost of needing to sign in again on a hard page reload.
|
export const useAuthStore = create<AuthState>()(
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
persist(
|
||||||
accessToken: null,
|
(set) => ({
|
||||||
refreshToken: null,
|
accessToken: null,
|
||||||
person: null,
|
refreshToken: null,
|
||||||
setSession: ({ accessToken, refreshToken, person }) => set({ accessToken, refreshToken, person }),
|
person: null,
|
||||||
setAccessToken: (accessToken) => set({ accessToken }),
|
setSession: ({ accessToken, refreshToken, person }) => set({ accessToken, refreshToken, person }),
|
||||||
clearSession: () => set({ accessToken: null, refreshToken: null, person: null }),
|
setAccessToken: (accessToken) => set({ accessToken }),
|
||||||
}));
|
clearSession: () => set({ accessToken: null, refreshToken: null, person: null }),
|
||||||
|
}),
|
||||||
|
{ name: 'account-manager-auth-session' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -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;
|
||||||
+48
-4
@@ -105,6 +105,7 @@ model AccountSegmentValue {
|
|||||||
glAccountLinks GLAccountSegmentValue[]
|
glAccountLinks GLAccountSegmentValue[]
|
||||||
customers Customer[]
|
customers Customer[]
|
||||||
suppliers Supplier[]
|
suppliers Supplier[]
|
||||||
|
warehouses Warehouse[]
|
||||||
|
|
||||||
@@unique([segmentDefId, code])
|
@@unique([segmentDefId, code])
|
||||||
}
|
}
|
||||||
@@ -134,7 +135,6 @@ enum DebitCredit {
|
|||||||
enum ControlAccountType {
|
enum ControlAccountType {
|
||||||
AR
|
AR
|
||||||
AP
|
AP
|
||||||
STOCK
|
|
||||||
TAX_OUTPUT
|
TAX_OUTPUT
|
||||||
TAX_INPUT
|
TAX_INPUT
|
||||||
BANK
|
BANK
|
||||||
@@ -253,13 +253,11 @@ model JournalEntryLine {
|
|||||||
credit Decimal @default(0) @db.Decimal(18, 2)
|
credit Decimal @default(0) @db.Decimal(18, 2)
|
||||||
description String?
|
description String?
|
||||||
branchSegmentValueId Int?
|
branchSegmentValueId Int?
|
||||||
// Sub-ledger cross-references for drill-down. arInvoiceId/apInvoiceId are real FKs now that
|
// Sub-ledger cross-references for drill-down.
|
||||||
// AR/AP exist; stockMovementId stays a plain id until Stock lands in its own phase.
|
|
||||||
arInvoiceId Int?
|
arInvoiceId Int?
|
||||||
arInvoice ARInvoice? @relation(fields: [arInvoiceId], references: [id])
|
arInvoice ARInvoice? @relation(fields: [arInvoiceId], references: [id])
|
||||||
apInvoiceId Int?
|
apInvoiceId Int?
|
||||||
apInvoice APInvoice? @relation(fields: [apInvoiceId], references: [id])
|
apInvoice APInvoice? @relation(fields: [apInvoiceId], references: [id])
|
||||||
stockMovementId Int?
|
|
||||||
currencyCode String @default("BASE")
|
currencyCode String @default("BASE")
|
||||||
exchangeRateToBase Decimal @default(1) @db.Decimal(18, 6)
|
exchangeRateToBase Decimal @default(1) @db.Decimal(18, 6)
|
||||||
|
|
||||||
@@ -615,6 +613,52 @@ model APAllocation {
|
|||||||
@@index([debitNoteId])
|
@@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
|
// Auth / RBAC
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+36
-3
@@ -18,6 +18,8 @@ const PERMISSIONS = [
|
|||||||
'ap.invoice.manage',
|
'ap.invoice.manage',
|
||||||
'ap.debitnote.manage',
|
'ap.debitnote.manage',
|
||||||
'ap.payment.manage',
|
'ap.payment.manage',
|
||||||
|
'stock.warehouse.manage',
|
||||||
|
'stock.item.manage',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||||
@@ -35,6 +37,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
'ap.invoice.manage',
|
'ap.invoice.manage',
|
||||||
'ap.debitnote.manage',
|
'ap.debitnote.manage',
|
||||||
'ap.payment.manage',
|
'ap.payment.manage',
|
||||||
|
'stock.warehouse.manage',
|
||||||
|
'stock.item.manage',
|
||||||
],
|
],
|
||||||
AR_AP_CLERK: [
|
AR_AP_CLERK: [
|
||||||
'ar.customer.manage',
|
'ar.customer.manage',
|
||||||
@@ -46,6 +50,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
'ap.debitnote.manage',
|
'ap.debitnote.manage',
|
||||||
'ap.payment.manage',
|
'ap.payment.manage',
|
||||||
],
|
],
|
||||||
|
STOCK_CLERK: ['stock.warehouse.manage', 'stock.item.manage'],
|
||||||
BRANCH_USER: [],
|
BRANCH_USER: [],
|
||||||
READ_ONLY: [],
|
READ_ONLY: [],
|
||||||
};
|
};
|
||||||
@@ -98,14 +103,17 @@ async function main() {
|
|||||||
{ code: '1100', description: 'Accounts Receivable Control' },
|
{ code: '1100', description: 'Accounts Receivable Control' },
|
||||||
{ code: '1200', description: 'Inventory / Stock Control' },
|
{ code: '1200', description: 'Inventory / Stock Control' },
|
||||||
{ code: '2000', description: 'Accounts Payable Control' },
|
{ code: '2000', description: 'Accounts Payable Control' },
|
||||||
|
{ code: '2050', description: 'Goods Received Not Invoiced' },
|
||||||
{ code: '2100', description: 'Output Tax Payable' },
|
{ code: '2100', description: 'Output Tax Payable' },
|
||||||
{ code: '2200', description: 'Input Tax Receivable' },
|
{ code: '2200', description: 'Input Tax Receivable' },
|
||||||
|
{ code: '2300', description: 'Accrued Expenses' },
|
||||||
{ code: '3000', description: 'Retained Earnings' },
|
{ code: '3000', description: 'Retained Earnings' },
|
||||||
{ code: '3100', description: 'Share Capital' },
|
{ code: '3100', description: 'Share Capital' },
|
||||||
{ code: '4000', description: 'Sales Income' },
|
{ code: '4000', description: 'Sales Income' },
|
||||||
{ code: '5000', description: 'Cost of Goods Sold' },
|
{ code: '5000', description: 'Cost of Goods Sold' },
|
||||||
{ code: '5100', description: 'Operating Expenses' },
|
{ code: '5100', description: 'Operating Expenses' },
|
||||||
{ code: '5200', description: 'Stock Variance' },
|
{ code: '5200', description: 'Stock Variance' },
|
||||||
|
{ code: '5300', description: 'Purchase Price Variance' },
|
||||||
];
|
];
|
||||||
const masterByCode: Record<string, { id: number }> = {};
|
const masterByCode: Record<string, { id: number }> = {};
|
||||||
for (const value of masterValues) {
|
for (const value of masterValues) {
|
||||||
@@ -157,14 +165,19 @@ async function main() {
|
|||||||
}> = [
|
}> = [
|
||||||
{ masterCode: '1000', accountName: 'Bank / Cash', accountType: 'ASSET', normalBalance: 'DEBIT', controlType: 'BANK' },
|
{ 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: '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: '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: '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: '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: '4000', accountName: 'Sales Income', accountType: 'INCOME', normalBalance: 'CREDIT' },
|
||||||
{ masterCode: '5000', accountName: 'Cost of Goods Sold', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
|
{ masterCode: '5000', accountName: 'Cost of Goods Sold', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
|
||||||
{ masterCode: '5100', accountName: 'Operating Expenses', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
|
{ masterCode: '5100', accountName: 'Operating Expenses', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
|
||||||
{ masterCode: '5200', accountName: 'Stock Variance', 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)) {
|
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 },
|
create: { code: 'CASH_PAYMENT', name: 'Cash Payments Journal', journalType: JournalType.CASH_PAYMENT },
|
||||||
update: {},
|
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...');
|
console.log('Seeding tax codes...');
|
||||||
const taxCodes = [
|
const taxCodes = [
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { suppliersRouter } from './modules/ap/suppliers.routes';
|
|||||||
import { apInvoicesRouter } from './modules/ap/apInvoices.routes';
|
import { apInvoicesRouter } from './modules/ap/apInvoices.routes';
|
||||||
import { apDebitNotesRouter } from './modules/ap/apDebitNotes.routes';
|
import { apDebitNotesRouter } from './modules/ap/apDebitNotes.routes';
|
||||||
import { apPaymentsRouter } from './modules/ap/apPayments.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 {
|
export function createApp(): Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -46,6 +48,8 @@ export function createApp(): Express {
|
|||||||
app.use('/api/ap/invoices', apInvoicesRouter);
|
app.use('/api/ap/invoices', apInvoicesRouter);
|
||||||
app.use('/api/ap/debit-notes', apDebitNotesRouter);
|
app.use('/api/ap/debit-notes', apDebitNotesRouter);
|
||||||
app.use('/api/ap/payments', apPaymentsRouter);
|
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((req, res) => res.status(404).json({ error: 'Not found' }));
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
import { Prisma } from '@prisma/client';
|
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 { ForbiddenBranchScopeError } from '../lib/branchFilter';
|
||||||
import { ControlAccountNotFoundError } from '../lib/controlAccounts';
|
import { ControlAccountNotFoundError } from '../lib/controlAccounts';
|
||||||
import { ARDocumentStateError, ARAllocationError } from '../modules/ar/ar.errors';
|
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 ARDocumentStateError ||
|
||||||
error instanceof ARAllocationError ||
|
error instanceof ARAllocationError ||
|
||||||
error instanceof APDocumentStateError ||
|
error instanceof APDocumentStateError ||
|
||||||
error instanceof APAllocationError
|
error instanceof APAllocationError ||
|
||||||
|
error instanceof InvalidLineError ||
|
||||||
|
error instanceof ClosedPeriodError
|
||||||
) {
|
) {
|
||||||
res.status(422).json({ error: error.message });
|
res.status(422).json({ error: error.message });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { APDebitNote, APDebitNoteLine, APInvoice, APInvoiceLine, APPayment, JournalEntry, Supplier } from '@prisma/client';
|
import { APDebitNote, APDebitNoteLine, APInvoice, APInvoiceLine, APPayment, JournalEntry, Supplier } from '@prisma/client';
|
||||||
import { prisma } from '../../lib/prisma';
|
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 { resolveControlAccount } from '../../lib/controlAccounts';
|
||||||
import { PostingService } from '../gl/posting.service';
|
import { PostingService } from '../gl/posting.service';
|
||||||
import { JournalEntryDraft, JournalLineDraft } from '../gl/posting.types';
|
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 DebitNoteForPosting = APDebitNote & { lines: APDebitNoteLine[]; supplier: Supplier };
|
||||||
type PaymentForPosting = APPayment & { supplier: Supplier };
|
type PaymentForPosting = APPayment & { supplier: Supplier };
|
||||||
|
|
||||||
function groupByAccount(entries: { glAccountId: number; amount: import('../../lib/money').Money }[]) {
|
function groupByAccount(entries: { glAccountId: number; amount: Money }[]) {
|
||||||
const byAccount = new Map<number, import('../../lib/money').Money>();
|
const byAccount = new Map<number, Money>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
byAccount.set(entry.glAccountId, (byAccount.get(entry.glAccountId) ?? ZERO).plus(entry.amount));
|
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) };
|
return { subtotal, tax, total: subtotal.plus(tax) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sumLines(values: import('../../lib/money').Money[]) {
|
export function sumLines(values: Money[]) {
|
||||||
return sum(values);
|
return sum(values);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as glAccountsService from './glAccounts.service';
|
|||||||
|
|
||||||
const glAccountTypeSchema = z.enum(['ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE']);
|
const glAccountTypeSchema = z.enum(['ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE']);
|
||||||
const debitCreditSchema = z.enum(['DEBIT', 'CREDIT']);
|
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({
|
export const createGLAccountSchema = z.object({
|
||||||
accountName: z.string().min(1),
|
accountName: z.string().min(1),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { prisma } from '../../lib/prisma';
|
import { prisma } from '../../lib/prisma';
|
||||||
import { BranchScope } from '../../lib/branchFilter';
|
import { BranchScope } from '../../lib/branchFilter';
|
||||||
import { PostingService } from './posting.service';
|
import { PostingService } from './posting.service';
|
||||||
import { JournalEntryDraft } from './posting.types';
|
import { InvalidLineError, JournalEntryDraft } from './posting.types';
|
||||||
|
|
||||||
export interface ManualJournalLineInput {
|
export interface ManualJournalLineInput {
|
||||||
glAccountId: number;
|
glAccountId: number;
|
||||||
@@ -18,7 +18,27 @@ export interface CreateManualEntryInput {
|
|||||||
lines: ManualJournalLineInput[];
|
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<void> {
|
||||||
|
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 = {
|
const draft: JournalEntryDraft = {
|
||||||
journalCode: input.journalCode,
|
journalCode: input.journalCode,
|
||||||
entryDate: new Date(input.entryDate),
|
entryDate: new Date(input.entryDate),
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export async function postEntry(draft: JournalEntryDraft): Promise<JournalEntry>
|
|||||||
branchSegmentValueId: line.branchSegmentValueId,
|
branchSegmentValueId: line.branchSegmentValueId,
|
||||||
arInvoiceId: line.arInvoiceId,
|
arInvoiceId: line.arInvoiceId,
|
||||||
apInvoiceId: line.apInvoiceId,
|
apInvoiceId: line.apInvoiceId,
|
||||||
stockMovementId: line.stockMovementId,
|
|
||||||
currencyCode: line.currencyCode ?? 'BASE',
|
currencyCode: line.currencyCode ?? 'BASE',
|
||||||
exchangeRateToBase: line.exchangeRateToBase ? money(line.exchangeRateToBase) : money(1),
|
exchangeRateToBase: line.exchangeRateToBase ? money(line.exchangeRateToBase) : money(1),
|
||||||
})),
|
})),
|
||||||
@@ -124,7 +123,6 @@ export async function reverseEntry(journalEntryId: number, createdById: number,
|
|||||||
branchSegmentValueId: line.branchSegmentValueId ?? undefined,
|
branchSegmentValueId: line.branchSegmentValueId ?? undefined,
|
||||||
arInvoiceId: line.arInvoiceId ?? undefined,
|
arInvoiceId: line.arInvoiceId ?? undefined,
|
||||||
apInvoiceId: line.apInvoiceId ?? undefined,
|
apInvoiceId: line.apInvoiceId ?? undefined,
|
||||||
stockMovementId: line.stockMovementId ?? undefined,
|
|
||||||
currencyCode: line.currencyCode,
|
currencyCode: line.currencyCode,
|
||||||
exchangeRateToBase: line.exchangeRateToBase,
|
exchangeRateToBase: line.exchangeRateToBase,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export interface JournalLineDraft {
|
|||||||
branchSegmentValueId?: number;
|
branchSegmentValueId?: number;
|
||||||
arInvoiceId?: number;
|
arInvoiceId?: number;
|
||||||
apInvoiceId?: number;
|
apInvoiceId?: number;
|
||||||
stockMovementId?: number;
|
|
||||||
currencyCode?: string;
|
currencyCode?: string;
|
||||||
exchangeRateToBase?: Prisma.Decimal.Value;
|
exchangeRateToBase?: Prisma.Decimal.Value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
res.json(await stockItemsService.listStockItems());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStockItemHandler(req: Request, res: Response): Promise<void> {
|
||||||
|
const input = req.body as z.infer<typeof createStockItemSchema>;
|
||||||
|
res.status(201).json(await stockItemsService.createStockItem(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStockItemHandler(req: Request, res: Response): Promise<void> {
|
||||||
|
const input = req.body as z.infer<typeof updateStockItemSchema>;
|
||||||
|
res.json(await stockItemsService.updateStockItem(Number(req.params.id), input));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStockItemHandler(req: Request, res: Response): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
const warehouseId = req.query.warehouseId ? Number(req.query.warehouseId) : undefined;
|
||||||
|
res.json(await stockItemsService.listStockLevels(warehouseId));
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
@@ -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<StockItemInput> & { 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' } }],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
res.json(await warehousesService.listWarehouses(req.branchScope!));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWarehouseHandler(req: Request, res: Response): Promise<void> {
|
||||||
|
const input = req.body as z.infer<typeof createWarehouseSchema>;
|
||||||
|
res.status(201).json(await warehousesService.createWarehouse(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWarehouseHandler(req: Request, res: Response): Promise<void> {
|
||||||
|
const input = req.body as z.infer<typeof updateWarehouseSchema>;
|
||||||
|
res.json(await warehousesService.updateWarehouse(Number(req.params.id), input));
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
@@ -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<WarehouseInput> & { isActive?: boolean }) {
|
||||||
|
return prisma.warehouse.update({ where: { id }, data: input });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user