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(' / ') ); } }