Files
autocrm/app/Services/Warehouse/WarehouseService.php
T
Vasyka 426156fe45 Stage 5.1 — Warehouse ERP: batches + FIFO + reservations + multi-warehouse
Schema:
- warehouses (multi-warehouse, code unique per company, is_default)
- part_batches (lot per receipt, qty_in/qty_remaining, buy_price, FIFO-indexed)
- warehouse_events (immutable ledger: opening/receipt/issue/transfer/adjustment/write_off)
- part_reservations (per-WO allocations from specific batches, active/consumed/released)
- companies.default_warehouse_id + parts.qty_reserved

Backfill: 1 default warehouse + 1 opening batch per existing part per company.

WarehouseService:
- receive / issue (FIFO) / reserve / release / consume / transfer / adjust
- DB::transaction + lockForUpdate on batch rows
- InsufficientStockException with requested + available context
- Auto-syncs parts.qty as aggregate cache (source of truth = sum(qty_remaining))

WO integration:
- WorkOrderPart created/updated → reserve from FIFO batches
- WorkOrderPart deleted → release
- WorkOrder status=done → consume reservations into issue events
- WorkOrder status=cancelled → release reservations

Filament:
- WarehouseResource (CRUD)
- BatchesRelationManager on PartResource (FIFO list with qty_remaining + cost)
- "Recepție" action on parts list → calls WarehouseService::receive
- qty_reserved column added on parts list

Tests (8 new, all pass):
- receipt creates batch + event
- FIFO order verified across 3 batches with different received_at
- InsufficientStockException on over-issue
- Reservations block other reservations but don't deplete on-hand
- WO done consumes; WO cancelled releases
- Batches tenant-isolated
- Transfer between warehouses with weighted-avg cost

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:29:19 +00:00

441 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Warehouse;
use App\Models\Tenant\Part;
use App\Models\Tenant\PartBatch;
use App\Models\Tenant\PartReservation;
use App\Models\Tenant\Supplier;
use App\Models\Tenant\Warehouse;
use App\Models\Tenant\WarehouseEvent;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderPart;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class WarehouseService
{
/**
* Resolve the warehouse to operate on. Default = company.default_warehouse_id.
*/
public function defaultWarehouse(int $companyId): Warehouse
{
$company = \App\Models\Central\Company::withoutGlobalScopes()->findOrFail($companyId);
if ($company->default_warehouse_id) {
return Warehouse::findOrFail($company->default_warehouse_id);
}
// Lazy-create a default warehouse if missing (e.g. tenant created
// before warehouse migration). This makes the service self-healing.
$wh = Warehouse::create([
'company_id' => $companyId,
'code' => 'MAIN',
'name' => 'Depozit principal',
'is_default' => true,
]);
$company->forceFill(['default_warehouse_id' => $wh->id])->saveQuietly();
return $wh;
}
/**
* Receive new stock — creates a batch + receipt event + updates cached qty.
*/
public function receive(
Part $part,
float $qty,
float $buyPrice,
?Warehouse $warehouse = null,
?Supplier $supplier = null,
?string $batchRef = null,
?Model $ref = null,
?string $notes = null,
?Carbon $occurredAt = null,
): PartBatch {
if ($qty <= 0) {
throw new \InvalidArgumentException('Cantitatea de recepție trebuie să fie pozitivă.');
}
$warehouse ??= $this->defaultWarehouse($part->company_id);
$occurredAt ??= now();
return DB::transaction(function () use ($part, $qty, $buyPrice, $warehouse, $supplier, $batchRef, $ref, $notes, $occurredAt) {
$batch = PartBatch::create([
'company_id' => $part->company_id,
'part_id' => $part->id,
'warehouse_id' => $warehouse->id,
'supplier_id' => $supplier?->id,
'batch_ref' => $batchRef,
'qty_in' => $qty,
'qty_remaining' => $qty,
'buy_price' => $buyPrice,
'received_at' => $occurredAt,
'notes' => $notes,
]);
$this->logEvent($part, $batch, $warehouse, 'receipt', $qty, $buyPrice, $ref, $notes, $occurredAt);
$this->syncPartCachedQty($part);
return $batch;
});
}
/**
* Consume stock FIFO across batches. Writes one event per consumed batch.
* Throws InsufficientStockException if total available < qty.
*/
public function issue(
Part $part,
float $qty,
?Warehouse $warehouse = null,
?Model $ref = null,
?string $notes = null,
): array {
if ($qty <= 0) {
throw new \InvalidArgumentException('Cantitatea de consum trebuie să fie pozitivă.');
}
$warehouse ??= $this->defaultWarehouse($part->company_id);
return DB::transaction(function () use ($part, $qty, $warehouse, $ref, $notes) {
$available = $this->availableForIssue($part, $warehouse);
if ($available < $qty) {
throw new InsufficientStockException($part->id, $qty, $available);
}
$remaining = $qty;
$events = [];
$batches = $this->fifoBatches($part, $warehouse)->lockForUpdate()->get();
foreach ($batches as $batch) {
if ($remaining <= 0) break;
$take = min($remaining, (float) $batch->qty_remaining);
if ($take <= 0) continue;
$batch->qty_remaining = (float) $batch->qty_remaining - $take;
$batch->save();
$events[] = $this->logEvent(
$part, $batch, $warehouse, 'issue',
-$take, (float) $batch->buy_price, $ref, $notes
);
$remaining -= $take;
}
$this->syncPartCachedQty($part);
return $events;
});
}
/**
* Reserve qty from FIFO batches against a WorkOrderPart. Returns reservation rows.
*/
public function reserve(WorkOrderPart $wop): array
{
return DB::transaction(function () use ($wop) {
$part = $wop->part;
if (! $part) return []; // free-text part with no catalog link — skip
$warehouse = $this->defaultWarehouse($part->company_id);
$qty = (float) $wop->qty;
$available = $this->availableForReservation($part, $warehouse);
if ($available < $qty) {
throw new InsufficientStockException($part->id, $qty, $available);
}
$remaining = $qty;
$reservations = [];
$batches = $this->fifoBatchesAvailable($part, $warehouse)->lockForUpdate()->get();
foreach ($batches as $batch) {
if ($remaining <= 0) break;
$reservedOnBatch = (float) PartReservation::where('batch_id', $batch->id)
->where('status', PartReservation::STATUS_ACTIVE)
->sum('qty');
$free = (float) $batch->qty_remaining - $reservedOnBatch;
if ($free <= 0) continue;
$take = min($remaining, $free);
$reservations[] = PartReservation::create([
'company_id' => $part->company_id,
'work_order_id' => $wop->work_order_id,
'work_order_part_id' => $wop->id,
'part_id' => $part->id,
'batch_id' => $batch->id,
'qty' => $take,
'status' => PartReservation::STATUS_ACTIVE,
'reserved_at' => now(),
]);
$remaining -= $take;
}
$part->qty_reserved = (float) $part->qty_reserved + ($qty - $remaining);
$part->saveQuietly();
return $reservations;
});
}
/**
* Release active reservations on a WorkOrderPart (e.g. WO cancelled / line removed).
*/
public function release(WorkOrderPart $wop): int
{
return DB::transaction(function () use ($wop) {
$active = PartReservation::where('work_order_part_id', $wop->id)
->where('status', PartReservation::STATUS_ACTIVE)
->lockForUpdate()
->get();
$totalReleased = 0.0;
foreach ($active as $r) {
$r->status = PartReservation::STATUS_RELEASED;
$r->save();
$totalReleased += (float) $r->qty;
}
if ($wop->part_id && $totalReleased > 0) {
$part = Part::find($wop->part_id);
if ($part) {
$part->qty_reserved = max(0.0, (float) $part->qty_reserved - $totalReleased);
$part->saveQuietly();
}
}
return $active->count();
});
}
/**
* Consume reservations against a closed WO — converts each active reservation
* into an issue event and decrements batch qty_remaining.
*/
public function consume(WorkOrder $wo): int
{
return DB::transaction(function () use ($wo) {
$active = PartReservation::with(['batch', 'part'])
->where('work_order_id', $wo->id)
->where('status', PartReservation::STATUS_ACTIVE)
->lockForUpdate()
->get();
foreach ($active as $r) {
$batch = $r->batch;
if (! $batch) continue;
$take = min((float) $r->qty, (float) $batch->qty_remaining);
if ($take <= 0) continue;
$batch->qty_remaining = (float) $batch->qty_remaining - $take;
$batch->save();
$this->logEvent(
$r->part, $batch, $batch->warehouse,
'issue', -$take, (float) $batch->buy_price,
$wo, "WO #{$wo->number}"
);
$r->status = PartReservation::STATUS_CONSUMED;
$r->consumed_at = now();
$r->save();
if ($r->part) {
$r->part->qty_reserved = max(0.0, (float) $r->part->qty_reserved - (float) $r->qty);
$r->part->saveQuietly();
}
}
// Re-sync all touched parts.
$partIds = $active->pluck('part_id')->unique();
foreach ($partIds as $pid) {
if ($p = Part::find($pid)) $this->syncPartCachedQty($p);
}
return $active->count();
});
}
/**
* Move stock between warehouses (FIFO from source). Creates one transfer_out
* + one transfer_in batch in the destination warehouse.
*/
public function transfer(
Part $part,
float $qty,
Warehouse $from,
Warehouse $to,
?string $notes = null,
): PartBatch {
if ($qty <= 0) throw new \InvalidArgumentException('Cantitatea de transfer trebuie să fie pozitivă.');
if ($from->id === $to->id) throw new \InvalidArgumentException('Sursa și destinația sunt identice.');
return DB::transaction(function () use ($part, $qty, $from, $to, $notes) {
$available = $this->availableForIssue($part, $from);
if ($available < $qty) {
throw new InsufficientStockException($part->id, $qty, $available);
}
$remaining = $qty;
$totalCost = 0.0;
$batches = $this->fifoBatches($part, $from)->lockForUpdate()->get();
foreach ($batches as $batch) {
if ($remaining <= 0) break;
$take = min($remaining, (float) $batch->qty_remaining);
if ($take <= 0) continue;
$batch->qty_remaining = (float) $batch->qty_remaining - $take;
$batch->save();
$totalCost += $take * (float) $batch->buy_price;
$this->logEvent($part, $batch, $from, 'transfer_out', -$take, (float) $batch->buy_price, null, $notes);
$remaining -= $take;
}
$avgCost = $qty > 0 ? round($totalCost / $qty, 2) : 0.0;
$destBatch = PartBatch::create([
'company_id' => $part->company_id,
'part_id' => $part->id,
'warehouse_id' => $to->id,
'qty_in' => $qty,
'qty_remaining' => $qty,
'buy_price' => $avgCost,
'received_at' => now(),
'notes' => $notes ?? "Transfer din {$from->code}",
]);
$this->logEvent($part, $destBatch, $to, 'transfer_in', $qty, $avgCost, null, $notes);
return $destBatch;
});
}
/**
* Inventory adjustment (stock-take correction). Positive delta = mystery gain;
* negative = write-off. For losses we consume FIFO; for gains we open a new batch.
*/
public function adjust(Part $part, float $delta, ?Warehouse $warehouse = null, ?string $notes = null): void
{
if (abs($delta) < 0.001) return;
$warehouse ??= $this->defaultWarehouse($part->company_id);
DB::transaction(function () use ($part, $delta, $warehouse, $notes) {
if ($delta > 0) {
// Use average current cost or last buy_price as cost for the gain batch.
$cost = (float) ($part->batches()->latest('received_at')->value('buy_price') ?? $part->buy_price);
$batch = PartBatch::create([
'company_id' => $part->company_id,
'part_id' => $part->id,
'warehouse_id' => $warehouse->id,
'qty_in' => $delta,
'qty_remaining' => $delta,
'buy_price' => $cost,
'received_at' => now(),
'notes' => $notes ?? 'Ajustare manuală',
]);
$this->logEvent($part, $batch, $warehouse, 'adjustment', $delta, $cost, null, $notes);
} else {
$remaining = -$delta;
$batches = $this->fifoBatches($part, $warehouse)->lockForUpdate()->get();
foreach ($batches as $batch) {
if ($remaining <= 0) break;
$take = min($remaining, (float) $batch->qty_remaining);
if ($take <= 0) continue;
$batch->qty_remaining = (float) $batch->qty_remaining - $take;
$batch->save();
$this->logEvent($part, $batch, $warehouse, 'adjustment', -$take, (float) $batch->buy_price, null, $notes);
$remaining -= $take;
}
}
$this->syncPartCachedQty($part);
});
}
// ─── Internals ─────────────────────────────────────────────────
/**
* Available qty for FIFO issue ignoring reservations
* (caller decides whether reservations matter).
*/
public function availableForIssue(Part $part, Warehouse $warehouse): float
{
return (float) PartBatch::where('part_id', $part->id)
->where('warehouse_id', $warehouse->id)
->sum('qty_remaining');
}
/**
* Available for new reservation = on_hand active_reservations.
*/
public function availableForReservation(Part $part, Warehouse $warehouse): float
{
$onHand = $this->availableForIssue($part, $warehouse);
$reserved = (float) PartReservation::where('part_id', $part->id)
->where('status', PartReservation::STATUS_ACTIVE)
->whereHas('batch', fn ($q) => $q->where('warehouse_id', $warehouse->id))
->sum('qty');
return max(0.0, $onHand - $reserved);
}
protected function fifoBatches(Part $part, Warehouse $warehouse)
{
return PartBatch::where('part_id', $part->id)
->where('warehouse_id', $warehouse->id)
->where('qty_remaining', '>', 0)
->orderBy('received_at')
->orderBy('id');
}
protected function fifoBatchesAvailable(Part $part, Warehouse $warehouse)
{
return $this->fifoBatches($part, $warehouse);
}
protected function logEvent(
Part $part,
PartBatch $batch,
Warehouse $warehouse,
string $type,
float $qtyDelta,
?float $unitCost,
?Model $ref,
?string $notes,
?Carbon $occurredAt = null,
): WarehouseEvent {
return WarehouseEvent::create([
'company_id' => $part->company_id,
'part_id' => $part->id,
'batch_id' => $batch->id,
'warehouse_id' => $warehouse->id,
'type' => $type,
'qty_delta' => $qtyDelta,
'unit_cost' => $unitCost,
'ref_type' => $ref ? get_class($ref) : null,
'ref_id' => $ref?->getKey(),
'user_id' => auth()->id(),
'occurred_at' => $occurredAt ?? now(),
'notes' => $notes,
]);
}
/** Keep parts.qty in sync as a cached aggregate across all warehouses. */
protected function syncPartCachedQty(Part $part): void
{
$total = (float) PartBatch::where('part_id', $part->id)->sum('qty_remaining');
$part->qty = $total;
$part->saveQuietly();
}
}