Document Money, Org/Commercial, Staff, Doctor, Rx, Order, Item/UOM, Stock, and related envelopes so Admin and the cross-app master share one schema source. Co-authored-by: Cursor <cursoragent@cursor.com>
16 KiB
Gishen Admin — Entity schemas
Living schema pack for the Admin backend contract and cross-app master.
Spec sheet: admin-backend-spec.md · Master: GISHEN-MASTER-SPEC.md
| Field | Value |
|---|---|
| Source of types | src/types/index.ts, src/mocks/catalog.ts, src/mocks/data.ts |
| Wire format | JSON over REST (application/json) |
| Timestamps | ISO-8601 UTC (2026-08-06T08:30:00Z) unless noted |
| IDs | Prefer prefix + opaque id (org_*, rx_*, ord_*, adm_*) or UUID — pick one platform-wide |
When you change a field on a main element, update this file, the Admin sheet changelog, and the master schema summary in the same change.
Changelog
| Date | Change |
|---|---|
| 2026-08-08 | Initial schema pack for Money, Staff, Org/Commercial, Doctor, Rx, Order, Catalogue Item/UOM, Stock, Customer, Settlement, Dispatch trip, envelopes |
Index
| Schema | Kind | Cross-app |
|---|---|---|
| Money | Shared value object | B2B Money |
| Envelopes | List / error / event | B2B pagination preferred long-term |
| StaffUser | Admin principal | — |
| Branch | Location | Ecom /branches |
| Organisation | B2B / hospital account | B2B organisation.md |
| Doctor | External clinical actor | Open IdP |
| Prescription | Rx queue | B2B Rx clinical withhold |
| Order | Branch fulfilment | Ecom/Mob orders |
| MedicationItem | Catalogue master | Mob/Ecom PDP + catalog |
| StockRow | Branch inventory | ERP sync |
| Customer | CRM / loyalty | Shared customer_id |
| Settlement | Finance batch | — |
| RiderTrip | Dispatch | Dispatch Bot |
| MigrationJob | Admin imports | Distinct from B2B tenant migration |
Money
interface Money {
amount: string // decimal major units, e.g. "1250.00"
currency: 'ETB'
}
| Field | Type | Required | Notes |
|---|---|---|---|
amount |
string |
✓ | Decimal string — never float JSON numbers for money |
currency |
"ETB" |
✓ | Only ETB in v1 |
Admin UI may keep parallel numeric major-ETB fields (creditLimitEtb, totalEtb) for demos — API wiring uses Money (or syncs numbers from commercial).
API envelopes
Success (single)
type DataEnvelope<T> = { data: T }
Success (list) — target platform
type ListEnvelope<T> = {
data: T[]
pagination: {
page: number
page_size: number
total_items: number
total_pages: number
}
}
Legacy Admin sketches: ?page=&limit= → { data, meta: { page, limit, total } }. Prefer page_size / pagination when consolidating with B2B.
Error
type ErrorEnvelope = {
error: {
code: string
message: string
details?: Array<{ field?: string; code?: string; message: string }> | Record<string, unknown>
request_id?: string
}
}
Domain event
type DomainEvent<T = Record<string, unknown>> = {
id: string
type: string // e.g. "org.activated", "prescription.review_updated"
occurred_at: string // ISO timestamp
org_id?: string | null
actor_id?: string | null
payload: T
schema_version: string // "1"
}
StaffUser
type StaffRole =
| 'pharmacist'
| 'stock_manager'
| 'procurement'
| 'finance'
| 'marketing_manager'
| 'operations'
| 'super_admin'
type AuthProvider = 'email' | 'google' | 'phone' | 'telegram'
interface StaffUser {
id: string
name: string
email: string
role: StaffRole
branchId?: string
branchName?: string
preferredLocale?: 'en' | 'am'
avatarUrl?: string
authProvider?: AuthProvider
phone?: string
}
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string |
✓ | Staff principal id |
name |
string |
✓ | Display name |
email |
string |
✓ | Login / Google subject email |
role |
StaffRole |
✓ | Single primary role in Admin SPA |
branchId |
string |
Required for pharmacist (and optionally stock) | |
preferredLocale |
"en" | "am" |
UI language | |
authProvider |
AuthProvider |
How this session was established |
Not a StaffRole: Doctor, B2B SUPER_USER / HR_ADMIN / FINANCE / MEMBER, retail customer.
Login response sketch
{
"accessToken": "...",
"refreshToken": "...",
"user": {
"id": "adm_01H...",
"name": "Hana Pharmacist",
"email": "pharmacist@gishen.et",
"role": "pharmacist",
"branchId": "br-bole",
"branchName": "Bole Branch",
"preferredLocale": "en",
"authProvider": "email"
}
}
Branch
interface Branch {
id: string
name: string
zone: string
phone: string
lat?: number
lng?: number
hours?: string
}
Organisation & CommercialTerms
type OrganisationType = 'corporate' | 'hospital' | 'clinic' | 'ngo' | 'other'
type OrgStatus = 'pending_activation' | 'active' | 'suspended' | 'closed' | 'rejected'
interface CommercialTerms {
credit_limit: Money
credit_used: Money
payment_terms_days: number
price_list_id?: string
contract_start?: string // ISO date YYYY-MM-DD
contract_end?: string
activated_at?: string
activated_by?: string // admin user id
}
interface Organisation {
id: string
name: string
orgType: OrganisationType
tin: string
vatNumber?: string
businessLicense?: string
commercialRegistration?: string
status: OrgStatus
commercial: CommercialTerms | null
/** Demo UI only — keep in sync with commercial when set */
creditLimitEtb: number
usedEtb: number
billingContact: string
source: 'admin' | 'self_register'
createdAt: string
reviewedBy?: string
reviewedAt?: string
decisionNote?: string
}
| Field | Type | Required | Notes |
|---|---|---|---|
orgType |
OrganisationType |
✓ | hospital / clinic unlock Doctors roster |
status |
OrgStatus |
✓ | Canonical lifecycle (not bare pending) |
commercial |
CommercialTerms | null |
✓ | null until activate / admin-create |
tin |
string |
✓ | 10-digit Ethiopian TIN |
source |
admin | self_register |
✓ | Who created the org |
Activate request body
type OrgActivateBody = {
commercial: {
credit_limit: Money
payment_terms_days: number
price_list_id?: string
contract_start?: string
contract_end?: string
}
admin_notes?: string
}
Emits org.activated. Sample commercial payload matches B2B organisation.md.
Doctor
interface Doctor {
id: string
name: string
specialty: string
licenseNumber: string
phone: string
email: string
status: 'active' | 'inactive'
orgId: string
orgName: string
branchId?: string
branchName?: string
hasLinkedAccount: boolean
createdAt: string
updatedAt: string
avatarUrl?: string
}
Affiliation org must be orgType hospital or clinic. Auth: POST /auth/doctor/login (separate JWT / actor=doctor).
Prescription
type DosingFrequency = 'QD' | 'BID' | 'TID' | 'QID' | 'QXH' | 'custom'
type PrescriptionStatus =
| 'draft' // B2B/Ecom may create drafts
| 'submitted'
| 'under_review'
| 'approved'
| 'queried'
| 'rejected'
interface PrescriptionItem {
name: string
qty: number
controlled?: boolean
frequency?: DosingFrequency
intervalHours?: number // when frequency === 'QXH'
times?: string[] // HH:mm 24h — Mob reminder seed
sku?: string
dosage?: string
instructions?: string
}
interface Prescription {
id: string
customerName: string
branchId: string
status: Exclude<PrescriptionStatus, 'draft'> | PrescriptionStatus
items: PrescriptionItem[]
submittedAt: string
prescriber?: string
doctorId?: string
orgId?: string
customerId?: string // shared platform identity when known
memberId?: string // B2B member when from institutional portal
reviewStartedAt?: string
reviewedBy?: string
reviewedAt?: string
decisionNote?: string
query_message?: string
rejection_reason?: string
days_supply?: number
refill_due_at?: string
}
| Rule | Notes |
|---|---|
| Clinical withhold | HR/Finance B2B never receive items, images, diagnosis |
| Verify | POST /admin/prescriptions/:id/verify → prescription.review_updated |
| Dosing helper | src/lib/dosingSchedule.ts |
Verify body
type PrescriptionVerifyBody = {
status: 'approved' | 'queried' | 'rejected'
medicine_lines?: PrescriptionItem[]
qtyAdjustments?: unknown
substitute?: unknown
notes?: string
query_message?: string
rejection_reason?: string
days_supply?: number
}
Order
type OrderCustomerType = 'registered' | 'guest'
type OrderFulfillment = 'delivery' | 'pickup'
/** Suggested shared status pipeline (align with Ecom) */
type OrderStatus =
| 'draft'
| 'requested'
| 'pending' // demo alias
| 'pending_approval'
| 'confirmed'
| 'picking'
| 'out_for_delivery'
| 'ready_for_pickup'
| 'completed'
| 'cancelled'
interface OrderLine {
sku: string
name: string
qty: number
unitEtb: number
}
interface Order {
id: string
customerName: string
customerType?: OrderCustomerType
customerId?: string
customerPhone?: string
notes?: string
branchId: string
fulfillment: OrderFulfillment
status: string // OrderStatus in production
totalEtb: number // demo major ETB; prefer Money later
channel: string // web | telegram | mobile | b2b | pos | doctor | …
assignedRiderId?: string
zone?: string
address?: string
createdAt: string
paid: boolean
rxApproved?: boolean
doctorId?: string
orgId?: string
lines?: OrderLine[]
}
| Rule | Notes |
|---|---|
| Guest | Requires customerPhone; no customerId; loyalty gated |
| Registered | Prefer customerId + shared platform identity |
| Doctor-authored | Set doctorId + hospital orgId |
MedicationItem & ItemUom
Catalogue master (/admin/catalog/items, UI /items). Retail reads as /catalog/products.
interface ItemUom {
uom: string
conversionFactor: number // how many base/stock units = 1 of this UOM
isStockUom?: boolean
isPurchaseUom?: boolean
isSalesUom?: boolean
}
interface ActiveIngredient {
name: string
strength: string
}
interface MedicationItem {
id: string
sku: string
genericName: string
brandName: string
manufacturer: string
countryOfOrigin: string
efdaRegistrationNumber: string
barcodes: { value: string; label?: string }[]
productType: string
therapeuticClass: string
ingredients: ActiveIngredient[]
dosageForm: string
routeOfAdministration?: string
controlledSchedule?: string
packSize: string
baseUnit: string
sellByUnit: string
packUnit: string
uoms: ItemUom[]
parentItemId?: string
costPriceEtb: number
sellingPriceEtb: number
currency: 'ETB'
sellingPriceOwner: 'platform' | 'erp' | string
vatApplicable: boolean
discountEligible: boolean
b2bPriceListId?: string
prescriptionRequired: boolean
efdaStatus: string
controlledSubstance: boolean
advertisingRestricted: boolean
ageRestriction: string
shortDescription: string
dosageGuidance: string
sideEffects: string
sideEffectsMild?: string[]
sideEffectsSevere?: string[]
warnings: string
storageInstructions: string
storageNotes?: { title: string; body: string }[]
directions?: { title: string; body: string }[]
pharmacistTip?: string
useCase?: string
storefrontUnit?: string
compareAtPriceEtb?: number
alternativeItemIds: string[]
defaultReorderPoint: number
defaultExpiryAlertDays: number
images: string[]
thumbnailUrl?: string
badges: string[]
seoSlug: string
metaTitle: string
metaDescription: string
ownership: Record<string, string> // FieldGroupOwnership
lastErpSyncAt?: string
erpSyncError?: string | null
b2bEligible: boolean
loyaltyEligible: boolean
createdBy: string
createdAt: string
updatedBy: string
updatedAt: string
status: 'active' | 'draft' | 'archived'
}
Stock qty is always in baseUnit. Use toStockQty / fromStockQty for sales/purchase UOM conversions.
StockRow
interface StockRow {
sku: string
name: string
category: string
branchId: string
qty: number // baseUnit
batch: string
expiry: string // YYYY-MM
erpQty: number
unitEtb: number
itemId?: string // → MedicationItem.id
reserved?: number
}
Customer
interface Customer {
id: string // platform customer_id
name: string
phone: string
tier: string
points: number
branch: string
joinedAt: string
orders: number
spendEtb: number
lastOrderAt: string | null
avatarUrl?: string
}
Same id links B2B member.customer_id and retail sessions when enrolled.
Settlement
interface Settlement {
id: string
channel: string // Chapa | Telebirr | COD | M-Pesa | …
date: string // YYYY-MM-DD
volumeEtb: number
status: 'reconciled' | 'pending' | 'matched' | 'unmatched' | 'refunded' | string
txnCount: number
feesEtb: number
}
Rider & RiderTrip
interface Rider {
id: string
name: string
phone: string
telegramId?: string
branchId: string
zone: string
avatarUrl?: string
}
interface RiderTrip {
id: string
orderId: string
customerName: string
zone: string
assignedAt: string
completedAt: string | null
status: 'delivered' | 'failed' | 'en_route' | 'returned'
minutes: number
onTime: boolean
codEtb: number
}
Dispatch board assigns riders; Telegram bot consumes trip.assigned / status updates.
MigrationJob
Admin platform import (stock, catalog, riders…) — not the same as B2B HR MigrationJob tenant imports.
interface MigrationJob {
id: string
templateId: string // stock | catalog | hr | branches | riders | generic
templateLabel: string
filename: string
rowsTotal: number
rowsImported: number
rowsFailed: number
status: 'completed' | 'partial' | 'failed' | 'running'
runBy: string
runAt: string
durationSec: number
mapping: Record<string, string>
}
Sample: Organisation (active)
{
"id": "org-1",
"name": "Horizon Bank",
"orgType": "corporate",
"tin": "0001234567",
"vatNumber": "VAT-ET-0001234567",
"businessLicense": "BL-AA/48291/2014",
"commercialRegistration": "CR/015842/2014",
"status": "active",
"commercial": {
"credit_limit": { "amount": "500000.00", "currency": "ETB" },
"credit_used": { "amount": "124000.00", "currency": "ETB" },
"payment_terms_days": 30,
"price_list_id": "pl_corporate_2026",
"contract_start": "2026-01-01",
"contract_end": "2026-12-31",
"activated_at": "2026-05-02T09:40:00Z",
"activated_by": "adm_finance"
},
"creditLimitEtb": 500000,
"usedEtb": 124000,
"billingContact": "finance@horizon.et",
"source": "admin",
"createdAt": "2026-05-01T00:00:00Z",
"reviewedBy": "Yonas Finance",
"reviewedAt": "2026-05-02T09:40:00Z"
}
Sample: Prescription (under review)
{
"id": "rx-1001",
"customerName": "Abebe Kebede",
"branchId": "br-bole",
"status": "under_review",
"items": [
{
"name": "Amoxicillin 500mg",
"qty": 21,
"frequency": "TID",
"times": ["08:00", "14:00", "20:00"]
}
],
"submittedAt": "2026-08-06T08:10:00Z",
"prescriber": "Dr. Selam",
"doctorId": "doc-1",
"orgId": "org-3"
}
Related
| Doc | Role |
|---|---|
admin-backend-spec.md |
Endpoints + modules using these schemas |
GISHEN-MASTER-SPEC.md |
Cross-app map + schema summary |
B2B docs/backend/entities/ |
Portal-facing entities (clinical withhold, members, packages) |
Ecom docs/backend.md |
Retail order/catalog contract |