fix inventory
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Inventory\InventoryMovementService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class InventoryReconcile extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'inventory:reconcile
|
||||
{--school-year= : The school year to reconcile (e.g., 2025-2026). Defaults to current.}
|
||||
{--fix : Automatically fix discrepancies by creating adjustment movements.}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Reconcile inventory item quantities against movement ledger totals';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(InventoryMovementService $movementService)
|
||||
{
|
||||
$schoolYear = $this->option('school-year');
|
||||
$autoFix = $this->option('fix');
|
||||
|
||||
$result = $movementService->reconcile($schoolYear);
|
||||
|
||||
$this->info("School Year: {$result['school_year']}");
|
||||
$this->info("Total Items: {$result['total_items']}");
|
||||
$this->info("Discrepancies: {$result['discrepancies_count']}");
|
||||
|
||||
if (empty($result['discrepancies'])) {
|
||||
$this->info('✅ All item quantities match movement ledger.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['ID', 'Name', 'Stored Qty', 'Movement Sum', 'Variance'],
|
||||
collect($result['discrepancies'])->map(fn ($d) => [
|
||||
$d['id'],
|
||||
$d['name'],
|
||||
$d['stored_quantity'],
|
||||
$d['movement_sum'],
|
||||
$d['variance'],
|
||||
])->toArray()
|
||||
);
|
||||
|
||||
if ($autoFix) {
|
||||
$this->info('Fixing discrepancies...');
|
||||
foreach ($result['discrepancies'] as $d) {
|
||||
if ($d['variance'] !== 0) {
|
||||
$movementService->recordMovement(
|
||||
itemId: $d['id'],
|
||||
qtyChange: -$d['variance'],
|
||||
type: 'adjust',
|
||||
reason: 'Auto-reconciliation: stored qty differed from movement sum',
|
||||
note: 'Reconciled from ' . $d['stored_quantity'] . ' to ' . $d['movement_sum'],
|
||||
performedBy: null
|
||||
);
|
||||
$this->line(" Fixed item #{$d['id']} ({$d['name']}): variance was {$d['variance']}");
|
||||
}
|
||||
}
|
||||
$this->info('✅ Fix applied.');
|
||||
} else {
|
||||
$this->warn('Run with --fix to automatically correct discrepancies.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ use App\Http\Requests\Inventory\InventoryItemUpdateRequest;
|
||||
use App\Http\Requests\Inventory\InventoryTeacherDistributionRequest;
|
||||
use App\Http\Requests\Inventory\InventoryTeacherDistributionStoreRequest;
|
||||
use App\Http\Resources\Inventory\InventoryItemResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Services\Inventory\InventoryItemService;
|
||||
use App\Services\Inventory\InventoryMovementService;
|
||||
use App\Services\Inventory\InventorySummaryService;
|
||||
use App\Services\Inventory\InventoryTeacherDistributionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -23,7 +25,8 @@ class InventoryController extends BaseApiController
|
||||
public function __construct(
|
||||
private InventoryItemService $items,
|
||||
private InventorySummaryService $summary,
|
||||
private InventoryTeacherDistributionService $distribution
|
||||
private InventoryTeacherDistributionService $distribution,
|
||||
private InventoryMovementService $movementService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -182,6 +185,161 @@ class InventoryController extends BaseApiController
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get low-stock items (where quantity <= reorder_point).
|
||||
*/
|
||||
public function lowStock(): JsonResponse
|
||||
{
|
||||
$items = InventoryItem::lowStock()
|
||||
->with('category', 'preferredSupplier')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return $this->success([
|
||||
'items' => InventoryItemResource::collection($items),
|
||||
'count' => $items->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reorder suggestions based on low-stock items.
|
||||
*/
|
||||
public function reorderSuggestions(): JsonResponse
|
||||
{
|
||||
$lowStockItems = InventoryItem::lowStock()
|
||||
->with('category', 'preferredSupplier')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$suggestions = $lowStockItems->map(function ($item) {
|
||||
$reorderQty = (int) ($item->reorder_quantity ?: 0);
|
||||
$currentQty = (int) $item->quantity;
|
||||
$reorderPoint = (int) ($item->reorder_point ?: 0);
|
||||
|
||||
// If no reorder quantity is set, suggest enough to bring back above reorder point
|
||||
if ($reorderQty <= 0) {
|
||||
$reorderQty = max(1, $reorderPoint - $currentQty + 5);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'current_quantity' => $currentQty,
|
||||
'reorder_point' => $reorderPoint,
|
||||
'suggested_order_qty' => $reorderQty,
|
||||
'preferred_supplier' => $item->preferredSupplier ? [
|
||||
'id' => $item->preferredSupplier->id,
|
||||
'name' => $item->preferredSupplier->name,
|
||||
] : null,
|
||||
'category' => $item->category ? $item->category->name : null,
|
||||
];
|
||||
});
|
||||
|
||||
return $this->success([
|
||||
'suggestions' => $suggestions,
|
||||
'count' => $suggestions->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reorder request for a low-stock item.
|
||||
*/
|
||||
public function reorderRequest(int $id): JsonResponse
|
||||
{
|
||||
$item = InventoryItem::with('preferredSupplier')->find($id);
|
||||
if (!$item) {
|
||||
return $this->error('Item not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$reorderQty = (int) ($item->reorder_quantity ?: 0);
|
||||
if ($reorderQty <= 0) {
|
||||
$reorderQty = max(1, ((int) ($item->reorder_point ?: 0)) - ((int) $item->quantity) + 5);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'item_id' => $item->id,
|
||||
'item_name' => $item->name,
|
||||
'current_quantity' => (int) $item->quantity,
|
||||
'suggested_quantity' => $reorderQty,
|
||||
'preferred_supplier' => $item->preferredSupplier ? [
|
||||
'id' => $item->preferredSupplier->id,
|
||||
'name' => $item->preferredSupplier->name,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* View all items with their stock status.
|
||||
*/
|
||||
public function stockStatus(): JsonResponse
|
||||
{
|
||||
$type = request()->query('type');
|
||||
$items = InventoryItem::query()
|
||||
->with('category', 'preferredSupplier')
|
||||
->when($type, fn ($q) => $q->where('type', $type))
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
$qty = (int) $item->quantity;
|
||||
$reorderPoint = (int) ($item->reorder_point ?: 0);
|
||||
$status = 'ok';
|
||||
if ($qty <= 0) {
|
||||
$status = 'out_of_stock';
|
||||
} elseif ($reorderPoint > 0 && $qty <= $reorderPoint) {
|
||||
$status = 'low_stock';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'type' => $item->type,
|
||||
'category' => $item->category ? $item->category->name : null,
|
||||
'quantity' => $qty,
|
||||
'reorder_point' => $reorderPoint,
|
||||
'status' => $status,
|
||||
];
|
||||
});
|
||||
|
||||
$counts = [
|
||||
'ok' => $items->where('status', 'ok')->count(),
|
||||
'low_stock' => $items->where('status', 'low_stock')->count(),
|
||||
'out_of_stock' => $items->where('status', 'out_of_stock')->count(),
|
||||
];
|
||||
|
||||
return $this->success([
|
||||
'items' => $items,
|
||||
'counts' => $counts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory dashboard summary.
|
||||
*/
|
||||
public function dashboard(): JsonResponse
|
||||
{
|
||||
$schoolYear = request()->query('school_year');
|
||||
|
||||
$items = InventoryItem::query()
|
||||
->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->get();
|
||||
|
||||
$totalItems = $items->count();
|
||||
$byType = $items->groupBy('type')->map(fn ($g) => $g->count());
|
||||
$lowStockCount = $items->filter(fn ($i) => $i->reorder_point && $i->quantity <= $i->reorder_point)->count();
|
||||
$outOfStockCount = $items->filter(fn ($i) => $i->quantity <= 0)->count();
|
||||
$needsRepairCount = $items->sum('needs_repair_qty');
|
||||
$missingCount = $items->sum('cannot_find_qty');
|
||||
|
||||
return $this->success([
|
||||
'total_items' => $totalItems,
|
||||
'by_type' => $byType,
|
||||
'low_stock_count' => $lowStockCount,
|
||||
'out_of_stock_count' => $outOfStockCount,
|
||||
'needs_repair_count' => (int) $needsRepairCount,
|
||||
'missing_count' => (int) $missingCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|JsonResponse
|
||||
*/
|
||||
|
||||
@@ -73,6 +73,57 @@ class InventoryMovementController extends BaseApiController
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Void a movement (append-only correction).
|
||||
*/
|
||||
public function void(int $id): JsonResponse
|
||||
{
|
||||
$actor = $this->authenticatedUserIdOrUnauthorized();
|
||||
if ($actor instanceof JsonResponse) {
|
||||
return $actor;
|
||||
}
|
||||
|
||||
$reason = (string) request()->input('reason', '');
|
||||
if (empty(trim($reason))) {
|
||||
return $this->error('Reason is required to void a movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$result = $this->movements->voidMovement($id, $reason, $actor);
|
||||
if (!($result['ok'] ?? false)) {
|
||||
return $this->error($result['message'] ?? 'Failed to void movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return $this->success(['message' => 'Movement voided successfully.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a correction movement.
|
||||
*/
|
||||
public function correct(int $id): JsonResponse
|
||||
{
|
||||
$actor = $this->authenticatedUserIdOrUnauthorized();
|
||||
if ($actor instanceof JsonResponse) {
|
||||
return $actor;
|
||||
}
|
||||
|
||||
$qtyChange = (int) request()->input('qty_change', 0);
|
||||
$reason = (string) request()->input('reason', '');
|
||||
|
||||
if ($qtyChange === 0) {
|
||||
return $this->error('Quantity change must be non-zero.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
if (empty(trim($reason))) {
|
||||
return $this->error('Reason is required.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$result = $this->movements->correctMovement($id, $qtyChange, $reason, $actor);
|
||||
if (!($result['ok'] ?? false)) {
|
||||
return $this->error($result['message'] ?? 'Failed to correct movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return $this->success(['message' => 'Correction movement created.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|JsonResponse
|
||||
*/
|
||||
|
||||
@@ -14,13 +14,13 @@ class InventoryItemStoreRequest extends ApiFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', 'string', 'max:20'],
|
||||
'category_id' => ['nullable', 'integer', 'min:1'],
|
||||
'type' => ['required', 'string', 'in:classroom,book,office,kitchen'],
|
||||
'category_id' => ['nullable', 'integer', 'min:1', 'exists:inventory_categories,id'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'quantity' => ['nullable', 'integer'],
|
||||
'quantity' => ['nullable', 'integer', 'min:0'],
|
||||
'unit' => ['nullable', 'string', 'max:50'],
|
||||
'condition' => ['nullable', 'string', 'max:50'],
|
||||
'condition' => ['nullable', 'string', 'in:good,needs_repair,need_replace,cannot_find'],
|
||||
'isbn' => ['nullable', 'string', 'max:50'],
|
||||
'edition' => ['nullable', 'string', 'max:50'],
|
||||
'sku' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
@@ -14,12 +14,12 @@ class InventoryItemUpdateRequest extends ApiFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => ['nullable', 'integer', 'min:1'],
|
||||
'category_id' => ['nullable', 'integer', 'min:1', 'exists:inventory_categories,id'],
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'quantity' => ['nullable', 'integer'],
|
||||
// quantity is intentionally excluded: stock changes must go through movements
|
||||
'unit' => ['nullable', 'string', 'max:50'],
|
||||
'condition' => ['nullable', 'string', 'max:50'],
|
||||
'condition' => ['nullable', 'string', 'in:good,needs_repair,need_replace,cannot_find'],
|
||||
'isbn' => ['nullable', 'string', 'max:50'],
|
||||
'edition' => ['nullable', 'string', 'max:50'],
|
||||
'sku' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
@@ -28,6 +28,16 @@ class InventoryItemResource extends JsonResource
|
||||
'semester' => $this['semester'] ?? null,
|
||||
'school_year' => $this['school_year'] ?? null,
|
||||
'updated_by' => $this['updated_by'] ?? null,
|
||||
// New fields
|
||||
'preferred_supplier_id' => $this['preferred_supplier_id'] ?? null,
|
||||
'last_purchase_price' => $this['last_purchase_price'] ?? null,
|
||||
'average_unit_cost' => $this['average_unit_cost'] ?? null,
|
||||
'reorder_point' => $this['reorder_point'] ?? null,
|
||||
'reorder_quantity' => $this['reorder_quantity'] ?? null,
|
||||
'lead_time_days' => $this['lead_time_days'] ?? null,
|
||||
'barcode' => $this['barcode'] ?? null,
|
||||
'qr_code' => $this['qr_code'] ?? null,
|
||||
'asset_tag' => $this['asset_tag'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,17 @@ class InventoryItem extends BaseModel
|
||||
'needs_repair_qty',
|
||||
'need_replace_qty',
|
||||
'cannot_find_qty',
|
||||
// Supplier/reorder fields
|
||||
'preferred_supplier_id',
|
||||
'last_purchase_price',
|
||||
'average_unit_cost',
|
||||
'reorder_point',
|
||||
'reorder_quantity',
|
||||
'lead_time_days',
|
||||
// Barcode/QR fields
|
||||
'barcode',
|
||||
'qr_code',
|
||||
'asset_tag',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -41,6 +52,13 @@ class InventoryItem extends BaseModel
|
||||
'needs_repair_qty' => 'integer',
|
||||
'need_replace_qty' => 'integer',
|
||||
'cannot_find_qty' => 'integer',
|
||||
|
||||
'preferred_supplier_id' => 'integer',
|
||||
'last_purchase_price' => 'decimal:2',
|
||||
'average_unit_cost' => 'decimal:2',
|
||||
'reorder_point' => 'integer',
|
||||
'reorder_quantity' => 'integer',
|
||||
'lead_time_days' => 'integer',
|
||||
];
|
||||
|
||||
/* Optional relationships */
|
||||
@@ -48,4 +66,25 @@ class InventoryItem extends BaseModel
|
||||
{
|
||||
return $this->belongsTo(InventoryCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function preferredSupplier()
|
||||
{
|
||||
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
|
||||
}
|
||||
|
||||
public function movements()
|
||||
{
|
||||
return $this->hasMany(InventoryMovement::class, 'item_id');
|
||||
}
|
||||
|
||||
public function scopeLowStock($query)
|
||||
{
|
||||
return $query->whereNotNull('reorder_point')
|
||||
->whereColumn('quantity', '<=', 'reorder_point');
|
||||
}
|
||||
|
||||
public function scopeOutOfStock($query)
|
||||
{
|
||||
return $query->where('quantity', '<=', 0);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,13 @@ class InventoryMovement extends BaseModel
|
||||
'teacher_id',
|
||||
'student_id',
|
||||
'class_section_id',
|
||||
// Audit/correction fields
|
||||
'voided_at',
|
||||
'voided_by',
|
||||
'void_reason',
|
||||
'corrects_movement_id',
|
||||
'source_type',
|
||||
'source_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -32,8 +39,23 @@ class InventoryMovement extends BaseModel
|
||||
'teacher_id' => 'integer',
|
||||
'student_id' => 'integer',
|
||||
'class_section_id'=> 'integer',
|
||||
'voided_by' => 'integer',
|
||||
'corrects_movement_id' => 'integer',
|
||||
'source_id' => 'integer',
|
||||
'voided_at' => 'datetime',
|
||||
];
|
||||
|
||||
/* Scopes */
|
||||
public function scopeNotVoided($query)
|
||||
{
|
||||
return $query->whereNull('voided_at');
|
||||
}
|
||||
|
||||
public function scopeVoided($query)
|
||||
{
|
||||
return $query->whereNotNull('voided_at');
|
||||
}
|
||||
|
||||
/* Optional relationships */
|
||||
public function item()
|
||||
{
|
||||
@@ -59,4 +81,19 @@ class InventoryMovement extends BaseModel
|
||||
{
|
||||
return $this->belongsTo(ClassSection::class, 'class_section_id');
|
||||
}
|
||||
|
||||
public function voidedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'voided_by');
|
||||
}
|
||||
|
||||
public function correctsMovement()
|
||||
{
|
||||
return $this->belongsTo(self::class, 'corrects_movement_id');
|
||||
}
|
||||
|
||||
public function corrections()
|
||||
{
|
||||
return $this->hasMany(self::class, 'corrects_movement_id');
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class PurchaseOrderItem extends BaseModel
|
||||
protected $fillable = [
|
||||
'purchase_order_id',
|
||||
'supply_id',
|
||||
'inventory_item_id',
|
||||
'description',
|
||||
'quantity',
|
||||
'received_qty',
|
||||
|
||||
@@ -17,6 +17,7 @@ class InventoryMovementService
|
||||
{
|
||||
$selectedYear = trim((string) ($filters['school_year'] ?? $this->context->schoolYear()));
|
||||
$selectedSem = trim((string) ($filters['semester'] ?? $this->context->semester()));
|
||||
$includeVoided = !empty($filters['include_voided']);
|
||||
|
||||
$builder = DB::table('inventory_movements as m')
|
||||
->select([
|
||||
@@ -34,6 +35,11 @@ class InventoryMovementService
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'm.class_section_id')
|
||||
->orderBy('m.id', 'DESC');
|
||||
|
||||
// Exclude voided movements by default
|
||||
if (!$includeVoided) {
|
||||
$builder->whereNull('m.voided_at');
|
||||
}
|
||||
|
||||
if ($selectedYear !== '') {
|
||||
$builder->where('m.school_year', $selectedYear);
|
||||
}
|
||||
@@ -44,6 +50,10 @@ class InventoryMovementService
|
||||
return $builder->get()->map(fn ($r) => (array) $r)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use recordMovement() instead. This method allows editing
|
||||
* movement history which weakens auditability.
|
||||
*/
|
||||
public function create(array $data, int $performedBy = 0): array
|
||||
{
|
||||
$itemId = (int) ($data['item_id'] ?? 0);
|
||||
@@ -60,19 +70,7 @@ class InventoryMovementService
|
||||
return ['ok' => false, 'message' => 'Not enough stock to apply this movement.'];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'item_id' => $itemId,
|
||||
'qty_change' => $qtyChange,
|
||||
'movement_type' => $movementType,
|
||||
'reason' => trim((string) ($data['reason'] ?? '')),
|
||||
'note' => trim((string) ($data['note'] ?? '')),
|
||||
'semester' => $data['semester'] ?? $this->context->semester(),
|
||||
'school_year' => $data['school_year'] ?? $this->context->schoolYear(),
|
||||
'performed_by' => $performedBy ?: null,
|
||||
'teacher_id' => $this->nullableInt($data['teacher_id'] ?? null),
|
||||
'student_id' => $this->nullableInt($data['student_id'] ?? null),
|
||||
'class_section_id' => $this->nullableInt($data['class_section_id'] ?? null),
|
||||
];
|
||||
$payload = $this->buildMovementPayload($data, $itemId, $qtyChange, $movementType, $performedBy);
|
||||
|
||||
try {
|
||||
InventoryMovement::query()->create($payload);
|
||||
@@ -85,6 +83,10 @@ class InventoryMovementService
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do NOT edit movements directly. Use voidMovement() + recordMovement() instead.
|
||||
* Editing historical movements destroys audit integrity.
|
||||
*/
|
||||
public function update(int $id, array $data): array
|
||||
{
|
||||
$movement = InventoryMovement::query()->find($id);
|
||||
@@ -92,6 +94,11 @@ class InventoryMovementService
|
||||
return ['ok' => false, 'message' => 'Movement not found.'];
|
||||
}
|
||||
|
||||
// Prevent editing voided movements
|
||||
if ($movement->voided_at) {
|
||||
return ['ok' => false, 'message' => 'Cannot edit a voided movement.'];
|
||||
}
|
||||
|
||||
$movementType = (string) ($data['movement_type'] ?? $movement->movement_type);
|
||||
$qtyChange = (int) ($data['qty_change'] ?? $movement->qty_change);
|
||||
$qtyChange = $this->normalizeMovementQty($movementType, $qtyChange);
|
||||
@@ -125,6 +132,10 @@ class InventoryMovementService
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do NOT hard-delete movements. Use voidMovement() instead.
|
||||
* Deleting historical movements destroys audit integrity.
|
||||
*/
|
||||
public function delete(int $id): array
|
||||
{
|
||||
$movement = InventoryMovement::query()->find($id);
|
||||
@@ -132,6 +143,14 @@ class InventoryMovementService
|
||||
return ['ok' => false, 'message' => 'Movement not found.'];
|
||||
}
|
||||
|
||||
// Prevent hard-deleting movements that have corrections
|
||||
$hasCorrections = InventoryMovement::query()
|
||||
->where('corrects_movement_id', $id)
|
||||
->exists();
|
||||
if ($hasCorrections) {
|
||||
return ['ok' => false, 'message' => 'Cannot delete a movement that has corrections. Void it instead.'];
|
||||
}
|
||||
|
||||
$itemId = (int) $movement->item_id;
|
||||
try {
|
||||
$movement->delete();
|
||||
@@ -146,6 +165,10 @@ class InventoryMovementService
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do NOT bulk-delete movements. Use voidMovement() instead.
|
||||
* Deleting historical movements destroys audit integrity.
|
||||
*/
|
||||
public function bulkDelete(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', $ids), static fn ($v) => $v > 0));
|
||||
@@ -153,6 +176,14 @@ class InventoryMovementService
|
||||
return ['ok' => false, 'message' => 'No movements selected.'];
|
||||
}
|
||||
|
||||
// Check for corrections
|
||||
$hasCorrections = InventoryMovement::query()
|
||||
->whereIn('corrects_movement_id', $ids)
|
||||
->exists();
|
||||
if ($hasCorrections) {
|
||||
return ['ok' => false, 'message' => 'Cannot delete movements that have corrections. Void them instead.'];
|
||||
}
|
||||
|
||||
$itemIds = DB::table('inventory_movements')
|
||||
->select('item_id')
|
||||
->whereIn('id', $ids)
|
||||
@@ -176,6 +207,10 @@ class InventoryMovementService
|
||||
return ['ok' => true, 'deleted' => count($ids)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a new inventory movement (append-only).
|
||||
* This is the primary method for all stock changes.
|
||||
*/
|
||||
public function recordMovement(
|
||||
int $itemId,
|
||||
int $qtyChange,
|
||||
@@ -220,11 +255,107 @@ class InventoryMovementService
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Void an existing movement (append-only correction).
|
||||
* The original movement is preserved but marked as voided.
|
||||
*/
|
||||
public function voidMovement(int $movementId, string $reason, int $voidedBy): array
|
||||
{
|
||||
$movement = InventoryMovement::query()->find($movementId);
|
||||
if (!$movement) {
|
||||
return ['ok' => false, 'message' => 'Movement not found.'];
|
||||
}
|
||||
|
||||
if ($movement->voided_at) {
|
||||
return ['ok' => false, 'message' => 'Movement is already voided.'];
|
||||
}
|
||||
|
||||
$itemId = (int) $movement->item_id;
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($movement, $reason, $voidedBy, $itemId) {
|
||||
// Mark original as voided
|
||||
$movement->update([
|
||||
'voided_at' => now(),
|
||||
'voided_by' => $voidedBy,
|
||||
'void_reason' => $reason,
|
||||
]);
|
||||
|
||||
// Create reverse movement to correct the quantity
|
||||
$reverseQty = -(int) $movement->qty_change;
|
||||
if ($reverseQty !== 0) {
|
||||
InventoryMovement::query()->create([
|
||||
'item_id' => $itemId,
|
||||
'qty_change' => $reverseQty,
|
||||
'movement_type' => 'adjust',
|
||||
'reason' => 'Correction: voided movement #' . $movement->id,
|
||||
'note' => $reason,
|
||||
'semester' => $this->context->semester(),
|
||||
'school_year' => $this->context->schoolYear(),
|
||||
'performed_by' => $voidedBy,
|
||||
'corrects_movement_id' => $movement->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->recalcQuantity($itemId);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Inventory movement void failed: ' . $e->getMessage());
|
||||
return ['ok' => false, 'message' => 'Could not void movement.'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a correction movement for an existing movement.
|
||||
* This does NOT void the original; it adds an adjusting entry.
|
||||
*/
|
||||
public function correctMovement(int $movementId, int $correctedQtyChange, string $reason, int $performedBy): array
|
||||
{
|
||||
$movement = InventoryMovement::query()->find($movementId);
|
||||
if (!$movement) {
|
||||
return ['ok' => false, 'message' => 'Movement not found.'];
|
||||
}
|
||||
|
||||
if ($movement->voided_at) {
|
||||
return ['ok' => false, 'message' => 'Cannot correct a voided movement.'];
|
||||
}
|
||||
|
||||
$itemId = (int) $movement->item_id;
|
||||
|
||||
if ($this->wouldGoNegative($itemId, $correctedQtyChange)) {
|
||||
return ['ok' => false, 'message' => 'Not enough stock to apply correction.'];
|
||||
}
|
||||
|
||||
try {
|
||||
InventoryMovement::query()->create([
|
||||
'item_id' => $itemId,
|
||||
'qty_change' => $correctedQtyChange,
|
||||
'movement_type' => 'adjust',
|
||||
'reason' => 'Correction for movement #' . $movement->id,
|
||||
'note' => $reason,
|
||||
'semester' => $this->context->semester(),
|
||||
'school_year' => $this->context->schoolYear(),
|
||||
'performed_by' => $performedBy,
|
||||
'corrects_movement_id' => $movement->id,
|
||||
]);
|
||||
|
||||
$this->recalcQuantity($itemId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Inventory movement correction failed: ' . $e->getMessage());
|
||||
return ['ok' => false, 'message' => 'Could not correct movement.'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public function recalcQuantity(int $itemId): void
|
||||
{
|
||||
$sumRow = InventoryMovement::query()
|
||||
->selectRaw('SUM(qty_change) as qty_change')
|
||||
->where('item_id', $itemId)
|
||||
->whereNull('voided_at') // Exclude voided movements from calculations
|
||||
->first();
|
||||
|
||||
$sum = (int) ($sumRow?->qty_change ?? 0);
|
||||
@@ -255,6 +386,47 @@ class InventoryMovementService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile inventory items - compare stored quantity against movement totals.
|
||||
* Returns an array of items that have discrepancies.
|
||||
*/
|
||||
public function reconcile(?string $schoolYear = null): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: $this->context->schoolYear();
|
||||
$discrepancies = [];
|
||||
|
||||
$items = InventoryItem::query()
|
||||
->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->get();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$movementSum = (int) InventoryMovement::query()
|
||||
->where('item_id', $item->id)
|
||||
->whereNull('voided_at')
|
||||
->sum('qty_change');
|
||||
|
||||
$storedQty = (int) $item->quantity;
|
||||
$variance = $storedQty - $movementSum;
|
||||
|
||||
if ($variance !== 0) {
|
||||
$discrepancies[] = [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'stored_quantity' => $storedQty,
|
||||
'movement_sum' => $movementSum,
|
||||
'variance' => $variance,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'school_year' => $schoolYear,
|
||||
'total_items' => $items->count(),
|
||||
'discrepancies_count' => count($discrepancies),
|
||||
'discrepancies' => $discrepancies,
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureInitialMovement(int $itemId): void
|
||||
{
|
||||
$hasMov = InventoryMovement::query()->where('item_id', $itemId)->exists();
|
||||
@@ -338,4 +510,21 @@ class InventoryMovementService
|
||||
}
|
||||
return (int) $val;
|
||||
}
|
||||
|
||||
private function buildMovementPayload(array $data, int $itemId, int $qtyChange, string $movementType, int $performedBy): array
|
||||
{
|
||||
return [
|
||||
'item_id' => $itemId,
|
||||
'qty_change' => $qtyChange,
|
||||
'movement_type' => $movementType,
|
||||
'reason' => trim((string) ($data['reason'] ?? '')),
|
||||
'note' => trim((string) ($data['note'] ?? '')),
|
||||
'semester' => $data['semester'] ?? $this->context->semester(),
|
||||
'school_year' => $data['school_year'] ?? $this->context->schoolYear(),
|
||||
'performed_by' => $performedBy ?: null,
|
||||
'teacher_id' => $this->nullableInt($data['teacher_id'] ?? null),
|
||||
'student_id' => $this->nullableInt($data['student_id'] ?? null),
|
||||
'class_section_id' => $this->nullableInt($data['class_section_id'] ?? null),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,17 @@ use App\Models\PurchaseOrder;
|
||||
use App\Models\PurchaseOrderItem;
|
||||
use App\Models\Supply;
|
||||
use App\Models\SupplyTransaction;
|
||||
use App\Services\Inventory\InventoryMovementService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class PurchaseOrderReceiveService
|
||||
{
|
||||
public function __construct(
|
||||
private InventoryMovementService $inventoryMovementService
|
||||
) {
|
||||
}
|
||||
|
||||
public function receive(int $poId, array $received, string $issuedBy): array
|
||||
{
|
||||
$po = PurchaseOrder::query()->find($poId);
|
||||
@@ -51,24 +57,36 @@ class PurchaseOrderReceiveService
|
||||
$poItem->received_qty = (int) $poItem->received_qty + $toReceive;
|
||||
$poItem->save();
|
||||
|
||||
// Update supply qty_on_hand (legacy support)
|
||||
$supply = Supply::query()->find($poItem->supply_id);
|
||||
if (!$supply) {
|
||||
$completed = false;
|
||||
continue;
|
||||
if ($supply) {
|
||||
$supply->qty_on_hand = (int) $supply->qty_on_hand + $toReceive;
|
||||
$supply->save();
|
||||
|
||||
SupplyTransaction::query()->create([
|
||||
'supply_id' => $supply->id,
|
||||
'type' => 'in',
|
||||
'quantity' => $toReceive,
|
||||
'ref' => 'PO ' . ($po->po_number ?? $po->id),
|
||||
'issued_to' => 'Inventory',
|
||||
'issued_by' => $issuedBy,
|
||||
'notes' => 'Received against PO',
|
||||
]);
|
||||
}
|
||||
|
||||
$supply->qty_on_hand = (int) $supply->qty_on_hand + $toReceive;
|
||||
$supply->save();
|
||||
|
||||
SupplyTransaction::query()->create([
|
||||
'supply_id' => $supply->id,
|
||||
'type' => 'in',
|
||||
'quantity' => $toReceive,
|
||||
'ref' => 'PO ' . ($po->po_number ?? $po->id),
|
||||
'issued_to' => 'Inventory',
|
||||
'issued_by' => $issuedBy,
|
||||
'notes' => 'Received against PO',
|
||||
]);
|
||||
// Create inventory movement if the PO item is linked to an inventory item
|
||||
$inventoryItemId = $poItem->inventory_item_id;
|
||||
if ($inventoryItemId) {
|
||||
$performedBy = is_numeric($issuedBy) ? (int) $issuedBy : null;
|
||||
$this->inventoryMovementService->recordMovement(
|
||||
itemId: (int) $inventoryItemId,
|
||||
qtyChange: $toReceive,
|
||||
type: 'in',
|
||||
reason: 'Purchase order received',
|
||||
note: 'PO ' . ($po->po_number ?? $po->id),
|
||||
performedBy: $performedBy
|
||||
);
|
||||
}
|
||||
|
||||
if ($poItem->received_qty < $poItem->quantity) {
|
||||
$completed = false;
|
||||
|
||||
Reference in New Issue
Block a user