@porulle/plugin-procurement 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ import { router } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import type { SupplierService } from "../services/supplier-service.js";
4
+ import type { PurchaseOrderService } from "../services/po-service.js";
5
+ import type { GRNService } from "../services/grn-service.js";
6
+ import type { PluginRouteRegistration } from "@porulle/core";
7
+
8
+ export function buildProcurementRoutes(
9
+ supplierSvc: SupplierService, poSvc: PurchaseOrderService, grnSvc: GRNService,
10
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
11
+ ): PluginRouteRegistration[] {
12
+ const r = router("Procurement", "/procurement", ctx);
13
+
14
+ // Suppliers
15
+ r.post("/suppliers").summary("Register supplier").permission("procurement:admin")
16
+ .input(z.object({ name: z.string().min(1), code: z.string().min(1), contactName: z.string().optional(), contactEmail: z.string().email().optional(), contactPhone: z.string().optional(), paymentTermsDays: z.number().int().optional(), currency: z.string().optional() }))
17
+ .handler(async ({ input, orgId }) => {
18
+ const result = await supplierSvc.create(orgId, input as Parameters<typeof supplierSvc.create>[1]);
19
+ if (!result.ok) throw new Error(result.error);
20
+ return result.value;
21
+ });
22
+
23
+ r.get("/suppliers").summary("List suppliers").permission("procurement:read")
24
+ .handler(async ({ orgId }) => {
25
+ const result = await supplierSvc.list(orgId);
26
+ if (!result.ok) throw new Error(result.error);
27
+ return result.value;
28
+ });
29
+
30
+ r.get("/suppliers/{id}").summary("Get supplier with items").permission("procurement:read")
31
+ .handler(async ({ params, orgId }) => {
32
+ const result = await supplierSvc.getById(orgId, params.id!);
33
+ if (!result.ok) throw new Error(result.error);
34
+ return result.value;
35
+ });
36
+
37
+ r.post("/suppliers/{id}/items").summary("Link item to supplier").permission("procurement:admin")
38
+ .input(z.object({ entityId: z.string().uuid(), variantId: z.string().uuid().optional(), supplierSku: z.string().optional(), unitCost: z.number().int().positive(), minOrderQuantity: z.number().int().optional(), leadTimeDays: z.number().int().optional(), isPreferred: z.boolean().optional() }))
39
+ .handler(async ({ params, input }) => {
40
+ const result = await supplierSvc.addItem(params.id!, input as Parameters<typeof supplierSvc.addItem>[1]);
41
+ if (!result.ok) throw new Error(result.error);
42
+ return result.value;
43
+ });
44
+
45
+ // Purchase Orders
46
+ r.post("/purchase-orders").summary("Create purchase order").permission("procurement:create")
47
+ .input(z.object({
48
+ supplierId: z.string().uuid(), warehouseId: z.string().uuid(), notes: z.string().optional(),
49
+ expectedDelivery: z.string().optional(),
50
+ items: z.array(z.object({ entityId: z.string().uuid(), variantId: z.string().uuid().optional(), itemName: z.string(), quantityOrdered: z.number().int().positive(), unitCost: z.number().int().positive() })).min(1),
51
+ }))
52
+ .handler(async ({ input, actor, orgId }) => {
53
+ const body = input as Parameters<typeof poSvc.create>[1];
54
+ const result = await poSvc.create(orgId, { ...body, requestedBy: actor!.userId });
55
+ if (!result.ok) throw new Error(result.error);
56
+ return result.value;
57
+ });
58
+
59
+ r.get("/purchase-orders").summary("List purchase orders").permission("procurement:read")
60
+ .query(z.object({ status: z.string().optional() }))
61
+ .handler(async ({ query, orgId }) => {
62
+ const result = await poSvc.list(orgId, (query as { status?: string }).status);
63
+ if (!result.ok) throw new Error(result.error);
64
+ return result.value;
65
+ });
66
+
67
+ r.get("/purchase-orders/{id}").summary("Get PO with items").permission("procurement:read")
68
+ .handler(async ({ params, orgId }) => {
69
+ const result = await poSvc.getById(orgId, params.id!);
70
+ if (!result.ok) throw new Error(result.error);
71
+ return result.value;
72
+ });
73
+
74
+ r.post("/purchase-orders/{id}/submit").summary("Submit PO for approval").permission("procurement:create")
75
+ .handler(async ({ params, orgId }) => {
76
+ const result = await poSvc.submit(orgId, params.id!);
77
+ if (!result.ok) throw new Error(result.error);
78
+ return result.value;
79
+ });
80
+
81
+ r.post("/purchase-orders/{id}/approve").summary("Approve PO").permission("procurement:admin")
82
+ .handler(async ({ params, actor, orgId }) => {
83
+ const result = await poSvc.approve(orgId, params.id!, actor!.userId);
84
+ if (!result.ok) throw new Error(result.error);
85
+ return result.value;
86
+ });
87
+
88
+ r.post("/purchase-orders/{id}/cancel").summary("Cancel PO").permission("procurement:admin")
89
+ .handler(async ({ params, orgId }) => {
90
+ const result = await poSvc.cancel(orgId, params.id!);
91
+ if (!result.ok) throw new Error(result.error);
92
+ return result.value;
93
+ });
94
+
95
+ // GRN
96
+ r.post("/grn").summary("Create GRN").permission("procurement:create")
97
+ .input(z.object({
98
+ poId: z.string().uuid(), supplierId: z.string().uuid(), warehouseId: z.string().uuid(), notes: z.string().optional(),
99
+ items: z.array(z.object({
100
+ poItemId: z.string().uuid(), entityId: z.string().uuid(), variantId: z.string().uuid().optional(),
101
+ quantityOrdered: z.number().int(), quantityReceived: z.number().int(), quantityAccepted: z.number().int(),
102
+ quantityRejected: z.number().int().optional(), rejectionReason: z.string().optional(),
103
+ batchNumber: z.string().optional(), expiryDate: z.string().optional(), unitCost: z.number().int(),
104
+ })).min(1),
105
+ }))
106
+ .handler(async ({ input, actor, orgId }) => {
107
+ const body = input as Parameters<typeof grnSvc.create>[1];
108
+ const result = await grnSvc.create(orgId, { ...body, receivedBy: actor!.userId });
109
+ if (!result.ok) throw new Error(result.error);
110
+ return result.value;
111
+ });
112
+
113
+ r.get("/grn").summary("List GRNs").permission("procurement:read")
114
+ .handler(async ({ orgId }) => {
115
+ const result = await grnSvc.list(orgId);
116
+ if (!result.ok) throw new Error(result.error);
117
+ return result.value;
118
+ });
119
+
120
+ r.get("/grn/{id}").summary("Get GRN with items").permission("procurement:read")
121
+ .handler(async ({ params, orgId }) => {
122
+ const result = await grnSvc.getById(orgId, params.id!);
123
+ if (!result.ok) throw new Error(result.error);
124
+ return result.value;
125
+ });
126
+
127
+ r.post("/grn/{id}/accept").summary("Accept GRN").permission("procurement:admin")
128
+ .handler(async ({ params, orgId }) => {
129
+ const result = await grnSvc.accept(orgId, params.id!);
130
+ if (!result.ok) throw new Error(result.error);
131
+ return result.value;
132
+ });
133
+
134
+ return r.routes();
135
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { pgTable, uuid, text, integer, boolean, timestamp, jsonb, index, uniqueIndex } from "@porulle/core/drizzle";
2
+
3
+ export const suppliers = pgTable("suppliers", {
4
+ id: uuid("id").defaultRandom().primaryKey(),
5
+ organizationId: text("organization_id").notNull(),
6
+ name: text("name").notNull(),
7
+ code: text("code").notNull(),
8
+ contactName: text("contact_name"),
9
+ contactEmail: text("contact_email"),
10
+ contactPhone: text("contact_phone"),
11
+ paymentTermsDays: integer("payment_terms_days").notNull().default(30),
12
+ currency: text("currency").notNull().default("USD"),
13
+ taxId: text("tax_id"),
14
+ isActive: boolean("is_active").notNull().default(true),
15
+ metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
16
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
17
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
18
+ }, (table) => ({
19
+ orgIdx: index("idx_suppliers_org").on(table.organizationId),
20
+ codeUnique: uniqueIndex("suppliers_org_code_unique").on(table.organizationId, table.code),
21
+ }));
22
+
23
+ export const supplierItems = pgTable("supplier_items", {
24
+ id: uuid("id").defaultRandom().primaryKey(),
25
+ supplierId: uuid("supplier_id").references(() => suppliers.id, { onDelete: "cascade" }).notNull(),
26
+ entityId: uuid("entity_id").notNull(),
27
+ variantId: uuid("variant_id"),
28
+ supplierSku: text("supplier_sku"),
29
+ unitCost: integer("unit_cost").notNull(),
30
+ minOrderQuantity: integer("min_order_quantity").notNull().default(1),
31
+ leadTimeDays: integer("lead_time_days").notNull().default(1),
32
+ isPreferred: boolean("is_preferred").notNull().default(false),
33
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
34
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
35
+ }, (table) => ({
36
+ supplierIdx: index("idx_supplier_items_supplier").on(table.supplierId),
37
+ }));
38
+
39
+ export const purchaseOrders = pgTable("purchase_orders", {
40
+ id: uuid("id").defaultRandom().primaryKey(),
41
+ organizationId: text("organization_id").notNull(),
42
+ poNumber: text("po_number").notNull(),
43
+ supplierId: uuid("supplier_id").references(() => suppliers.id).notNull(),
44
+ status: text("status", { enum: ["draft", "pending_approval", "approved", "sent", "partially_received", "received", "cancelled"] }).notNull().default("draft"),
45
+ warehouseId: uuid("warehouse_id").notNull(),
46
+ requestedBy: text("requested_by").notNull(),
47
+ approvedBy: text("approved_by"),
48
+ approvedAt: timestamp("approved_at", { withTimezone: true }),
49
+ expectedDelivery: timestamp("expected_delivery", { withTimezone: true }),
50
+ subtotal: integer("subtotal").notNull().default(0),
51
+ taxTotal: integer("tax_total").notNull().default(0),
52
+ grandTotal: integer("grand_total").notNull().default(0),
53
+ notes: text("notes"),
54
+ metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
55
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
56
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
57
+ }, (table) => ({
58
+ orgIdx: index("idx_purchase_orders_org").on(table.organizationId),
59
+ statusIdx: index("idx_purchase_orders_status").on(table.status),
60
+ poNumUnique: uniqueIndex("purchase_orders_org_po_unique").on(table.organizationId, table.poNumber),
61
+ }));
62
+
63
+ export const purchaseOrderItems = pgTable("purchase_order_items", {
64
+ id: uuid("id").defaultRandom().primaryKey(),
65
+ poId: uuid("po_id").references(() => purchaseOrders.id, { onDelete: "cascade" }).notNull(),
66
+ entityId: uuid("entity_id").notNull(),
67
+ variantId: uuid("variant_id"),
68
+ itemName: text("item_name").notNull(),
69
+ quantityOrdered: integer("quantity_ordered").notNull(),
70
+ quantityReceived: integer("quantity_received").notNull().default(0),
71
+ unitCost: integer("unit_cost").notNull(),
72
+ totalCost: integer("total_cost").notNull(),
73
+ notes: text("notes"),
74
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
75
+ }, (table) => ({
76
+ poIdx: index("idx_po_items_po").on(table.poId),
77
+ }));
78
+
79
+ export const goodsReceivedNotes = pgTable("goods_received_notes", {
80
+ id: uuid("id").defaultRandom().primaryKey(),
81
+ organizationId: text("organization_id").notNull(),
82
+ grnNumber: text("grn_number").notNull(),
83
+ poId: uuid("po_id").references(() => purchaseOrders.id).notNull(),
84
+ supplierId: uuid("supplier_id").references(() => suppliers.id).notNull(),
85
+ warehouseId: uuid("warehouse_id").notNull(),
86
+ receivedBy: text("received_by").notNull(),
87
+ status: text("status", { enum: ["draft", "inspecting", "accepted", "accepted_with_discrepancy", "rejected"] }).notNull().default("draft"),
88
+ receivedAt: timestamp("received_at", { withTimezone: true }).defaultNow().notNull(),
89
+ notes: text("notes"),
90
+ metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
91
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
92
+ }, (table) => ({
93
+ orgIdx: index("idx_grn_org").on(table.organizationId),
94
+ poIdx: index("idx_grn_po").on(table.poId),
95
+ grnNumUnique: uniqueIndex("grn_org_number_unique").on(table.organizationId, table.grnNumber),
96
+ }));
97
+
98
+ export const grnItems = pgTable("grn_items", {
99
+ id: uuid("id").defaultRandom().primaryKey(),
100
+ grnId: uuid("grn_id").references(() => goodsReceivedNotes.id, { onDelete: "cascade" }).notNull(),
101
+ poItemId: uuid("po_item_id").references(() => purchaseOrderItems.id).notNull(),
102
+ entityId: uuid("entity_id").notNull(),
103
+ variantId: uuid("variant_id"),
104
+ quantityOrdered: integer("quantity_ordered").notNull(),
105
+ quantityReceived: integer("quantity_received").notNull(),
106
+ quantityAccepted: integer("quantity_accepted").notNull(),
107
+ quantityRejected: integer("quantity_rejected").notNull().default(0),
108
+ rejectionReason: text("rejection_reason"),
109
+ batchNumber: text("batch_number"),
110
+ expiryDate: timestamp("expiry_date", { withTimezone: true }),
111
+ unitCost: integer("unit_cost").notNull(),
112
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
113
+ }, (table) => ({
114
+ grnIdx: index("idx_grn_items_grn").on(table.grnId),
115
+ }));
@@ -0,0 +1,91 @@
1
+ import { eq, and, sql } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import type { PluginResult } from "@porulle/core";
4
+ import { goodsReceivedNotes, grnItems, purchaseOrders, purchaseOrderItems } from "../schema.js";
5
+ import type { Db, GoodsReceivedNote, GRNItem } from "../types.js";
6
+
7
+ export class GRNService {
8
+ constructor(private db: Db) {}
9
+
10
+ async create(orgId: string, input: {
11
+ poId: string; supplierId: string; warehouseId: string; receivedBy: string; notes?: string;
12
+ items: Array<{
13
+ poItemId: string; entityId: string; variantId?: string;
14
+ quantityOrdered: number; quantityReceived: number; quantityAccepted: number;
15
+ quantityRejected?: number; rejectionReason?: string; batchNumber?: string;
16
+ expiryDate?: string; unitCost: number;
17
+ }>;
18
+ }): Promise<PluginResult<GoodsReceivedNote>> {
19
+ const grnNumber = await this.generateGRNNumber(orgId);
20
+
21
+ const rows = await this.db.insert(goodsReceivedNotes).values({
22
+ organizationId: orgId, grnNumber, poId: input.poId, supplierId: input.supplierId,
23
+ warehouseId: input.warehouseId, receivedBy: input.receivedBy, notes: input.notes,
24
+ }).returning();
25
+ const grn = rows[0]!;
26
+
27
+ for (const item of input.items) {
28
+ await this.db.insert(grnItems).values({
29
+ grnId: grn.id, poItemId: item.poItemId, entityId: item.entityId,
30
+ variantId: item.variantId, quantityOrdered: item.quantityOrdered,
31
+ quantityReceived: item.quantityReceived, quantityAccepted: item.quantityAccepted,
32
+ quantityRejected: item.quantityRejected ?? 0, rejectionReason: item.rejectionReason,
33
+ batchNumber: item.batchNumber,
34
+ ...(item.expiryDate ? { expiryDate: new Date(item.expiryDate) } : {}),
35
+ unitCost: item.unitCost,
36
+ });
37
+
38
+ // Update PO item received quantity
39
+ await this.db.update(purchaseOrderItems).set({
40
+ quantityReceived: sql`${purchaseOrderItems.quantityReceived} + ${item.quantityReceived}`,
41
+ }).where(eq(purchaseOrderItems.id, item.poItemId));
42
+ }
43
+
44
+ // Check if PO is fully received
45
+ const poItems = await this.db.select().from(purchaseOrderItems).where(eq(purchaseOrderItems.poId, input.poId));
46
+ const allReceived = poItems.every(pi => pi.quantityReceived >= pi.quantityOrdered);
47
+ const someReceived = poItems.some(pi => pi.quantityReceived > 0);
48
+
49
+ if (allReceived) {
50
+ await this.db.update(purchaseOrders).set({ status: "received", updatedAt: new Date() })
51
+ .where(eq(purchaseOrders.id, input.poId));
52
+ } else if (someReceived) {
53
+ await this.db.update(purchaseOrders).set({ status: "partially_received", updatedAt: new Date() })
54
+ .where(eq(purchaseOrders.id, input.poId));
55
+ }
56
+
57
+ return Ok(grn);
58
+ }
59
+
60
+ async list(orgId: string): Promise<PluginResult<GoodsReceivedNote[]>> {
61
+ const rows = await this.db.select().from(goodsReceivedNotes).where(eq(goodsReceivedNotes.organizationId, orgId));
62
+ return Ok(rows);
63
+ }
64
+
65
+ async getById(orgId: string, id: string): Promise<PluginResult<{ grn: GoodsReceivedNote; items: GRNItem[] }>> {
66
+ const rows = await this.db.select().from(goodsReceivedNotes)
67
+ .where(and(eq(goodsReceivedNotes.id, id), eq(goodsReceivedNotes.organizationId, orgId)));
68
+ if (rows.length === 0) return Err("GRN not found");
69
+ const items = await this.db.select().from(grnItems).where(eq(grnItems.grnId, id));
70
+ return Ok({ grn: rows[0]!, items });
71
+ }
72
+
73
+ async accept(orgId: string, id: string): Promise<PluginResult<GoodsReceivedNote>> {
74
+ const items = await this.db.select().from(grnItems)
75
+ .where(eq(grnItems.grnId, id));
76
+ const hasDiscrepancy = items.some(i => i.quantityRejected > 0);
77
+ const newStatus = hasDiscrepancy ? "accepted_with_discrepancy" : "accepted";
78
+
79
+ const rows = await this.db.update(goodsReceivedNotes).set({ status: newStatus })
80
+ .where(and(eq(goodsReceivedNotes.id, id), eq(goodsReceivedNotes.organizationId, orgId))).returning();
81
+ if (rows.length === 0) return Err("GRN not found");
82
+ return Ok(rows[0]!);
83
+ }
84
+
85
+ private async generateGRNNumber(orgId: string): Promise<string> {
86
+ const countRows = await this.db.select({ count: sql<number>`COUNT(*)`.as("count") })
87
+ .from(goodsReceivedNotes).where(eq(goodsReceivedNotes.organizationId, orgId));
88
+ const seq = Number(countRows[0]?.count ?? 0) + 1;
89
+ return `GRN-${String(seq).padStart(4, "0")}`;
90
+ }
91
+ }
@@ -0,0 +1,85 @@
1
+ import { eq, and, sql } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import type { PluginResult } from "@porulle/core";
4
+ import { purchaseOrders, purchaseOrderItems } from "../schema.js";
5
+ import type { Db, PurchaseOrder, PurchaseOrderItem } from "../types.js";
6
+
7
+ export class PurchaseOrderService {
8
+ constructor(private db: Db) {}
9
+
10
+ async create(orgId: string, input: {
11
+ supplierId: string; warehouseId: string; requestedBy: string;
12
+ expectedDelivery?: string; notes?: string;
13
+ items: Array<{ entityId: string; variantId?: string; itemName: string; quantityOrdered: number; unitCost: number }>;
14
+ }): Promise<PluginResult<PurchaseOrder>> {
15
+ const poNumber = await this.generatePONumber(orgId);
16
+ let subtotal = 0;
17
+ for (const item of input.items) subtotal += item.quantityOrdered * item.unitCost;
18
+
19
+ const rows = await this.db.insert(purchaseOrders).values({
20
+ organizationId: orgId, poNumber, supplierId: input.supplierId,
21
+ warehouseId: input.warehouseId, requestedBy: input.requestedBy,
22
+ ...(input.expectedDelivery ? { expectedDelivery: new Date(input.expectedDelivery) } : {}),
23
+ subtotal, grandTotal: subtotal, notes: input.notes,
24
+ }).returning();
25
+ const po = rows[0]!;
26
+
27
+ for (const item of input.items) {
28
+ await this.db.insert(purchaseOrderItems).values({
29
+ poId: po.id, entityId: item.entityId, variantId: item.variantId,
30
+ itemName: item.itemName, quantityOrdered: item.quantityOrdered,
31
+ unitCost: item.unitCost, totalCost: item.quantityOrdered * item.unitCost,
32
+ });
33
+ }
34
+ return Ok(po);
35
+ }
36
+
37
+ async list(orgId: string, status?: string): Promise<PluginResult<PurchaseOrder[]>> {
38
+ const conditions = [eq(purchaseOrders.organizationId, orgId)];
39
+ if (status) conditions.push(eq(purchaseOrders.status, status as PurchaseOrder["status"]));
40
+ const rows = await this.db.select().from(purchaseOrders).where(and(...conditions));
41
+ return Ok(rows);
42
+ }
43
+
44
+ async getById(orgId: string, id: string): Promise<PluginResult<{ po: PurchaseOrder; items: PurchaseOrderItem[] }>> {
45
+ const rows = await this.db.select().from(purchaseOrders).where(and(eq(purchaseOrders.id, id), eq(purchaseOrders.organizationId, orgId)));
46
+ if (rows.length === 0) return Err("Purchase order not found");
47
+ const items = await this.db.select().from(purchaseOrderItems).where(eq(purchaseOrderItems.poId, id));
48
+ return Ok({ po: rows[0]!, items });
49
+ }
50
+
51
+ async submit(orgId: string, id: string): Promise<PluginResult<PurchaseOrder>> {
52
+ return this.transition(orgId, id, "draft", "pending_approval");
53
+ }
54
+
55
+ async approve(orgId: string, id: string, approvedBy: string): Promise<PluginResult<PurchaseOrder>> {
56
+ const rows = await this.db.update(purchaseOrders).set({
57
+ status: "approved", approvedBy, approvedAt: new Date(), updatedAt: new Date(),
58
+ }).where(and(eq(purchaseOrders.id, id), eq(purchaseOrders.organizationId, orgId), eq(purchaseOrders.status, "pending_approval")))
59
+ .returning();
60
+ if (rows.length === 0) return Err("PO not found or not in pending_approval status");
61
+ return Ok(rows[0]!);
62
+ }
63
+
64
+ async cancel(orgId: string, id: string): Promise<PluginResult<PurchaseOrder>> {
65
+ const rows = await this.db.update(purchaseOrders).set({ status: "cancelled", updatedAt: new Date() })
66
+ .where(and(eq(purchaseOrders.id, id), eq(purchaseOrders.organizationId, orgId))).returning();
67
+ if (rows.length === 0) return Err("PO not found");
68
+ return Ok(rows[0]!);
69
+ }
70
+
71
+ private async transition(orgId: string, id: string, from: string, to: string): Promise<PluginResult<PurchaseOrder>> {
72
+ const rows = await this.db.update(purchaseOrders).set({ status: to as PurchaseOrder["status"], updatedAt: new Date() })
73
+ .where(and(eq(purchaseOrders.id, id), eq(purchaseOrders.organizationId, orgId), eq(purchaseOrders.status, from as PurchaseOrder["status"])))
74
+ .returning();
75
+ if (rows.length === 0) return Err(`PO not found or not in '${from}' status`);
76
+ return Ok(rows[0]!);
77
+ }
78
+
79
+ private async generatePONumber(orgId: string): Promise<string> {
80
+ const countRows = await this.db.select({ count: sql<number>`COUNT(*)`.as("count") })
81
+ .from(purchaseOrders).where(eq(purchaseOrders.organizationId, orgId));
82
+ const seq = Number(countRows[0]?.count ?? 0) + 1;
83
+ return `PO-${String(seq).padStart(4, "0")}`;
84
+ }
85
+ }
@@ -0,0 +1,40 @@
1
+ import { eq, and } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import type { PluginResult, PluginResultErr } from "@porulle/core";
4
+ import { suppliers, supplierItems } from "../schema.js";
5
+ import type { Db, Supplier, SupplierItem } from "../types.js";
6
+
7
+ export class SupplierService {
8
+ constructor(private db: Db) {}
9
+
10
+ async create(orgId: string, input: {
11
+ name: string; code: string; contactName?: string; contactEmail?: string;
12
+ contactPhone?: string; paymentTermsDays?: number; currency?: string;
13
+ }): Promise<PluginResult<Supplier>> {
14
+ const existing = await this.db.select().from(suppliers)
15
+ .where(and(eq(suppliers.organizationId, orgId), eq(suppliers.code, input.code)));
16
+ if (existing.length > 0) return Err(`Supplier code '${input.code}' already exists`);
17
+ const rows = await this.db.insert(suppliers).values({ organizationId: orgId, ...input }).returning();
18
+ return Ok(rows[0]!);
19
+ }
20
+
21
+ async list(orgId: string): Promise<PluginResult<Supplier[]>> {
22
+ const rows = await this.db.select().from(suppliers).where(eq(suppliers.organizationId, orgId));
23
+ return Ok(rows);
24
+ }
25
+
26
+ async getById(orgId: string, id: string): Promise<PluginResult<{ supplier: Supplier; items: SupplierItem[] }>> {
27
+ const rows = await this.db.select().from(suppliers).where(and(eq(suppliers.id, id), eq(suppliers.organizationId, orgId)));
28
+ if (rows.length === 0) return Err("Supplier not found");
29
+ const items = await this.db.select().from(supplierItems).where(eq(supplierItems.supplierId, id));
30
+ return Ok({ supplier: rows[0]!, items });
31
+ }
32
+
33
+ async addItem(supplierId: string, input: {
34
+ entityId: string; variantId?: string; supplierSku?: string; unitCost: number;
35
+ minOrderQuantity?: number; leadTimeDays?: number; isPreferred?: boolean;
36
+ }): Promise<PluginResult<SupplierItem>> {
37
+ const rows = await this.db.insert(supplierItems).values({ supplierId, ...input }).returning();
38
+ return Ok(rows[0]!);
39
+ }
40
+ }
package/src/types.ts ADDED
@@ -0,0 +1,9 @@
1
+ export type { PluginDb as Db } from "@porulle/core";
2
+ import type { suppliers, supplierItems, purchaseOrders, purchaseOrderItems, goodsReceivedNotes, grnItems } from "./schema.js";
3
+ export type Supplier = typeof suppliers.$inferSelect;
4
+ export type SupplierItem = typeof supplierItems.$inferSelect;
5
+ export type PurchaseOrder = typeof purchaseOrders.$inferSelect;
6
+ export type PurchaseOrderItem = typeof purchaseOrderItems.$inferSelect;
7
+ export type GoodsReceivedNote = typeof goodsReceivedNotes.$inferSelect;
8
+ export type GRNItem = typeof grnItems.$inferSelect;
9
+ export type POStatus = "draft" | "pending_approval" | "approved" | "sent" | "partially_received" | "received" | "cancelled";