refactor(assets): move workflows into use cases
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
import {
|
||||
type Assignment,
|
||||
type ItemStatus,
|
||||
Prisma,
|
||||
} from "@/generated/prisma/client"
|
||||
import prisma from "@/lib/prisma"
|
||||
import type {
|
||||
CreateAssetFormType,
|
||||
UpdateAssetFormType,
|
||||
} from "@/schemas/asset.schema"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { AssignmentService } from "@/services/assignment.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { MovementService } from "@/services/movement.service"
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
|
||||
type CreateAssetUseCaseInput = CreateAssetFormType & {
|
||||
actorId: string
|
||||
}
|
||||
|
||||
type UpdateAssetUseCaseInput = UpdateAssetFormType & {
|
||||
actorId: string
|
||||
}
|
||||
|
||||
type CreateAssetUseCaseResult =
|
||||
| {
|
||||
success: true
|
||||
assetId: string
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
errors: FieldErrors
|
||||
}
|
||||
|
||||
type UpdateAssetUseCaseResult =
|
||||
| {
|
||||
success: true
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
errors: FieldErrors
|
||||
}
|
||||
|
||||
function createAssetError(errors: FieldErrors): CreateAssetUseCaseResult {
|
||||
return {
|
||||
success: false,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssetError(errors: FieldErrors): UpdateAssetUseCaseResult {
|
||||
return {
|
||||
success: false,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
class AssetTransitionError extends Error {
|
||||
constructor(readonly errors: FieldErrors) {
|
||||
super("Asset transition failed")
|
||||
}
|
||||
}
|
||||
|
||||
type AssetTransitionInput = {
|
||||
previousStatus: ItemStatus
|
||||
nextStatus: ItemStatus
|
||||
previousItemId: string | null
|
||||
nextItemId: string
|
||||
activeAssignment: Assignment | null
|
||||
nextRecipientId?: string
|
||||
}
|
||||
|
||||
function getAssetTransition({
|
||||
previousStatus,
|
||||
nextStatus,
|
||||
previousItemId,
|
||||
nextItemId,
|
||||
activeAssignment,
|
||||
nextRecipientId,
|
||||
}: AssetTransitionInput) {
|
||||
return {
|
||||
previousStatus,
|
||||
nextStatus,
|
||||
previousItemId,
|
||||
nextItemId,
|
||||
statusChanged: previousStatus !== nextStatus,
|
||||
itemChanged: previousItemId !== nextItemId,
|
||||
activeAssignment,
|
||||
nextRecipientId,
|
||||
hasRecipient: Boolean(nextRecipientId),
|
||||
wasAssigned: previousStatus === "ASSIGNED",
|
||||
willBeAssigned: nextStatus === "ASSIGNED",
|
||||
wasAvailable: previousStatus === "AVAILABLE",
|
||||
willBeAvailable: nextStatus === "AVAILABLE",
|
||||
recipientChanged: activeAssignment?.recipientId !== nextRecipientId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAssetUseCase(
|
||||
input: CreateAssetUseCaseInput,
|
||||
): Promise<CreateAssetUseCaseResult> {
|
||||
const {
|
||||
actorId,
|
||||
itemId,
|
||||
serialNumber,
|
||||
deliveryNote,
|
||||
status,
|
||||
notes,
|
||||
recipientId,
|
||||
} = input
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const item = await ItemService.findByIdWithCategory(itemId, tx)
|
||||
|
||||
if (!item) {
|
||||
return createAssetError({ itemId: ["Item not found"] })
|
||||
}
|
||||
|
||||
const existentAsset = await AssetService.findBySerialNumber(
|
||||
serialNumber,
|
||||
tx,
|
||||
)
|
||||
|
||||
if (existentAsset) {
|
||||
return createAssetError({
|
||||
serialNumber: ["This serial number already exists"],
|
||||
})
|
||||
}
|
||||
|
||||
const newAsset = await AssetService.create(
|
||||
{
|
||||
item: { connect: { id: itemId } },
|
||||
serialNumber,
|
||||
deliveryNote,
|
||||
status,
|
||||
notes,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
|
||||
const createdAssignment =
|
||||
status === "ASSIGNED" && recipientId
|
||||
? await AssignmentService.create(
|
||||
{
|
||||
notes: "",
|
||||
itemId,
|
||||
assetId: newAsset.id,
|
||||
quantity: 1,
|
||||
recipientId,
|
||||
assignmentDate: new Date(),
|
||||
createdBy: actorId,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
: null
|
||||
|
||||
await MovementService.create(
|
||||
{
|
||||
itemId,
|
||||
assetId: newAsset.id,
|
||||
quantity: 1,
|
||||
type: status === "ASSIGNED" ? "ASSIGNMENT" : "IN",
|
||||
recipientId: createdAssignment?.recipientId || undefined,
|
||||
assignmentId: createdAssignment?.id,
|
||||
userId: actorId,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
|
||||
if (status === "AVAILABLE") {
|
||||
await ItemService.updateStock(itemId, 1, tx)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
assetId: newAsset.id,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2002"
|
||||
) {
|
||||
return createAssetError({
|
||||
serialNumber: ["This serial number already exists"],
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAssetUseCase(
|
||||
input: UpdateAssetUseCaseInput,
|
||||
): Promise<UpdateAssetUseCaseResult> {
|
||||
const {
|
||||
actorId,
|
||||
id,
|
||||
itemId,
|
||||
serialNumber,
|
||||
deliveryNote,
|
||||
status,
|
||||
notes,
|
||||
recipientId,
|
||||
} = input
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const item = await ItemService.findByIdWithCategory(itemId, tx)
|
||||
|
||||
if (!item) {
|
||||
return updateAssetError({ itemId: ["Item not found"] })
|
||||
}
|
||||
|
||||
const currentAsset = await AssetService.findById(id, tx)
|
||||
|
||||
if (!currentAsset) {
|
||||
return updateAssetError({ id: ["Asset not found"] })
|
||||
}
|
||||
|
||||
const transition = getAssetTransition({
|
||||
previousStatus: currentAsset.status,
|
||||
nextStatus: status,
|
||||
previousItemId: currentAsset.itemId,
|
||||
nextItemId: itemId,
|
||||
activeAssignment: currentAsset.assignment,
|
||||
nextRecipientId: recipientId,
|
||||
})
|
||||
|
||||
const existentAsset = await AssetService.findBySerialNumber(
|
||||
serialNumber,
|
||||
tx,
|
||||
)
|
||||
|
||||
if (
|
||||
existentAsset &&
|
||||
id !== existentAsset.id &&
|
||||
existentAsset.serialNumber === serialNumber
|
||||
) {
|
||||
return updateAssetError({
|
||||
serialNumber: ["This serial number already exists"],
|
||||
})
|
||||
}
|
||||
|
||||
await AssetService.update(
|
||||
id,
|
||||
{
|
||||
item: { connect: { id: itemId } },
|
||||
serialNumber,
|
||||
deliveryNote,
|
||||
status,
|
||||
notes,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
|
||||
let closedActiveAssignment = false
|
||||
|
||||
if (transition.activeAssignment && !transition.willBeAssigned) {
|
||||
const activeAssignment = transition.activeAssignment
|
||||
const assignmentWasReturned =
|
||||
await AssignmentService.markReturnedIfActive(activeAssignment.id, tx)
|
||||
|
||||
if (!assignmentWasReturned) {
|
||||
throw new AssetTransitionError({
|
||||
id: ["Assignment already returned"],
|
||||
})
|
||||
}
|
||||
|
||||
await MovementService.create(
|
||||
{
|
||||
type: "RETURN",
|
||||
quantity: activeAssignment.quantity || 1,
|
||||
itemId: transition.willBeAvailable
|
||||
? transition.nextItemId
|
||||
: activeAssignment.itemId || undefined,
|
||||
assetId: activeAssignment.assetId || undefined,
|
||||
recipientId: activeAssignment.recipientId || undefined,
|
||||
assignmentId: activeAssignment.id,
|
||||
userId: actorId,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
|
||||
closedActiveAssignment = true
|
||||
}
|
||||
|
||||
const shouldIncrementNextItemStock =
|
||||
transition.willBeAvailable &&
|
||||
(!transition.wasAvailable || transition.itemChanged)
|
||||
const shouldDecrementPreviousItemStock =
|
||||
transition.wasAvailable &&
|
||||
(!transition.willBeAvailable || transition.itemChanged)
|
||||
|
||||
if (
|
||||
transition.statusChanged &&
|
||||
!transition.hasRecipient &&
|
||||
!closedActiveAssignment
|
||||
) {
|
||||
const statusMovementItemId = transition.willBeAssigned
|
||||
? transition.nextItemId
|
||||
: shouldDecrementPreviousItemStock &&
|
||||
!shouldIncrementNextItemStock &&
|
||||
transition.previousItemId
|
||||
? transition.previousItemId
|
||||
: transition.nextItemId
|
||||
|
||||
await MovementService.create(
|
||||
{
|
||||
itemId: statusMovementItemId,
|
||||
assetId: id,
|
||||
quantity: 1,
|
||||
type: transition.willBeAssigned
|
||||
? "ASSIGNMENT"
|
||||
: transition.nextStatus === "AVAILABLE"
|
||||
? "IN"
|
||||
: "ADJUSTMENT",
|
||||
details: `Status changed from ${transition.previousStatus} to ${transition.nextStatus}`,
|
||||
userId: actorId,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
transition.itemChanged &&
|
||||
transition.wasAvailable &&
|
||||
transition.willBeAvailable
|
||||
) {
|
||||
if (!transition.previousItemId) {
|
||||
throw new AssetTransitionError({
|
||||
itemId: ["Previous item not found for available asset"],
|
||||
})
|
||||
}
|
||||
|
||||
await MovementService.create(
|
||||
{
|
||||
itemId: transition.previousItemId,
|
||||
assetId: id,
|
||||
quantity: 1,
|
||||
type: "OUT",
|
||||
details: `Asset moved from item ${transition.previousItemId} to ${transition.nextItemId}`,
|
||||
userId: actorId,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
|
||||
await MovementService.create(
|
||||
{
|
||||
itemId: transition.nextItemId,
|
||||
assetId: id,
|
||||
quantity: 1,
|
||||
type: "IN",
|
||||
details: `Asset moved from item ${transition.previousItemId} to ${transition.nextItemId}`,
|
||||
userId: actorId,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
transition.itemChanged &&
|
||||
transition.wasAvailable &&
|
||||
transition.willBeAssigned
|
||||
) {
|
||||
if (!transition.previousItemId) {
|
||||
throw new AssetTransitionError({
|
||||
itemId: ["Previous item not found for available asset"],
|
||||
})
|
||||
}
|
||||
|
||||
await MovementService.create(
|
||||
{
|
||||
itemId: transition.previousItemId,
|
||||
assetId: id,
|
||||
quantity: 1,
|
||||
type: "OUT",
|
||||
details: `Asset assigned from item ${transition.previousItemId} to item ${transition.nextItemId}`,
|
||||
userId: actorId,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
}
|
||||
|
||||
if (shouldIncrementNextItemStock) {
|
||||
await ItemService.updateStock(transition.nextItemId, 1, tx)
|
||||
}
|
||||
|
||||
if (shouldDecrementPreviousItemStock) {
|
||||
if (!transition.previousItemId) {
|
||||
throw new AssetTransitionError({
|
||||
itemId: ["Previous item not found for available asset"],
|
||||
})
|
||||
}
|
||||
|
||||
const stockWasDecremented = await ItemService.decrementStockIfAvailable(
|
||||
transition.previousItemId,
|
||||
1,
|
||||
tx,
|
||||
)
|
||||
|
||||
if (!stockWasDecremented) {
|
||||
throw new AssetTransitionError({
|
||||
stock: ["Item does not have enough stock"],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (transition.willBeAssigned && transition.nextRecipientId) {
|
||||
const activeAssignment = transition.activeAssignment
|
||||
|
||||
if (activeAssignment) {
|
||||
if (transition.recipientChanged) {
|
||||
await MovementService.create(
|
||||
{
|
||||
type: "RETURN",
|
||||
quantity: activeAssignment.quantity || 1,
|
||||
itemId: activeAssignment.itemId || undefined,
|
||||
assetId: activeAssignment.assetId || undefined,
|
||||
recipientId: activeAssignment.recipientId || undefined,
|
||||
assignmentId: activeAssignment.id,
|
||||
userId: actorId,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
|
||||
await MovementService.create(
|
||||
{
|
||||
type: "ASSIGNMENT",
|
||||
quantity: 1,
|
||||
itemId,
|
||||
assetId: id,
|
||||
recipientId: transition.nextRecipientId,
|
||||
assignmentId: activeAssignment.id,
|
||||
userId: actorId,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
|
||||
await AssignmentService.update(
|
||||
activeAssignment.id,
|
||||
{
|
||||
createdBy: actorId,
|
||||
itemId,
|
||||
assetId: id,
|
||||
recipientId: transition.nextRecipientId,
|
||||
quantity: 1,
|
||||
returnDate: null,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
} else {
|
||||
await AssignmentService.update(
|
||||
activeAssignment.id,
|
||||
{
|
||||
itemId,
|
||||
assetId: id,
|
||||
recipientId: transition.nextRecipientId,
|
||||
quantity: 1,
|
||||
returnDate: null,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const createdAssignment = await AssignmentService.create(
|
||||
{
|
||||
itemId,
|
||||
assetId: id,
|
||||
quantity: 1,
|
||||
recipientId: transition.nextRecipientId,
|
||||
assignmentDate: new Date(),
|
||||
createdBy: actorId,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
|
||||
await MovementService.create(
|
||||
{
|
||||
type: "ASSIGNMENT",
|
||||
quantity: 1,
|
||||
itemId,
|
||||
assetId: id,
|
||||
recipientId: transition.nextRecipientId,
|
||||
assignmentId: createdAssignment.id,
|
||||
userId: actorId,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof AssetTransitionError) {
|
||||
return updateAssetError(error.errors)
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2002"
|
||||
) {
|
||||
return updateAssetError({
|
||||
serialNumber: ["This serial number already exists"],
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user