@porulle/plugin-uom 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.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @porulle/plugin-uom
2
+
3
+ Units of measure, conversions between units, and per-entity UOM assignments for catalog and operations.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @porulle/plugin-uom
9
+ ```
10
+
11
+ Add to `commerce.config.ts`:
12
+
13
+ ```typescript
14
+ import { uomPlugin } from "@porulle/plugin-uom";
15
+
16
+ export default defineConfig({
17
+ plugins: [uomPlugin()],
18
+ });
19
+ ```
20
+
21
+ Add to `drizzle.config.ts`:
22
+
23
+ ```typescript
24
+ schema: [
25
+ "./node_modules/@porulle/plugin-uom/src/schema.ts",
26
+ // ...
27
+ ],
28
+ ```
29
+
30
+ ## What it does
31
+
32
+ Maintains a canonical unit list, directed conversion factors, optional per-entity stocking/selling units, and a convert helper for quantity translation across the graph.
33
+
34
+ ## Routes exposed
35
+
36
+ **`/uom`** — `POST/GET /units`, `POST/GET /conversions`, `POST /convert`, `POST/GET /entities/{id}/uom`
37
+
38
+ ## Hooks
39
+
40
+ **Emitted:** none.
41
+
42
+ **Consumed:** none.
43
+
44
+ ## MCP tools
45
+
46
+ None.
47
+
48
+ ## Configuration options
49
+
50
+ None (`uomPlugin()` takes no options).
51
+
52
+ ## License
53
+
54
+ MIT
@@ -0,0 +1,4 @@
1
+ export type { Db } from "./types.js";
2
+ export { UOMService } from "./services/uom-service.js";
3
+ export declare function uomPlugin(): import("@porulle/core").CommercePlugin;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,YAAY,EAAE,EAAE,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAEvD,wBAAgB,SAAS,2CAgBxB"}
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ import { defineCommercePlugin } from "@porulle/core";
2
+ import { unitsOfMeasure, uomConversions, entityUom } from "./schema.js";
3
+ import { UOMService } from "./services/uom-service.js";
4
+ import { buildUOMRoutes } from "./routes/uom.js";
5
+ export { UOMService } from "./services/uom-service.js";
6
+ export function uomPlugin() {
7
+ return defineCommercePlugin({
8
+ id: "uom",
9
+ version: "1.0.0",
10
+ permissions: [
11
+ { scope: "uom:admin", description: "Create/edit units, conversions, entity UOM assignments." },
12
+ { scope: "uom:read", description: "View units, conversions, convert quantities." },
13
+ ],
14
+ schema: () => ({ unitsOfMeasure, uomConversions, entityUom }),
15
+ hooks: () => [],
16
+ routes: (ctx) => {
17
+ const db = ctx.database.db;
18
+ if (!db)
19
+ return [];
20
+ return buildUOMRoutes(new UOMService(db), ctx);
21
+ },
22
+ });
23
+ }
@@ -0,0 +1,9 @@
1
+ import type { UOMService } from "../services/uom-service.js";
2
+ import type { PluginRouteRegistration } from "@porulle/core";
3
+ export declare function buildUOMRoutes(service: UOMService, ctx: {
4
+ services?: Record<string, unknown>;
5
+ database?: {
6
+ db: unknown;
7
+ };
8
+ }): PluginRouteRegistration[];
9
+ //# sourceMappingURL=uom.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uom.d.ts","sourceRoot":"","sources":["../../src/routes/uom.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAE7D,wBAAgB,cAAc,CAC5B,OAAO,EAAE,UAAU,EACnB,GAAG,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE;QAAE,EAAE,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GACtE,uBAAuB,EAAE,CA+D3B"}
@@ -0,0 +1,65 @@
1
+ import { router } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ export function buildUOMRoutes(service, ctx) {
4
+ const r = router("Units of Measure", "/uom", ctx);
5
+ r.post("/units").summary("Create unit").permission("uom:admin")
6
+ .input(z.object({ code: z.string().min(1).max(20), name: z.string().min(1), category: z.enum(["weight", "volume", "length", "count", "area", "time"]), isBaseUnit: z.boolean().optional() }))
7
+ .handler(async ({ input, orgId }) => {
8
+ const body = input;
9
+ const result = await service.createUnit(orgId, body);
10
+ if (!result.ok)
11
+ throw new Error(result.error);
12
+ return result.value;
13
+ });
14
+ r.get("/units").summary("List units").permission("uom:read")
15
+ .query(z.object({ category: z.enum(["weight", "volume", "length", "count", "area", "time"]).optional() }))
16
+ .handler(async ({ query, orgId }) => {
17
+ const q = query;
18
+ const result = await service.listUnits(orgId, q.category);
19
+ if (!result.ok)
20
+ throw new Error(result.error);
21
+ return result.value;
22
+ });
23
+ r.post("/conversions").summary("Create conversion").permission("uom:admin")
24
+ .input(z.object({ fromUnitId: z.string().uuid(), toUnitId: z.string().uuid(), factor: z.number().int().positive() }))
25
+ .handler(async ({ input, orgId }) => {
26
+ const body = input;
27
+ const result = await service.createConversion(orgId, body);
28
+ if (!result.ok)
29
+ throw new Error(result.error);
30
+ return result.value;
31
+ });
32
+ r.get("/conversions").summary("List conversions").permission("uom:read")
33
+ .handler(async ({ orgId }) => {
34
+ const result = await service.listConversions(orgId);
35
+ if (!result.ok)
36
+ throw new Error(result.error);
37
+ return result.value;
38
+ });
39
+ r.post("/convert").summary("Convert quantity").permission("uom:read")
40
+ .input(z.object({ fromUnitId: z.string().uuid(), toUnitId: z.string().uuid(), quantity: z.number().positive() }))
41
+ .handler(async ({ input, orgId }) => {
42
+ const body = input;
43
+ const result = await service.convert(orgId, body);
44
+ if (!result.ok)
45
+ throw new Error(result.error);
46
+ return result.value;
47
+ });
48
+ r.post("/entities/{id}/uom").summary("Set entity UOM").permission("uom:admin")
49
+ .input(z.object({ purchaseUomId: z.string().uuid(), stockUomId: z.string().uuid(), saleUomId: z.string().uuid(), yieldPercentage: z.number().int().min(1).max(100).optional() }))
50
+ .handler(async ({ params, input, orgId }) => {
51
+ const body = input;
52
+ const result = await service.setEntityUom(orgId, { entityId: params.id, ...body });
53
+ if (!result.ok)
54
+ throw new Error(result.error);
55
+ return result.value;
56
+ });
57
+ r.get("/entities/{id}/uom").summary("Get entity UOM").permission("uom:read")
58
+ .handler(async ({ params, orgId }) => {
59
+ const result = await service.getEntityUom(orgId, params.id);
60
+ if (!result.ok)
61
+ throw new Error(result.error);
62
+ return result.value;
63
+ });
64
+ return r.routes();
65
+ }
@@ -0,0 +1,413 @@
1
+ export declare const unitsOfMeasure: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
2
+ name: "units_of_measure";
3
+ schema: undefined;
4
+ columns: {
5
+ id: import("@porulle/core/drizzle").PgColumn<{
6
+ name: "id";
7
+ tableName: "units_of_measure";
8
+ dataType: "string";
9
+ columnType: "PgUUID";
10
+ data: string;
11
+ driverParam: string;
12
+ notNull: true;
13
+ hasDefault: true;
14
+ isPrimaryKey: true;
15
+ isAutoincrement: false;
16
+ hasRuntimeDefault: false;
17
+ enumValues: undefined;
18
+ baseColumn: never;
19
+ identity: undefined;
20
+ generated: undefined;
21
+ }, {}, {}>;
22
+ organizationId: import("@porulle/core/drizzle").PgColumn<{
23
+ name: "organization_id";
24
+ tableName: "units_of_measure";
25
+ dataType: "string";
26
+ columnType: "PgText";
27
+ data: string;
28
+ driverParam: string;
29
+ notNull: true;
30
+ hasDefault: false;
31
+ isPrimaryKey: false;
32
+ isAutoincrement: false;
33
+ hasRuntimeDefault: false;
34
+ enumValues: [string, ...string[]];
35
+ baseColumn: never;
36
+ identity: undefined;
37
+ generated: undefined;
38
+ }, {}, {}>;
39
+ code: import("@porulle/core/drizzle").PgColumn<{
40
+ name: "code";
41
+ tableName: "units_of_measure";
42
+ dataType: "string";
43
+ columnType: "PgText";
44
+ data: string;
45
+ driverParam: string;
46
+ notNull: true;
47
+ hasDefault: false;
48
+ isPrimaryKey: false;
49
+ isAutoincrement: false;
50
+ hasRuntimeDefault: false;
51
+ enumValues: [string, ...string[]];
52
+ baseColumn: never;
53
+ identity: undefined;
54
+ generated: undefined;
55
+ }, {}, {}>;
56
+ name: import("@porulle/core/drizzle").PgColumn<{
57
+ name: "name";
58
+ tableName: "units_of_measure";
59
+ dataType: "string";
60
+ columnType: "PgText";
61
+ data: string;
62
+ driverParam: string;
63
+ notNull: true;
64
+ hasDefault: false;
65
+ isPrimaryKey: false;
66
+ isAutoincrement: false;
67
+ hasRuntimeDefault: false;
68
+ enumValues: [string, ...string[]];
69
+ baseColumn: never;
70
+ identity: undefined;
71
+ generated: undefined;
72
+ }, {}, {}>;
73
+ category: import("@porulle/core/drizzle").PgColumn<{
74
+ name: "category";
75
+ tableName: "units_of_measure";
76
+ dataType: "string";
77
+ columnType: "PgText";
78
+ data: "length" | "weight" | "volume" | "count" | "area" | "time";
79
+ driverParam: string;
80
+ notNull: true;
81
+ hasDefault: false;
82
+ isPrimaryKey: false;
83
+ isAutoincrement: false;
84
+ hasRuntimeDefault: false;
85
+ enumValues: ["weight", "volume", "length", "count", "area", "time"];
86
+ baseColumn: never;
87
+ identity: undefined;
88
+ generated: undefined;
89
+ }, {}, {}>;
90
+ isBaseUnit: import("@porulle/core/drizzle").PgColumn<{
91
+ name: "is_base_unit";
92
+ tableName: "units_of_measure";
93
+ dataType: "boolean";
94
+ columnType: "PgBoolean";
95
+ data: boolean;
96
+ driverParam: boolean;
97
+ notNull: true;
98
+ hasDefault: true;
99
+ isPrimaryKey: false;
100
+ isAutoincrement: false;
101
+ hasRuntimeDefault: false;
102
+ enumValues: undefined;
103
+ baseColumn: never;
104
+ identity: undefined;
105
+ generated: undefined;
106
+ }, {}, {}>;
107
+ createdAt: import("@porulle/core/drizzle").PgColumn<{
108
+ name: "created_at";
109
+ tableName: "units_of_measure";
110
+ dataType: "date";
111
+ columnType: "PgTimestamp";
112
+ data: Date;
113
+ driverParam: string;
114
+ notNull: true;
115
+ hasDefault: true;
116
+ isPrimaryKey: false;
117
+ isAutoincrement: false;
118
+ hasRuntimeDefault: false;
119
+ enumValues: undefined;
120
+ baseColumn: never;
121
+ identity: undefined;
122
+ generated: undefined;
123
+ }, {}, {}>;
124
+ updatedAt: import("@porulle/core/drizzle").PgColumn<{
125
+ name: "updated_at";
126
+ tableName: "units_of_measure";
127
+ dataType: "date";
128
+ columnType: "PgTimestamp";
129
+ data: Date;
130
+ driverParam: string;
131
+ notNull: true;
132
+ hasDefault: true;
133
+ isPrimaryKey: false;
134
+ isAutoincrement: false;
135
+ hasRuntimeDefault: false;
136
+ enumValues: undefined;
137
+ baseColumn: never;
138
+ identity: undefined;
139
+ generated: undefined;
140
+ }, {}, {}>;
141
+ };
142
+ dialect: "pg";
143
+ }>;
144
+ export declare const uomConversions: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
145
+ name: "uom_conversions";
146
+ schema: undefined;
147
+ columns: {
148
+ id: import("@porulle/core/drizzle").PgColumn<{
149
+ name: "id";
150
+ tableName: "uom_conversions";
151
+ dataType: "string";
152
+ columnType: "PgUUID";
153
+ data: string;
154
+ driverParam: string;
155
+ notNull: true;
156
+ hasDefault: true;
157
+ isPrimaryKey: true;
158
+ isAutoincrement: false;
159
+ hasRuntimeDefault: false;
160
+ enumValues: undefined;
161
+ baseColumn: never;
162
+ identity: undefined;
163
+ generated: undefined;
164
+ }, {}, {}>;
165
+ organizationId: import("@porulle/core/drizzle").PgColumn<{
166
+ name: "organization_id";
167
+ tableName: "uom_conversions";
168
+ dataType: "string";
169
+ columnType: "PgText";
170
+ data: string;
171
+ driverParam: string;
172
+ notNull: true;
173
+ hasDefault: false;
174
+ isPrimaryKey: false;
175
+ isAutoincrement: false;
176
+ hasRuntimeDefault: false;
177
+ enumValues: [string, ...string[]];
178
+ baseColumn: never;
179
+ identity: undefined;
180
+ generated: undefined;
181
+ }, {}, {}>;
182
+ fromUnitId: import("@porulle/core/drizzle").PgColumn<{
183
+ name: "from_unit_id";
184
+ tableName: "uom_conversions";
185
+ dataType: "string";
186
+ columnType: "PgUUID";
187
+ data: string;
188
+ driverParam: string;
189
+ notNull: true;
190
+ hasDefault: false;
191
+ isPrimaryKey: false;
192
+ isAutoincrement: false;
193
+ hasRuntimeDefault: false;
194
+ enumValues: undefined;
195
+ baseColumn: never;
196
+ identity: undefined;
197
+ generated: undefined;
198
+ }, {}, {}>;
199
+ toUnitId: import("@porulle/core/drizzle").PgColumn<{
200
+ name: "to_unit_id";
201
+ tableName: "uom_conversions";
202
+ dataType: "string";
203
+ columnType: "PgUUID";
204
+ data: string;
205
+ driverParam: string;
206
+ notNull: true;
207
+ hasDefault: false;
208
+ isPrimaryKey: false;
209
+ isAutoincrement: false;
210
+ hasRuntimeDefault: false;
211
+ enumValues: undefined;
212
+ baseColumn: never;
213
+ identity: undefined;
214
+ generated: undefined;
215
+ }, {}, {}>;
216
+ factor: import("@porulle/core/drizzle").PgColumn<{
217
+ name: "factor";
218
+ tableName: "uom_conversions";
219
+ dataType: "number";
220
+ columnType: "PgInteger";
221
+ data: number;
222
+ driverParam: string | number;
223
+ notNull: true;
224
+ hasDefault: false;
225
+ isPrimaryKey: false;
226
+ isAutoincrement: false;
227
+ hasRuntimeDefault: false;
228
+ enumValues: undefined;
229
+ baseColumn: never;
230
+ identity: undefined;
231
+ generated: undefined;
232
+ }, {}, {}>;
233
+ createdAt: import("@porulle/core/drizzle").PgColumn<{
234
+ name: "created_at";
235
+ tableName: "uom_conversions";
236
+ dataType: "date";
237
+ columnType: "PgTimestamp";
238
+ data: Date;
239
+ driverParam: string;
240
+ notNull: true;
241
+ hasDefault: true;
242
+ isPrimaryKey: false;
243
+ isAutoincrement: false;
244
+ hasRuntimeDefault: false;
245
+ enumValues: undefined;
246
+ baseColumn: never;
247
+ identity: undefined;
248
+ generated: undefined;
249
+ }, {}, {}>;
250
+ };
251
+ dialect: "pg";
252
+ }>;
253
+ export declare const entityUom: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
254
+ name: "entity_uom";
255
+ schema: undefined;
256
+ columns: {
257
+ id: import("@porulle/core/drizzle").PgColumn<{
258
+ name: "id";
259
+ tableName: "entity_uom";
260
+ dataType: "string";
261
+ columnType: "PgUUID";
262
+ data: string;
263
+ driverParam: string;
264
+ notNull: true;
265
+ hasDefault: true;
266
+ isPrimaryKey: true;
267
+ isAutoincrement: false;
268
+ hasRuntimeDefault: false;
269
+ enumValues: undefined;
270
+ baseColumn: never;
271
+ identity: undefined;
272
+ generated: undefined;
273
+ }, {}, {}>;
274
+ organizationId: import("@porulle/core/drizzle").PgColumn<{
275
+ name: "organization_id";
276
+ tableName: "entity_uom";
277
+ dataType: "string";
278
+ columnType: "PgText";
279
+ data: string;
280
+ driverParam: string;
281
+ notNull: true;
282
+ hasDefault: false;
283
+ isPrimaryKey: false;
284
+ isAutoincrement: false;
285
+ hasRuntimeDefault: false;
286
+ enumValues: [string, ...string[]];
287
+ baseColumn: never;
288
+ identity: undefined;
289
+ generated: undefined;
290
+ }, {}, {}>;
291
+ entityId: import("@porulle/core/drizzle").PgColumn<{
292
+ name: "entity_id";
293
+ tableName: "entity_uom";
294
+ dataType: "string";
295
+ columnType: "PgUUID";
296
+ data: string;
297
+ driverParam: string;
298
+ notNull: true;
299
+ hasDefault: false;
300
+ isPrimaryKey: false;
301
+ isAutoincrement: false;
302
+ hasRuntimeDefault: false;
303
+ enumValues: undefined;
304
+ baseColumn: never;
305
+ identity: undefined;
306
+ generated: undefined;
307
+ }, {}, {}>;
308
+ purchaseUomId: import("@porulle/core/drizzle").PgColumn<{
309
+ name: "purchase_uom_id";
310
+ tableName: "entity_uom";
311
+ dataType: "string";
312
+ columnType: "PgUUID";
313
+ data: string;
314
+ driverParam: string;
315
+ notNull: true;
316
+ hasDefault: false;
317
+ isPrimaryKey: false;
318
+ isAutoincrement: false;
319
+ hasRuntimeDefault: false;
320
+ enumValues: undefined;
321
+ baseColumn: never;
322
+ identity: undefined;
323
+ generated: undefined;
324
+ }, {}, {}>;
325
+ stockUomId: import("@porulle/core/drizzle").PgColumn<{
326
+ name: "stock_uom_id";
327
+ tableName: "entity_uom";
328
+ dataType: "string";
329
+ columnType: "PgUUID";
330
+ data: string;
331
+ driverParam: string;
332
+ notNull: true;
333
+ hasDefault: false;
334
+ isPrimaryKey: false;
335
+ isAutoincrement: false;
336
+ hasRuntimeDefault: false;
337
+ enumValues: undefined;
338
+ baseColumn: never;
339
+ identity: undefined;
340
+ generated: undefined;
341
+ }, {}, {}>;
342
+ saleUomId: import("@porulle/core/drizzle").PgColumn<{
343
+ name: "sale_uom_id";
344
+ tableName: "entity_uom";
345
+ dataType: "string";
346
+ columnType: "PgUUID";
347
+ data: string;
348
+ driverParam: string;
349
+ notNull: true;
350
+ hasDefault: false;
351
+ isPrimaryKey: false;
352
+ isAutoincrement: false;
353
+ hasRuntimeDefault: false;
354
+ enumValues: undefined;
355
+ baseColumn: never;
356
+ identity: undefined;
357
+ generated: undefined;
358
+ }, {}, {}>;
359
+ yieldPercentage: import("@porulle/core/drizzle").PgColumn<{
360
+ name: "yield_percentage";
361
+ tableName: "entity_uom";
362
+ dataType: "number";
363
+ columnType: "PgInteger";
364
+ data: number;
365
+ driverParam: string | number;
366
+ notNull: true;
367
+ hasDefault: true;
368
+ isPrimaryKey: false;
369
+ isAutoincrement: false;
370
+ hasRuntimeDefault: false;
371
+ enumValues: undefined;
372
+ baseColumn: never;
373
+ identity: undefined;
374
+ generated: undefined;
375
+ }, {}, {}>;
376
+ createdAt: import("@porulle/core/drizzle").PgColumn<{
377
+ name: "created_at";
378
+ tableName: "entity_uom";
379
+ dataType: "date";
380
+ columnType: "PgTimestamp";
381
+ data: Date;
382
+ driverParam: string;
383
+ notNull: true;
384
+ hasDefault: true;
385
+ isPrimaryKey: false;
386
+ isAutoincrement: false;
387
+ hasRuntimeDefault: false;
388
+ enumValues: undefined;
389
+ baseColumn: never;
390
+ identity: undefined;
391
+ generated: undefined;
392
+ }, {}, {}>;
393
+ updatedAt: import("@porulle/core/drizzle").PgColumn<{
394
+ name: "updated_at";
395
+ tableName: "entity_uom";
396
+ dataType: "date";
397
+ columnType: "PgTimestamp";
398
+ data: Date;
399
+ driverParam: string;
400
+ notNull: true;
401
+ hasDefault: true;
402
+ isPrimaryKey: false;
403
+ isAutoincrement: false;
404
+ hasRuntimeDefault: false;
405
+ enumValues: undefined;
406
+ baseColumn: never;
407
+ identity: undefined;
408
+ generated: undefined;
409
+ }, {}, {}>;
410
+ };
411
+ dialect: "pg";
412
+ }>;
413
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAYxB,CAAC;AAEJ,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASxB,CAAC;AAEJ,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAYnB,CAAC"}
package/dist/schema.js ADDED
@@ -0,0 +1,37 @@
1
+ import { pgTable, uuid, text, integer, boolean, timestamp, index, uniqueIndex } from "@porulle/core/drizzle";
2
+ export const unitsOfMeasure = pgTable("units_of_measure", {
3
+ id: uuid("id").defaultRandom().primaryKey(),
4
+ organizationId: text("organization_id").notNull(),
5
+ code: text("code").notNull(),
6
+ name: text("name").notNull(),
7
+ category: text("category", { enum: ["weight", "volume", "length", "count", "area", "time"] }).notNull(),
8
+ isBaseUnit: boolean("is_base_unit").notNull().default(false),
9
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
10
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
11
+ }, (table) => ({
12
+ orgIdx: index("idx_uom_org").on(table.organizationId),
13
+ codeUnique: uniqueIndex("uom_org_code_unique").on(table.organizationId, table.code),
14
+ }));
15
+ export const uomConversions = pgTable("uom_conversions", {
16
+ id: uuid("id").defaultRandom().primaryKey(),
17
+ organizationId: text("organization_id").notNull(),
18
+ fromUnitId: uuid("from_unit_id").references(() => unitsOfMeasure.id, { onDelete: "cascade" }).notNull(),
19
+ toUnitId: uuid("to_unit_id").references(() => unitsOfMeasure.id, { onDelete: "cascade" }).notNull(),
20
+ factor: integer("factor").notNull(),
21
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
22
+ }, (table) => ({
23
+ orgIdx: index("idx_uom_conversions_org").on(table.organizationId),
24
+ }));
25
+ export const entityUom = pgTable("entity_uom", {
26
+ id: uuid("id").defaultRandom().primaryKey(),
27
+ organizationId: text("organization_id").notNull(),
28
+ entityId: uuid("entity_id").notNull(),
29
+ purchaseUomId: uuid("purchase_uom_id").references(() => unitsOfMeasure.id).notNull(),
30
+ stockUomId: uuid("stock_uom_id").references(() => unitsOfMeasure.id).notNull(),
31
+ saleUomId: uuid("sale_uom_id").references(() => unitsOfMeasure.id).notNull(),
32
+ yieldPercentage: integer("yield_percentage").notNull().default(100),
33
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
34
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
35
+ }, (table) => ({
36
+ entityUnique: uniqueIndex("entity_uom_org_entity_unique").on(table.organizationId, table.entityId),
37
+ }));
@@ -0,0 +1,40 @@
1
+ import type { PluginResult } from "@porulle/core";
2
+ import type { Db, UnitOfMeasure, UOMConversion, EntityUOM, UOMCategory } from "../types.js";
3
+ export declare class UOMService {
4
+ private db;
5
+ constructor(db: Db);
6
+ createUnit(orgId: string, input: {
7
+ code: string;
8
+ name: string;
9
+ category: UOMCategory;
10
+ isBaseUnit?: boolean;
11
+ }): Promise<PluginResult<UnitOfMeasure>>;
12
+ listUnits(orgId: string, category?: UOMCategory): Promise<PluginResult<UnitOfMeasure[]>>;
13
+ createConversion(orgId: string, input: {
14
+ fromUnitId: string;
15
+ toUnitId: string;
16
+ factor: number;
17
+ }): Promise<PluginResult<UOMConversion>>;
18
+ listConversions(orgId: string): Promise<PluginResult<UOMConversion[]>>;
19
+ convert(orgId: string, input: {
20
+ fromUnitId: string;
21
+ toUnitId: string;
22
+ quantity: number;
23
+ }): Promise<PluginResult<{
24
+ result: number;
25
+ fromCode: string;
26
+ toCode: string;
27
+ }>>;
28
+ setEntityUom(orgId: string, input: {
29
+ entityId: string;
30
+ purchaseUomId: string;
31
+ stockUomId: string;
32
+ saleUomId: string;
33
+ yieldPercentage?: number;
34
+ }): Promise<PluginResult<EntityUOM>>;
35
+ getEntityUom(orgId: string, entityId: string): Promise<PluginResult<EntityUOM>>;
36
+ calculateYield(yieldPercentage: number, requiredQuantity: number): Promise<PluginResult<{
37
+ purchaseQuantity: number;
38
+ }>>;
39
+ }
40
+ //# sourceMappingURL=uom-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uom-service.d.ts","sourceRoot":"","sources":["../../src/services/uom-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,KAAK,EAAE,EAAE,EAAE,aAAa,EAAE,aAAa,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE5F,qBAAa,UAAU;IACT,OAAO,CAAC,EAAE;gBAAF,EAAE,EAAE,EAAE;IAEpB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACrC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,WAAW,CAAC;QAAC,UAAU,CAAC,EAAE,OAAO,CAAC;KACzE,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;IAWlC,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,EAAE,CAAC,CAAC;IAOxF,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QAC3C,UAAU,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;KACtD,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;IASlC,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,EAAE,CAAC,CAAC;IAMtE,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QAClC,UAAU,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;KACxD,GAAG,OAAO,CAAC,YAAY,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IA+BzE,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACvC,QAAQ,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,eAAe,CAAC,EAAE,MAAM,CAAC;KAC1G,GAAG,OAAO,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC;IAkB9B,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC;IAO/E,cAAc,CAAC,eAAe,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;QAAE,gBAAgB,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAI7H"}
@@ -0,0 +1,91 @@
1
+ import { eq, and } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import { unitsOfMeasure, uomConversions, entityUom } from "../schema.js";
4
+ export class UOMService {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ async createUnit(orgId, input) {
10
+ const existing = await this.db.select().from(unitsOfMeasure)
11
+ .where(and(eq(unitsOfMeasure.organizationId, orgId), eq(unitsOfMeasure.code, input.code)));
12
+ if (existing.length > 0)
13
+ return Err(`Unit '${input.code}' already exists`);
14
+ const rows = await this.db.insert(unitsOfMeasure).values({
15
+ organizationId: orgId, code: input.code, name: input.name,
16
+ category: input.category, isBaseUnit: input.isBaseUnit ?? false,
17
+ }).returning();
18
+ return Ok(rows[0]);
19
+ }
20
+ async listUnits(orgId, category) {
21
+ const conditions = [eq(unitsOfMeasure.organizationId, orgId)];
22
+ if (category)
23
+ conditions.push(eq(unitsOfMeasure.category, category));
24
+ const rows = await this.db.select().from(unitsOfMeasure).where(and(...conditions));
25
+ return Ok(rows);
26
+ }
27
+ async createConversion(orgId, input) {
28
+ if (input.factor <= 0)
29
+ return Err("Factor must be positive");
30
+ const rows = await this.db.insert(uomConversions).values({
31
+ organizationId: orgId, fromUnitId: input.fromUnitId,
32
+ toUnitId: input.toUnitId, factor: input.factor,
33
+ }).returning();
34
+ return Ok(rows[0]);
35
+ }
36
+ async listConversions(orgId) {
37
+ const rows = await this.db.select().from(uomConversions)
38
+ .where(eq(uomConversions.organizationId, orgId));
39
+ return Ok(rows);
40
+ }
41
+ async convert(orgId, input) {
42
+ if (input.fromUnitId === input.toUnitId) {
43
+ const unit = await this.db.select().from(unitsOfMeasure).where(eq(unitsOfMeasure.id, input.fromUnitId));
44
+ return Ok({ result: input.quantity, fromCode: unit[0]?.code ?? "", toCode: unit[0]?.code ?? "" });
45
+ }
46
+ // Try forward conversion
47
+ const forward = await this.db.select().from(uomConversions).where(and(eq(uomConversions.organizationId, orgId), eq(uomConversions.fromUnitId, input.fromUnitId), eq(uomConversions.toUnitId, input.toUnitId)));
48
+ const fromUnit = await this.db.select().from(unitsOfMeasure).where(eq(unitsOfMeasure.id, input.fromUnitId));
49
+ const toUnit = await this.db.select().from(unitsOfMeasure).where(eq(unitsOfMeasure.id, input.toUnitId));
50
+ const fromCode = fromUnit[0]?.code ?? "";
51
+ const toCode = toUnit[0]?.code ?? "";
52
+ if (forward.length > 0) {
53
+ return Ok({ result: Math.round(input.quantity * forward[0].factor / 10000), fromCode, toCode });
54
+ }
55
+ // Try reverse
56
+ const reverse = await this.db.select().from(uomConversions).where(and(eq(uomConversions.organizationId, orgId), eq(uomConversions.fromUnitId, input.toUnitId), eq(uomConversions.toUnitId, input.fromUnitId)));
57
+ if (reverse.length > 0) {
58
+ return Ok({ result: Math.round(input.quantity * 10000 / reverse[0].factor), fromCode, toCode });
59
+ }
60
+ return Err(`No conversion found between '${fromCode}' and '${toCode}'`);
61
+ }
62
+ async setEntityUom(orgId, input) {
63
+ const existing = await this.db.select().from(entityUom)
64
+ .where(and(eq(entityUom.organizationId, orgId), eq(entityUom.entityId, input.entityId)));
65
+ if (existing.length > 0) {
66
+ const rows = await this.db.update(entityUom).set({
67
+ purchaseUomId: input.purchaseUomId, stockUomId: input.stockUomId,
68
+ saleUomId: input.saleUomId, yieldPercentage: input.yieldPercentage ?? 100, updatedAt: new Date(),
69
+ }).where(eq(entityUom.id, existing[0].id)).returning();
70
+ return Ok(rows[0]);
71
+ }
72
+ const rows = await this.db.insert(entityUom).values({
73
+ organizationId: orgId, entityId: input.entityId,
74
+ purchaseUomId: input.purchaseUomId, stockUomId: input.stockUomId,
75
+ saleUomId: input.saleUomId, yieldPercentage: input.yieldPercentage ?? 100,
76
+ }).returning();
77
+ return Ok(rows[0]);
78
+ }
79
+ async getEntityUom(orgId, entityId) {
80
+ const rows = await this.db.select().from(entityUom)
81
+ .where(and(eq(entityUom.organizationId, orgId), eq(entityUom.entityId, entityId)));
82
+ if (rows.length === 0)
83
+ return Err("No UOM assignment for this entity");
84
+ return Ok(rows[0]);
85
+ }
86
+ async calculateYield(yieldPercentage, requiredQuantity) {
87
+ if (yieldPercentage <= 0 || yieldPercentage > 100)
88
+ return Ok({ purchaseQuantity: requiredQuantity });
89
+ return Ok({ purchaseQuantity: Math.ceil(requiredQuantity * 100 / yieldPercentage) });
90
+ }
91
+ }
@@ -0,0 +1,7 @@
1
+ export type { PluginDb as Db } from "@porulle/core";
2
+ import type { unitsOfMeasure, uomConversions, entityUom } from "./schema.js";
3
+ export type UnitOfMeasure = typeof unitsOfMeasure.$inferSelect;
4
+ export type UOMConversion = typeof uomConversions.$inferSelect;
5
+ export type EntityUOM = typeof entityUom.$inferSelect;
6
+ export type UOMCategory = "weight" | "volume" | "length" | "count" | "area" | "time";
7
+ //# 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,cAAc,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7E,MAAM,MAAM,aAAa,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAC/D,MAAM,MAAM,aAAa,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAC/D,MAAM,MAAM,SAAS,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AACtD,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@porulle/plugin-uom",
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
+ "description": "Units of measure, conversions between units, and per-entity UOM assignments for catalog and operations.",
45
+ "homepage": "https://porulle-docs.vercel.app",
46
+ "bugs": {
47
+ "url": "https://github.com/asyncdotengineering/porulle/issues"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/asyncdotengineering/porulle.git",
52
+ "directory": "packages/plugins/plugin-uom"
53
+ },
54
+ "author": "Porulle contributors"
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { defineCommercePlugin } from "@porulle/core";
2
+ import { unitsOfMeasure, uomConversions, entityUom } from "./schema.js";
3
+ import { UOMService } from "./services/uom-service.js";
4
+ import { buildUOMRoutes } from "./routes/uom.js";
5
+ import type { Db } from "./types.js";
6
+
7
+ export type { Db } from "./types.js";
8
+ export { UOMService } from "./services/uom-service.js";
9
+
10
+ export function uomPlugin() {
11
+ return defineCommercePlugin({
12
+ id: "uom",
13
+ version: "1.0.0",
14
+ permissions: [
15
+ { scope: "uom:admin", description: "Create/edit units, conversions, entity UOM assignments." },
16
+ { scope: "uom:read", description: "View units, conversions, convert quantities." },
17
+ ],
18
+ schema: () => ({ unitsOfMeasure, uomConversions, entityUom }),
19
+ hooks: () => [],
20
+ routes: (ctx) => {
21
+ const db = ctx.database.db;
22
+ if (!db) return [];
23
+ return buildUOMRoutes(new UOMService(db), ctx);
24
+ },
25
+ });
26
+ }
@@ -0,0 +1,72 @@
1
+ import { router } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import type { UOMService } from "../services/uom-service.js";
4
+ import type { PluginRouteRegistration } from "@porulle/core";
5
+
6
+ export function buildUOMRoutes(
7
+ service: UOMService,
8
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
9
+ ): PluginRouteRegistration[] {
10
+ const r = router("Units of Measure", "/uom", ctx);
11
+
12
+ r.post("/units").summary("Create unit").permission("uom:admin")
13
+ .input(z.object({ code: z.string().min(1).max(20), name: z.string().min(1), category: z.enum(["weight", "volume", "length", "count", "area", "time"]), isBaseUnit: z.boolean().optional() }))
14
+ .handler(async ({ input, orgId }) => {
15
+ const body = input as { code: string; name: string; category: "weight" | "volume" | "length" | "count" | "area" | "time"; isBaseUnit?: boolean };
16
+ const result = await service.createUnit(orgId, body);
17
+ if (!result.ok) throw new Error(result.error);
18
+ return result.value;
19
+ });
20
+
21
+ r.get("/units").summary("List units").permission("uom:read")
22
+ .query(z.object({ category: z.enum(["weight", "volume", "length", "count", "area", "time"]).optional() }))
23
+ .handler(async ({ query, orgId }) => {
24
+ const q = query as { category?: "weight" | "volume" | "length" | "count" | "area" | "time" };
25
+ const result = await service.listUnits(orgId, q.category);
26
+ if (!result.ok) throw new Error(result.error);
27
+ return result.value;
28
+ });
29
+
30
+ r.post("/conversions").summary("Create conversion").permission("uom:admin")
31
+ .input(z.object({ fromUnitId: z.string().uuid(), toUnitId: z.string().uuid(), factor: z.number().int().positive() }))
32
+ .handler(async ({ input, orgId }) => {
33
+ const body = input as { fromUnitId: string; toUnitId: string; factor: number };
34
+ const result = await service.createConversion(orgId, body);
35
+ if (!result.ok) throw new Error(result.error);
36
+ return result.value;
37
+ });
38
+
39
+ r.get("/conversions").summary("List conversions").permission("uom:read")
40
+ .handler(async ({ orgId }) => {
41
+ const result = await service.listConversions(orgId);
42
+ if (!result.ok) throw new Error(result.error);
43
+ return result.value;
44
+ });
45
+
46
+ r.post("/convert").summary("Convert quantity").permission("uom:read")
47
+ .input(z.object({ fromUnitId: z.string().uuid(), toUnitId: z.string().uuid(), quantity: z.number().positive() }))
48
+ .handler(async ({ input, orgId }) => {
49
+ const body = input as { fromUnitId: string; toUnitId: string; quantity: number };
50
+ const result = await service.convert(orgId, body);
51
+ if (!result.ok) throw new Error(result.error);
52
+ return result.value;
53
+ });
54
+
55
+ r.post("/entities/{id}/uom").summary("Set entity UOM").permission("uom:admin")
56
+ .input(z.object({ purchaseUomId: z.string().uuid(), stockUomId: z.string().uuid(), saleUomId: z.string().uuid(), yieldPercentage: z.number().int().min(1).max(100).optional() }))
57
+ .handler(async ({ params, input, orgId }) => {
58
+ const body = input as { purchaseUomId: string; stockUomId: string; saleUomId: string; yieldPercentage?: number };
59
+ const result = await service.setEntityUom(orgId, { entityId: params.id!, ...body });
60
+ if (!result.ok) throw new Error(result.error);
61
+ return result.value;
62
+ });
63
+
64
+ r.get("/entities/{id}/uom").summary("Get entity UOM").permission("uom:read")
65
+ .handler(async ({ params, orgId }) => {
66
+ const result = await service.getEntityUom(orgId, params.id!);
67
+ if (!result.ok) throw new Error(result.error);
68
+ return result.value;
69
+ });
70
+
71
+ return r.routes();
72
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { pgTable, uuid, text, integer, boolean, timestamp, index, uniqueIndex } from "@porulle/core/drizzle";
2
+
3
+ export const unitsOfMeasure = pgTable("units_of_measure", {
4
+ id: uuid("id").defaultRandom().primaryKey(),
5
+ organizationId: text("organization_id").notNull(),
6
+ code: text("code").notNull(),
7
+ name: text("name").notNull(),
8
+ category: text("category", { enum: ["weight", "volume", "length", "count", "area", "time"] }).notNull(),
9
+ isBaseUnit: boolean("is_base_unit").notNull().default(false),
10
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
11
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
12
+ }, (table) => ({
13
+ orgIdx: index("idx_uom_org").on(table.organizationId),
14
+ codeUnique: uniqueIndex("uom_org_code_unique").on(table.organizationId, table.code),
15
+ }));
16
+
17
+ export const uomConversions = pgTable("uom_conversions", {
18
+ id: uuid("id").defaultRandom().primaryKey(),
19
+ organizationId: text("organization_id").notNull(),
20
+ fromUnitId: uuid("from_unit_id").references(() => unitsOfMeasure.id, { onDelete: "cascade" }).notNull(),
21
+ toUnitId: uuid("to_unit_id").references(() => unitsOfMeasure.id, { onDelete: "cascade" }).notNull(),
22
+ factor: integer("factor").notNull(),
23
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
24
+ }, (table) => ({
25
+ orgIdx: index("idx_uom_conversions_org").on(table.organizationId),
26
+ }));
27
+
28
+ export const entityUom = pgTable("entity_uom", {
29
+ id: uuid("id").defaultRandom().primaryKey(),
30
+ organizationId: text("organization_id").notNull(),
31
+ entityId: uuid("entity_id").notNull(),
32
+ purchaseUomId: uuid("purchase_uom_id").references(() => unitsOfMeasure.id).notNull(),
33
+ stockUomId: uuid("stock_uom_id").references(() => unitsOfMeasure.id).notNull(),
34
+ saleUomId: uuid("sale_uom_id").references(() => unitsOfMeasure.id).notNull(),
35
+ yieldPercentage: integer("yield_percentage").notNull().default(100),
36
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
37
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
38
+ }, (table) => ({
39
+ entityUnique: uniqueIndex("entity_uom_org_entity_unique").on(table.organizationId, table.entityId),
40
+ }));
@@ -0,0 +1,111 @@
1
+ import { eq, and } from "@porulle/core/drizzle";
2
+ import { Ok, Err } from "@porulle/core";
3
+ import type { PluginResult } from "@porulle/core";
4
+ import { unitsOfMeasure, uomConversions, entityUom } from "../schema.js";
5
+ import type { Db, UnitOfMeasure, UOMConversion, EntityUOM, UOMCategory } from "../types.js";
6
+
7
+ export class UOMService {
8
+ constructor(private db: Db) {}
9
+
10
+ async createUnit(orgId: string, input: {
11
+ code: string; name: string; category: UOMCategory; isBaseUnit?: boolean;
12
+ }): Promise<PluginResult<UnitOfMeasure>> {
13
+ const existing = await this.db.select().from(unitsOfMeasure)
14
+ .where(and(eq(unitsOfMeasure.organizationId, orgId), eq(unitsOfMeasure.code, input.code)));
15
+ if (existing.length > 0) return Err(`Unit '${input.code}' already exists`);
16
+ const rows = await this.db.insert(unitsOfMeasure).values({
17
+ organizationId: orgId, code: input.code, name: input.name,
18
+ category: input.category, isBaseUnit: input.isBaseUnit ?? false,
19
+ }).returning();
20
+ return Ok(rows[0]!);
21
+ }
22
+
23
+ async listUnits(orgId: string, category?: UOMCategory): Promise<PluginResult<UnitOfMeasure[]>> {
24
+ const conditions = [eq(unitsOfMeasure.organizationId, orgId)];
25
+ if (category) conditions.push(eq(unitsOfMeasure.category, category));
26
+ const rows = await this.db.select().from(unitsOfMeasure).where(and(...conditions));
27
+ return Ok(rows);
28
+ }
29
+
30
+ async createConversion(orgId: string, input: {
31
+ fromUnitId: string; toUnitId: string; factor: number;
32
+ }): Promise<PluginResult<UOMConversion>> {
33
+ if (input.factor <= 0) return Err("Factor must be positive");
34
+ const rows = await this.db.insert(uomConversions).values({
35
+ organizationId: orgId, fromUnitId: input.fromUnitId,
36
+ toUnitId: input.toUnitId, factor: input.factor,
37
+ }).returning();
38
+ return Ok(rows[0]!);
39
+ }
40
+
41
+ async listConversions(orgId: string): Promise<PluginResult<UOMConversion[]>> {
42
+ const rows = await this.db.select().from(uomConversions)
43
+ .where(eq(uomConversions.organizationId, orgId));
44
+ return Ok(rows);
45
+ }
46
+
47
+ async convert(orgId: string, input: {
48
+ fromUnitId: string; toUnitId: string; quantity: number;
49
+ }): Promise<PluginResult<{ result: number; fromCode: string; toCode: string }>> {
50
+ if (input.fromUnitId === input.toUnitId) {
51
+ const unit = await this.db.select().from(unitsOfMeasure).where(eq(unitsOfMeasure.id, input.fromUnitId));
52
+ return Ok({ result: input.quantity, fromCode: unit[0]?.code ?? "", toCode: unit[0]?.code ?? "" });
53
+ }
54
+ // Try forward conversion
55
+ const forward = await this.db.select().from(uomConversions).where(and(
56
+ eq(uomConversions.organizationId, orgId),
57
+ eq(uomConversions.fromUnitId, input.fromUnitId),
58
+ eq(uomConversions.toUnitId, input.toUnitId),
59
+ ));
60
+ const fromUnit = await this.db.select().from(unitsOfMeasure).where(eq(unitsOfMeasure.id, input.fromUnitId));
61
+ const toUnit = await this.db.select().from(unitsOfMeasure).where(eq(unitsOfMeasure.id, input.toUnitId));
62
+ const fromCode = fromUnit[0]?.code ?? "";
63
+ const toCode = toUnit[0]?.code ?? "";
64
+
65
+ if (forward.length > 0) {
66
+ return Ok({ result: Math.round(input.quantity * forward[0]!.factor / 10000), fromCode, toCode });
67
+ }
68
+ // Try reverse
69
+ const reverse = await this.db.select().from(uomConversions).where(and(
70
+ eq(uomConversions.organizationId, orgId),
71
+ eq(uomConversions.fromUnitId, input.toUnitId),
72
+ eq(uomConversions.toUnitId, input.fromUnitId),
73
+ ));
74
+ if (reverse.length > 0) {
75
+ return Ok({ result: Math.round(input.quantity * 10000 / reverse[0]!.factor), fromCode, toCode });
76
+ }
77
+ return Err(`No conversion found between '${fromCode}' and '${toCode}'`);
78
+ }
79
+
80
+ async setEntityUom(orgId: string, input: {
81
+ entityId: string; purchaseUomId: string; stockUomId: string; saleUomId: string; yieldPercentage?: number;
82
+ }): Promise<PluginResult<EntityUOM>> {
83
+ const existing = await this.db.select().from(entityUom)
84
+ .where(and(eq(entityUom.organizationId, orgId), eq(entityUom.entityId, input.entityId)));
85
+ if (existing.length > 0) {
86
+ const rows = await this.db.update(entityUom).set({
87
+ purchaseUomId: input.purchaseUomId, stockUomId: input.stockUomId,
88
+ saleUomId: input.saleUomId, yieldPercentage: input.yieldPercentage ?? 100, updatedAt: new Date(),
89
+ }).where(eq(entityUom.id, existing[0]!.id)).returning();
90
+ return Ok(rows[0]!);
91
+ }
92
+ const rows = await this.db.insert(entityUom).values({
93
+ organizationId: orgId, entityId: input.entityId,
94
+ purchaseUomId: input.purchaseUomId, stockUomId: input.stockUomId,
95
+ saleUomId: input.saleUomId, yieldPercentage: input.yieldPercentage ?? 100,
96
+ }).returning();
97
+ return Ok(rows[0]!);
98
+ }
99
+
100
+ async getEntityUom(orgId: string, entityId: string): Promise<PluginResult<EntityUOM>> {
101
+ const rows = await this.db.select().from(entityUom)
102
+ .where(and(eq(entityUom.organizationId, orgId), eq(entityUom.entityId, entityId)));
103
+ if (rows.length === 0) return Err("No UOM assignment for this entity");
104
+ return Ok(rows[0]!);
105
+ }
106
+
107
+ async calculateYield(yieldPercentage: number, requiredQuantity: number): Promise<PluginResult<{ purchaseQuantity: number }>> {
108
+ if (yieldPercentage <= 0 || yieldPercentage > 100) return Ok({ purchaseQuantity: requiredQuantity });
109
+ return Ok({ purchaseQuantity: Math.ceil(requiredQuantity * 100 / yieldPercentage) });
110
+ }
111
+ }
package/src/types.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type { PluginDb as Db } from "@porulle/core";
2
+ import type { unitsOfMeasure, uomConversions, entityUom } from "./schema.js";
3
+ export type UnitOfMeasure = typeof unitsOfMeasure.$inferSelect;
4
+ export type UOMConversion = typeof uomConversions.$inferSelect;
5
+ export type EntityUOM = typeof entityUom.$inferSelect;
6
+ export type UOMCategory = "weight" | "volume" | "length" | "count" | "area" | "time";