470 lines
16 KiB
PHP
470 lines
16 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\OriginCountry;
|
||
use App\Models\StockEntry;
|
||
use App\Models\StockEntrySection;
|
||
use App\Models\StockEntryStatus;
|
||
use App\Models\StockEntryStatusHistory;
|
||
use App\Models\StockPosition;
|
||
use App\Models\PhysicalItem;
|
||
use App\Models\StockSection;
|
||
use App\Models\Supplier;
|
||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\Validator;
|
||
use Illuminate\Validation\Rule;
|
||
use Illuminate\Validation\ValidationException;
|
||
use OwenIt\Auditing\Models\Audit;
|
||
|
||
class StockEntryController extends Controller
|
||
{
|
||
/**
|
||
* Display a paginated listing of stock entries.
|
||
*
|
||
* @param Request $request
|
||
* @return \Illuminate\Http\JsonResponse
|
||
*/
|
||
public function index(Request $request)
|
||
{
|
||
$query = StockEntry::query()
|
||
->with(['physicalItem', 'supplier', 'sections', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin']);
|
||
|
||
// Apply filters if provided
|
||
if ($request->has('search')) {
|
||
$search = $request->search;
|
||
$query->whereHas('physicalItem', function($q) use ($search) {
|
||
$q->where('name', 'like', "%{$search}%");
|
||
});
|
||
}
|
||
|
||
// Sort
|
||
$sortField = $request->input('sort_field', 'updated_at');
|
||
$sortDirection = $request->input('sort_direction', 'desc');
|
||
$query->orderBy($sortField, $sortDirection);
|
||
|
||
// Paginate
|
||
$perPage = $request->input('per_page', 10);
|
||
$page = $request->input('page', 1);
|
||
|
||
$entries = $query->paginate($perPage, ['*'], 'page', $page);
|
||
|
||
return response()->json([
|
||
'data' => $entries->items(),
|
||
'meta' => [
|
||
'total' => $entries->total(),
|
||
'per_page' => $entries->perPage(),
|
||
'current_page' => $entries->currentPage(),
|
||
'last_page' => $entries->lastPage(),
|
||
],
|
||
]);
|
||
}
|
||
|
||
public function fetchOnTheWay(Request $request)
|
||
{
|
||
$query = StockEntry::query()
|
||
->with(['physicalItem', 'supplier', 'sections', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin'])->where('on_the_way', 1);
|
||
|
||
|
||
// Paginate
|
||
$perPage = $request->input('per_page', 10);
|
||
$page = $request->input('page', 1);
|
||
|
||
$entries = $query->paginate($perPage, ['*'], 'page', $page);
|
||
|
||
return response()->json([
|
||
'data' => $entries->items(),
|
||
'meta' => [
|
||
'total' => $entries->total(),
|
||
'per_page' => $entries->perPage(),
|
||
'current_page' => $entries->currentPage(),
|
||
'last_page' => $entries->lastPage(),
|
||
],
|
||
]);
|
||
}
|
||
|
||
public function addData(Request $request)
|
||
{
|
||
// build base rules
|
||
$rules = [
|
||
'physical_item_id' => 'required|integer|exists:vat_warehouse.physical_item,id',
|
||
'supplier_id' => 'required|integer|exists:vat_warehouse.supplier,id',
|
||
'count' => 'required|integer|min:0',
|
||
'price' => 'nullable|numeric|min:0',
|
||
'bought' => 'nullable|date',
|
||
'description' => 'nullable|string',
|
||
'note' => 'nullable|string',
|
||
'stock_batch_id' => 'nullable|integer|exists:stock_batch,id',
|
||
'country_of_origin_id' => 'required|integer|exists:vat_warehouse.country_of_origin,id',
|
||
'on_the_way' => 'boolean',
|
||
];
|
||
|
||
|
||
$validator = Validator::make($request->all(), $rules);
|
||
|
||
if ($validator->fails()) {
|
||
return response()->json(['errors' => $validator->errors()], 422);
|
||
}
|
||
|
||
// 1) create the main stock entry
|
||
$entry = StockEntry::create($request->only([
|
||
'physical_item_id',
|
||
'supplier_id',
|
||
'count',
|
||
'price',
|
||
'bought',
|
||
'description',
|
||
'note',
|
||
'country_of_origin_id',
|
||
'on_the_way',
|
||
'stock_batch_id',
|
||
]) + [
|
||
'created_by' => auth()->id() ?? 1,
|
||
]);
|
||
|
||
// 3) eager-load relations (including the full address hierarchy)
|
||
$entry->load([
|
||
'physicalItem',
|
||
'supplier',
|
||
'stockBatch',
|
||
'sections.position.shelf.rack.line.room',
|
||
]);
|
||
|
||
return response()->json([
|
||
'message' => 'Stock entry created successfully',
|
||
'data' => $entry,
|
||
], 201);
|
||
}
|
||
|
||
/**
|
||
* Display the specified stock entry.
|
||
*
|
||
* @param int $id
|
||
* @return \Illuminate\Http\JsonResponse
|
||
*/
|
||
public function show($id)
|
||
{
|
||
$entry = StockEntry::with(['physicalItem', 'supplier', 'stockPosition', 'stockBatch'])
|
||
->findOrFail($id);
|
||
|
||
return response()->json(['data' => $entry]);
|
||
}
|
||
|
||
/**
|
||
* Update the specified stock entry.
|
||
*
|
||
* @param Request $request
|
||
* @param int $id
|
||
* @return \Illuminate\Http\JsonResponse
|
||
*/
|
||
public function updateData(Request $request, $id)
|
||
{
|
||
// 1) pull apart
|
||
$entryForm = $request->input('entryForm', []);
|
||
$sections = $request->input('sections', []);
|
||
|
||
// 2) validation rules as before…
|
||
$rules = [
|
||
'entryForm.physical_item_id' => ['sometimes','integer','exists:vat_warehouse.physical_item,id'],
|
||
'entryForm.supplier_id' => ['sometimes','integer','exists:vat_warehouse.supplier,id'],
|
||
'entryForm.count' => ['sometimes','integer','min:0'],
|
||
'entryForm.price' => ['nullable','numeric','min:0'],
|
||
'entryForm.bought' => ['nullable','date'],
|
||
'entryForm.description' => ['nullable','string'],
|
||
'entryForm.note' => ['nullable','string'],
|
||
'entryForm.stock_batch_id' => ['nullable','integer','exists:stock_batch,id'],
|
||
'entryForm.country_of_origin_id' => ['sometimes','integer','exists:vat_warehouse.country_of_origin,id'],
|
||
'entryForm.on_the_way' => ['sometimes','boolean'],
|
||
];
|
||
|
||
// determine if sections are required
|
||
$needsSection = function() use ($request) {
|
||
return ! $request->input('entryForm.on_the_way', false)
|
||
&& ! is_null($request->input('entryForm.stock_batch_id'));
|
||
};
|
||
|
||
// validate sections array if needed
|
||
$rules['sections'] = [Rule::requiredIf($needsSection), 'array'];
|
||
$rules['sections.*.stock_position_id'] = [Rule::requiredIf($needsSection), 'integer', 'exists:stock_section,section_id'];
|
||
$rules['sections.*.count'] = [Rule::requiredIf($needsSection), 'integer', 'min:1'];
|
||
|
||
|
||
$validator = Validator::make($request->all(), $rules);
|
||
if ($validator->fails()) {
|
||
return response()->json(['errors'=>$validator->errors()], 422);
|
||
}
|
||
|
||
/** @var StockEntry $entry */
|
||
$entry = StockEntry::with('sections')->findOrFail($id);
|
||
|
||
// 3) Update the main row (this is auto‐audited by OwenIt)
|
||
$entry->update(array_merge(
|
||
array_filter($entryForm, fn($v) => !is_null($v)),
|
||
['updated_by'=> auth()->id() ?? 1]
|
||
));
|
||
|
||
// 4) Manual pivot‐audit:
|
||
// a) snapshot what was there
|
||
$oldPivots = $entry->sections
|
||
->pluck('pivot.count','pivot.section_id')
|
||
->toArray(); // [ section_id => oldCount, … ]
|
||
|
||
// b) perform the sync
|
||
$syncData = [];
|
||
foreach ($sections as $sec) {
|
||
if (!empty($sec['stock_position_id']) && !empty($sec['count'])) {
|
||
$syncData[(int)$sec['stock_position_id']] = ['count'=>(int)$sec['count']];
|
||
}
|
||
}
|
||
// $entry->sections()->sync($syncData);
|
||
// b) perform the sync, but don’t let the AuditableObserver fire here…
|
||
StockEntrySection::withoutAuditing(function() use ($entry, $syncData) {
|
||
$entry->sections()->sync($syncData);
|
||
});
|
||
|
||
// c) now compare old↔new and write audit rows
|
||
$userId = auth()->id() ?? null;
|
||
foreach ($syncData as $sectionId => $pivotData) {
|
||
$newCount = $pivotData['count'];
|
||
if (! array_key_exists($sectionId, $oldPivots)) {
|
||
// *** attached ***
|
||
Audit::create([
|
||
'user_id' => $userId,
|
||
'auditable_type'=> StockEntrySection::class,
|
||
'auditable_id' => $entry->id, // pivot has no single PK; we use entry ID
|
||
'event' => 'created',
|
||
'old_values' => [],
|
||
'new_values' => ['section_id'=>$sectionId,'count'=>$newCount],
|
||
]);
|
||
} elseif ($oldPivots[$sectionId] !== $newCount) {
|
||
// *** updated ***
|
||
Audit::create([
|
||
'user_id' => $userId,
|
||
'auditable_type'=> StockEntrySection::class,
|
||
'auditable_id' => $entry->id,
|
||
'event' => 'updated',
|
||
'old_values' => ['count'=>$oldPivots[$sectionId]],
|
||
'new_values' => ['count'=>$newCount],
|
||
]);
|
||
}
|
||
}
|
||
// d) any removed?
|
||
foreach (array_diff_key($oldPivots, $syncData) as $sectionId => $oldCount) {
|
||
Audit::create([
|
||
'user_id' => $userId,
|
||
'auditable_type'=> StockEntrySection::class,
|
||
'auditable_id' => $entry->id,
|
||
'event' => 'deleted',
|
||
'old_values' => ['section_id'=>$sectionId,'count'=>$oldCount],
|
||
'new_values' => [],
|
||
]);
|
||
}
|
||
|
||
// 5) reload and return
|
||
$entry->load([
|
||
'physicalItem',
|
||
'supplier',
|
||
'stockBatch',
|
||
'sections.position.shelf.rack.line.room',
|
||
]);
|
||
|
||
return response()->json([
|
||
'message'=>'Stock entry updated successfully',
|
||
'data' =>$entry,
|
||
]);
|
||
}
|
||
/**
|
||
* Remove the specified stock entry.
|
||
*
|
||
* @param int $id
|
||
* @return \Illuminate\Http\JsonResponse
|
||
*/
|
||
public function destroy($id)
|
||
{
|
||
$entry = StockEntry::findOrFail($id);
|
||
$entry->delete();
|
||
|
||
return response()->json([
|
||
'message' => 'Stock entry deleted successfully'
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Get options for dropdown lists.
|
||
*
|
||
* @return \Illuminate\Http\JsonResponse
|
||
*/
|
||
public function getOptions()
|
||
{
|
||
|
||
$stockPositions = StockSection::with('position.shelf.rack.line.room')
|
||
->get()
|
||
->map(function (StockSection $section) {
|
||
$pos = $section->position;
|
||
$shelf = $pos->shelf;
|
||
$rack = $shelf->rack;
|
||
$line = $rack->line;
|
||
$room = $line->room;
|
||
|
||
return [
|
||
'id' => $section->section_id,
|
||
'name' => sprintf(
|
||
'%s-%s-%s-%s-%s-%s',
|
||
$room->room_symbol,
|
||
$line->line_symbol,
|
||
$rack->rack_symbol,
|
||
$shelf->shelf_symbol,
|
||
$pos->position_symbol,
|
||
$section->section_symbol
|
||
),
|
||
'capacity' => $section->capacity,
|
||
'occupied' => $section->occupied()
|
||
];
|
||
});
|
||
|
||
// Get suppliers from warehouse DB
|
||
$suppliers = Supplier::select('id', 'name')->get();
|
||
|
||
// Get physical items from warehouse DB
|
||
$countriesOrigin = OriginCountry::select('id', 'code as name')->get();
|
||
|
||
return response()->json([
|
||
'stockPositions' => $stockPositions,
|
||
'suppliers' => $suppliers,
|
||
'countriesOrigin' => $countriesOrigin,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Get options for dropdown lists.
|
||
*
|
||
* @return \Illuminate\Http\JsonResponse
|
||
*/
|
||
public function getItems(Request $request)
|
||
{
|
||
// Get physical items from warehouse DB
|
||
$physicalItems = PhysicalItem::with(['type', 'manufacturer'])
|
||
->where('name', 'like', '%' . $request->input('item_name', '') . '%')
|
||
->limit(100)
|
||
->get();
|
||
|
||
return response()->json([
|
||
'physicalItems' => $physicalItems,
|
||
]);
|
||
}
|
||
|
||
public function audit(Request $request, $id)
|
||
{
|
||
// 1) Load the entry (so we can get its audits)
|
||
$entry = StockEntry::findOrFail($id);
|
||
|
||
// 2) Get audits for the entry model itself
|
||
$entryAudits = $entry->audits()->orderBy('created_at')->get();
|
||
|
||
// 3) Get audits for all pivot changes you logged under the pivot model
|
||
$pivotAudits = Audit::query()
|
||
->where('auditable_type', StockEntrySection::class)
|
||
->where('auditable_id', $id) // we used entry->id when creating these
|
||
->orderBy('created_at')
|
||
->get();
|
||
|
||
return response()->json([
|
||
'entry_audits' => $entryAudits,
|
||
'pivot_audits' => $pivotAudits,
|
||
]);
|
||
}
|
||
|
||
public function getStatusList(Request $request)
|
||
{
|
||
$entry = StockEntryStatus::all();
|
||
|
||
return response()->json([
|
||
'statuses' => $entry,
|
||
]);
|
||
}
|
||
|
||
public function countStock(Request $request)
|
||
{
|
||
// 1) Validate input
|
||
try {
|
||
$payload = $request->validate([
|
||
'entryId' => 'required|integer|exists:stock_entries,id',
|
||
'quantity' => 'required|integer|min:0',
|
||
]);
|
||
} catch (ValidationException $e) {
|
||
// Return 422 + a JSON body with errors
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'validation_failed',
|
||
'message' => $e->errors(),
|
||
], 422);
|
||
}
|
||
|
||
try {
|
||
// 2) Fetch the StockEntry (will throw ModelNotFoundException if not exists)
|
||
$entry = StockEntry::findOrFail($payload['entryId']);
|
||
|
||
// 3) If original_count is already set → 409 conflict
|
||
if ($entry->original_count !== 0) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'already_counted',
|
||
'message' => 'This stock entry has already been counted.',
|
||
], 409);
|
||
}
|
||
|
||
$quantity = $payload['quantity'];
|
||
// Determine which status to record
|
||
if ($entry->original_count_invoice === $quantity) {
|
||
StockEntryStatusHistory::create([
|
||
'stock_entries_id' => $entry->id,
|
||
'stock_entries_status_id' => StockEntryStatus::NEW_GOODS_COUNTED,
|
||
]);
|
||
} else {
|
||
$statusId = $entry->original_count_invoice > $quantity
|
||
? StockEntryStatus::NEW_GOODS_MISSING
|
||
: StockEntryStatus::NEW_GOODS_SURPLUS;
|
||
|
||
StockEntryStatusHistory::create([
|
||
'stock_entries_id' => $entry->id,
|
||
'stock_entries_status_id' => $statusId,
|
||
'status_note' => 'Count: ' . ($quantity - $entry->original_count_invoice)
|
||
]);
|
||
}
|
||
|
||
|
||
$entry->update([
|
||
'original_count' => $quantity,
|
||
'count' => $quantity,
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
]); // HTTP 200
|
||
|
||
} catch (ModelNotFoundException $e) {
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'not_found',
|
||
'message' => 'Stock entry not found.',
|
||
], 404);
|
||
|
||
} catch (\Exception $e) {
|
||
// Log the exception for debugging
|
||
Log::error('countStock error', [
|
||
'exception' => $e->getMessage(),
|
||
'trace' => $e->getTraceAsString(),
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => false,
|
||
'error' => 'server_error',
|
||
'message' => 'An unexpected error occurred.',
|
||
], 500);
|
||
}
|
||
}
|
||
}
|