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",
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),
})
}
+10 -1
View File
@@ -2,7 +2,16 @@ import type { Category, Item as PrismaItem } from "@/generated/prisma/client"
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">
}
+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 { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
import { buildItemSku } from "./item.helpers"
type FieldErrors = Record<string, string[]>
type CreateItemUseCaseInput = CreateItemData & {
type CreateItemUseCaseInput = Omit<CreateItemData, "trackingType" | "status"> &
Partial<Pick<CreateItemData, "trackingType" | "status">> & {
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<ItemUseCaseResult> {
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<ItemUseCaseResult> {
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,
@@ -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)
+69
View File
@@ -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: "" })
@@ -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")
})
})