@unifiedcommerce/plugin-uom 0.2.0 → 0.2.2
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/package.json +2 -1
- package/src/index.ts +26 -0
- package/src/routes/uom.ts +72 -0
- package/src/schema.ts +40 -0
- package/src/services/uom-service.ts +111 -0
- package/src/types.ts +6 -0
package/package.json
CHANGED
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineCommercePlugin } from "@unifiedcommerce/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 "@unifiedcommerce/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
import type { UOMService } from "../services/uom-service.js";
|
|
4
|
+
import type { PluginRouteRegistration } from "@unifiedcommerce/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 "drizzle-orm/pg-core";
|
|
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 "drizzle-orm";
|
|
2
|
+
import { Ok, Err } from "@unifiedcommerce/core";
|
|
3
|
+
import type { PluginResult } from "@unifiedcommerce/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 "@unifiedcommerce/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";
|