491 lines
11 KiB
TypeScript
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
|
|
}
|
|
}
|