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",
|
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
@@ -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">
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user