feat(items): adapt item flows to inventory schema defaults and SKU generation

This commit is contained in:
2026-06-19 17:14:22 +02:00
parent 7b8a415c6a
commit 965a04a468
7 changed files with 306 additions and 19 deletions
+67
View File
@@ -8,9 +8,70 @@ const defaultItemSchemaCopy: ItemSchemaCopy = {
nameRequired: "Name is required", nameRequired: "Name is required",
categoryRequired: "Category is required", categoryRequired: "Category is required",
stockRequired: "Stock 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", 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) { export function buildCreateItemSchema(copy: ItemSchemaCopy) {
return z.object({ return z.object({
name: z.string().min(1, { name: z.string().min(1, {
@@ -26,6 +87,10 @@ export function buildCreateItemSchema(copy: ItemSchemaCopy) {
.min(0, { .min(0, {
error: copy.stockRequired, 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, { id: z.string().min(1, {
error: copy.itemRequired, error: copy.itemRequired,
}), }),
trackingType: buildOptionalTrackingTypeSchema(copy),
status: buildOptionalStatusSchema(copy),
}) })
} }
+10 -1
View File
@@ -2,7 +2,16 @@ import type { Category, Item as PrismaItem } from "@/generated/prisma/client"
export type Item = PrismaItem export type Item = PrismaItem
export type ItemSummary = Pick<Item, "id" | "name" | "stock"> & { export type ItemSummary = Pick<
Item,
| "id"
| "name"
| "stock"
| "trackingType"
| "status"
| "minStock"
| "targetStock"
> & {
category: Pick<Category, "id" | "name"> category: Pick<Category, "id" | "name">
} }
+13
View File
@@ -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}`
}
+46 -17
View File
@@ -3,10 +3,12 @@ import prisma from "@/lib/prisma"
import type { CreateItemData, UpdateItemData } from "@/schemas/item.schema" import type { CreateItemData, UpdateItemData } from "@/schemas/item.schema"
import { ItemService } from "@/services/item.service" import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service" import { MovementService } from "@/services/movement.service"
import { buildItemSku } from "./item.helpers"
type FieldErrors = Record<string, string[]> type FieldErrors = Record<string, string[]>
type CreateItemUseCaseInput = CreateItemData & { type CreateItemUseCaseInput = Omit<CreateItemData, "trackingType" | "status"> &
Partial<Pick<CreateItemData, "trackingType" | "status">> & {
actorId: string 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( export async function createItemUseCase(
input: CreateItemUseCaseInput, input: CreateItemUseCaseInput,
): Promise<ItemUseCaseResult> { ): Promise<ItemUseCaseResult> {
const { actorId, name, categoryId, stock } = input const {
actorId,
name,
categoryId,
stock,
trackingType = "QUANTITY",
status = "ACTIVE",
minStock,
targetStock,
} = input
if (stock < 0) { if (stock < 0) {
return itemError({ stock: ["Stock cannot be negative"] }) 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( const item = await ItemService.create(
{ {
sku: buildSkuFromName(name), sku: buildItemSku(name, existingSkuCount),
name, name,
trackingType: "QUANTITY", trackingType,
status,
minStock,
targetStock,
category: { connect: { id: categoryId } }, category: { connect: { id: categoryId } },
stock: stock || 0, stock: stock || 0,
}, },
@@ -103,7 +118,17 @@ export async function createItemUseCase(
export async function updateItemUseCase( export async function updateItemUseCase(
input: UpdateItemUseCaseInput, input: UpdateItemUseCaseInput,
): Promise<ItemUseCaseResult> { ): Promise<ItemUseCaseResult> {
const { actorId, id, stock, name, categoryId } = input const {
actorId,
id,
stock,
name,
categoryId,
trackingType,
status,
minStock,
targetStock,
} = input
try { try {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
@@ -122,21 +147,25 @@ export async function updateItemUseCase(
await ItemService.update( await ItemService.update(
id, id,
{ {
stock: stock || existingItem.stock, stock: stock ?? existingItem.stock,
name: name || existingItem.name, name: name || existingItem.name,
trackingType: trackingType ?? existingItem.trackingType,
status: status ?? existingItem.status,
minStock: minStock ?? existingItem.minStock,
targetStock: targetStock ?? existingItem.targetStock,
category: { connect: { id: categoryId } }, category: { connect: { id: categoryId } },
}, },
tx, tx,
) )
const quantity = stock - existingItem.stock const updatedStock = stock ?? existingItem.stock
if (stock && stock > existingItem.stock) { if (updatedStock > existingItem.stock) {
await MovementService.create( await MovementService.create(
{ {
type: "IN", type: "IN",
itemId: id, itemId: id,
quantity, quantity: updatedStock - existingItem.stock,
userId: actorId, userId: actorId,
}, },
tx, tx,
@@ -10,6 +10,7 @@ import {
let prisma: PrismaClient let prisma: PrismaClient
let createItemUseCase: typeof import("@/use-cases/item.use-cases").createItemUseCase let createItemUseCase: typeof import("@/use-cases/item.use-cases").createItemUseCase
let deleteItemUseCase: typeof import("@/use-cases/item.use-cases").deleteItemUseCase let deleteItemUseCase: typeof import("@/use-cases/item.use-cases").deleteItemUseCase
let updateItemUseCase: typeof import("@/use-cases/item.use-cases").updateItemUseCase
beforeAll(async () => { beforeAll(async () => {
await startIntegrationTestDatabase() await startIntegrationTestDatabase()
@@ -20,6 +21,7 @@ beforeAll(async () => {
prisma = prismaModule.prisma prisma = prismaModule.prisma
createItemUseCase = itemUseCases.createItemUseCase createItemUseCase = itemUseCases.createItemUseCase
deleteItemUseCase = itemUseCases.deleteItemUseCase deleteItemUseCase = itemUseCases.deleteItemUseCase
updateItemUseCase = itemUseCases.updateItemUseCase
}) })
beforeEach(async () => { beforeEach(async () => {
@@ -32,7 +34,7 @@ afterAll(async () => {
}) })
describe("item use-cases", () => { 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 actor = await createTestUser(prisma)
const category = await createTestCategory(prisma) const category = await createTestCategory(prisma)
@@ -41,6 +43,10 @@ describe("item use-cases", () => {
name: "Laptop", name: "Laptop",
categoryId: category.id, categoryId: category.id,
stock: 3, stock: 3,
trackingType: "QUANTITY",
status: "ACTIVE",
minStock: 1,
targetStock: 6,
}) })
expect(result).toEqual({ success: true }) expect(result).toEqual({ success: true })
@@ -54,6 +60,10 @@ describe("item use-cases", () => {
name: "Laptop", name: "Laptop",
categoryId: category.id, categoryId: category.id,
stock: 3, stock: 3,
trackingType: "QUANTITY",
status: "ACTIVE",
minStock: 1,
targetStock: 6,
deletedAt: null, deletedAt: null,
}) })
expect(item?.stockMovementLines).toHaveLength(1) 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 () => { it("rejects duplicate item names", async () => {
const actor = await createTestUser(prisma) const actor = await createTestUser(prisma)
const category = await createTestCategory(prisma) const category = await createTestCategory(prisma)
+69
View File
@@ -10,6 +10,10 @@ const schemaCopy = {
nameRequired: "El nombre es obligatorio", nameRequired: "El nombre es obligatorio",
categoryRequired: "La categoría es obligatoria", categoryRequired: "La categoría es obligatoria",
stockRequired: "El stock es obligatorio", 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", 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", () => { it("uses localized update identifier validation messages", () => {
const result = buildUpdateItemSchema(schemaCopy).safeParse({ const result = buildUpdateItemSchema(schemaCopy).safeParse({
id: "", 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", () => { it("uses localized get-by-id validation messages", () => {
const result = buildGetItemByIdSchema(schemaCopy).safeParse({ id: "" }) const result = buildGetItemByIdSchema(schemaCopy).safeParse({ id: "" })
@@ -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")
})
})