@unifiedcommerce/plugin-production 0.0.1

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,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAgBxB,CAAC;AAEJ,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAe5B,CAAC;AAEJ,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAoB1B,CAAC;AAEJ,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAe/B,CAAC"}
package/dist/schema.js ADDED
@@ -0,0 +1,71 @@
1
+ import { pgTable, uuid, text, integer, boolean, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
2
+ export const productionBoms = pgTable("production_boms", {
3
+ id: uuid("id").defaultRandom().primaryKey(),
4
+ organizationId: text("organization_id").notNull(),
5
+ entityId: uuid("entity_id").notNull(),
6
+ name: text("name").notNull(),
7
+ version: integer("version").default(1),
8
+ yieldQuantity: integer("yield_quantity").default(1),
9
+ yieldUomId: uuid("yield_uom_id"),
10
+ isActive: boolean("is_active").default(true),
11
+ level: integer("level").default(0),
12
+ totalCost: integer("total_cost").default(0),
13
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
14
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
15
+ }, (table) => ({
16
+ orgIdx: index("idx_production_boms_org").on(table.organizationId),
17
+ entityIdx: index("idx_production_boms_entity").on(table.entityId),
18
+ }));
19
+ export const productionBomItems = pgTable("production_bom_items", {
20
+ id: uuid("id").defaultRandom().primaryKey(),
21
+ bomId: uuid("bom_id").references(() => productionBoms.id, { onDelete: "cascade" }).notNull(),
22
+ entityId: uuid("entity_id").notNull(),
23
+ itemName: text("item_name").notNull(),
24
+ quantity: integer("quantity").notNull(),
25
+ unitCost: integer("unit_cost").default(0),
26
+ totalCost: integer("total_cost").default(0),
27
+ uomId: uuid("uom_id"),
28
+ isSubAssembly: boolean("is_sub_assembly").default(false),
29
+ subBomId: uuid("sub_bom_id").references(() => productionBoms.id),
30
+ sortOrder: integer("sort_order").default(0),
31
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
32
+ }, (table) => ({
33
+ bomIdx: index("idx_production_bom_items_bom").on(table.bomId),
34
+ }));
35
+ export const productionOrders = pgTable("production_orders", {
36
+ id: uuid("id").defaultRandom().primaryKey(),
37
+ organizationId: text("organization_id").notNull(),
38
+ orderNumber: text("order_number").notNull(),
39
+ bomId: uuid("bom_id").references(() => productionBoms.id).notNull(),
40
+ entityId: uuid("entity_id").notNull(),
41
+ quantity: integer("quantity").notNull(),
42
+ warehouseId: uuid("warehouse_id").notNull(),
43
+ status: text("status", { enum: ["planned", "in_progress", "completed", "cancelled"] }).default("planned").notNull(),
44
+ plannedDate: timestamp("planned_date", { withTimezone: true }).notNull(),
45
+ startedAt: timestamp("started_at", { withTimezone: true }),
46
+ completedAt: timestamp("completed_at", { withTimezone: true }),
47
+ producedBy: text("produced_by"),
48
+ notes: text("notes"),
49
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
50
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
51
+ }, (table) => ({
52
+ orgIdx: index("idx_production_orders_org").on(table.organizationId),
53
+ statusIdx: index("idx_production_orders_status").on(table.status),
54
+ orderNumUnique: uniqueIndex("production_orders_org_number_unique").on(table.organizationId, table.orderNumber),
55
+ }));
56
+ export const productionConsumption = pgTable("production_consumption", {
57
+ id: uuid("id").defaultRandom().primaryKey(),
58
+ productionOrderId: uuid("production_order_id").references(() => productionOrders.id, { onDelete: "cascade" }).notNull(),
59
+ entityId: uuid("entity_id").notNull(),
60
+ variantId: uuid("variant_id"),
61
+ plannedQuantity: integer("planned_quantity").notNull(),
62
+ actualQuantity: integer("actual_quantity").notNull(),
63
+ uomId: uuid("uom_id"),
64
+ unitCost: integer("unit_cost").default(0),
65
+ totalCost: integer("total_cost").default(0),
66
+ batchNumber: text("batch_number"),
67
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
68
+ }, (table) => ({
69
+ orderIdx: index("idx_production_consumption_order").on(table.productionOrderId),
70
+ entityIdx: index("idx_production_consumption_entity").on(table.entityId),
71
+ }));
@@ -0,0 +1,34 @@
1
+ import type { PluginResult } from "@unifiedcommerce/core";
2
+ import type { Db, ProductionOrder, Consumption } from "../types";
3
+ export declare class ProductionOrderService {
4
+ private db;
5
+ constructor(db: Db);
6
+ create(orgId: string, input: {
7
+ bomId: string;
8
+ entityId: string;
9
+ quantity: number;
10
+ warehouseId: string;
11
+ plannedDate: Date;
12
+ notes?: string;
13
+ }): Promise<PluginResult<ProductionOrder>>;
14
+ list(orgId: string, status?: string): Promise<PluginResult<ProductionOrder[]>>;
15
+ getById(orgId: string, id: string): Promise<PluginResult<ProductionOrder & {
16
+ consumption: Consumption[];
17
+ }>>;
18
+ start(orgId: string, id: string, producedBy: string): Promise<PluginResult<ProductionOrder>>;
19
+ recordConsumption(orgId: string, orderId: string, items: Array<{
20
+ entityId: string;
21
+ variantId?: string;
22
+ plannedQuantity: number;
23
+ actualQuantity: number;
24
+ uomId?: string;
25
+ unitCost: number;
26
+ batchNumber?: string;
27
+ }>): Promise<PluginResult<Consumption[]>>;
28
+ complete(orgId: string, id: string): Promise<PluginResult<ProductionOrder & {
29
+ consumption: Consumption[];
30
+ }>>;
31
+ cancel(orgId: string, id: string): Promise<PluginResult<ProductionOrder>>;
32
+ private generateOrderNumber;
33
+ }
34
+ //# sourceMappingURL=production-order-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"production-order-service.d.ts","sourceRoot":"","sources":["../../src/services/production-order-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAE1D,OAAO,KAAK,EAAE,EAAE,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAEjE,qBAAa,sBAAsB;IACrB,OAAO,CAAC,EAAE;gBAAF,EAAE,EAAE,EAAE;IAEpB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACjC,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,WAAW,EAAE,MAAM,CAAC;QACpB,WAAW,EAAE,IAAI,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,GAAG,OAAO,CAAC,YAAY,CAAC,eAAe,CAAC,CAAC;IAqBpC,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,eAAe,EAAE,CAAC,CAAC;IAS9E,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,eAAe,GAAG;QAAE,WAAW,EAAE,WAAW,EAAE,CAAA;KAAE,CAAC,CAAC;IAY3G,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,eAAe,CAAC,CAAC;IAiB5F,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,CAAC;QACnE,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,eAAe,EAAE,MAAM,CAAC;QACxB,cAAc,EAAE,MAAM,CAAC;QACvB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC,GAAG,OAAO,CAAC,YAAY,CAAC,WAAW,EAAE,CAAC,CAAC;IA4BnC,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,eAAe,GAAG;QAAE,WAAW,EAAE,WAAW,EAAE,CAAA;KAAE,CAAC,CAAC;IAgD5G,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,eAAe,CAAC,CAAC;YAgBjE,mBAAmB;CAMlC"}
@@ -0,0 +1,154 @@
1
+ import { eq, and, sql } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import { productionBoms, productionBomItems, productionOrders, productionConsumption } from "../schema";
4
+ export class ProductionOrderService {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ async create(orgId, input) {
10
+ // Verify BOM exists
11
+ const boms = await this.db.select().from(productionBoms)
12
+ .where(and(eq(productionBoms.id, input.bomId), eq(productionBoms.organizationId, orgId)));
13
+ if (boms.length === 0)
14
+ return Err("BOM not found");
15
+ const orderNumber = await this.generateOrderNumber(orgId);
16
+ const rows = await this.db.insert(productionOrders).values({
17
+ organizationId: orgId,
18
+ orderNumber,
19
+ bomId: input.bomId,
20
+ entityId: input.entityId,
21
+ quantity: input.quantity,
22
+ warehouseId: input.warehouseId,
23
+ plannedDate: input.plannedDate,
24
+ notes: input.notes,
25
+ }).returning();
26
+ return Ok(rows[0]);
27
+ }
28
+ async list(orgId, status) {
29
+ const conditions = [eq(productionOrders.organizationId, orgId)];
30
+ if (status) {
31
+ conditions.push(eq(productionOrders.status, status));
32
+ }
33
+ const rows = await this.db.select().from(productionOrders).where(and(...conditions));
34
+ return Ok(rows);
35
+ }
36
+ async getById(orgId, id) {
37
+ const orders = await this.db.select().from(productionOrders)
38
+ .where(and(eq(productionOrders.id, id), eq(productionOrders.organizationId, orgId)));
39
+ if (orders.length === 0)
40
+ return Err("Order not found");
41
+ const order = orders[0];
42
+ const consumption = await this.db.select().from(productionConsumption)
43
+ .where(eq(productionConsumption.productionOrderId, id));
44
+ return Ok({ ...order, consumption });
45
+ }
46
+ async start(orgId, id, producedBy) {
47
+ const orders = await this.db.select().from(productionOrders)
48
+ .where(and(eq(productionOrders.id, id), eq(productionOrders.organizationId, orgId)));
49
+ if (orders.length === 0)
50
+ return Err("Order not found");
51
+ const order = orders[0];
52
+ if (order.status !== "planned")
53
+ return Err(`Cannot start order in '${order.status}' status`);
54
+ const rows = await this.db.update(productionOrders).set({
55
+ status: "in_progress",
56
+ producedBy,
57
+ startedAt: new Date(),
58
+ updatedAt: new Date(),
59
+ }).where(eq(productionOrders.id, id)).returning();
60
+ return Ok(rows[0]);
61
+ }
62
+ async recordConsumption(orgId, orderId, items) {
63
+ // Verify order exists and is in_progress
64
+ const orders = await this.db.select().from(productionOrders)
65
+ .where(and(eq(productionOrders.id, orderId), eq(productionOrders.organizationId, orgId)));
66
+ if (orders.length === 0)
67
+ return Err("Order not found");
68
+ const order = orders[0];
69
+ if (order.status !== "in_progress")
70
+ return Err(`Cannot record consumption for order in '${order.status}' status`);
71
+ const insertedItems = [];
72
+ for (const item of items) {
73
+ const totalCost = item.actualQuantity * item.unitCost;
74
+ const rows = await this.db.insert(productionConsumption).values({
75
+ productionOrderId: orderId,
76
+ entityId: item.entityId,
77
+ variantId: item.variantId,
78
+ plannedQuantity: item.plannedQuantity,
79
+ actualQuantity: item.actualQuantity,
80
+ uomId: item.uomId,
81
+ unitCost: item.unitCost,
82
+ totalCost,
83
+ batchNumber: item.batchNumber,
84
+ }).returning();
85
+ insertedItems.push(rows[0]);
86
+ }
87
+ return Ok(insertedItems);
88
+ }
89
+ async complete(orgId, id) {
90
+ const orders = await this.db.select().from(productionOrders)
91
+ .where(and(eq(productionOrders.id, id), eq(productionOrders.organizationId, orgId)));
92
+ if (orders.length === 0)
93
+ return Err("Order not found");
94
+ const order = orders[0];
95
+ if (order.status !== "in_progress")
96
+ return Err(`Cannot complete order in '${order.status}' status`);
97
+ // If no consumption records yet, auto-generate from BOM explosion
98
+ const existingConsumption = await this.db.select().from(productionConsumption)
99
+ .where(eq(productionConsumption.productionOrderId, id));
100
+ if (existingConsumption.length === 0) {
101
+ // Auto-consume based on BOM items
102
+ const bomItems = await this.db.select().from(productionBomItems)
103
+ .where(eq(productionBomItems.bomId, order.bomId));
104
+ const boms = await this.db.select().from(productionBoms)
105
+ .where(eq(productionBoms.id, order.bomId));
106
+ const yieldQty = boms.length > 0 ? (boms[0].yieldQuantity ?? 1) : 1;
107
+ const multiplier = order.quantity / yieldQty;
108
+ for (const bomItem of bomItems) {
109
+ if (bomItem.isSubAssembly)
110
+ continue; // Skip sub-assemblies for auto-consumption
111
+ const plannedQty = Math.round((bomItem.quantity ?? 0) * multiplier);
112
+ const unitCost = bomItem.unitCost ?? 0;
113
+ await this.db.insert(productionConsumption).values({
114
+ productionOrderId: id,
115
+ entityId: bomItem.entityId,
116
+ plannedQuantity: plannedQty,
117
+ actualQuantity: plannedQty,
118
+ unitCost,
119
+ totalCost: plannedQty * unitCost,
120
+ });
121
+ }
122
+ }
123
+ const rows = await this.db.update(productionOrders).set({
124
+ status: "completed",
125
+ completedAt: new Date(),
126
+ updatedAt: new Date(),
127
+ }).where(eq(productionOrders.id, id)).returning();
128
+ const consumption = await this.db.select().from(productionConsumption)
129
+ .where(eq(productionConsumption.productionOrderId, id));
130
+ return Ok({ ...rows[0], consumption });
131
+ }
132
+ async cancel(orgId, id) {
133
+ const orders = await this.db.select().from(productionOrders)
134
+ .where(and(eq(productionOrders.id, id), eq(productionOrders.organizationId, orgId)));
135
+ if (orders.length === 0)
136
+ return Err("Order not found");
137
+ const order = orders[0];
138
+ if (order.status === "completed")
139
+ return Err("Cannot cancel a completed order");
140
+ if (order.status === "cancelled")
141
+ return Err("Order is already cancelled");
142
+ const rows = await this.db.update(productionOrders).set({
143
+ status: "cancelled",
144
+ updatedAt: new Date(),
145
+ }).where(eq(productionOrders.id, id)).returning();
146
+ return Ok(rows[0]);
147
+ }
148
+ async generateOrderNumber(orgId) {
149
+ const countRows = await this.db.select({ count: sql `COUNT(*)`.as("count") })
150
+ .from(productionOrders).where(eq(productionOrders.organizationId, orgId));
151
+ const seq = Number(countRows[0]?.count ?? 0) + 1;
152
+ return `PRD-${String(seq).padStart(4, "0")}`;
153
+ }
154
+ }
@@ -0,0 +1,48 @@
1
+ import type { PluginResult } from "@unifiedcommerce/core";
2
+ import type { Db, BOM, BOMItem } from "../types";
3
+ export interface ExplodedItem {
4
+ entityId: string;
5
+ itemName: string;
6
+ totalQuantity: number;
7
+ unitCost: number;
8
+ totalCost: number;
9
+ }
10
+ export declare class ProductionService {
11
+ private db;
12
+ constructor(db: Db);
13
+ createBOM(orgId: string, input: {
14
+ entityId: string;
15
+ name: string;
16
+ yieldQuantity?: number;
17
+ yieldUomId?: string;
18
+ level?: number;
19
+ items: Array<{
20
+ entityId: string;
21
+ itemName: string;
22
+ quantity: number;
23
+ unitCost: number;
24
+ uomId?: string;
25
+ isSubAssembly?: boolean;
26
+ subBomId?: string;
27
+ }>;
28
+ }): Promise<PluginResult<BOM & {
29
+ items: BOMItem[];
30
+ }>>;
31
+ getBOM(orgId: string, id: string): Promise<PluginResult<BOM & {
32
+ items: BOMItem[];
33
+ }>>;
34
+ listBOMs(orgId: string): Promise<PluginResult<BOM[]>>;
35
+ addBOMItem(orgId: string, bomId: string, input: {
36
+ entityId: string;
37
+ itemName: string;
38
+ quantity: number;
39
+ unitCost: number;
40
+ uomId?: string;
41
+ isSubAssembly?: boolean;
42
+ subBomId?: string;
43
+ }): Promise<PluginResult<BOMItem>>;
44
+ costRollup(orgId: string, id: string): Promise<PluginResult<BOM>>;
45
+ explode(orgId: string, bomId: string, quantity: number): Promise<PluginResult<ExplodedItem[]>>;
46
+ private explodeRecursive;
47
+ }
48
+ //# sourceMappingURL=production-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"production-service.d.ts","sourceRoot":"","sources":["../../src/services/production-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAE1D,OAAO,KAAK,EAAE,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAEjD,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,qBAAa,iBAAiB;IAChB,OAAO,CAAC,EAAE;gBAAF,EAAE,EAAE,EAAE;IAEpB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACpC,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,KAAK,CAAC;YACX,QAAQ,EAAE,MAAM,CAAC;YACjB,QAAQ,EAAE,MAAM,CAAC;YACjB,QAAQ,EAAE,MAAM,CAAC;YACjB,QAAQ,EAAE,MAAM,CAAC;YACjB,KAAK,CAAC,EAAE,MAAM,CAAC;YACf,aAAa,CAAC,EAAE,OAAO,CAAC;YACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;SACnB,CAAC,CAAC;KACJ,GAAG,OAAO,CAAC,YAAY,CAAC,GAAG,GAAG;QAAE,KAAK,EAAE,OAAO,EAAE,CAAA;KAAE,CAAC,CAAC;IA6E/C,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,GAAG,GAAG;QAAE,KAAK,EAAE,OAAO,EAAE,CAAA;KAAE,CAAC,CAAC;IAapF,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,GAAG,EAAE,CAAC,CAAC;IAMrD,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACpD,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,aAAa,CAAC,EAAE,OAAO,CAAC;QACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;IAgD5B,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC;IAuCjE,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,YAAY,EAAE,CAAC,CAAC;YAYtF,gBAAgB;CA0C/B"}
@@ -0,0 +1,202 @@
1
+ import { eq, and } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import { productionBoms, productionBomItems } from "../schema";
4
+ export class ProductionService {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ async createBOM(orgId, input) {
10
+ const yieldQty = input.yieldQuantity ?? 1;
11
+ // Calculate total cost, resolving sub-assembly costs
12
+ let totalCost = 0;
13
+ const itemsWithCosts = [];
14
+ for (let i = 0; i < input.items.length; i++) {
15
+ const item = input.items[i];
16
+ let unitCost = item.unitCost;
17
+ if (item.isSubAssembly && item.subBomId) {
18
+ const subBom = await this.db.select().from(productionBoms)
19
+ .where(and(eq(productionBoms.id, item.subBomId), eq(productionBoms.organizationId, orgId)));
20
+ if (subBom.length > 0) {
21
+ const sub = subBom[0];
22
+ unitCost = Math.round((sub.totalCost ?? 0) / (sub.yieldQuantity ?? 1));
23
+ }
24
+ }
25
+ const itemTotal = item.quantity * unitCost;
26
+ totalCost += itemTotal;
27
+ itemsWithCosts.push({
28
+ entityId: item.entityId,
29
+ itemName: item.itemName,
30
+ quantity: item.quantity,
31
+ unitCost,
32
+ totalCost: itemTotal,
33
+ uomId: item.uomId,
34
+ isSubAssembly: item.isSubAssembly ?? false,
35
+ subBomId: item.subBomId,
36
+ sortOrder: i,
37
+ });
38
+ }
39
+ const bomRows = await this.db.insert(productionBoms).values({
40
+ organizationId: orgId,
41
+ entityId: input.entityId,
42
+ name: input.name,
43
+ yieldQuantity: yieldQty,
44
+ yieldUomId: input.yieldUomId,
45
+ level: input.level ?? 0,
46
+ totalCost,
47
+ }).returning();
48
+ const bom = bomRows[0];
49
+ const insertedItems = [];
50
+ for (const item of itemsWithCosts) {
51
+ const rows = await this.db.insert(productionBomItems).values({
52
+ bomId: bom.id,
53
+ entityId: item.entityId,
54
+ itemName: item.itemName,
55
+ quantity: item.quantity,
56
+ unitCost: item.unitCost,
57
+ totalCost: item.totalCost,
58
+ uomId: item.uomId,
59
+ isSubAssembly: item.isSubAssembly,
60
+ subBomId: item.subBomId,
61
+ sortOrder: item.sortOrder,
62
+ }).returning();
63
+ insertedItems.push(rows[0]);
64
+ }
65
+ return Ok({ ...bom, items: insertedItems });
66
+ }
67
+ async getBOM(orgId, id) {
68
+ const boms = await this.db.select().from(productionBoms)
69
+ .where(and(eq(productionBoms.id, id), eq(productionBoms.organizationId, orgId)));
70
+ if (boms.length === 0)
71
+ return Err("BOM not found");
72
+ const bom = boms[0];
73
+ const items = await this.db.select().from(productionBomItems)
74
+ .where(eq(productionBomItems.bomId, id))
75
+ .orderBy(productionBomItems.sortOrder);
76
+ return Ok({ ...bom, items });
77
+ }
78
+ async listBOMs(orgId) {
79
+ const rows = await this.db.select().from(productionBoms)
80
+ .where(eq(productionBoms.organizationId, orgId));
81
+ return Ok(rows);
82
+ }
83
+ async addBOMItem(orgId, bomId, input) {
84
+ // Verify BOM exists and belongs to org
85
+ const boms = await this.db.select().from(productionBoms)
86
+ .where(and(eq(productionBoms.id, bomId), eq(productionBoms.organizationId, orgId)));
87
+ if (boms.length === 0)
88
+ return Err("BOM not found");
89
+ let unitCost = input.unitCost;
90
+ if (input.isSubAssembly && input.subBomId) {
91
+ const subBom = await this.db.select().from(productionBoms)
92
+ .where(and(eq(productionBoms.id, input.subBomId), eq(productionBoms.organizationId, orgId)));
93
+ if (subBom.length > 0) {
94
+ const sub = subBom[0];
95
+ unitCost = Math.round((sub.totalCost ?? 0) / (sub.yieldQuantity ?? 1));
96
+ }
97
+ }
98
+ const itemTotal = input.quantity * unitCost;
99
+ // Get current max sort order
100
+ const existingItems = await this.db.select().from(productionBomItems)
101
+ .where(eq(productionBomItems.bomId, bomId));
102
+ const maxSort = existingItems.reduce((max, i) => Math.max(max, i.sortOrder ?? 0), -1);
103
+ const rows = await this.db.insert(productionBomItems).values({
104
+ bomId,
105
+ entityId: input.entityId,
106
+ itemName: input.itemName,
107
+ quantity: input.quantity,
108
+ unitCost,
109
+ totalCost: itemTotal,
110
+ uomId: input.uomId,
111
+ isSubAssembly: input.isSubAssembly ?? false,
112
+ subBomId: input.subBomId,
113
+ sortOrder: maxSort + 1,
114
+ }).returning();
115
+ // Recalculate BOM total cost
116
+ const allItems = await this.db.select().from(productionBomItems)
117
+ .where(eq(productionBomItems.bomId, bomId));
118
+ const newTotal = allItems.reduce((sum, i) => sum + (i.totalCost ?? 0), 0);
119
+ await this.db.update(productionBoms).set({
120
+ totalCost: newTotal,
121
+ updatedAt: new Date(),
122
+ }).where(eq(productionBoms.id, bomId));
123
+ return Ok(rows[0]);
124
+ }
125
+ async costRollup(orgId, id) {
126
+ const boms = await this.db.select().from(productionBoms)
127
+ .where(and(eq(productionBoms.id, id), eq(productionBoms.organizationId, orgId)));
128
+ if (boms.length === 0)
129
+ return Err("BOM not found");
130
+ const items = await this.db.select().from(productionBomItems)
131
+ .where(eq(productionBomItems.bomId, id));
132
+ let totalCost = 0;
133
+ for (const item of items) {
134
+ let unitCost = item.unitCost ?? 0;
135
+ if (item.isSubAssembly && item.subBomId) {
136
+ // Recursively roll up sub-assembly first
137
+ const subRollup = await this.costRollup(orgId, item.subBomId);
138
+ if (subRollup.ok) {
139
+ unitCost = Math.round((subRollup.value.totalCost ?? 0) / (subRollup.value.yieldQuantity ?? 1));
140
+ }
141
+ }
142
+ const itemTotal = (item.quantity ?? 0) * unitCost;
143
+ totalCost += itemTotal;
144
+ // Update item costs
145
+ await this.db.update(productionBomItems).set({
146
+ unitCost,
147
+ totalCost: itemTotal,
148
+ }).where(eq(productionBomItems.id, item.id));
149
+ }
150
+ // Update BOM total
151
+ const updated = await this.db.update(productionBoms).set({
152
+ totalCost,
153
+ updatedAt: new Date(),
154
+ }).where(eq(productionBoms.id, id)).returning();
155
+ return Ok(updated[0]);
156
+ }
157
+ async explode(orgId, bomId, quantity) {
158
+ const boms = await this.db.select().from(productionBoms)
159
+ .where(and(eq(productionBoms.id, bomId), eq(productionBoms.organizationId, orgId)));
160
+ if (boms.length === 0)
161
+ return Err("BOM not found");
162
+ const bom = boms[0];
163
+ const materialMap = new Map();
164
+ await this.explodeRecursive(orgId, bomId, quantity, bom.yieldQuantity ?? 1, materialMap);
165
+ return Ok(Array.from(materialMap.values()));
166
+ }
167
+ async explodeRecursive(orgId, bomId, quantity, yieldQuantity, materialMap) {
168
+ const items = await this.db.select().from(productionBomItems)
169
+ .where(eq(productionBomItems.bomId, bomId))
170
+ .orderBy(productionBomItems.sortOrder);
171
+ const multiplier = quantity / yieldQuantity;
172
+ for (const item of items) {
173
+ const requiredQty = Math.round((item.quantity ?? 0) * multiplier);
174
+ if (item.isSubAssembly && item.subBomId) {
175
+ // Recurse into sub-assembly
176
+ const subBoms = await this.db.select().from(productionBoms)
177
+ .where(and(eq(productionBoms.id, item.subBomId), eq(productionBoms.organizationId, orgId)));
178
+ if (subBoms.length > 0) {
179
+ const subBom = subBoms[0];
180
+ await this.explodeRecursive(orgId, item.subBomId, requiredQty, subBom.yieldQuantity ?? 1, materialMap);
181
+ }
182
+ }
183
+ else {
184
+ // Raw material — accumulate
185
+ const existing = materialMap.get(item.entityId);
186
+ if (existing) {
187
+ existing.totalQuantity += requiredQty;
188
+ existing.totalCost += requiredQty * (item.unitCost ?? 0);
189
+ }
190
+ else {
191
+ materialMap.set(item.entityId, {
192
+ entityId: item.entityId,
193
+ itemName: item.itemName,
194
+ totalQuantity: requiredQty,
195
+ unitCost: item.unitCost ?? 0,
196
+ totalCost: requiredQty * (item.unitCost ?? 0),
197
+ });
198
+ }
199
+ }
200
+ }
201
+ }
202
+ }