- Given the accountant is on Inventory → Catalog with permission
inventory.manage - When they submit SKU, name, unit, category, sale price, cost price, reorder level
- Then the item is saved as active, SKU is unique per tenant, and it appears in the issue picker of every branch.
PRD 08 · Inventory
Books, materials and merchandise held per branch, issued to students against a tuition line or a hand-out, with low-stock warnings. Phase 2 module; no backend exists today.
Key facts, origin and limits
Drafted by an AI assistant in a single session on 10/09/2026, from the original request quoted verbatim on the hub page; the Proposed design section was added on 16/09/2026. A proposal for team discussion, not a decision of the product team.
Sources
- Public competitor research (PSE ONE, pse4.one), the
center-crm-frontendcodebase and the staging/v3OpenAPI specification (182 paths), as of 10/09/2026. - The existing / extend / new tags on this page come from a comparison with the staging
/v3specification at that date.
Limits
- Verification pending in-app survey, because automated login to the reference product was not authorised in the drafting session:
- How the competitor links a hand-out ("phát đồ dùng học tập") to a receipt: same document or separate.
- Whether stock is tracked per branch or per tenant, and whether transfers exist.
- Exact fields of their product catalog (unit, cost, sale price, supplier) and whether low-stock alert is a report or a notification.
- Whether returns and damaged stock are modelled.
Proposed design
Inventory is a single page under Finance with four tabs (Stock · Movements · Catalog · Low stock); every stock change goes through a document (receipt, transfer, adjustment) or an issue to a student, and the key point for the reader is that no "quantity" field is ever edited directly.








1Purpose
Centers sell or hand out textbooks, workbooks, uniforms and stationery. Today this is tracked in Excel or not at all, and the accountant cannot reconcile stock against receipts. Trường Số keeps a per-branch stock ledger, lets staff issue items to a student in one step, and shows what is running low. The module is deliberately small: it is a ledger with a catalog, not a retail POS.
Goals
- Every item that leaves the shelf is linked to a student, a class hand-out, a transfer or a write-off.
- Stock on hand per branch is always derivable from the ledger; no editable "quantity" field.
- Issuing a paid item creates a finance line (PRD 05) so revenue and stock agree.
Non-goals (out of scope)
- Supplier purchase orders, barcode scanning, serial numbers, multi-warehouse within one branch.
- Cost accounting (FIFO/average). Only a reference cost price is stored for margin reports.
- Selling to non-students. The counterparty of an issue is always a student, a class or a branch.
2Personas and roles
| Persona | Role code | Uses inventory for |
|---|---|---|
| Accountant (Kế toán) | accountant | Stock-in, counts, valuation report, reconciliation with receipts. |
| Branch manager (Quản lý chi nhánh) | branch_manager | Approve write-offs and transfers, watch low-stock list. |
| Front desk / academic admin (Giáo vụ) | academic_admin | Issue books to a newly enrolled student, class hand-outs. |
| Owner (Chủ trung tâm) | owner | Cross-branch stock and margin view. |
| Teacher (Giáo viên) | teacher | Read only: sees which students in the class have received the book. |
3User stories and acceptance criteria
- Given an active item and a branch
- When a stock-in document with one or more lines (item, quantity > 0, unit cost, reference) is posted
- Then a ledger movement of type
inper line is written, on-hand for that branch increases, and the document becomes read-only.
- Given a student with an active enrollment and on-hand quantity ≥ requested
- When the admin picks the student, item, quantity and "chargeable" flag
- Then a movement of type
issueis written, on-hand decreases, the student profile shows the item under Materials, and if chargeable a tuition line of typematerialis created in PRD 05 with amount = quantity × sale price. - Given on-hand is below the requested quantity
- When the admin submits
- Then the form blocks with "Không đủ tồn kho (còn N)" and no movement is written.
- Given a class with N active students
- When the admin selects the class, item, and un-ticks students who already received it
- Then one movement per selected student is written in a single transaction; if stock is insufficient for all, the dialog shows the shortfall and lets the admin choose to issue to the first K only or cancel.
- Given the user has
inventory.manageon both branches or is owner - When a transfer document is posted
- Then a paired
transfer_outat A andtransfer_inat B are written atomically with the samedocument_id.
- Given a count sheet listing counted quantities
- When it is posted
- Then for each item where counted ≠ on-hand an
adjustmovement with signed delta and mandatory reason is written; adjustments above 10 units or 2,000,000 VND require branch manager approval (statuspendinguntil approved).
- Given reorder level is set on an item
- When on-hand at a branch ≤ reorder level
- Then the item appears in the Low stock tab with a Sắp hết chip, and a daily 08:00 in-app notification lists all such items for users with
inventory.manage.
- Given an existing
issuemovement - When the admin clicks Return on that row and chooses condition (new / used)
- Then a
returnmovement referencing the original is written; new-condition returns increase on-hand, used-condition returns go to quantitydamaged; if the original was chargeable the finance line is flagged for credit in PRD 05 (not auto-refunded).
4Screens
Branch selector (defaults to the user's branch; owner sees "Tất cả chi nhánh"). Three KPI tiles: Items in stock, Stock value (sum on-hand × cost), Low-stock items. Tabs: Stock (Tồn kho) · Movements (Nhập xuất) · Catalog (Danh mục) · Low stock (Sắp hết).
Stock table
| Column | Source | Notes |
|---|---|---|
| SKU | item.sku | monospace, sortable |
| Tên hàng | item.name | link to item drawer |
| Danh mục | item.category | filter chip |
| Đơn vị | item.unit | |
| Tồn | sum(movements) per branch | right-aligned; red when ≤ reorder level |
| Hỏng | damaged qty | hidden when 0 |
| Giá bán | item.sale_price | VND, thousands separator |
| Giá trị tồn | on-hand × cost_price | owner/accountant only |
| Thao tác | Nhập · Xuất · Điều chỉnh |
Filters: search (SKU or name), category, "chỉ hàng sắp hết" toggle. Empty state: "Chưa có hàng hoá. Tạo danh mục trước, sau đó nhập kho." with primary button Tạo mặt hàng. Pagination 50 rows.
Movements table
| Column | Notes |
|---|---|
| Thời gian | created_at, dd/MM/yyyy HH:mm |
| Loại | chip: Nhập (ok) · Xuất (exc) · Chuyển (grey) · Điều chỉnh (late) · Trả lại (grey) |
| Mặt hàng | name + SKU |
| SL | signed quantity |
| Đối tượng | student name / class / branch / supplier ref |
| Chứng từ | document code, link |
| Người thực hiện | actor display_name |
Filters: date range, type, item, student. Export CSV (permission inventory.export).
| Field | Type | Validation |
|---|---|---|
| Mã hàng (sku) | text 3–32 | required, unique per tenant, uppercase A–Z0–9- |
| Tên hàng (name) | text 2–120 | required |
| Danh mục (category) | select from tenant list; inline add | required |
| Đơn vị (unit) | select: cuốn, bộ, cái, hộp | required |
| Giá bán (sale_price) | currency VND | ≥ 0 |
| Giá vốn (cost_price) | currency VND | ≥ 0; hidden from academic_admin |
| Mức cảnh báo (reorder_level) | integer | ≥ 0, default 5 |
| Tính phí khi phát (chargeable_default) | toggle | default on for books |
| Gắn với khoá (course_ids) | multi-select course | optional; drives the "suggested materials" list at enrollment |
| Trạng thái | active / inactive | inactive items cannot be issued but keep history |
Errors: duplicate SKU shows inline "Mã hàng đã tồn tại". Delete is not offered; deactivate only.
Header: type, branch (from/to for transfer), date (default now, cannot be future), reference (free text, e.g. supplier invoice), note. Lines: item picker (search by SKU/name), quantity, unit cost (stock-in only), reason (adjust only, select: hỏng, mất, kiểm kê, khác). Footer: line count, total quantity, total value. Button Ghi sổ posts; after posting the document is read-only and shows code NK-YYYYMM-#### / CK-… / DC-….
Fields: student (search; pre-filled from context), item, quantity (default 1), chargeable toggle (default from item), price (editable when chargeable, permission inventory.override_price), note. Shows on-hand next to the item. On submit shows a toast "Đã phát 1 cuốn Family and Friends 2 cho Nguyễn An" with link to the finance line when created.
Table: date, item, quantity, chargeable, payment status (from finance line), returned. Action Return per row. Empty state: "Học viên chưa nhận tài liệu nào."
5Business rules
- On-hand(branch, item) = Σ quantity of movements where branch matches; there is no stored quantity column that can be edited.
- Posted documents are immutable. Corrections are new
adjustmovements referencing the original. - An
issuerequires a student with status Active, Trial or Paused (PRD 02); Withdrawn students cannot receive items. - Chargeable issue creates exactly one finance line; deleting that line is blocked while the movement exists, and a return flags the line rather than deleting it.
- Adjustments above threshold (configurable per tenant, default 10 units or 2,000,000 VND) require approval by
branch_managerorowner. - Negative on-hand is never allowed; the API rejects with
INSUFFICIENT_STOCK. - Cost price is visible only to
ownerandaccountant. - Documents are numbered per tenant per month; numbering gaps are allowed (cancelled drafts).
6Data model
| Entity | Field | Type | Notes |
|---|---|---|---|
| inventory_item | id | uuid | PK |
| tenant_id | int64 | = school_id | |
| sku | text | unique (tenant_id, sku) | |
| name | text | ||
| category_id | uuid | FK inventory_category | |
| unit | text | ||
| sale_price | numeric(14,0) | VND | |
| cost_price | numeric(14,0) | ||
| reorder_level | int | ||
| chargeable_default | bool | ||
| course_ids | uuid[] | FK course (PRD 03) | |
| is_active, created_at, updated_at | |||
| inventory_category | id | uuid | |
| tenant_id | int64 | ||
| name | text | unique per tenant | |
| position | int | ||
| inventory_document | id | uuid | |
| tenant_id | int64 | ||
| code | text | NK/XK/CK/DC-YYYYMM-#### | |
| type | enum in|issue|transfer|adjust|return | ||
| branch_id | uuid | FK branch (PRD 10); for transfer = from | |
| to_branch_id | uuid null | transfer only | |
| status | enum draft|pending|posted|cancelled | ||
| reference, note | text | ||
| approved_by, approved_at | nullable | ||
| created_by, posted_at | |||
| inventory_movement | id | uuid | |
| document_id | uuid | FK | |
| item_id | uuid | FK | |
| branch_id | uuid | the branch whose on-hand changes | |
| quantity | int | signed; in/transfer_in/return(new) > 0; issue/transfer_out/adjust(-) < 0 | |
| unit_cost | numeric | stock-in only | |
| student_id | int64 null | FK student (issue/return) | |
| class_id | int64 null | class hand-out | |
| tuition_line_id | uuid null | FK finance line when chargeable | |
| condition | enum new|damaged null | returns | |
| reason, created_by, created_at |
Indexes: (tenant_id, branch_id, item_id) on movement for on-hand aggregation; a materialised view inventory_onhand refreshed on write is acceptable.
7API contract
All endpoints are NEW. Prefix /v3/school/inventory; auth header as existing /v3 (Auth Token); X-Locale honoured for labels. Responses use the existing DataResponse / ListResponse envelopes with Pagination.
| Method | Path | Request | Response | Status |
|---|---|---|---|---|
| GET | /v3/school/inventory/items | q, category_id, is_active, branch_id (adds on_hand), page, limit | list of InventoryItem {..., on_hand, damaged, low_stock} | new |
| POST | /v3/school/inventory/items | {sku,name,category_id,unit,sale_price,cost_price,reorder_level,chargeable_default,course_ids} | InventoryItem | new |
| PATCH | /v3/school/inventory/items/{id} | partial | InventoryItem | new |
| GET/POST | /v3/school/inventory/categories | {name} | Category[] | new |
| GET | /v3/school/inventory/stock | branch_id (required unless owner), q, low_only | [{item, on_hand, damaged, value}] | new |
| GET | /v3/school/inventory/documents | type, branch_id, from, to, status, page | Document[] with line counts | new |
| POST | /v3/school/inventory/documents | {type, branch_id, to_branch_id?, reference, note, lines:[{item_id, quantity, unit_cost?, reason?, student_id?, class_id?, chargeable?, price?}]} | Document with movements; 409 INSUFFICIENT_STOCK {item_id, on_hand} | new |
| POST | /v3/school/inventory/documents/{id}/approve | Document (pending → posted) | new | |
| POST | /v3/school/inventory/documents/{id}/cancel | {reason} | only draft/pending | new |
| POST | /v3/school/inventory/issue | {branch_id, student_id | class_id, item_id, quantity, chargeable, price?, exclude_student_ids?} | {document, movements[], tuition_line_ids[]} | new |
| POST | /v3/school/inventory/movements/{id}/return | {condition, note} | Movement | new |
| GET | /v3/school/inventory/movements | branch_id, item_id, student_id, type, from, to, page | Movement[] | new |
| GET | /v3/school/students/{id}/materials | Movement[] joined with tuition line status | new | |
| GET | /v3/school/inventory/export | same filters as stock or movements, format=csv | file | new |
Existing endpoints reused: GET /v3/teacher/school-finance/students and /v3/teacher/students/quick-search for the student picker; GET /v3/teacher/classes for the class picker; finance line creation goes through the PRD 05 tuition endpoint (POST /v3/teacher/school-finance/tuitions, EXISTING) with title = item name until a typed line item exists.
8Permissions
| Permission code | owner | branch_manager | accountant | academic_admin | admissions | teacher |
|---|---|---|---|---|---|---|
inventory.view | yes | yes | yes | yes | no | class scope |
inventory.manage (catalog, stock-in, adjust, transfer) | yes | yes | yes | no | no | no |
inventory.issue | yes | yes | yes | yes | no | no |
inventory.approve | yes | yes | no | no | no | no |
inventory.override_price | yes | yes | yes | no | no | no |
inventory.view_cost | yes | no | yes | no | no | no |
inventory.export | yes | yes | yes | no | no | no |
Branch scoping: every permission is evaluated against the branches the user is assigned to (PRD 10). Owner is implicitly all branches.
9Analytics events
| Event | Properties |
|---|---|
| inventory_item_created | tenant_id, category, chargeable_default |
| inventory_document_posted | type, line_count, total_qty, total_value, branch_id |
| inventory_issue | target (student|class), item_id, quantity, chargeable, created_finance_line |
| inventory_issue_blocked | reason (insufficient_stock|student_status) |
| inventory_return | condition |
| inventory_low_stock_viewed | count |
| inventory_export | scope, rows |
10Edge cases
- Two admins issue the last copy at the same time: the API serialises on (branch, item) and the second call receives 409.
- Item deactivated while a draft document references it: posting fails with
ITEM_INACTIVEand highlights the line. - Student transferred to another branch after receiving a book: the movement keeps the original branch; the Materials tab shows branch name.
- Price change after issue: the finance line keeps the price at issue time.
- Class hand-out to 40 students with 38 in stock: partial issue offered, ordered by student display name; shortfall listed in the toast.
- Transfer to the same branch is rejected client- and server-side.
- Import of opening balances: use a stock-in document with reference "Số dư đầu kỳ"; a CSV import of opening balances is part of the PRD 11 migration toolkit.
11Open questions
- Should material sales be VAT-bearing separately from tuition (books at 5%, tuition exempt)? Affects PRD 05 line type.
- Do centers need per-teacher hand-out (teacher takes 10 copies to class) as an intermediate step, or is class-level issue enough?
- Is a supplier list needed in phase 2, or is a free-text reference sufficient?
Trường Số (truongso.net) · FLYER internal