712 lines
26 KiB
PHP
712 lines
26 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\StockBatch;
|
||
use App\Models\StockEntries2Section;
|
||
use App\Models\StockEntry;
|
||
use App\Models\StockPosition;
|
||
use App\Models\StockSection;
|
||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\Validator;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
|
||
class StockSectionController extends Controller
|
||
{
|
||
|
||
public function getSection(Request $request, int $id)
|
||
{
|
||
try {
|
||
return response()->json([
|
||
'section' => StockSection::with(['position', 'entries.physicalItem'])->findOrFail($id)
|
||
], 200);
|
||
}
|
||
catch (\Exception $e) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'not_found',
|
||
'message' => 'StockSection not found.',
|
||
], 404);
|
||
}
|
||
|
||
|
||
}
|
||
|
||
|
||
public function setSection(Request $request)
|
||
{
|
||
// 1) Validate input
|
||
try {
|
||
$data = $request->validate([
|
||
'section_id' => 'required|integer|exists:stock_section,section_id',
|
||
'entry_id' => 'required|integer|exists:stock_entries,id',
|
||
'count_to_be_stored' => 'required|integer|min:1',
|
||
]);
|
||
} catch (ValidationException $e) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'validation_failed',
|
||
'message' => $e->errors(),
|
||
], 422);
|
||
}
|
||
|
||
$sectionId = $data['section_id'];
|
||
$entryId = $data['entry_id'];
|
||
$count = $data['count_to_be_stored'];
|
||
|
||
try {
|
||
// 2) Locate section and entry (404 if not found)
|
||
$section = StockSection::findOrFail($sectionId);
|
||
$entry = StockEntry::findOrFail($entryId);
|
||
|
||
// 3) If the section is already occupied → 409 Conflict
|
||
if ($section->occupied()) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'section_occupied',
|
||
'message' => 'That section is already occupied.',
|
||
], 409);
|
||
}
|
||
|
||
// 4) Check capacity: count_to_be_stored must be <= section’s capacity
|
||
if ($count > $section->capacity) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'insufficient_capacity',
|
||
'message' => 'Not enough capacity in this section.',
|
||
'available_capacity' => $section->capacity,
|
||
], 409);
|
||
}
|
||
|
||
// 5) Create the pivot record and mark section as occupied
|
||
StockEntries2Section::create([
|
||
'entry_id' => $entry->id,
|
||
'section_id' => $section->section_id,
|
||
'count' => $count,
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'message' => 'Entry assigned to section successfully.',
|
||
], 200);
|
||
|
||
} catch (ModelNotFoundException $e) {
|
||
// Either the section or the entry was not found
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'not_found',
|
||
'message' => 'StockSection or StockEntry not found.',
|
||
], 404);
|
||
|
||
} catch (\Exception $e) {
|
||
// Log unexpected exceptions
|
||
Log::error('Error in setSection', [
|
||
'section_id' => $sectionId,
|
||
'entry_id' => $entryId,
|
||
'count' => $count,
|
||
'exception' => $e->getMessage(),
|
||
'trace' => $e->getTraceAsString(),
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'server_error',
|
||
'message' => 'An unexpected error occurred.',
|
||
], 500);
|
||
}
|
||
}
|
||
|
||
public function storeStock(Request $request)
|
||
{
|
||
// 1) Validate input
|
||
try {
|
||
$data = $request->validate([
|
||
'section_id' => 'required|integer|exists:stock_section,section_id', // new section
|
||
'current_section' => 'required|integer|exists:stock_section,section_id', // old section
|
||
'entry_id' => 'required|integer|exists:stock_entries,id',
|
||
'count_to_be_stored' => 'required|integer|min:1',
|
||
]);
|
||
} catch (ValidationException $e) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'validation_failed',
|
||
'message' => $e->errors(),
|
||
], 422);
|
||
}
|
||
|
||
$newSectionId = $data['section_id'];
|
||
$currentSectionId = $data['current_section'];
|
||
$entryId = $data['entry_id'];
|
||
$moveCount = $data['count_to_be_stored'];
|
||
|
||
try {
|
||
DB::transaction(function () use (
|
||
$newSectionId,
|
||
$currentSectionId,
|
||
$entryId,
|
||
$moveCount
|
||
) {
|
||
// 2) Load sections and entry
|
||
$currentSection = StockSection::findOrFail($currentSectionId);
|
||
$newSection = StockSection::findOrFail($newSectionId);
|
||
$entry = StockEntry::findOrFail($entryId);
|
||
|
||
// 3) Check occupation: allow if occupied only by this same entry
|
||
$existingInNew = StockEntries2Section::where('section_id', $newSectionId)
|
||
->where('entry_id', $entryId)
|
||
->first();
|
||
if ($newSection->occupied() && ! $existingInNew) {
|
||
abort(409, json_encode([
|
||
'success' => false,
|
||
'error' => 'section_occupied',
|
||
'message' => 'Target section is already occupied by another item.',
|
||
]));
|
||
}
|
||
|
||
// 4) Locate pivot in current section
|
||
$pivot = StockEntries2Section::where('section_id', $currentSectionId)
|
||
->where('entry_id', $entryId)
|
||
->firstOrFail();
|
||
|
||
// 5) Ensure there is enough count in current section
|
||
if ($pivot->count < $moveCount) {
|
||
abort(409, json_encode([
|
||
'success' => false,
|
||
'error' => 'insufficient_count',
|
||
'message' => 'Not enough items in the current section to move.',
|
||
'available' => $pivot->count,
|
||
]));
|
||
}
|
||
|
||
// 6) Decrement or remove pivot in current section
|
||
$remaining = $pivot->count - $moveCount;
|
||
if ($remaining > 0) {
|
||
DB::table('stock_entries2section')
|
||
->where('entry_id', $entryId)
|
||
->where('section_id', $currentSectionId)
|
||
->limit(1)
|
||
->update(['count' => $remaining]);
|
||
} else {
|
||
DB::table('stock_entries2section')
|
||
->where('entry_id', $entryId)
|
||
->where('section_id', $currentSectionId)
|
||
->limit(1)
|
||
->delete();
|
||
|
||
// if no more entries in this section, mark it unoccupied
|
||
if (! DB::table('stock_entries2section')->where('section_id', $currentSectionId)->exists()) {
|
||
$currentSection->update(['occupied' => false]);
|
||
}
|
||
}
|
||
|
||
// 7) Upsert into new section, merging counts if already present
|
||
// lockForUpdate to avoid race conditions
|
||
$existing = DB::table('stock_entries2section')
|
||
->where('entry_id', $entryId)
|
||
->where('section_id', $newSectionId)
|
||
->lockForUpdate()
|
||
->first();
|
||
|
||
$currentInNew = $existing->count ?? 0;
|
||
$totalAfter = $currentInNew + $moveCount;
|
||
|
||
// 8) Ensure capacity in new section
|
||
if ($totalAfter > $newSection->capacity) {
|
||
abort(409, json_encode([
|
||
'success' => false,
|
||
'error' => 'insufficient_capacity',
|
||
'message' => 'Not enough capacity in target section.',
|
||
'available_capacity' => $newSection->capacity - $currentInNew,
|
||
]));
|
||
}
|
||
|
||
if ($existing) {
|
||
DB::table('stock_entries2section')
|
||
->where('entry_id', $entryId)
|
||
->where('section_id', $newSectionId)
|
||
->limit(1)
|
||
->update(['count' => $totalAfter]);
|
||
} else {
|
||
StockEntries2Section::create([
|
||
'entry_id' => $entry->id,
|
||
'section_id' => $newSectionId,
|
||
'count' => $moveCount,
|
||
]);
|
||
}
|
||
|
||
// 9) Mark new section occupied
|
||
$newSection->update(['occupied' => true]);
|
||
});
|
||
|
||
// success response
|
||
return response()->json([
|
||
'success' => true,
|
||
'message' => 'Moved stock successfully.',
|
||
], 200);
|
||
|
||
} catch (ModelNotFoundException $e) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'not_found',
|
||
'message' => 'StockSection or StockEntry not found.',
|
||
], 404);
|
||
|
||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||
$payload = json_decode($e->getMessage(), true);
|
||
return response()->json($payload, $e->getStatusCode());
|
||
|
||
} catch (\Exception $e) {
|
||
Log::error('Error in storeStock', [
|
||
'current_section' => $currentSectionId,
|
||
'new_section' => $newSectionId,
|
||
'entry_id' => $entryId,
|
||
'count' => $moveCount,
|
||
'exception' => $e->getMessage(),
|
||
'trace' => $e->getTraceAsString(),
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'server_error',
|
||
'message' => 'An unexpected error occurred.',
|
||
], 500);
|
||
}
|
||
}
|
||
|
||
public function setSectionForBatch(Request $request)
|
||
{
|
||
// 1) Validate input
|
||
try {
|
||
$data = $request->validate([
|
||
'position_id' => 'required|integer|exists:stock_position,position_id',
|
||
'batch_id' => 'required|integer|exists:stock_batch,id',
|
||
]);
|
||
} catch (ValidationException $e) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'validation_failed',
|
||
'message' => $e->errors(),
|
||
], 422);
|
||
}
|
||
|
||
$positionId = $data['position_id'];
|
||
$batchId = $data['batch_id'];
|
||
|
||
try {
|
||
// 2) Locate section and entry (404 if not found)
|
||
$position = StockPosition::findOrFail($positionId);
|
||
$batch = StockBatch::with(['stockEntries'])->findOrFail($batchId);
|
||
|
||
$anyOccupied = $position->sections->contains(function($section) {
|
||
return $section->occupied();
|
||
});
|
||
// 3) If the section is already occupied → 409 Conflict
|
||
if ($anyOccupied) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'position_occupied',
|
||
'message' => 'That position is already occupied.',
|
||
], 409);
|
||
}
|
||
|
||
if (!$position->temporary) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'section_not_tmp',
|
||
'message' => 'That section is not suitable for entire batch temporary storage.',
|
||
], 409);
|
||
}
|
||
|
||
// Delete all existing sections for this position
|
||
StockSection::where('position_id', $position->position_id)->delete();
|
||
|
||
// Create new sections for each entry
|
||
$sectionCounter = 1;
|
||
foreach ($batch->stockEntries as $entry) {
|
||
// Create new section
|
||
$section = StockSection::create([
|
||
'position_id' => $position->position_id,
|
||
'section_name' => (string)$sectionCounter,
|
||
'section_symbol' => (string)$sectionCounter,
|
||
'capacity' => $entry->count,
|
||
'retrievable' => true
|
||
]);
|
||
|
||
// Create mapping
|
||
StockEntries2Section::create([
|
||
'entry_id' => $entry->id,
|
||
'section_id' => $section->section_id,
|
||
'count' => $entry->count
|
||
]);
|
||
|
||
$sectionCounter++;
|
||
}
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'message' => 'Batch stored to position successfully.',
|
||
], 200);
|
||
|
||
} catch (ModelNotFoundException $e) {
|
||
// Either the section or the entry was not found
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'not_found',
|
||
'message' => 'StockSection or StockEntry not found.',
|
||
], 404);
|
||
|
||
} catch (\Exception $e) {
|
||
// Log unexpected exceptions
|
||
Log::error('Error in setSectionForBatch', [
|
||
'position_id' => $positionId,
|
||
'batch_id' => $batchId,
|
||
'exception' => $e->getMessage(),
|
||
'trace' => $e->getTraceAsString(),
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'server_error',
|
||
'message' => 'An unexpected error occurred.',
|
||
], 500);
|
||
}
|
||
}
|
||
|
||
public function movePosition(Request $request)
|
||
{
|
||
// 1) Validate input
|
||
try {
|
||
$data = $request->validate([
|
||
'section_id' => 'required|integer|exists:stock_section,section_id',
|
||
'new_section_id' => 'required|integer|exists:stock_section,section_id',
|
||
'entry_id' => 'required|integer|exists:stock_entries,id',
|
||
'count' => 'required|integer|min:1',
|
||
]);
|
||
} catch (ValidationException $e) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'validation_failed',
|
||
'message' => $e->errors(),
|
||
], 422);
|
||
}
|
||
|
||
$sectionId = $data['section_id'];
|
||
$newSectionId = $data['new_section_id'];
|
||
$entryId = $data['entry_id'];
|
||
$count = $data['count'];
|
||
|
||
try {
|
||
// 2) Locate section and entry (404 if not found)
|
||
$new_section = StockSection::findOrFail($newSectionId);
|
||
$entry = StockEntry::findOrFail($entryId);
|
||
|
||
// 3) If the section is already occupied → 409 Conflict
|
||
if ($new_section->occupied()) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'section_occupied',
|
||
'message' => 'That section is already occupied.',
|
||
], 409);
|
||
}
|
||
|
||
// 4) Check capacity: count_to_be_stored must be <= section’s capacity
|
||
if ($count > $new_section->capacity) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'insufficient_capacity',
|
||
'message' => 'Not enough capacity in this section.',
|
||
'available_capacity' => $new_section->capacity,
|
||
], 409);
|
||
}
|
||
|
||
// use query builder because of composite PK
|
||
DB::table('stock_entries2section')
|
||
->where('entry_id', $entryId)
|
||
->where('section_id', $sectionId)
|
||
->limit(1) // ensure only one row is touched
|
||
->update([
|
||
'section_id' => $new_section->section_id,
|
||
'count' => $count,
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'message' => 'Section changed successfully.',
|
||
], 200);
|
||
|
||
} catch (ModelNotFoundException $e) {
|
||
// Either the section or the entry was not found
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'not_found',
|
||
'message' => 'StockSection or StockEntry not found.',
|
||
], 404);
|
||
|
||
} catch (\Exception $e) {
|
||
// Log unexpected exceptions
|
||
Log::error('Error in setSection', [
|
||
'section_id' => $sectionId,
|
||
'new_section_id' => $newSectionId,
|
||
'entry_id' => $entryId,
|
||
'count' => $count,
|
||
'exception' => $e->getMessage(),
|
||
'trace' => $e->getTraceAsString(),
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'server_error',
|
||
'message' => 'An unexpected error occurred.',
|
||
], 500);
|
||
}
|
||
}
|
||
|
||
public function changeCount(Request $request)
|
||
{
|
||
// 1) Validate input
|
||
try {
|
||
$data = $request->validate([
|
||
'section_id' => 'required|integer|exists:stock_section,section_id',
|
||
'entry_id' => 'required|integer|exists:stock_entries,id',
|
||
'count' => 'required|integer|min:1',
|
||
'new_count' => 'required|integer|min:1',
|
||
]);
|
||
} catch (ValidationException $e) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'validation_failed',
|
||
'message' => $e->errors(),
|
||
], 422);
|
||
}
|
||
|
||
$sectionId = $data['section_id'];
|
||
$entryId = $data['entry_id'];
|
||
$count = $data['count'];
|
||
$new_count = $data['new_count'];
|
||
|
||
try {
|
||
// 2) Locate section and entry (404 if not found)
|
||
$section = StockSection::findOrFail($sectionId);
|
||
$entry = StockEntry::findOrFail($entryId);
|
||
|
||
|
||
// Check capacity: count_to_be_stored must be <= section’s capacity
|
||
if ($new_count > $section->capacity) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'insufficient_capacity',
|
||
'message' => 'Not enough capacity in this section.',
|
||
'available_capacity' => $section->capacity,
|
||
], 409);
|
||
}
|
||
|
||
// use query builder because of composite PK
|
||
DB::table('stock_entries2section')
|
||
->where('entry_id', $entryId)
|
||
->where('section_id', $sectionId)
|
||
->limit(1) // ensure only one row is touched
|
||
->update([
|
||
'count' => $new_count,
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'message' => 'Section changed successfully.',
|
||
], 200);
|
||
|
||
} catch (ModelNotFoundException $e) {
|
||
// Either the section or the entry was not found
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'not_found',
|
||
'message' => 'StockSection or StockEntry not found.',
|
||
], 404);
|
||
|
||
} catch (\Exception $e) {
|
||
// Log unexpected exceptions
|
||
Log::error('Error in setSection', [
|
||
'section_id' => $sectionId,
|
||
'new_section_id' => $newSectionId,
|
||
'entry_id' => $entryId,
|
||
'count' => $count,
|
||
'exception' => $e->getMessage(),
|
||
'trace' => $e->getTraceAsString(),
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'server_error',
|
||
'message' => 'An unexpected error occurred.',
|
||
], 500);
|
||
}
|
||
}
|
||
|
||
public function store(Request $request)
|
||
{
|
||
// 1) Validate incoming payload
|
||
$validator = Validator::make($request->all(), [
|
||
'position_id' => 'required|integer|exists:stock_position,position_id',
|
||
'section_symbol' => 'required|string|max:5',
|
||
'section_name' => 'required|string|max:32',
|
||
'capacity' => 'required|integer|min:0',
|
||
'retrievable' => 'required|boolean',
|
||
]);
|
||
|
||
if ($validator->fails()) {
|
||
return response()
|
||
->json([
|
||
'success' => false,
|
||
'error' => 'validation_failed',
|
||
'message' => $validator->errors()->first(),
|
||
], 422);
|
||
}
|
||
|
||
// 2) Find the parent position
|
||
$position = StockPosition::find($request->input('position_id'));
|
||
if (!$position) {
|
||
return response()
|
||
->json([
|
||
'success' => false,
|
||
'error' => 'not_found',
|
||
'message' => 'Position not found.',
|
||
], 404);
|
||
}
|
||
|
||
$newCapacity = $request->input('capacity');
|
||
|
||
// 3) Check that (existing sections sum) + newCapacity ≤ position.capacity
|
||
$existingSum = $position
|
||
->sections()
|
||
->sum('capacity');
|
||
|
||
if ($existingSum + $newCapacity > $position->capacity) {
|
||
return response()
|
||
->json([
|
||
'success' => false,
|
||
'error' => 'insufficient_capacity',
|
||
'message' => "Creating this section would push total capacity ({$existingSum} + {$newCapacity}) over position capacity ({$position->capacity}).",
|
||
], 422);
|
||
}
|
||
|
||
// 4) Create the new section
|
||
$section = new StockSection();
|
||
$section->position_id = $position->position_id;
|
||
$section->section_symbol = $request->input('section_symbol');
|
||
$section->section_name = $request->input('section_name');
|
||
$section->capacity = $newCapacity;
|
||
$section->retrievable = $request->input('retrievable');
|
||
$section->save();
|
||
|
||
return response()
|
||
->json([
|
||
'success' => true,
|
||
'section_id' => $section->section_id,
|
||
], 200);
|
||
}
|
||
|
||
/**
|
||
* Update an existing StockSection.
|
||
*
|
||
* Endpoint: PUT /api/stockSections/{id}
|
||
*
|
||
* Request payload:
|
||
* {
|
||
* "section_name": <string>,
|
||
* "capacity": <integer ≥ 0>,
|
||
* "retrievable": <boolean>
|
||
* }
|
||
*
|
||
* Responses:
|
||
* • 200 → { success: true }
|
||
* • 404 → { success: false, error: "not_found", message: "Section not found." }
|
||
* • 422 → { success: false, error: "validation_failed" / "insufficient_capacity", message: "..." }
|
||
*/
|
||
public function update(Request $request, $id)
|
||
{
|
||
// 1) Find the section
|
||
$section = StockSection::find($id);
|
||
if (!$section) {
|
||
return response()
|
||
->json([
|
||
'success' => false,
|
||
'error' => 'not_found',
|
||
'message' => 'Section not found.',
|
||
], 404);
|
||
}
|
||
|
||
// 2) Validate incoming payload
|
||
$validator = Validator::make($request->all(), [
|
||
'section_name' => 'required|string|max:255',
|
||
'capacity' => 'required|integer|min:0',
|
||
'retrievable' => 'required|boolean',
|
||
]);
|
||
|
||
if ($validator->fails()) {
|
||
return response()
|
||
->json([
|
||
'success' => false,
|
||
'error' => 'validation_failed',
|
||
'message' => $validator->errors()->first(),
|
||
], 422);
|
||
}
|
||
|
||
$updatedCapacity = $request->input('capacity');
|
||
$position = $section->position; // via relation
|
||
|
||
// 3) Check that sum of *other* sections + updatedCapacity ≤ position.capacity
|
||
$otherSectionsSum = $position
|
||
->sections()
|
||
->where('section_id', '!=', $id)
|
||
->sum('capacity');
|
||
|
||
if ($otherSectionsSum + $updatedCapacity > $position->capacity) {
|
||
return response()
|
||
->json([
|
||
'success' => false,
|
||
'error' => 'insufficient_capacity',
|
||
'message' => "Updating to capacity {$updatedCapacity} would push total of other sections ({$otherSectionsSum}) over position capacity ({$position->capacity}).",
|
||
], 422);
|
||
}
|
||
|
||
// 4) Apply changes
|
||
$section->section_name = $request->input('section_name');
|
||
$section->capacity = $updatedCapacity;
|
||
$section->retrievable = $request->input('retrievable');
|
||
$section->save();
|
||
|
||
return response()->json(['success' => true], 200);
|
||
}
|
||
|
||
/**
|
||
* Delete a StockSection by its ID.
|
||
*
|
||
* Endpoint: DELETE /api/stockSections/{id}
|
||
*
|
||
* Responses:
|
||
* • 200 → { success: true }
|
||
* • 404 → { success: false, error: "not_found", message: "Section not found." }
|
||
*/
|
||
public function destroy($id)
|
||
{
|
||
$section = StockSection::find($id);
|
||
if (!$section) {
|
||
return response()
|
||
->json([
|
||
'success' => false,
|
||
'error' => 'not_found',
|
||
'message' => 'Section not found.',
|
||
], 404);
|
||
}
|
||
|
||
$section->delete();
|
||
|
||
return response()->json(['success' => true], 200);
|
||
}
|
||
|
||
}
|