feat(items): adapt item flows to inventory schema defaults and SKU generation
This commit is contained in:
@@ -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
@@ -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">
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user