Files
stock-manager/src/use-cases/assignment.use-cases.ts
T

491 lines
11 KiB
TypeScript

import type { Prisma } from "@/generated/prisma/client"
import prisma from "@/lib/prisma"
import type {
CreateAssignmentData,
UpdateAssignmentData,
} from "@/schemas/assignment.schema"
import { AssetService } from "@/services/asset.service"
import {
AssignmentService,
ConcurrentReturnError,
} from "@/services/assignment.service"
import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
import type {
Assignment,
AssignmentStockLineInput,
AssignmentStockReturnInput,
} from "@/types"
type FieldErrors = Record<string, string[]>
type CreateAssignmentUseCaseInput = Omit<
CreateAssignmentData,
"itemId" | "quantity"
> & {
actorId: string
itemId?: string
quantity?: number
lines?: AssignmentStockLineInput[]
}
type ReturnAssignmentUseCaseInput = {
id: string
actorId: string
returns?: AssignmentStockReturnInput[]
}
type UpdateAssignmentUseCaseInput = Omit<
UpdateAssignmentData,
"itemId" | "quantity"
> & {
actorId: string
itemId?: string
quantity?: number
lines?: AssignmentStockLineInput[]
}
type CreateAssignmentUseCaseResult =
| {
success: true
assignmentId: string
}
| {
success: false
errors: FieldErrors
}
type ReturnAssignmentUseCaseResult =
| {
success: true
}
| {
success: false
errors: FieldErrors
}
type UpdateAssignmentUseCaseResult =
| {
success: true
}
| {
success: false
errors: FieldErrors
}
function createAssignmentError(
errors: FieldErrors,
): CreateAssignmentUseCaseResult {
return {
success: false,
errors,
}
}
function returnAssignmentError(
errors: FieldErrors,
): ReturnAssignmentUseCaseResult {
return {
success: false,
errors,
}
}
function updateAssignmentError(
errors: FieldErrors,
): UpdateAssignmentUseCaseResult {
return {
success: false,
errors,
}
}
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 type ReassignAssignmentArgs = {
oldAssignment: Assignment
newPersonId: string
newItemId?: string
newAssetId?: string
newQuantity: number
newNotes?: string
actorId: string
tx: Prisma.TransactionClient
}
export async function reassignAssignment(
args: ReassignAssignmentArgs,
): Promise<{ newAssignment: Assignment }> {
const {
oldAssignment,
newPersonId,
newItemId,
newAssetId,
newQuantity,
newNotes,
actorId,
tx,
} = args
const returnedQuantity = oldAssignment.quantity ?? 1
// 1. Close old assignment (handles both QUANTITY and SERIALIZED via markReturnedIfActive)
await AssignmentService.markReturnedIfActive(oldAssignment.id, actorId, tx)
// 2. Mutate item.stock to restore the returned quantity, then write RETURN movement
// Skip for SERIALIZED items (stock is constrained to 0 and is not affected by assignments).
if (oldAssignment.itemId) {
const oldItem = await ItemService.findById(oldAssignment.itemId, tx)
if (oldItem && oldItem.trackingType === "QUANTITY") {
await ItemService.updateStock(oldAssignment.itemId, returnedQuantity, tx)
}
}
await MovementService.create(
{
type: "RETURN",
quantity: returnedQuantity,
itemId: oldAssignment.itemId || undefined,
assetId: oldAssignment.assetId || undefined,
personId: oldAssignment.personId || undefined,
assignmentId: oldAssignment.id,
userId: actorId,
},
tx,
)
// 3. Create new assignment for the new person
const newAssignment = await AssignmentService.create(
{
personId: newPersonId,
itemId: newItemId,
assetId: newAssetId,
quantity: newQuantity,
notes: newNotes,
assignmentDate: new Date(),
createdBy: actorId,
},
tx,
)
// 4. Mutate item.stock to deduct the new assigned quantity, then write ASSIGNMENT movement
if (newItemId) {
const newItem = await ItemService.findById(newItemId, tx)
if (newItem && newItem.trackingType === "QUANTITY") {
await ItemService.updateStock(newItemId, -newQuantity, tx)
}
}
await MovementService.create(
{
type: "ASSIGNMENT",
quantity: newQuantity,
itemId: newItemId,
assetId: newAssetId,
personId: newPersonId,
assignmentId: newAssignment.id,
userId: actorId,
},
tx,
)
return { newAssignment }
}
export async function createAssignmentUseCase(
input: CreateAssignmentUseCaseInput,
): Promise<CreateAssignmentUseCaseResult> {
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"] })
}
return prisma.$transaction(async (tx) => {
const item = await ItemService.findById(itemId, tx)
if (!item) {
return createAssignmentError({ itemId: ["Item not found"] })
}
if (item.trackingType === "QUANTITY" && item.stock < quantity) {
return createAssignmentError({
quantity: ["Item does not have enough stock"],
})
}
if (assetId) {
const asset = await AssetService.findById(assetId, tx)
if (!asset) {
return createAssignmentError({ assetId: ["Asset not found"] })
}
if (asset.itemId !== item.id) {
return createAssignmentError({
assetId: ["Asset does not belong to item"],
})
}
}
if (item.trackingType === "QUANTITY") {
const stockWasDecremented = await ItemService.decrementStockIfAvailable(
itemId,
quantity,
tx,
)
if (!stockWasDecremented) {
return createAssignmentError({
quantity: ["Item does not have enough stock"],
})
}
}
if (assetId) {
await AssetService.update(
assetId,
{
status: "ASSIGNED",
},
tx,
)
}
const createdAssignment = await AssignmentService.create(
{
itemId,
assetId: assetId || undefined,
quantity,
personId,
notes: notes ?? input.notes,
assignmentDate: input.assignmentDate,
createdBy: actorId,
},
tx,
)
await MovementService.create(
{
itemId,
assetId: assetId || undefined,
quantity,
type: "ASSIGNMENT",
personId,
assignmentId: createdAssignment.id,
userId: actorId,
},
tx,
)
return {
success: true,
assignmentId: createdAssignment.id,
}
})
}
export async function updateAssignmentUseCase(
input: UpdateAssignmentUseCaseInput,
): Promise<UpdateAssignmentUseCaseResult> {
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"] })
}
return prisma.$transaction(async (tx) => {
const assignment = await AssignmentService.findById(id, tx)
if (!assignment) {
return updateAssignmentError({ id: ["Assignment not found"] })
}
if (assignment.status === "RETURNED" || assignment.status === "CANCELLED") {
return updateAssignmentError({ id: ["Assignment is closed"] })
}
let item = null
if (itemId) {
item = await ItemService.findById(itemId, tx)
if (!item) {
return updateAssignmentError({ itemId: ["Item not found"] })
}
}
if (assetId) {
const asset = await AssetService.findById(assetId, tx)
if (!asset) {
return updateAssignmentError({ assetId: ["Asset not found"] })
}
if (asset.itemId !== itemId) {
return updateAssignmentError({
assetId: ["Asset does not belong to item"],
})
}
}
if (assignment.personId !== personId) {
await reassignAssignment({
oldAssignment: assignment,
newPersonId: personId,
newItemId: itemId,
newAssetId: assetId,
newQuantity: quantity,
newNotes: notes,
actorId,
tx,
})
} else {
if (item && assignment.quantity !== quantity) {
const stockDelta = quantity - (assignment.quantity ?? 0)
const adjustmentItemId = item.id
await ItemService.updateStock(adjustmentItemId, -stockDelta, tx)
await MovementService.create(
{
type: "ADJUSTMENT",
itemId: adjustmentItemId,
quantity: Math.abs(stockDelta),
stockDeltaSign: stockDelta < 0 ? 1 : -1,
userId: actorId,
},
tx,
)
}
await AssignmentService.update(
id,
{
personId: personId,
itemId,
assetId,
quantity,
notes,
assignmentDate,
returnDate: null,
},
tx,
)
}
return {
success: true,
}
})
}
export async function returnAssignmentUseCase(
input: ReturnAssignmentUseCaseInput,
): Promise<ReturnAssignmentUseCaseResult> {
const { id, actorId, returns } = input
if (!id) {
return returnAssignmentError({ id: ["Assignment ID is required"] })
}
try {
return await prisma.$transaction(async (tx) => {
const assignment = await AssignmentService.findById(id, tx)
if (!assignment) {
return returnAssignmentError({ id: ["Assignment not found"] })
}
if (assignment.returnDate) {
return returnAssignmentError({ id: ["Assignment already returned"] })
}
const returnResult = await AssignmentService.returnStockAssignment(
id,
actorId,
returns,
tx,
)
if (!returnResult) {
return returnAssignmentError({ error: ["Invalid assignment data"] })
}
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) {
await AssetService.update(
assignment.assetId,
{
status: "AVAILABLE",
},
tx,
)
}
return {
success: true,
}
})
} catch (error) {
if (error instanceof ConcurrentReturnError) {
return returnAssignmentError({ error: ["errorConcurrent"] })
}
throw error
}
}