changes to management

This commit is contained in:
2026-08-17 10:18:11 +07:00
parent cc615a58ca
commit ff6be8ba1e
29 changed files with 2785 additions and 19 deletions

View File

@@ -0,0 +1,472 @@
<?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(' / ')
);
}
}