@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 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkBnB,CAAC;AAEJ,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAcvB,CAAC;AAEJ,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsBxB,CAAC;AAEJ,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAc5B,CAAC;AAEJ,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiB5B,CAAC;AAEJ,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiBlB,CAAC"}
package/dist/schema.js ADDED
@@ -0,0 +1,109 @@
1
+ import { pgTable, uuid, text, integer, boolean, timestamp, jsonb, index, uniqueIndex } from "@porulle/core/drizzle";
2
+ export const suppliers = pgTable("suppliers", {
3
+ id: uuid("id").defaultRandom().primaryKey(),
4
+ organizationId: text("organization_id").notNull(),
5
+ name: text("name").notNull(),
6
+ code: text("code").notNull(),
7
+ contactName: text("contact_name"),
8
+ contactEmail: text("contact_email"),
9
+ contactPhone: text("contact_phone"),
10
+ paymentTermsDays: integer("payment_terms_days").notNull().default(30),
11
+ currency: text("currency").notNull().default("USD"),
12
+ taxId: text("tax_id"),
13
+ isActive: boolean("is_active").notNull().default(true),
14
+ metadata: jsonb("metadata").$type().default({}),
15
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
16
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
17
+ }, (table) => ({
18
+ orgIdx: index("idx_suppliers_org").on(table.organizationId),
19
+ codeUnique: uniqueIndex("suppliers_org_code_unique").on(table.organizationId, table.code),
20
+ }));
21
+ export const supplierItems = pgTable("supplier_items", {
22
+ id: uuid("id").defaultRandom().primaryKey(),
23
+ supplierId: uuid("supplier_id").references(() => suppliers.id, { onDelete: "cascade" }).notNull(),
24
+ entityId: uuid("entity_id").notNull(),
25
+ variantId: uuid("variant_id"),
26
+ supplierSku: text("supplier_sku"),
27
+ unitCost: integer("unit_cost").notNull(),
28
+ minOrderQuantity: integer("min_order_quantity").notNull().default(1),
29
+ leadTimeDays: integer("lead_time_days").notNull().default(1),
30
+ isPreferred: boolean("is_preferred").notNull().default(false),
31
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
32
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
33
+ }, (table) => ({
34
+ supplierIdx: index("idx_supplier_items_supplier").on(table.supplierId),
35
+ }));
36
+ export const purchaseOrders = pgTable("purchase_orders", {
37
+ id: uuid("id").defaultRandom().primaryKey(),
38
+ organizationId: text("organization_id").notNull(),
39
+ poNumber: text("po_number").notNull(),
40
+ supplierId: uuid("supplier_id").references(() => suppliers.id).notNull(),
41
+ status: text("status", { enum: ["draft", "pending_approval", "approved", "sent", "partially_received", "received", "cancelled"] }).notNull().default("draft"),
42
+ warehouseId: uuid("warehouse_id").notNull(),
43
+ requestedBy: text("requested_by").notNull(),
44
+ approvedBy: text("approved_by"),
45
+ approvedAt: timestamp("approved_at", { withTimezone: true }),
46
+ expectedDelivery: timestamp("expected_delivery", { withTimezone: true }),
47
+ subtotal: integer("subtotal").notNull().default(0),
48
+ taxTotal: integer("tax_total").notNull().default(0),
49
+ grandTotal: integer("grand_total").notNull().default(0),
50
+ notes: text("notes"),
51
+ metadata: jsonb("metadata").$type().default({}),
52
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
53
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
54
+ }, (table) => ({
55
+ orgIdx: index("idx_purchase_orders_org").on(table.organizationId),
56
+ statusIdx: index("idx_purchase_orders_status").on(table.status),
57
+ poNumUnique: uniqueIndex("purchase_orders_org_po_unique").on(table.organizationId, table.poNumber),
58
+ }));
59
+ export const purchaseOrderItems = pgTable("purchase_order_items", {
60
+ id: uuid("id").defaultRandom().primaryKey(),
61
+ poId: uuid("po_id").references(() => purchaseOrders.id, { onDelete: "cascade" }).notNull(),
62
+ entityId: uuid("entity_id").notNull(),
63
+ variantId: uuid("variant_id"),
64
+ itemName: text("item_name").notNull(),
65
+ quantityOrdered: integer("quantity_ordered").notNull(),
66
+ quantityReceived: integer("quantity_received").notNull().default(0),
67
+ unitCost: integer("unit_cost").notNull(),
68
+ totalCost: integer("total_cost").notNull(),
69
+ notes: text("notes"),
70
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
71
+ }, (table) => ({
72
+ poIdx: index("idx_po_items_po").on(table.poId),
73
+ }));
74
+ export const goodsReceivedNotes = pgTable("goods_received_notes", {
75
+ id: uuid("id").defaultRandom().primaryKey(),
76
+ organizationId: text("organization_id").notNull(),
77
+ grnNumber: text("grn_number").notNull(),
78
+ poId: uuid("po_id").references(() => purchaseOrders.id).notNull(),
79
+ supplierId: uuid("supplier_id").references(() => suppliers.id).notNull(),
80
+ warehouseId: uuid("warehouse_id").notNull(),
81
+ receivedBy: text("received_by").notNull(),
82
+ status: text("status", { enum: ["draft", "inspecting", "accepted", "accepted_with_discrepancy", "rejected"] }).notNull().default("draft"),
83
+ receivedAt: timestamp("received_at", { withTimezone: true }).defaultNow().notNull(),
84
+ notes: text("notes"),
85
+ metadata: jsonb("metadata").$type().default({}),
86
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
87
+ }, (table) => ({
88
+ orgIdx: index("idx_grn_org").on(table.organizationId),
89
+ poIdx: index("idx_grn_po").on(table.poId),
90
+ grnNumUnique: uniqueIndex("grn_org_number_unique").on(table.organizationId, table.grnNumber),
91
+ }));
92
+ export const grnItems = pgTable("grn_items", {
93
+ id: uuid("id").defaultRandom().primaryKey(),
94
+ grnId: uuid("grn_id").references(() => goodsReceivedNotes.id, { onDelete: "cascade" }).notNull(),
95
+ poItemId: uuid("po_item_id").references(() => purchaseOrderItems.id).notNull(),
96
+ entityId: uuid("entity_id").notNull(),
97
+ variantId: uuid("variant_id"),
98
+ quantityOrdered: integer("quantity_ordered").notNull(),
99
+ quantityReceived: integer("quantity_received").notNull(),
100
+ quantityAccepted: integer("quantity_accepted").notNull(),
101
+ quantityRejected: integer("quantity_rejected").notNull().default(0),
102
+ rejectionReason: text("rejection_reason"),
103
+ batchNumber: text("batch_number"),
104
+ expiryDate: timestamp("expiry_date", { withTimezone: true }),
105
+ unitCost: integer("unit_cost").notNull(),
106
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
107
+ }, (table) => ({
108
+ grnIdx: index("idx_grn_items_grn").on(table.grnId),
109
+ }));
@@ -0,0 +1,34 @@
1
+ import type { PluginResult } from "@porulle/core";
2
+ import type { Db, GoodsReceivedNote, GRNItem } from "../types.js";
3
+ export declare class GRNService {
4
+ private db;
5
+ constructor(db: Db);
6
+ create(orgId: string, input: {
7
+ poId: string;
8
+ supplierId: string;
9
+ warehouseId: string;
10
+ receivedBy: string;
11
+ notes?: string;
12
+ items: Array<{
13
+ poItemId: string;
14
+ entityId: string;
15
+ variantId?: string;
16
+ quantityOrdered: number;
17
+ quantityReceived: number;
18
+ quantityAccepted: number;
19
+ quantityRejected?: number;
20
+ rejectionReason?: string;
21
+ batchNumber?: string;
22
+ expiryDate?: string;
23
+ unitCost: number;
24
+ }>;
25
+ }): Promise<PluginResult<GoodsReceivedNote>>;
26
+ list(orgId: string): Promise<PluginResult<GoodsReceivedNote[]>>;
27
+ getById(orgId: string, id: string): Promise<PluginResult<{
28
+ grn: GoodsReceivedNote;
29
+ items: GRNItem[];
30
+ }>>;
31
+ accept(orgId: string, id: string): Promise<PluginResult<GoodsReceivedNote>>;
32
+ private generateGRNNumber;
33
+ }
34
+ //# sourceMappingURL=grn-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"grn-service.d.ts","sourceRoot":"","sources":["../../src/services/grn-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAElE,qBAAa,UAAU;IACT,OAAO,CAAC,EAAE;gBAAF,EAAE,EAAE,EAAE;IAEpB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACjC,IAAI,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAC1F,KAAK,EAAE,KAAK,CAAC;YACX,QAAQ,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAC;YAAC,SAAS,CAAC,EAAE,MAAM,CAAC;YACvD,eAAe,EAAE,MAAM,CAAC;YAAC,gBAAgB,EAAE,MAAM,CAAC;YAAC,gBAAgB,EAAE,MAAM,CAAC;YAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;YAAC,eAAe,CAAC,EAAE,MAAM,CAAC;YAAC,WAAW,CAAC,EAAE,MAAM,CAAC;YAC1E,UAAU,CAAC,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAC;SACvC,CAAC,CAAC;KACJ,GAAG,OAAO,CAAC,YAAY,CAAC,iBAAiB,CAAC,CAAC;IA0CtC,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,iBAAiB,EAAE,CAAC,CAAC;IAK/D,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;QAAE,GAAG,EAAE,iBAAiB,CAAC;QAAC,KAAK,EAAE,OAAO,EAAE,CAAA;KAAE,CAAC,CAAC;IAQvG,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,iBAAiB,CAAC,CAAC;YAYnE,iBAAiB;CAMhC"}
@@ -0,0 +1,74 @@
1
+ import { eq, and, sql } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import { goodsReceivedNotes, grnItems, purchaseOrders, purchaseOrderItems } from "../schema.js";
4
+ export class GRNService {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ async create(orgId, input) {
10
+ const grnNumber = await this.generateGRNNumber(orgId);
11
+ const rows = await this.db.insert(goodsReceivedNotes).values({
12
+ organizationId: orgId, grnNumber, poId: input.poId, supplierId: input.supplierId,
13
+ warehouseId: input.warehouseId, receivedBy: input.receivedBy, notes: input.notes,
14
+ }).returning();
15
+ const grn = rows[0];
16
+ for (const item of input.items) {
17
+ await this.db.insert(grnItems).values({
18
+ grnId: grn.id, poItemId: item.poItemId, entityId: item.entityId,
19
+ variantId: item.variantId, quantityOrdered: item.quantityOrdered,
20
+ quantityReceived: item.quantityReceived, quantityAccepted: item.quantityAccepted,
21
+ quantityRejected: item.quantityRejected ?? 0, rejectionReason: item.rejectionReason,
22
+ batchNumber: item.batchNumber,
23
+ ...(item.expiryDate ? { expiryDate: new Date(item.expiryDate) } : {}),
24
+ unitCost: item.unitCost,
25
+ });
26
+ // Update PO item received quantity
27
+ await this.db.update(purchaseOrderItems).set({
28
+ quantityReceived: sql `${purchaseOrderItems.quantityReceived} + ${item.quantityReceived}`,
29
+ }).where(eq(purchaseOrderItems.id, item.poItemId));
30
+ }
31
+ // Check if PO is fully received
32
+ const poItems = await this.db.select().from(purchaseOrderItems).where(eq(purchaseOrderItems.poId, input.poId));
33
+ const allReceived = poItems.every(pi => pi.quantityReceived >= pi.quantityOrdered);
34
+ const someReceived = poItems.some(pi => pi.quantityReceived > 0);
35
+ if (allReceived) {
36
+ await this.db.update(purchaseOrders).set({ status: "received", updatedAt: new Date() })
37
+ .where(eq(purchaseOrders.id, input.poId));
38
+ }
39
+ else if (someReceived) {
40
+ await this.db.update(purchaseOrders).set({ status: "partially_received", updatedAt: new Date() })
41
+ .where(eq(purchaseOrders.id, input.poId));
42
+ }
43
+ return Ok(grn);
44
+ }
45
+ async list(orgId) {
46
+ const rows = await this.db.select().from(goodsReceivedNotes).where(eq(goodsReceivedNotes.organizationId, orgId));
47
+ return Ok(rows);
48
+ }
49
+ async getById(orgId, id) {
50
+ const rows = await this.db.select().from(goodsReceivedNotes)
51
+ .where(and(eq(goodsReceivedNotes.id, id), eq(goodsReceivedNotes.organizationId, orgId)));
52
+ if (rows.length === 0)
53
+ return Err("GRN not found");
54
+ const items = await this.db.select().from(grnItems).where(eq(grnItems.grnId, id));
55
+ return Ok({ grn: rows[0], items });
56
+ }
57
+ async accept(orgId, id) {
58
+ const items = await this.db.select().from(grnItems)
59
+ .where(eq(grnItems.grnId, id));
60
+ const hasDiscrepancy = items.some(i => i.quantityRejected > 0);
61
+ const newStatus = hasDiscrepancy ? "accepted_with_discrepancy" : "accepted";
62
+ const rows = await this.db.update(goodsReceivedNotes).set({ status: newStatus })
63
+ .where(and(eq(goodsReceivedNotes.id, id), eq(goodsReceivedNotes.organizationId, orgId))).returning();
64
+ if (rows.length === 0)
65
+ return Err("GRN not found");
66
+ return Ok(rows[0]);
67
+ }
68
+ async generateGRNNumber(orgId) {
69
+ const countRows = await this.db.select({ count: sql `COUNT(*)`.as("count") })
70
+ .from(goodsReceivedNotes).where(eq(goodsReceivedNotes.organizationId, orgId));
71
+ const seq = Number(countRows[0]?.count ?? 0) + 1;
72
+ return `GRN-${String(seq).padStart(4, "0")}`;
73
+ }
74
+ }
@@ -0,0 +1,31 @@
1
+ import type { PluginResult } from "@porulle/core";
2
+ import type { Db, PurchaseOrder, PurchaseOrderItem } from "../types.js";
3
+ export declare class PurchaseOrderService {
4
+ private db;
5
+ constructor(db: Db);
6
+ create(orgId: string, input: {
7
+ supplierId: string;
8
+ warehouseId: string;
9
+ requestedBy: string;
10
+ expectedDelivery?: string;
11
+ notes?: string;
12
+ items: Array<{
13
+ entityId: string;
14
+ variantId?: string;
15
+ itemName: string;
16
+ quantityOrdered: number;
17
+ unitCost: number;
18
+ }>;
19
+ }): Promise<PluginResult<PurchaseOrder>>;
20
+ list(orgId: string, status?: string): Promise<PluginResult<PurchaseOrder[]>>;
21
+ getById(orgId: string, id: string): Promise<PluginResult<{
22
+ po: PurchaseOrder;
23
+ items: PurchaseOrderItem[];
24
+ }>>;
25
+ submit(orgId: string, id: string): Promise<PluginResult<PurchaseOrder>>;
26
+ approve(orgId: string, id: string, approvedBy: string): Promise<PluginResult<PurchaseOrder>>;
27
+ cancel(orgId: string, id: string): Promise<PluginResult<PurchaseOrder>>;
28
+ private transition;
29
+ private generatePONumber;
30
+ }
31
+ //# sourceMappingURL=po-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"po-service.d.ts","sourceRoot":"","sources":["../../src/services/po-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAExE,qBAAa,oBAAoB;IACnB,OAAO,CAAC,EAAE;gBAAF,EAAE,EAAE,EAAE;IAEpB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACjC,UAAU,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAC1C,KAAK,EAAE,KAAK,CAAC;YAAE,QAAQ,EAAE,MAAM,CAAC;YAAC,SAAS,CAAC,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAC;YAAC,eAAe,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KACrH,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;IAuBlC,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,EAAE,CAAC,CAAC;IAO5E,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;QAAE,EAAE,EAAE,aAAa,CAAC;QAAC,KAAK,EAAE,iBAAiB,EAAE,CAAA;KAAE,CAAC,CAAC;IAO5G,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;IAIvE,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;IAS5F,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;YAO/D,UAAU;YAQV,gBAAgB;CAM/B"}
@@ -0,0 +1,77 @@
1
+ import { eq, and, sql } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import { purchaseOrders, purchaseOrderItems } from "../schema.js";
4
+ export class PurchaseOrderService {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ async create(orgId, input) {
10
+ const poNumber = await this.generatePONumber(orgId);
11
+ let subtotal = 0;
12
+ for (const item of input.items)
13
+ subtotal += item.quantityOrdered * item.unitCost;
14
+ const rows = await this.db.insert(purchaseOrders).values({
15
+ organizationId: orgId, poNumber, supplierId: input.supplierId,
16
+ warehouseId: input.warehouseId, requestedBy: input.requestedBy,
17
+ ...(input.expectedDelivery ? { expectedDelivery: new Date(input.expectedDelivery) } : {}),
18
+ subtotal, grandTotal: subtotal, notes: input.notes,
19
+ }).returning();
20
+ const po = rows[0];
21
+ for (const item of input.items) {
22
+ await this.db.insert(purchaseOrderItems).values({
23
+ poId: po.id, entityId: item.entityId, variantId: item.variantId,
24
+ itemName: item.itemName, quantityOrdered: item.quantityOrdered,
25
+ unitCost: item.unitCost, totalCost: item.quantityOrdered * item.unitCost,
26
+ });
27
+ }
28
+ return Ok(po);
29
+ }
30
+ async list(orgId, status) {
31
+ const conditions = [eq(purchaseOrders.organizationId, orgId)];
32
+ if (status)
33
+ conditions.push(eq(purchaseOrders.status, status));
34
+ const rows = await this.db.select().from(purchaseOrders).where(and(...conditions));
35
+ return Ok(rows);
36
+ }
37
+ async getById(orgId, id) {
38
+ const rows = await this.db.select().from(purchaseOrders).where(and(eq(purchaseOrders.id, id), eq(purchaseOrders.organizationId, orgId)));
39
+ if (rows.length === 0)
40
+ return Err("Purchase order not found");
41
+ const items = await this.db.select().from(purchaseOrderItems).where(eq(purchaseOrderItems.poId, id));
42
+ return Ok({ po: rows[0], items });
43
+ }
44
+ async submit(orgId, id) {
45
+ return this.transition(orgId, id, "draft", "pending_approval");
46
+ }
47
+ async approve(orgId, id, approvedBy) {
48
+ const rows = await this.db.update(purchaseOrders).set({
49
+ status: "approved", approvedBy, approvedAt: new Date(), updatedAt: new Date(),
50
+ }).where(and(eq(purchaseOrders.id, id), eq(purchaseOrders.organizationId, orgId), eq(purchaseOrders.status, "pending_approval")))
51
+ .returning();
52
+ if (rows.length === 0)
53
+ return Err("PO not found or not in pending_approval status");
54
+ return Ok(rows[0]);
55
+ }
56
+ async cancel(orgId, id) {
57
+ const rows = await this.db.update(purchaseOrders).set({ status: "cancelled", updatedAt: new Date() })
58
+ .where(and(eq(purchaseOrders.id, id), eq(purchaseOrders.organizationId, orgId))).returning();
59
+ if (rows.length === 0)
60
+ return Err("PO not found");
61
+ return Ok(rows[0]);
62
+ }
63
+ async transition(orgId, id, from, to) {
64
+ const rows = await this.db.update(purchaseOrders).set({ status: to, updatedAt: new Date() })
65
+ .where(and(eq(purchaseOrders.id, id), eq(purchaseOrders.organizationId, orgId), eq(purchaseOrders.status, from)))
66
+ .returning();
67
+ if (rows.length === 0)
68
+ return Err(`PO not found or not in '${from}' status`);
69
+ return Ok(rows[0]);
70
+ }
71
+ async generatePONumber(orgId) {
72
+ const countRows = await this.db.select({ count: sql `COUNT(*)`.as("count") })
73
+ .from(purchaseOrders).where(eq(purchaseOrders.organizationId, orgId));
74
+ const seq = Number(countRows[0]?.count ?? 0) + 1;
75
+ return `PO-${String(seq).padStart(4, "0")}`;
76
+ }
77
+ }
@@ -0,0 +1,30 @@
1
+ import type { PluginResult } from "@porulle/core";
2
+ import type { Db, Supplier, SupplierItem } from "../types.js";
3
+ export declare class SupplierService {
4
+ private db;
5
+ constructor(db: Db);
6
+ create(orgId: string, input: {
7
+ name: string;
8
+ code: string;
9
+ contactName?: string;
10
+ contactEmail?: string;
11
+ contactPhone?: string;
12
+ paymentTermsDays?: number;
13
+ currency?: string;
14
+ }): Promise<PluginResult<Supplier>>;
15
+ list(orgId: string): Promise<PluginResult<Supplier[]>>;
16
+ getById(orgId: string, id: string): Promise<PluginResult<{
17
+ supplier: Supplier;
18
+ items: SupplierItem[];
19
+ }>>;
20
+ addItem(supplierId: string, input: {
21
+ entityId: string;
22
+ variantId?: string;
23
+ supplierSku?: string;
24
+ unitCost: number;
25
+ minOrderQuantity?: number;
26
+ leadTimeDays?: number;
27
+ isPreferred?: boolean;
28
+ }): Promise<PluginResult<SupplierItem>>;
29
+ }
30
+ //# sourceMappingURL=supplier-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supplier-service.d.ts","sourceRoot":"","sources":["../../src/services/supplier-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAmB,MAAM,eAAe,CAAC;AAEnE,OAAO,KAAK,EAAE,EAAE,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE9D,qBAAa,eAAe;IACd,OAAO,CAAC,EAAE;gBAAF,EAAE,EAAE,EAAE;IAEpB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACjC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAC;QACxE,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;KACrE,GAAG,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;IAQ7B,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,QAAQ,EAAE,CAAC,CAAC;IAKtD,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;QAAE,QAAQ,EAAE,QAAQ,CAAC;QAAC,KAAK,EAAE,YAAY,EAAE,CAAA;KAAE,CAAC,CAAC;IAOxG,OAAO,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE;QACvC,QAAQ,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAC7E,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,OAAO,CAAC;KACzE,GAAG,OAAO,CAAC,YAAY,CAAC,YAAY,CAAC,CAAC;CAIxC"}
@@ -0,0 +1,32 @@
1
+ import { eq, and } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import { suppliers, supplierItems } from "../schema.js";
4
+ export class SupplierService {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ async create(orgId, input) {
10
+ const existing = await this.db.select().from(suppliers)
11
+ .where(and(eq(suppliers.organizationId, orgId), eq(suppliers.code, input.code)));
12
+ if (existing.length > 0)
13
+ return Err(`Supplier code '${input.code}' already exists`);
14
+ const rows = await this.db.insert(suppliers).values({ organizationId: orgId, ...input }).returning();
15
+ return Ok(rows[0]);
16
+ }
17
+ async list(orgId) {
18
+ const rows = await this.db.select().from(suppliers).where(eq(suppliers.organizationId, orgId));
19
+ return Ok(rows);
20
+ }
21
+ async getById(orgId, id) {
22
+ const rows = await this.db.select().from(suppliers).where(and(eq(suppliers.id, id), eq(suppliers.organizationId, orgId)));
23
+ if (rows.length === 0)
24
+ return Err("Supplier not found");
25
+ const items = await this.db.select().from(supplierItems).where(eq(supplierItems.supplierId, id));
26
+ return Ok({ supplier: rows[0], items });
27
+ }
28
+ async addItem(supplierId, input) {
29
+ const rows = await this.db.insert(supplierItems).values({ supplierId, ...input }).returning();
30
+ return Ok(rows[0]);
31
+ }
32
+ }
@@ -0,0 +1,10 @@
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";
10
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,cAAc,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC9H,MAAM,MAAM,QAAQ,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AACrD,MAAM,MAAM,YAAY,GAAG,OAAO,aAAa,CAAC,YAAY,CAAC;AAC7D,MAAM,MAAM,aAAa,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAC/D,MAAM,MAAM,iBAAiB,GAAG,OAAO,kBAAkB,CAAC,YAAY,CAAC;AACvE,MAAM,MAAM,iBAAiB,GAAG,OAAO,kBAAkB,CAAC,YAAY,CAAC;AACvE,MAAM,MAAM,OAAO,GAAG,OAAO,QAAQ,CAAC,YAAY,CAAC;AACnD,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,kBAAkB,GAAG,UAAU,GAAG,MAAM,GAAG,oBAAoB,GAAG,UAAU,GAAG,WAAW,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@porulle/plugin-procurement",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "bun": "./src/index.ts",
9
+ "import": "./dist/index.js",
10
+ "types": "./src/index.ts"
11
+ },
12
+ "./schema": {
13
+ "bun": "./src/schema.ts",
14
+ "import": "./dist/schema.js",
15
+ "require": "./dist/schema.js",
16
+ "types": "./src/schema.ts"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "rm -rf dist tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
21
+ "check-types": "tsc --noEmit",
22
+ "test": "vitest run"
23
+ },
24
+ "dependencies": {
25
+ "@hono/zod-openapi": "^1.2.2",
26
+ "@porulle/core": "workspace:*",
27
+ "hono": "^4.12.5"
28
+ },
29
+ "devDependencies": {
30
+ "@repo/eslint-config": "*",
31
+ "@repo/typescript-config": "*",
32
+ "@types/node": "^24.5.2",
33
+ "typescript": "5.9.2",
34
+ "vitest": "^3.2.4"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "files": [
40
+ "src",
41
+ "dist",
42
+ "README.md"
43
+ ],
44
+ "peerDependencies": {
45
+ "zod": ">=4.0.0"
46
+ },
47
+ "description": "Suppliers with catalog links, purchase orders through approval, and goods-received notes for inbound receiving.",
48
+ "homepage": "https://porulle-docs.vercel.app",
49
+ "bugs": {
50
+ "url": "https://github.com/asyncdotengineering/porulle/issues"
51
+ },
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/asyncdotengineering/porulle.git",
55
+ "directory": "packages/plugins/plugin-procurement"
56
+ },
57
+ "author": "Porulle contributors"
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { defineCommercePlugin } from "@porulle/core";
2
+ import { suppliers, supplierItems, purchaseOrders, purchaseOrderItems, goodsReceivedNotes, grnItems } from "./schema.js";
3
+ import { SupplierService } from "./services/supplier-service.js";
4
+ import { PurchaseOrderService } from "./services/po-service.js";
5
+ import { GRNService } from "./services/grn-service.js";
6
+ import { buildProcurementRoutes } from "./routes/procurement.js";
7
+ export type { Db } from "./types.js";
8
+ export { SupplierService } from "./services/supplier-service.js";
9
+ export { PurchaseOrderService } from "./services/po-service.js";
10
+ export { GRNService } from "./services/grn-service.js";
11
+
12
+ export function procurementPlugin() {
13
+ return defineCommercePlugin({
14
+ id: "procurement",
15
+ version: "1.0.0",
16
+ permissions: [
17
+ { scope: "procurement:admin", description: "Approve POs, manage suppliers, accept GRNs." },
18
+ { scope: "procurement:create", description: "Create POs, create GRNs." },
19
+ { scope: "procurement:read", description: "View POs, GRNs, suppliers." },
20
+ ],
21
+ schema: () => ({ suppliers, supplierItems, purchaseOrders, purchaseOrderItems, goodsReceivedNotes, grnItems }),
22
+ hooks: () => [],
23
+ routes: (ctx) => {
24
+ const db = ctx.database.db;
25
+ if (!db) return [];
26
+ return buildProcurementRoutes(new SupplierService(db), new PurchaseOrderService(db), new GRNService(db), ctx);
27
+ },
28
+ });
29
+ }