Skip to main content

PurchaseOrder

purchaseOrder is the core procurement document. It's created as a Draft, submitted to begin the workflow (Pending), and flows through step approvals (InProgress โ†’ Approved/Rejected โ†’ Completed/Cancelled). It carries an embedded history[] of every performed action and, on submit, encumbers budget against a budgetLine.

// models/purchaseOrder.ts (definition, trimmed of the doc comment)
import { coreApp } from "../mod.ts";
import {
array, coerce, date, defaulted, enums, number, object, optional,
type RelationDataType, type RelationSortOrderType, string,
} from "lesan";
import { createUpdateAt } from "@lib";
import {
budgetLine_excludes, file_excludes, organization_excludes, process_excludes,
product_excludes, stepApproval_excludes, tender_excludes, unit_excludes, user_excludes,
} from "./excludes.ts";

export const purchaseOrder_status_array = [
"Draft", "Pending", "InProgress", "Approved", "Rejected", "Completed", "Cancelled",
];
export const purchaseOrder_status_emums = enums(purchaseOrder_status_array);

export const purchaseOrder_pure = {
title: string(),
description: optional(string()),
estimatedAmount: defaulted(number(), 0),
status: defaulted(
coerce(purchaseOrder_status_emums, string(), (value) => value as typeof purchaseOrder_status_array[number]),
"Draft",
),
currentStep: defaulted(number(), 0),
requestedAt: optional(coerce(date(), string(), (value) => new Date(value))),
completedAt: optional(coerce(date(), string(), (value) => new Date(value))),
history: defaulted(
array(
object({
action: string(),
performed: object({
by: string(),
name: string(),
at: coerce(date(), string(), (value) => new Date(value)),
role: object({
id: string(),
name: string(),
scopeType: optional(string()),
scopeId: optional(string()),
}),
}),
unit: optional(object({ _id: string(), name: string() })),
details: optional(object({})),
}),
),
[],
),
...createUpdateAt,
};

Pure fieldsโ€‹

FieldTypeNotes
titlestring()also in the text index
descriptionoptional(string())also in the text index
estimatedAmountdefaulted(number(), 0)the encumbered amount on submit
statuscoerce(enums([...7]), "Draft")lifecycle state machine
currentStepdefaulted(number(), 0)which step the order is on (0 = not started)
requestedAtoptional(coerce(date()))when it entered the workflow
completedAtoptional(coerce(date()))set on finalize/cancel
historydefaulted(array(entry), [])immutable action log
createdAt / updatedAtspread from createUpdateAt

history is the audit trail โ€” an array of { action, performed: { by, name, at, role }, unit?, details? }. Every submit, decision, finalize, or cancel appends (never mutates) an entry, so the full story of the order is recoverable. getHistory reads it.

Relationsโ€‹

export const purchaseOrder_relations = {
requester: { schemaName: "user", type: "single" as RelationDataType, optional: false, excludes: user_excludes,
relatedRelations: { purchaseOrders: { type: "multiple" as RelationDataType, limit: 50, sort: { field: "_id", order: "desc" as RelationSortOrderType } } } },
organization: { schemaName: "organization", type: "single" as RelationDataType, optional: true, excludes: organization_excludes,
relatedRelations: { purchaseOrders: { type: "multiple" as RelationDataType, limit: 50, sort: { field: "_id", order: "desc" as RelationSortOrderType } } } },
requestingUnit: { schemaName: "unit", type: "single" as RelationDataType, optional: true, excludes: unit_excludes,
relatedRelations: { purchaseOrders: { type: "multiple" as RelationDataType, limit: 50, sort: { field: "_id", order: "desc" as RelationSortOrderType } } } },
product: { schemaName: "product", type: "single" as RelationDataType, optional: false, excludes: product_excludes,
relatedRelations: { purchaseOrders: { type: "multiple" as RelationDataType, limit: 50, sort: { field: "_id", order: "desc" as RelationSortOrderType } } } },
process: { schemaName: "process", type: "single" as RelationDataType, optional: true, excludes: process_excludes,
relatedRelations: { purchaseOrders: { type: "multiple" as RelationDataType, limit: 50, sort: { field: "_id", order: "desc" as RelationSortOrderType } } } },
attachments: { schemaName: "file", type: "multiple" as RelationDataType, optional: true, excludes: file_excludes,
limit: 50, sort: { field: "_id", order: "desc" as RelationSortOrderType }, relatedRelations: {} },
budgetLine: { schemaName: "budgetLine", type: "single" as RelationDataType, optional: true, excludes: budgetLine_excludes,
relatedRelations: { purchaseOrders: { type: "multiple" as RelationDataType, limit: 50, sort: { field: "_id", order: "desc" as RelationSortOrderType } } } },
tender: { schemaName: "tender", type: "single" as RelationDataType, optional: true, excludes: tender_excludes,
relatedRelations: { purchaseOrders: { type: "multiple" as RelationDataType, limit: 50, sort: { field: "_id", order: "desc" as RelationSortOrderType } } } },
};
RelationTargetTypeNotes
requesterusersingle (required)who raised the order
organizationorganizationsingle (optional)
requestingUnitunitsingle (optional)the unit that needs the goods
productproductsingle (required)what is being bought
processprocesssingle (optional)resolved on submit
attachmentsfilemultiple (optional, limit 50)docs/quotations
budgetLinebudgetLinesingle (optional)where the money comes from
tendertendersingle (optional)competitive bidding link

Every forward relation has a back-reference on its target (e.g. user.purchaseOrders, budgetLine.purchaseOrders), kept in sync automatically by the relation engine.

The status machineโ€‹

Draft โ”€โ”€submitโ”€โ”€> Pending โ”€โ”€create stepApprovalsโ”€โ”€> InProgress
โ”‚ โ”‚
all approved โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€ any rejected โ”€โ”€> Rejected
โ”‚
Approved โ”€โ”€finalizeโ”€โ”€> Completed
โ”‚
Draft / InProgress / Pending โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€cancelโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€> Cancelled

The transitions are enforced inside the acts, not the schema โ€” see the workflow chapter.

Factory and text indexโ€‹

export const purchaseOrders = () =>
coreApp.odm.newModel("purchaseOrder", purchaseOrder_pure, purchaseOrder_relations, {
createIndex: {
indexSpec: { title: "text", description: "text" },
},
});

The text index on title/description powers the free-text search in the gets/count acts.

In the workflowโ€‹

Run itโ€‹

curl -X POST http://localhost:1380/lesan \
-H "Content-Type: application/json" \
-H "token: <jwt>" \
-d '{
"service": "main",
"model": "purchaseOrder",
"act": "gets",
"details": {
"set": { "query": { "$text": "TSH" }, "page": 1 },
"get": { "title": true, "status": true, "estimatedAmount": true, "product": { "name": true } }
}
}'

Errors & fixesโ€‹

ErrorCauseFix
purchaseOrder not foundact given an unknown _idpass a real _id
status-guard errors (can not ... because status is ...)transition attempted from an illegal stateonly valid transitions exist (see each act's Errors table)
Insufficient remaining budgetsubmit amount exceeds the budgetLine's remainingBudgetreduce amount or allocate more budget
note

Runtime On npm/Bun import the framework from @hemedani/lesan; on Deno from jsr:@hemedani/lesan. The repo app itself uses the lesan path alias.