feat(assignments): support line-based returns and authenticated updates

This commit is contained in:
2026-06-19 17:14:22 +02:00
parent 965a04a468
commit c1763ed007
5 changed files with 427 additions and 52 deletions
+14
View File
@@ -37,6 +37,13 @@ export async function createAssignment(formData: CreateAssignmentFormType) {
const result = await createAssignmentUseCase({
...validatedFields.data,
lines: [
{
itemId: validatedFields.data.itemId!,
quantity: validatedFields.data.quantity!,
notes: validatedFields.data.notes,
},
],
actorId: createdBy,
})
@@ -81,6 +88,13 @@ export async function updateAssignment(formData: UpdateAssignmentFormType) {
const result = await updateAssignmentUseCase({
...validatedFields.data,
lines: [
{
itemId: validatedFields.data.itemId!,
quantity: validatedFields.data.quantity!,
notes: validatedFields.data.notes,
},
],
actorId: createdBy,
})
+163 -10
View File
@@ -2,7 +2,11 @@ import type { Prisma } from "@/generated/prisma/client"
import { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma"
import type { CreateAssignmentData } from "@/schemas/assignment.schema"
import type { Assignment, AssignmentWithPersonItemAsset } from "@/types"
import type {
Assignment,
AssignmentStockReturnInput,
AssignmentWithPersonItemAsset,
} from "@/types"
type LegacyAssignmentWriteData = CreateAssignmentData & {
createdBy?: string
@@ -13,6 +17,12 @@ type LegacyAssignmentUpdateData = Partial<LegacyAssignmentWriteData> & {
returnDate?: Date | null
}
type ReturnStockAssignmentResult = {
returnedQuantity: number
fullyReturned: boolean
returnedLines: { itemId: string; quantity: number }[]
}
type AssignmentWithLines = Prisma.AssignmentGetPayload<{
include: {
person: true
@@ -27,6 +37,29 @@ const assignmentInclude = {
assetLines: { include: { asset: { include: { item: true } } } },
} satisfies Prisma.AssignmentInclude
function normalizeReturnLines(
returns: AssignmentStockReturnInput[],
): AssignmentStockReturnInput[] {
const aggregated = new Map<string, AssignmentStockReturnInput>()
for (const entry of returns) {
const existing = aggregated.get(entry.assignmentLineId)
if (existing) {
aggregated.set(entry.assignmentLineId, {
...existing,
quantity: existing.quantity + entry.quantity,
notes: existing.notes ?? entry.notes,
})
continue
}
aggregated.set(entry.assignmentLineId, { ...entry })
}
return [...aggregated.values()]
}
function toLegacyAssignment(
assignment: AssignmentWithLines,
): AssignmentWithPersonItemAsset {
@@ -51,7 +84,7 @@ function toLegacyAssignment(
}
function toLegacyAssignmentRecord(
assignment: Prisma.AssignmentGetPayload<{}>,
assignment: Prisma.AssignmentGetPayload<Prisma.AssignmentDefaultArgs>,
data: Pick<LegacyAssignmentUpdateData, "itemId" | "assetId" | "quantity">,
): Assignment {
return {
@@ -132,6 +165,120 @@ export const AssignmentService = {
return assignment ? toLegacyAssignment(assignment) : null
},
returnStockAssignment: async (
id: string,
receivedById: string | undefined,
returns: AssignmentStockReturnInput[] | undefined,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<ReturnStockAssignmentResult | null> => {
const assignment = await db.assignment.findFirst({
where: {
id,
status: { in: ["OPEN", "PARTIALLY_RETURNED"] },
},
include: {
stockLines: {
orderBy: {
createdAt: "asc",
},
},
},
})
if (!assignment || assignment.stockLines.length === 0) {
return null
}
const returnerId = receivedById ?? assignment.createdById
const remainingReturns =
returns !== undefined
? normalizeReturnLines(returns)
: assignment.stockLines
.filter((line) => line.returnedQuantity < line.quantity)
.map((line) => ({
assignmentLineId: line.id,
quantity: line.quantity - line.returnedQuantity,
notes: undefined,
}))
if (remainingReturns.length === 0) {
return null
}
const stockLinesById = new Map(
assignment.stockLines.map((line) => [line.id, line]),
)
const returnTimestamp = new Date()
let returnedQuantity = 0
const returnedLines: { itemId: string; quantity: number }[] = []
for (const returnLine of remainingReturns) {
const stockLine = stockLinesById.get(returnLine.assignmentLineId)
if (!stockLine) {
return null
}
if (!Number.isInteger(returnLine.quantity) || returnLine.quantity <= 0) {
return null
}
const remainingQuantity = stockLine.quantity - stockLine.returnedQuantity
if (returnLine.quantity > remainingQuantity) {
return null
}
await db.assignmentStockReturn.create({
data: {
assignmentLineId: stockLine.id,
quantity: returnLine.quantity,
receivedById: returnerId,
returnedAt: returnTimestamp,
notes: returnLine.notes,
},
})
await db.assignmentStockLine.update({
where: { id: stockLine.id },
data: {
returnedQuantity: {
increment: returnLine.quantity,
},
},
})
returnedQuantity += returnLine.quantity
returnedLines.push({
itemId: stockLine.itemId,
quantity: returnLine.quantity,
})
}
const fullyReturned = assignment.stockLines.every(
(line) =>
line.returnedQuantity +
(remainingReturns.find((entry) => entry.assignmentLineId === line.id)
?.quantity ?? 0) >=
line.quantity,
)
await db.assignment.update({
where: { id },
data: {
status: fullyReturned ? "RETURNED" : "PARTIALLY_RETURNED",
closedAt: fullyReturned ? returnTimestamp : null,
closedById: fullyReturned ? returnerId : null,
},
})
return {
returnedQuantity,
fullyReturned,
returnedLines,
}
},
findAllByPerson: async (
personId: string,
): Promise<AssignmentWithPersonItemAsset[]> => {
@@ -233,19 +380,25 @@ export const AssignmentService = {
id,
status: { in: ["OPEN", "PARTIALLY_RETURNED"] },
},
include: { stockLines: true, assetLines: { include: { asset: true } } },
include: {
stockLines: true,
assetLines: { include: { asset: true } },
},
})
if (!assignment) return false
await Promise.all(
assignment.stockLines.map((line) =>
db.assignmentStockLine.update({
where: { id: line.id },
data: { returnedQuantity: line.quantity },
}),
),
if (assignment.stockLines.length > 0) {
const result = await AssignmentService.returnStockAssignment(
id,
actorId ?? assignment.createdById,
undefined,
db,
)
return Boolean(result?.fullyReturned)
}
await Promise.all(
assignment.assetLines
.filter((line) => !line.returnedAt)
+12
View File
@@ -14,6 +14,18 @@ export type Assignment = PrismaAssignment & {
export type AssignmentSummary = Pick<Assignment, "id" | "quantity">
export type AssignmentStockLineInput = {
itemId: string
quantity: number
notes?: string
}
export type AssignmentStockReturnInput = {
assignmentLineId: string
quantity: number
notes?: string
}
export type AssignmentWithPersonItemAsset = Assignment & {
assignmentDate: Date
returnDate: Date | null
+85 -32
View File
@@ -7,20 +7,37 @@ import { AssetService } from "@/services/asset.service"
import { AssignmentService } from "@/services/assignment.service"
import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
import type {
AssignmentStockLineInput,
AssignmentStockReturnInput,
} from "@/types"
type FieldErrors = Record<string, string[]>
type CreateAssignmentUseCaseInput = CreateAssignmentData & {
type CreateAssignmentUseCaseInput = Omit<
CreateAssignmentData,
"itemId" | "quantity"
> & {
actorId: string
itemId?: string
quantity?: number
lines?: AssignmentStockLineInput[]
}
type ReturnAssignmentUseCaseInput = {
id: string
actorId: string
returns?: AssignmentStockReturnInput[]
}
type UpdateAssignmentUseCaseInput = UpdateAssignmentData & {
type UpdateAssignmentUseCaseInput = Omit<
UpdateAssignmentData,
"itemId" | "quantity"
> & {
actorId: string
itemId?: string
quantity?: number
lines?: AssignmentStockLineInput[]
}
type CreateAssignmentUseCaseResult =
@@ -78,10 +95,45 @@ function updateAssignmentError(
}
}
function resolveAssignmentLine(
input:
| Pick<
CreateAssignmentUseCaseInput,
"itemId" | "quantity" | "notes" | "lines"
>
| Pick<
UpdateAssignmentUseCaseInput,
"itemId" | "quantity" | "notes" | "lines"
>,
): AssignmentStockLineInput | null {
const providedLine = input.lines?.[0]
if (providedLine) {
return providedLine
}
if (!input.itemId || typeof input.quantity !== "number") {
return null
}
return {
itemId: input.itemId,
quantity: input.quantity,
notes: input.notes,
}
}
export async function createAssignmentUseCase(
input: CreateAssignmentUseCaseInput,
): Promise<CreateAssignmentUseCaseResult> {
const { actorId, itemId, assetId, quantity, personId } = input
const { actorId, assetId, personId } = input
const line = resolveAssignmentLine(input)
if (!line || !personId || line.quantity <= 0) {
return createAssignmentError({ error: ["Invalid assignment data"] })
}
const { itemId, quantity, notes } = line
if (!itemId || !personId || quantity <= 0) {
return createAssignmentError({ error: ["Invalid assignment data"] })
@@ -142,7 +194,7 @@ export async function createAssignmentUseCase(
assetId: assetId || undefined,
quantity,
personId,
notes: input.notes,
notes: notes ?? input.notes,
assignmentDate: input.assignmentDate,
createdBy: actorId,
},
@@ -172,16 +224,12 @@ export async function createAssignmentUseCase(
export async function updateAssignmentUseCase(
input: UpdateAssignmentUseCaseInput,
): Promise<UpdateAssignmentUseCaseResult> {
const {
actorId,
id,
personId,
itemId,
assetId,
quantity,
notes,
assignmentDate,
} = input
const { actorId, id, personId, assetId } = input
const line = resolveAssignmentLine(input)
const itemId = line?.itemId ?? input.itemId
const quantity = line?.quantity ?? input.quantity ?? 0
const notes = line?.notes ?? input.notes
const assignmentDate = input.assignmentDate
if (!id) {
return updateAssignmentError({ id: ["Assignment ID is required"] })
@@ -282,7 +330,7 @@ export async function updateAssignmentUseCase(
export async function returnAssignmentUseCase(
input: ReturnAssignmentUseCaseInput,
): Promise<ReturnAssignmentUseCaseResult> {
const { id, actorId } = input
const { id, actorId, returns } = input
if (!id) {
return returnAssignmentError({ id: ["Assignment ID is required"] })
@@ -299,18 +347,35 @@ export async function returnAssignmentUseCase(
return returnAssignmentError({ id: ["Assignment already returned"] })
}
const assignmentWasReturned = await AssignmentService.markReturnedIfActive(
const returnResult = await AssignmentService.returnStockAssignment(
id,
actorId,
returns,
tx,
)
if (!assignmentWasReturned) {
return returnAssignmentError({ id: ["Assignment already returned"] })
if (!returnResult) {
return returnAssignmentError({ error: ["Invalid assignment data"] })
}
if (assignment.itemId && assignment.quantity) {
await ItemService.updateStock(assignment.itemId, assignment.quantity, tx)
for (const returnedLine of returnResult.returnedLines) {
await ItemService.updateStock(
returnedLine.itemId,
returnedLine.quantity,
tx,
)
await MovementService.create(
{
type: "RETURN",
quantity: returnedLine.quantity,
itemId: returnedLine.itemId,
assetId: assignment.assetId || undefined,
assignmentId: id,
userId: actorId,
},
tx,
)
}
if (assignment.assetId) {
@@ -323,18 +388,6 @@ export async function returnAssignmentUseCase(
)
}
await MovementService.create(
{
type: "RETURN",
quantity: assignment.quantity || 1,
itemId: assignment.itemId || undefined,
assetId: assignment.assetId || undefined,
assignmentId: id,
userId: actorId,
},
tx,
)
return {
success: true,
}
@@ -13,6 +13,7 @@ import {
let prisma: PrismaClient
let createAssignmentUseCase: typeof import("@/use-cases/assignment.use-cases").createAssignmentUseCase
let updateAssignmentUseCase: typeof import("@/use-cases/assignment.use-cases").updateAssignmentUseCase
let returnAssignmentUseCase: typeof import("@/use-cases/assignment.use-cases").returnAssignmentUseCase
beforeAll(async () => {
@@ -23,6 +24,7 @@ beforeAll(async () => {
prisma = prismaModule.prisma
createAssignmentUseCase = assignmentUseCases.createAssignmentUseCase
updateAssignmentUseCase = assignmentUseCases.updateAssignmentUseCase
returnAssignmentUseCase = assignmentUseCases.returnAssignmentUseCase
})
@@ -45,11 +47,10 @@ describe("assignment use-cases", () => {
const result = await createAssignmentUseCase({
actorId: actor.id,
itemId: item.id,
personId: person.id,
quantity: 2,
assignmentDate,
notes: "Initial assignment",
lines: [{ itemId: item.id, quantity: 2 }],
})
expect(result.success).toBe(true)
@@ -89,6 +90,44 @@ describe("assignment use-cases", () => {
expect(movements[0].stockLines[0]).toMatchObject({
itemId: item.id,
stockDelta: -2,
previousStock: 5,
newStock: 3,
})
})
it("updates an assignment from a quantity line DTO and keeps the edited line quantity", async () => {
const actor = await createTestUser(prisma)
const person = await createTestPerson(prisma)
const item = await createTestItem(prisma, { stock: 5 })
const created = await createAssignmentUseCase({
actorId: actor.id,
personId: person.id,
lines: [{ itemId: item.id, quantity: 2 }],
})
expect(created.success).toBe(true)
if (!created.success)
throw new Error("Expected assignment creation success")
const updated = await updateAssignmentUseCase({
actorId: actor.id,
id: created.assignmentId,
personId: person.id,
lines: [{ itemId: item.id, quantity: 1 }],
})
expect(updated.success).toBe(true)
const assignment = await prisma.assignment.findUniqueOrThrow({
where: { id: created.assignmentId },
include: { stockLines: true },
})
expect(assignment.stockLines).toHaveLength(1)
expect(assignment.stockLines[0]).toMatchObject({
itemId: item.id,
quantity: 1,
})
})
@@ -111,11 +150,13 @@ describe("assignment use-cases", () => {
},
})
await expect(
prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
).resolves.toMatchObject({ stock: 1 })
await expect(prisma.assignment.count()).resolves.toBe(0)
await expect(prisma.inventoryMovement.count()).resolves.toBe(0)
expect(
await prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
).toMatchObject({
stock: 1,
})
expect(await prisma.assignment.count()).toBe(0)
expect(await prisma.inventoryMovement.count()).toBe(0)
})
it("returns an assignment, restores stock, closes it, and records a RETURN movement", async () => {
@@ -145,7 +186,7 @@ describe("assignment use-cases", () => {
prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
prisma.assignment.findUniqueOrThrow({
where: { id: created.assignmentId },
include: { stockLines: true },
include: { stockLines: { include: { returns: true } } },
}),
prisma.inventoryMovement.findMany({
include: { stockLines: true },
@@ -158,12 +199,18 @@ describe("assignment use-cases", () => {
expect(assignment).toMatchObject({
personId: person.id,
status: "RETURNED",
closedById: actor.id,
})
expect(assignment.stockLines[0]).toMatchObject({
itemId: item.id,
quantity: 3,
returnedQuantity: 3,
})
expect(assignment.stockLines[0].returns).toHaveLength(1)
expect(assignment.stockLines[0].returns[0]).toMatchObject({
quantity: 3,
receivedById: actor.id,
})
expect(movements).toHaveLength(2)
expect(movements[0]).toMatchObject({
type: "ASSIGNMENT",
@@ -173,6 +220,8 @@ describe("assignment use-cases", () => {
expect(movements[0].stockLines[0]).toMatchObject({
itemId: item.id,
stockDelta: -3,
previousStock: 4,
newStock: 1,
})
expect(movements[1]).toMatchObject({
type: "RETURN",
@@ -182,6 +231,100 @@ describe("assignment use-cases", () => {
expect(movements[1].stockLines[0]).toMatchObject({
itemId: item.id,
stockDelta: 3,
previousStock: 1,
newStock: 4,
})
})
it("records partial quantity returns before fully closing the assignment", async () => {
const actor = await createTestUser(prisma)
const person = await createTestPerson(prisma)
const item = await createTestItem(prisma, { stock: 5 })
const created = await createAssignmentUseCase({
actorId: actor.id,
itemId: item.id,
personId: person.id,
quantity: 5,
})
expect(created.success).toBe(true)
if (!created.success)
throw new Error("Expected assignment creation success")
const assignment = await prisma.assignment.findUniqueOrThrow({
where: { id: created.assignmentId },
include: { stockLines: true },
})
const firstReturn = await returnAssignmentUseCase({
id: created.assignmentId,
actorId: actor.id,
returns: [
{
assignmentLineId: assignment.stockLines[0].id,
quantity: 2,
notes: "First return batch",
},
],
})
expect(firstReturn).toEqual({ success: true })
const partiallyReturned = await prisma.assignment.findUniqueOrThrow({
where: { id: created.assignmentId },
include: { stockLines: { include: { returns: true } } },
})
expect(partiallyReturned).toMatchObject({
status: "PARTIALLY_RETURNED",
closedAt: null,
closedById: null,
})
expect(partiallyReturned.stockLines[0]).toMatchObject({
quantity: 5,
returnedQuantity: 2,
})
expect(partiallyReturned.stockLines[0].returns).toHaveLength(1)
expect(partiallyReturned.stockLines[0].returns[0]).toMatchObject({
quantity: 2,
receivedById: actor.id,
notes: "First return batch",
})
const secondReturn = await returnAssignmentUseCase({
id: created.assignmentId,
actorId: actor.id,
returns: [
{
assignmentLineId: assignment.stockLines[0].id,
quantity: 3,
},
],
})
expect(secondReturn).toEqual({ success: true })
const fullyReturned = await prisma.assignment.findUniqueOrThrow({
where: { id: created.assignmentId },
include: { stockLines: { include: { returns: true } } },
})
expect(fullyReturned).toMatchObject({
status: "RETURNED",
closedById: actor.id,
})
expect(fullyReturned.closedAt).toBeInstanceOf(Date)
expect(fullyReturned.stockLines[0]).toMatchObject({
quantity: 5,
returnedQuantity: 5,
})
expect(fullyReturned.stockLines[0].returns).toHaveLength(2)
expect(
await prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
).toMatchObject({
stock: 5,
})
})
@@ -212,6 +355,6 @@ describe("assignment use-cases", () => {
errors: { id: ["Assignment already returned"] },
})
await expect(prisma.inventoryMovement.count()).resolves.toBe(2)
expect(await prisma.inventoryMovement.count()).toBe(2)
})
})