473 lines
19 KiB
PHP
473 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Enums\RecordStatusEnum;
|
|
use App\Models\AliqoutMovement;
|
|
use App\Models\Aliquot;
|
|
use App\Models\AliquotTransaction;
|
|
use App\Models\BaseModel;
|
|
use App\Models\Box;
|
|
use App\Models\ContainerType;
|
|
use App\Models\Pathogen;
|
|
use App\Models\PathogenInventorySample;
|
|
use App\Models\Position;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Session;
|
|
|
|
class StoragePositionController extends Controller
|
|
{
|
|
protected $baseOrganizationId;
|
|
protected $base;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->baseOrganizationId = Session::get('base_organization_id');
|
|
$this->base = Session::get('base_organization');
|
|
}
|
|
|
|
public function index(Box $box)
|
|
{
|
|
$box->load(['rack.equipment.room.building']);
|
|
|
|
$positions = Position::with(['aliquot.sample.pathogen', 'aliquot.containerType.containerName'])
|
|
->where('box_id', $box->id)
|
|
->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE)
|
|
->orderBy('row_label')
|
|
->orderByRaw('CAST(column_label AS UNSIGNED)')
|
|
->get();
|
|
|
|
$samples = PathogenInventorySample::with(['pathogen'])
|
|
->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE)
|
|
->where('organization_id', $this->baseOrganizationId)
|
|
->orderBy(BaseModel::CREATED_AT_FIELD, 'desc')
|
|
->limit(200)
|
|
->get();
|
|
|
|
$pathogens = Pathogen::with(['riskGroup', 'bsl'])
|
|
->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE)
|
|
->orderBy('scientific_name')
|
|
->get();
|
|
|
|
$containerTypes = ContainerType::with(['containerName'])
|
|
->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE)
|
|
->get();
|
|
|
|
$emptyPositions = Position::with(['box.rack.equipment.room.building'])
|
|
->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE)
|
|
->where('occupied', 0)
|
|
->orderByRaw('box_id = ? DESC', [$box->id])
|
|
->orderBy('box_id')
|
|
->orderBy('row_label')
|
|
->orderByRaw('CAST(column_label AS UNSIGNED)')
|
|
->get();
|
|
|
|
$recentMovements = AliqoutMovement::with([
|
|
'aliquot.sample.pathogen',
|
|
'fromPosition.box.rack.equipment.room.building',
|
|
'toPosition.box.rack.equipment.room.building',
|
|
])
|
|
->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE)
|
|
->where(function ($query) use ($box) {
|
|
$query->whereHas('fromPosition', function ($query) use ($box) {
|
|
$query->where('box_id', $box->id);
|
|
})->orWhereHas('toPosition', function ($query) use ($box) {
|
|
$query->where('box_id', $box->id);
|
|
});
|
|
})
|
|
->orderBy(BaseModel::CREATED_AT_FIELD, 'desc')
|
|
->limit(10)
|
|
->get();
|
|
|
|
return view('box_positions', [
|
|
'box' => $box,
|
|
'positions' => $positions,
|
|
'samples' => $samples,
|
|
'pathogens' => $pathogens,
|
|
'containerTypes' => $containerTypes,
|
|
'emptyPositions' => $emptyPositions,
|
|
'recentMovements' => $recentMovements,
|
|
]);
|
|
}
|
|
|
|
public function generate(Request $request)
|
|
{
|
|
$request->validate(['box_id' => 'required']);
|
|
|
|
try {
|
|
$box = Box::query()->findOrFail($request->box_id);
|
|
|
|
if ($box->rows < 1 || $box->columns < 1) {
|
|
return response()->json(['success' => false, 'message' => 'Box rows and columns must be configured before generating positions.']);
|
|
}
|
|
|
|
DB::beginTransaction();
|
|
|
|
for ($row = 1; $row <= $box->rows; $row++) {
|
|
$rowLabel = $this->rowLabel($row);
|
|
|
|
for ($column = 1; $column <= $box->columns; $column++) {
|
|
Position::query()->firstOrCreate(
|
|
[
|
|
'box_id' => $box->id,
|
|
'row_label' => $rowLabel,
|
|
'column_label' => (string) $column,
|
|
],
|
|
[
|
|
'code' => $rowLabel . $column,
|
|
'occupied' => 0,
|
|
BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::CREATED_BY_FIELD => Auth::id(),
|
|
BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
DB::commit();
|
|
|
|
return response()->json(['success' => true, 'message' => 'Box positions generated successfully.']);
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
return response()->json(['success' => false, 'message' => 'Unable to generate box positions.', 'errors' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
public function storeAliquot(Request $request)
|
|
{
|
|
try {
|
|
$validator = \Validator::make($request->all(), [
|
|
'storage_position_id' => 'required',
|
|
'aliquot_number' => 'required',
|
|
'initial_volume' => 'required|numeric|min:0',
|
|
'remaining_volume' => 'required|numeric|min:0',
|
|
'volume_unit' => 'required',
|
|
'status' => 'required',
|
|
]);
|
|
|
|
if ($validator->fails()) {
|
|
return response()->json(['success' => false, 'message' => 'Unable to save aliquot.', 'errors' => $validator->errors()->all()]);
|
|
}
|
|
|
|
$position = Position::with(['aliquot'])->findOrFail($request->storage_position_id);
|
|
if (!empty($position->aliquot)) {
|
|
return response()->json(['success' => false, 'message' => 'This position is already occupied. Remove or transfer the current aliquot first.']);
|
|
}
|
|
|
|
DB::beginTransaction();
|
|
|
|
$sampleId = $request->sample_id;
|
|
if (empty($sampleId) && !empty($request->pathogen_id)) {
|
|
$sampleId = PathogenInventorySample::query()->insertGetId([
|
|
'organization_id' => $this->baseOrganizationId,
|
|
'pathogen_id' => $request->pathogen_id,
|
|
'sample_code' => $request->sample_code ?: $request->aliquot_number,
|
|
'initial_volume' => $request->initial_volume,
|
|
'remaining_volume' => $request->remaining_volume,
|
|
'volume_unit' => $request->volume_unit,
|
|
'status' => 'Stored',
|
|
BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::CREATED_BY_FIELD => Auth::id(),
|
|
BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE,
|
|
]);
|
|
}
|
|
|
|
$aliquotId = Aliquot::query()->insertGetId([
|
|
'sample_id' => $sampleId,
|
|
'container_type_id' => $request->container_type_id,
|
|
'aliquot_number' => $request->aliquot_number,
|
|
'initial_volume' => $request->initial_volume,
|
|
'remaining_volume' => $request->remaining_volume,
|
|
'volume_unit' => $request->volume_unit,
|
|
'concentration' => $request->concentration,
|
|
'storage_position_id' => $request->storage_position_id,
|
|
'expiry_date' => !empty($request->expiry_date) ? date('Y-m-d H:i:s', strtotime($request->expiry_date)) : null,
|
|
'status' => $request->status,
|
|
'last_accessed_at' => date('Y-m-d H:i:s'),
|
|
'description' => $request->description,
|
|
BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::CREATED_BY_FIELD => Auth::id(),
|
|
BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE,
|
|
]);
|
|
|
|
$position->update([
|
|
'occupied' => 1,
|
|
BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::UPDATED_BY_FIELD => Auth::id(),
|
|
]);
|
|
|
|
AliqoutMovement::query()->insert([
|
|
'aliquot_id' => $aliquotId,
|
|
'from_position_id' => null,
|
|
'to_position_id' => $request->storage_position_id,
|
|
'movement_reason' => 'Initial storage placement',
|
|
'moved_by' => optional(Auth::user())->name ?? optional(Auth::user())->email ?? (string) Auth::id(),
|
|
BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::CREATED_BY_FIELD => Auth::id(),
|
|
BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE,
|
|
]);
|
|
|
|
DB::commit();
|
|
|
|
return response()->json(['success' => true, 'message' => 'Aliquot placed in storage position.']);
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
return response()->json(['success' => false, 'message' => 'Unable to save aliquot.', 'errors' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
public function removeAliquot(Request $request)
|
|
{
|
|
try {
|
|
$validator = \Validator::make($request->all(), [
|
|
'aliquot_id' => 'required',
|
|
]);
|
|
|
|
if ($validator->fails()) {
|
|
return response()->json(['success' => false, 'message' => 'Unable to remove aliquot.', 'errors' => $validator->errors()->all()]);
|
|
}
|
|
|
|
$aliquot = Aliquot::query()->findOrFail($request->aliquot_id);
|
|
|
|
DB::beginTransaction();
|
|
|
|
$positionId = $aliquot->storage_position_id;
|
|
$aliquot->update([
|
|
'status' => 'Removed',
|
|
BaseModel::DELETED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::DELETED_BY_FIELD => Auth::id(),
|
|
BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::DELETE,
|
|
]);
|
|
|
|
Position::query()->where('id', $positionId)->update([
|
|
'occupied' => 0,
|
|
BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::UPDATED_BY_FIELD => Auth::id(),
|
|
]);
|
|
|
|
AliqoutMovement::query()->insert([
|
|
'aliquot_id' => $aliquot->id,
|
|
'from_position_id' => $positionId,
|
|
'to_position_id' => null,
|
|
'movement_reason' => $request->movement_reason ?: 'Removed from storage',
|
|
'moved_by' => optional(Auth::user())->name ?? optional(Auth::user())->email ?? (string) Auth::id(),
|
|
BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::CREATED_BY_FIELD => Auth::id(),
|
|
BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE,
|
|
]);
|
|
|
|
DB::commit();
|
|
|
|
return response()->json(['success' => true, 'message' => 'Aliquot removed from this position.']);
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
return response()->json(['success' => false, 'message' => 'Unable to remove aliquot.', 'errors' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
public function moveAliquot(Request $request)
|
|
{
|
|
try {
|
|
$validator = \Validator::make($request->all(), [
|
|
'aliquot_id' => 'required',
|
|
'to_position_id' => 'required',
|
|
'movement_reason' => 'required',
|
|
]);
|
|
|
|
if ($validator->fails()) {
|
|
return response()->json(['success' => false, 'message' => 'Unable to move aliquot.', 'errors' => $validator->errors()->all()]);
|
|
}
|
|
|
|
$aliquot = Aliquot::query()
|
|
->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE)
|
|
->findOrFail($request->aliquot_id);
|
|
|
|
$fromPositionId = $aliquot->storage_position_id;
|
|
$toPosition = Position::with(['aliquot'])->findOrFail($request->to_position_id);
|
|
|
|
if ((int) $fromPositionId === (int) $toPosition->id) {
|
|
return response()->json(['success' => false, 'message' => 'The aliquot is already in that position.']);
|
|
}
|
|
|
|
if (!empty($toPosition->aliquot) || (int) $toPosition->occupied === 1) {
|
|
return response()->json(['success' => false, 'message' => 'Target position is occupied. Choose an empty position.']);
|
|
}
|
|
|
|
DB::beginTransaction();
|
|
|
|
Position::query()->where('id', $fromPositionId)->update([
|
|
'occupied' => 0,
|
|
BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::UPDATED_BY_FIELD => Auth::id(),
|
|
]);
|
|
|
|
Position::query()->where('id', $toPosition->id)->update([
|
|
'occupied' => 1,
|
|
BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::UPDATED_BY_FIELD => Auth::id(),
|
|
]);
|
|
|
|
$aliquot->update([
|
|
'storage_position_id' => $toPosition->id,
|
|
'last_accessed_at' => date('Y-m-d H:i:s'),
|
|
BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::UPDATED_BY_FIELD => Auth::id(),
|
|
]);
|
|
|
|
AliqoutMovement::query()->insert([
|
|
'aliquot_id' => $aliquot->id,
|
|
'from_position_id' => $fromPositionId,
|
|
'to_position_id' => $toPosition->id,
|
|
'movement_reason' => $request->movement_reason,
|
|
'moved_by' => optional(Auth::user())->name ?? optional(Auth::user())->email ?? (string) Auth::id(),
|
|
BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::CREATED_BY_FIELD => Auth::id(),
|
|
BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE,
|
|
]);
|
|
|
|
DB::commit();
|
|
|
|
return response()->json(['success' => true, 'message' => 'Aliquot moved successfully.']);
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
return response()->json(['success' => false, 'message' => 'Unable to move aliquot.', 'errors' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
public function movementHistory(Request $request)
|
|
{
|
|
try {
|
|
$validator = \Validator::make($request->all(), [
|
|
'aliquot_id' => 'required',
|
|
]);
|
|
|
|
if ($validator->fails()) {
|
|
return response()->json(['success' => false, 'message' => 'Unable to retrieve movement history.', 'errors' => $validator->errors()->all()]);
|
|
}
|
|
|
|
$movements = AliqoutMovement::with([
|
|
'fromPosition.box.rack.equipment.room.building',
|
|
'toPosition.box.rack.equipment.room.building',
|
|
])
|
|
->where('aliquot_id', $request->aliquot_id)
|
|
->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE)
|
|
->orderBy(BaseModel::CREATED_AT_FIELD, 'desc')
|
|
->get()
|
|
->map(function ($movement) {
|
|
return [
|
|
'from' => $this->positionLabel($movement->fromPosition),
|
|
'to' => $this->positionLabel($movement->toPosition),
|
|
'reason' => $movement->movement_reason,
|
|
'moved_by' => $movement->moved_by,
|
|
'moved_at' => $movement->created_at,
|
|
];
|
|
});
|
|
|
|
return response()->json(['success' => true, 'message' => 'Movement history retrieved.', 'data' => $movements]);
|
|
} catch (\Exception $e) {
|
|
return response()->json(['success' => false, 'message' => 'Unable to retrieve movement history.', 'errors' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
public function useVolume(Request $request)
|
|
{
|
|
try {
|
|
$validator = \Validator::make($request->all(), [
|
|
'aliquot_id' => 'required',
|
|
'used_volume' => 'required|numeric|min:0.01',
|
|
'use_reason' => 'required',
|
|
]);
|
|
|
|
if ($validator->fails()) {
|
|
return response()->json(['success' => false, 'message' => 'Unable to record aliquot use.', 'errors' => $validator->errors()->all()]);
|
|
}
|
|
|
|
$aliquot = Aliquot::query()
|
|
->where(BaseModel::RECORD_STATUS_FIELD, RecordStatusEnum::ACTIVE)
|
|
->findOrFail($request->aliquot_id);
|
|
|
|
$sourceVolume = (float) $aliquot->remaining_volume;
|
|
$usedVolume = (float) $request->used_volume;
|
|
|
|
if ($usedVolume > $sourceVolume) {
|
|
return response()->json(['success' => false, 'message' => 'Used volume cannot be greater than remaining volume.']);
|
|
}
|
|
|
|
$remainingVolume = $sourceVolume - $usedVolume;
|
|
|
|
DB::beginTransaction();
|
|
|
|
$aliquot->update([
|
|
'remaining_volume' => $remainingVolume,
|
|
'status' => $remainingVolume <= 0 ? 'Consumed' : $aliquot->status,
|
|
'last_accessed_at' => date('Y-m-d H:i:s'),
|
|
BaseModel::UPDATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::UPDATED_BY_FIELD => Auth::id(),
|
|
]);
|
|
|
|
AliquotTransaction::query()->insert([
|
|
'aliquot_id' => $aliquot->id,
|
|
'transaction_at' => date('Y-m-d H:i:s'),
|
|
'performed_by' => optional(Auth::user())->name ?? optional(Auth::user())->email ?? (string) Auth::id(),
|
|
'source_volumn' => $sourceVolume,
|
|
'affected_volume' => $usedVolume,
|
|
'remaining_volume' => $remainingVolume,
|
|
'volume_unit' => $aliquot->volume_unit,
|
|
'storage_position_id' => $aliquot->storage_position_id,
|
|
'reference_number' => $request->reference_number,
|
|
'description' => $request->use_reason,
|
|
'state' => 'approved',
|
|
BaseModel::CREATED_AT_FIELD => date('Y-m-d H:i:s'),
|
|
BaseModel::CREATED_BY_FIELD => Auth::id(),
|
|
BaseModel::RECORD_STATUS_FIELD => RecordStatusEnum::ACTIVE,
|
|
]);
|
|
|
|
DB::commit();
|
|
|
|
return response()->json(['success' => true, 'message' => 'Aliquot use recorded. Remaining volume: ' . $remainingVolume . ' ' . $aliquot->volume_unit]);
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
return response()->json(['success' => false, 'message' => 'Unable to record aliquot use.', 'errors' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
private function rowLabel(int $row): string
|
|
{
|
|
$label = '';
|
|
|
|
while ($row > 0) {
|
|
$row--;
|
|
$label = chr(65 + ($row % 26)) . $label;
|
|
$row = intdiv($row, 26);
|
|
}
|
|
|
|
return $label;
|
|
}
|
|
|
|
private function positionLabel($position): string
|
|
{
|
|
if (empty($position)) {
|
|
return 'Outside storage';
|
|
}
|
|
|
|
$box = $position->box;
|
|
$rack = optional($box)->rack;
|
|
$equipment = optional($rack)->equipment;
|
|
$room = optional($equipment)->room;
|
|
$building = optional($room)->building;
|
|
|
|
return trim(
|
|
collect([
|
|
optional($building)->name,
|
|
optional($room)->name,
|
|
optional($equipment)->name,
|
|
optional($rack)->name,
|
|
optional($box)->name,
|
|
$position->code,
|
|
])->filter()->implode(' / ')
|
|
);
|
|
}
|
|
}
|