Trường Số PRD/08 Inventory
Trường Số · Product Requirements · 09/2026

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
Status
Draft v1
Owner module
Inventory (Kho)
Backend
NEW (no /v3 entity)
Phase
P2
Depends on
02 Students, 05 Finance, 10 Settings

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-frontend codebase and the staging /v3 OpenAPI specification (182 paths), as of 10/09/2026.
  • The existing / extend / new tags on this page come from a comparison with the staging /v3 specification 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.

1 · Inventory overview · Stock tab

/inventory · desktop

Inventory overview, Stock tab
Branch selector, three KPI tiles (items on hand, stock value = on hand × cost, low-stock items) and the stock table with all nine columns from section 4. Three rows at or below the reorder level carry a "Sắp hết" (low) or "Hết hàng" (out) label (INV-07); the Damaged column is blank when zero; the Stock value column is shown only to owner and accountant (rule 7).

2 · Movements tab · stock ledger

/inventory (tab Nhập xuất) · desktop

Stock movements table
Filters for date range, type, item and student, plus Export CSV (inventory.export). Each row is one signed movement tied to its target (student, class, branch, supplier) and document code NK/XK/CK/DC. Adjustment DC-202609-0003 exceeds the 10-unit threshold and therefore sits in Pending approval (INV-06, rule 5); the return row records the book condition (INV-08).

3 · Item form (drawer)

/inventory/catalog/new · desktop

Create item drawer
Drawer over the Catalog tab with all ten fields from section 4: SKU, name, category (quick add inline), unit, sale price, cost price, reorder level (default 5), chargeable on issue, linked courses and status. It shows the inline error "Mã hàng đã tồn tại" for a duplicate SKU (INV-01). There is no delete button, only the deactivate switch.

4 · Goods receipt (multi-line modal)

/inventory/documents/new?type=in · desktop

Goods receipt document
Header with document type, branch, date (no future dates), reference and note; lines with item, quantity and unit cost (receipts only); footer with line count, total quantity and total value. Post writes one movement of type in per line and assigns the code NK-YYYYMM-#### (INV-02).

5 · Posted transfer document (read-only)

/inventory/documents/:id · desktop

Posted transfer document
The same form in its posted state: fields locked, code CK-202609-0007 in the title, and each line showing the pair of movements, transfer_out at the source branch and transfer_in at the destination, sharing one document id (INV-05). Posted documents are immutable; mistakes are corrected only by a new adjustment document (rule 2).

6 · Issue to a student (modal from the student profile)

/inventory/issue · desktop

Issue materials to a student modal
The student is pre-filled from context with the Active status shown (rule 3); on-hand stock appears next to the item; the chargeable switch follows the item default and the price is editable with inventory.override_price. Quantity 4 exceeds the 3 on hand, so the form is blocked with "Không đủ tồn kho (còn 3)" and no movement is written (INV-03, rule 6). The callout states that a material tuition line will be created in PRD 05.

7 · Student profile · Materials tab

/students/:id#materials · desktop

Materials tab in the student profile
Table of date, item, quantity, chargeable, payment status (from the finance line) and returned, with a Return action on every row not yet returned. The toast confirms the issue just completed and links to the tuition line (INV-03); the right rail sums outstanding material debt and lists suggested materials for the course.

8 · Stock count sheet (variances)

/inventory/documents/new?type=adjust · desktop

Stock count sheet
Each line shows book quantity, counted quantity, signed variance, variance value and a mandatory reason (damaged, lost, count, other) whenever there is a variance; the IELTS Junior 3 line at −12 exceeds the threshold, so the document will go to Pending approval by the branch manager (INV-06, rule 5). The Polo size M line is missing its reason and blocks posting.

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

Non-goals (out of scope)

2Personas and roles

PersonaRole codeUses inventory for
Accountant (Kế toán)accountantStock-in, counts, valuation report, reconciliation with receipts.
Branch manager (Quản lý chi nhánh)branch_managerApprove write-offs and transfers, watch low-stock list.
Front desk / academic admin (Giáo vụ)academic_adminIssue books to a newly enrolled student, class hand-outs.
Owner (Chủ trung tâm)ownerCross-branch stock and margin view.
Teacher (Giáo viên)teacherRead only: sees which students in the class have received the book.

3User stories and acceptance criteria

INV-01Define a catalog item
As an accountant I create an item so that it can be stocked and issued.
  • 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.
INV-02Receive stock into a branch
As an accountant I record a stock-in so that on-hand quantity increases.
  • 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 in per line is written, on-hand for that branch increases, and the document becomes read-only.
INV-03Issue an item to a student
As an academic admin I issue a book to a student so that the hand-out is recorded and, if it is chargeable, billed.
  • 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 issue is written, on-hand decreases, the student profile shows the item under Materials, and if chargeable a tuition line of type material is 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.
INV-04Issue to a whole class
As an academic admin I hand a workbook to every student of a class in one action.
  • 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.
INV-05Transfer between branches
As a branch manager I move stock from branch A to branch B.
  • Given the user has inventory.manage on both branches or is owner
  • When a transfer document is posted
  • Then a paired transfer_out at A and transfer_in at B are written atomically with the same document_id.
INV-06Adjust after a physical count
As an accountant I record a count so that the ledger matches the shelf.
  • Given a count sheet listing counted quantities
  • When it is posted
  • Then for each item where counted ≠ on-hand an adjust movement with signed delta and mandatory reason is written; adjustments above 10 units or 2,000,000 VND require branch manager approval (status pending until approved).
INV-07Low-stock warning
As a branch manager I see items at or below reorder level.
  • 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.
INV-08Return an issued item
As an admin I record that a student returned a book (wrong level, refund).
  • Given an existing issue movement
  • When the admin clicks Return on that row and chooses condition (new / used)
  • Then a return movement referencing the original is written; new-condition returns increase on-hand, used-condition returns go to quantity damaged; if the original was chargeable the finance line is flagged for credit in PRD 05 (not auto-refunded).

4Screens

Inventory overview /inventory

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

ColumnSourceNotes
SKUitem.skumonospace, sortable
Tên hàngitem.namelink to item drawer
Danh mụcitem.categoryfilter chip
Đơn vịitem.unit
Tồnsum(movements) per branchright-aligned; red when ≤ reorder level
Hỏngdamaged qtyhidden when 0
Giá bánitem.sale_priceVND, thousands separator
Giá trị tồnon-hand × cost_priceowner/accountant only
Thao tácNhậ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

ColumnNotes
Thời giancreated_at, dd/MM/yyyy HH:mm
Loạichip: Nhập (ok) · Xuất (exc) · Chuyển (grey) · Điều chỉnh (late) · Trả lại (grey)
Mặt hàngname + SKU
SLsigned quantity
Đối tượngstudent name / class / branch / supplier ref
Chứng từdocument code, link
Người thực hiệnactor display_name

Filters: date range, type, item, student. Export CSV (permission inventory.export).

Item form (drawer) /inventory/catalog/new · /inventory/catalog/:id
FieldTypeValidation
Mã hàng (sku)text 3–32required, unique per tenant, uppercase A–Z0–9-
Tên hàng (name)text 2–120required
Danh mục (category)select from tenant list; inline addrequired
Đơn vị (unit)select: cuốn, bộ, cái, hộprequired
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)toggledefault on for books
Gắn với khoá (course_ids)multi-select courseoptional; drives the "suggested materials" list at enrollment
Trạng tháiactive / inactiveinactive 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.

Stock-in / transfer / adjust document (modal, multi-line) /inventory/documents/new?type=in|transfer|adjust

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-….

Issue to student (modal, launched from student profile, class page or stock row) /inventory/issue

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.

Student profile · Materials tab /students/:id#materials

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

  1. On-hand(branch, item) = Σ quantity of movements where branch matches; there is no stored quantity column that can be edited.
  2. Posted documents are immutable. Corrections are new adjust movements referencing the original.
  3. An issue requires a student with status Active, Trial or Paused (PRD 02); Withdrawn students cannot receive items.
  4. 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.
  5. Adjustments above threshold (configurable per tenant, default 10 units or 2,000,000 VND) require approval by branch_manager or owner.
  6. Negative on-hand is never allowed; the API rejects with INSUFFICIENT_STOCK.
  7. Cost price is visible only to owner and accountant.
  8. Documents are numbered per tenant per month; numbering gaps are allowed (cancelled drafts).

6Data model

EntityFieldTypeNotes
inventory_itemiduuidPK
tenant_idint64= school_id
skutextunique (tenant_id, sku)
nametext
category_iduuidFK inventory_category
unittext
sale_pricenumeric(14,0)VND
cost_pricenumeric(14,0)
reorder_levelint
chargeable_defaultbool
course_idsuuid[]FK course (PRD 03)
is_active, created_at, updated_at
inventory_categoryiduuid
tenant_idint64
nametextunique per tenant
positionint
inventory_documentiduuid
tenant_idint64
codetextNK/XK/CK/DC-YYYYMM-####
typeenum in|issue|transfer|adjust|return
branch_iduuidFK branch (PRD 10); for transfer = from
to_branch_iduuid nulltransfer only
statusenum draft|pending|posted|cancelled
reference, notetext
approved_by, approved_atnullable
created_by, posted_at
inventory_movementiduuid
document_iduuidFK
item_iduuidFK
branch_iduuidthe branch whose on-hand changes
quantityintsigned; in/transfer_in/return(new) > 0; issue/transfer_out/adjust(-) < 0
unit_costnumericstock-in only
student_idint64 nullFK student (issue/return)
class_idint64 nullclass hand-out
tuition_line_iduuid nullFK finance line when chargeable
conditionenum new|damaged nullreturns
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.

MethodPathRequestResponseStatus
GET/v3/school/inventory/itemsq, category_id, is_active, branch_id (adds on_hand), page, limitlist 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}InventoryItemnew
PATCH/v3/school/inventory/items/{id}partialInventoryItemnew
GET/POST/v3/school/inventory/categories{name}Category[]new
GET/v3/school/inventory/stockbranch_id (required unless owner), q, low_only[{item, on_hand, damaged, value}]new
GET/v3/school/inventory/documentstype, branch_id, from, to, status, pageDocument[] with line countsnew
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}/approveDocument (pending → posted)new
POST/v3/school/inventory/documents/{id}/cancel{reason}only draft/pendingnew
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}Movementnew
GET/v3/school/inventory/movementsbranch_id, item_id, student_id, type, from, to, pageMovement[]new
GET/v3/school/students/{id}/materialsMovement[] joined with tuition line statusnew
GET/v3/school/inventory/exportsame filters as stock or movements, format=csvfilenew

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 codeownerbranch_manageraccountantacademic_adminadmissionsteacher
inventory.viewyesyesyesyesnoclass scope
inventory.manage (catalog, stock-in, adjust, transfer)yesyesyesnonono
inventory.issueyesyesyesyesnono
inventory.approveyesyesnononono
inventory.override_priceyesyesyesnonono
inventory.view_costyesnoyesnonono
inventory.exportyesyesyesnonono

Branch scoping: every permission is evaluated against the branches the user is assigned to (PRD 10). Owner is implicitly all branches.

9Analytics events

EventProperties
inventory_item_createdtenant_id, category, chargeable_default
inventory_document_postedtype, line_count, total_qty, total_value, branch_id
inventory_issuetarget (student|class), item_id, quantity, chargeable, created_finance_line
inventory_issue_blockedreason (insufficient_stock|student_status)
inventory_returncondition
inventory_low_stock_viewedcount
inventory_exportscope, rows

10Edge cases

11Open questions

Trường Số (truongso.net) · FLYER internal