diff --git a/src/schemas/item.schema.ts b/src/schemas/item.schema.ts index 015442f..3d4bfeb 100644 --- a/src/schemas/item.schema.ts +++ b/src/schemas/item.schema.ts @@ -8,9 +8,70 @@ const defaultItemSchemaCopy: ItemSchemaCopy = { nameRequired: "Name is required", categoryRequired: "Category is required", stockRequired: "Stock is required", + trackingTypeRequired: "Tracking type is required", + invalidTrackingType: "Invalid tracking type", + statusRequired: "Status is required", + invalidStatus: "Invalid status", itemRequired: "Item is required", } +const itemTrackingTypes = ["QUANTITY", "SERIALIZED"] as const +const itemStatuses = ["ACTIVE", "DISCONTINUED", "ARCHIVED"] as const + +function buildOptionalNonNegativeIntSchema(copy: ItemSchemaCopy) { + return z + .preprocess( + (value) => (value === "" || value === null ? undefined : value), + z.coerce + .number({ error: copy.stockRequired }) + .int({ error: copy.stockRequired }) + .nonnegative({ error: copy.stockRequired }), + ) + .optional() +} + +function buildTrackingTypeSchema(copy: ItemSchemaCopy) { + return z.preprocess( + (value) => (value === "" || value === undefined ? "QUANTITY" : value), + z.enum(itemTrackingTypes, { + error: (issue) => + issue.input === undefined || issue.input === "" + ? copy.trackingTypeRequired + : copy.invalidTrackingType, + }), + ) +} + +function buildOptionalTrackingTypeSchema(copy: ItemSchemaCopy) { + return z.preprocess( + (value) => (value === "" || value === null ? undefined : value), + z.enum(itemTrackingTypes, { + error: () => copy.invalidTrackingType, + }), + ).optional() +} + +function buildStatusSchema(copy: ItemSchemaCopy) { + return z.preprocess( + (value) => (value === "" || value === undefined ? "ACTIVE" : value), + z.enum(itemStatuses, { + error: (issue) => + issue.input === undefined || issue.input === "" + ? copy.statusRequired + : copy.invalidStatus, + }), + ) +} + +function buildOptionalStatusSchema(copy: ItemSchemaCopy) { + return z.preprocess( + (value) => (value === "" || value === null ? undefined : value), + z.enum(itemStatuses, { + error: () => copy.invalidStatus, + }), + ).optional() +} + export function buildCreateItemSchema(copy: ItemSchemaCopy) { return z.object({ name: z.string().min(1, { @@ -26,6 +87,10 @@ export function buildCreateItemSchema(copy: ItemSchemaCopy) { .min(0, { error: copy.stockRequired, }), + trackingType: buildTrackingTypeSchema(copy), + status: buildStatusSchema(copy), + minStock: buildOptionalNonNegativeIntSchema(copy), + targetStock: buildOptionalNonNegativeIntSchema(copy), }) } @@ -39,6 +104,8 @@ export function buildUpdateItemSchema(copy: ItemSchemaCopy) { id: z.string().min(1, { error: copy.itemRequired, }), + trackingType: buildOptionalTrackingTypeSchema(copy), + status: buildOptionalStatusSchema(copy), }) } diff --git a/src/types/item.ts b/src/types/item.ts index f863464..fbdff81 100644 --- a/src/types/item.ts +++ b/src/types/item.ts @@ -2,7 +2,16 @@ import type { Category, Item as PrismaItem } from "@/generated/prisma/client" export type Item = PrismaItem -export type ItemSummary = Pick & { +export type ItemSummary = Pick< + Item, + | "id" + | "name" + | "stock" + | "trackingType" + | "status" + | "minStock" + | "targetStock" +> & { category: Pick } diff --git a/src/use-cases/item.helpers.ts b/src/use-cases/item.helpers.ts new file mode 100644 index 0000000..987800a --- /dev/null +++ b/src/use-cases/item.helpers.ts @@ -0,0 +1,13 @@ +export function buildItemSku(name: string, occurrenceIndex = 0) { + const baseSku = name + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "-") + .replace(/^-|-$/g, "") + + if (!baseSku) { + return occurrenceIndex === 0 ? "ITEM" : `ITEM-${occurrenceIndex + 1}` + } + + return occurrenceIndex === 0 ? baseSku : `${baseSku}-${occurrenceIndex + 1}` +} diff --git a/src/use-cases/item.use-cases.ts b/src/use-cases/item.use-cases.ts index 579aa3a..9375800 100644 --- a/src/use-cases/item.use-cases.ts +++ b/src/use-cases/item.use-cases.ts @@ -3,10 +3,12 @@ import prisma from "@/lib/prisma" import type { CreateItemData, UpdateItemData } from "@/schemas/item.schema" import { ItemService } from "@/services/item.service" import { MovementService } from "@/services/movement.service" +import { buildItemSku } from "./item.helpers" type FieldErrors = Record -type CreateItemUseCaseInput = CreateItemData & { +type CreateItemUseCaseInput = Omit & + Partial> & { actorId: string } @@ -37,18 +39,19 @@ function isUniqueConstraintError(error: unknown) { ) } -function buildSkuFromName(name: string) { - return name - .trim() - .toUpperCase() - .replace(/[^A-Z0-9]+/g, "-") - .replace(/^-|-$/g, "") -} - export async function createItemUseCase( input: CreateItemUseCaseInput, ): Promise { - const { actorId, name, categoryId, stock } = input + const { + actorId, + name, + categoryId, + stock, + trackingType = "QUANTITY", + status = "ACTIVE", + minStock, + targetStock, + } = input if (stock < 0) { return itemError({ stock: ["Stock cannot be negative"] }) @@ -64,11 +67,23 @@ export async function createItemUseCase( }) } + const skuBase = buildItemSku(name) + const existingSkuCount = await tx.item.count({ + where: { + sku: { + startsWith: skuBase, + }, + }, + }) + const item = await ItemService.create( { - sku: buildSkuFromName(name), + sku: buildItemSku(name, existingSkuCount), name, - trackingType: "QUANTITY", + trackingType, + status, + minStock, + targetStock, category: { connect: { id: categoryId } }, stock: stock || 0, }, @@ -103,7 +118,17 @@ export async function createItemUseCase( export async function updateItemUseCase( input: UpdateItemUseCaseInput, ): Promise { - const { actorId, id, stock, name, categoryId } = input + const { + actorId, + id, + stock, + name, + categoryId, + trackingType, + status, + minStock, + targetStock, + } = input try { return await prisma.$transaction(async (tx) => { @@ -122,21 +147,25 @@ export async function updateItemUseCase( await ItemService.update( id, { - stock: stock || existingItem.stock, + stock: stock ?? existingItem.stock, name: name || existingItem.name, + trackingType: trackingType ?? existingItem.trackingType, + status: status ?? existingItem.status, + minStock: minStock ?? existingItem.minStock, + targetStock: targetStock ?? existingItem.targetStock, category: { connect: { id: categoryId } }, }, tx, ) - const quantity = stock - existingItem.stock + const updatedStock = stock ?? existingItem.stock - if (stock && stock > existingItem.stock) { + if (updatedStock > existingItem.stock) { await MovementService.create( { type: "IN", itemId: id, - quantity, + quantity: updatedStock - existingItem.stock, userId: actorId, }, tx, diff --git a/tests/integration/use-cases/item.use-cases.test.ts b/tests/integration/use-cases/item.use-cases.test.ts index d609fe2..b6a5949 100644 --- a/tests/integration/use-cases/item.use-cases.test.ts +++ b/tests/integration/use-cases/item.use-cases.test.ts @@ -10,6 +10,7 @@ import { let prisma: PrismaClient let createItemUseCase: typeof import("@/use-cases/item.use-cases").createItemUseCase let deleteItemUseCase: typeof import("@/use-cases/item.use-cases").deleteItemUseCase +let updateItemUseCase: typeof import("@/use-cases/item.use-cases").updateItemUseCase beforeAll(async () => { await startIntegrationTestDatabase() @@ -20,6 +21,7 @@ beforeAll(async () => { prisma = prismaModule.prisma createItemUseCase = itemUseCases.createItemUseCase deleteItemUseCase = itemUseCases.deleteItemUseCase + updateItemUseCase = itemUseCases.updateItemUseCase }) beforeEach(async () => { @@ -32,7 +34,7 @@ afterAll(async () => { }) describe("item use-cases", () => { - it("creates an item with initial stock and records an IN movement", async () => { + it("creates an item with operational fields and records an IN movement", async () => { const actor = await createTestUser(prisma) const category = await createTestCategory(prisma) @@ -41,6 +43,10 @@ describe("item use-cases", () => { name: "Laptop", categoryId: category.id, stock: 3, + trackingType: "QUANTITY", + status: "ACTIVE", + minStock: 1, + targetStock: 6, }) expect(result).toEqual({ success: true }) @@ -54,6 +60,10 @@ describe("item use-cases", () => { name: "Laptop", categoryId: category.id, stock: 3, + trackingType: "QUANTITY", + status: "ACTIVE", + minStock: 1, + targetStock: 6, deletedAt: null, }) expect(item?.stockMovementLines).toHaveLength(1) @@ -66,6 +76,83 @@ describe("item use-cases", () => { }) }) + it("generates unique skus for different names with the same normalized base", async () => { + const actor = await createTestUser(prisma) + const category = await createTestCategory(prisma) + + await createItemUseCase({ + actorId: actor.id, + name: "Item A!", + categoryId: category.id, + stock: 0, + }) + + const secondCreate = await createItemUseCase({ + actorId: actor.id, + name: "Item A?", + categoryId: category.id, + stock: 0, + }) + + expect(secondCreate).toEqual({ success: true }) + + const items = await prisma.item.findMany({ + where: { categoryId: category.id }, + orderBy: { sku: "asc" }, + select: { sku: true, name: true }, + }) + + expect(items).toEqual([ + { sku: "ITEM-A", name: "Item A!" }, + { sku: "ITEM-A-2", name: "Item A?" }, + ]) + }) + + it("updates operational item fields without changing the sku", async () => { + const actor = await createTestUser(prisma) + const category = await createTestCategory(prisma) + + const createResult = await createItemUseCase({ + actorId: actor.id, + name: "Monitor", + categoryId: category.id, + stock: 1, + }) + + expect(createResult).toEqual({ success: true }) + + const item = await prisma.item.findUniqueOrThrow({ + where: { sku: "MONITOR" }, + }) + + const updateResult = await updateItemUseCase({ + actorId: actor.id, + id: item.id, + name: "Monitor", + categoryId: category.id, + stock: 0, + trackingType: "SERIALIZED", + status: "DISCONTINUED", + minStock: 2, + targetStock: 8, + }) + + expect(updateResult).toEqual({ success: true }) + + const updatedItem = await prisma.item.findUniqueOrThrow({ + where: { id: item.id }, + }) + + expect(updatedItem).toMatchObject({ + sku: "MONITOR", + stock: 0, + trackingType: "SERIALIZED", + status: "DISCONTINUED", + minStock: 2, + targetStock: 8, + }) + }) + it("rejects duplicate item names", async () => { const actor = await createTestUser(prisma) const category = await createTestCategory(prisma) diff --git a/tests/unit/schemas/item.schema.test.ts b/tests/unit/schemas/item.schema.test.ts index 62c2a02..e9d1501 100644 --- a/tests/unit/schemas/item.schema.test.ts +++ b/tests/unit/schemas/item.schema.test.ts @@ -10,6 +10,10 @@ const schemaCopy = { nameRequired: "El nombre es obligatorio", categoryRequired: "La categoría es obligatoria", stockRequired: "El stock es obligatorio", + trackingTypeRequired: "El tipo de seguimiento es obligatorio", + invalidTrackingType: "Tipo de seguimiento inválido", + statusRequired: "El estado es obligatorio", + invalidStatus: "Estado inválido", itemRequired: "El artículo es obligatorio", } @@ -31,6 +35,47 @@ describe("item schema localization", () => { } }) + it("supports operational item fields with default tracking metadata", () => { + const result = buildCreateItemSchema(schemaCopy).safeParse({ + name: "Laptop", + categoryId: "category-1", + stock: "2", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toMatchObject({ + name: "Laptop", + categoryId: "category-1", + stock: 2, + trackingType: "QUANTITY", + status: "ACTIVE", + }) + } + }) + + it("accepts explicit operational item fields", () => { + const result = buildCreateItemSchema(schemaCopy).safeParse({ + name: "Laptop", + categoryId: "category-1", + stock: "2", + trackingType: "SERIALIZED", + status: "DISCONTINUED", + minStock: "1", + targetStock: "5", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toMatchObject({ + trackingType: "SERIALIZED", + status: "DISCONTINUED", + minStock: 1, + targetStock: 5, + }) + } + }) + it("uses localized update identifier validation messages", () => { const result = buildUpdateItemSchema(schemaCopy).safeParse({ id: "", @@ -47,6 +92,30 @@ describe("item schema localization", () => { } }) + it("allows operational item fields in update payloads", () => { + const result = buildUpdateItemSchema(schemaCopy).safeParse({ + id: "item-1", + name: "Laptop", + categoryId: "category-1", + stock: 3, + trackingType: "SERIALIZED", + status: "ARCHIVED", + minStock: "2", + targetStock: "6", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toMatchObject({ + id: "item-1", + trackingType: "SERIALIZED", + status: "ARCHIVED", + minStock: 2, + targetStock: 6, + }) + } + }) + it("uses localized get-by-id validation messages", () => { const result = buildGetItemByIdSchema(schemaCopy).safeParse({ id: "" }) diff --git a/tests/unit/use-cases/item.use-cases.test.ts b/tests/unit/use-cases/item.use-cases.test.ts new file mode 100644 index 0000000..3504db0 --- /dev/null +++ b/tests/unit/use-cases/item.use-cases.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest" + +import { buildItemSku } from "@/use-cases/item.helpers" + +describe("item sku generation", () => { + it("builds a normalized sku from the item name", () => { + expect(buildItemSku("Item A!", 0)).toBe("ITEM-A") + }) + + it("adds a numeric suffix for repeated normalized names", () => { + expect(buildItemSku("Item A?", 1)).toBe("ITEM-A-2") + }) +})