Files
autocrm/app/Services/Warehouse/WarehouseService.php
T
Vasyka e48ef1b755 Stage 7+14 — Mechanic Board + Scan Center
Mechanic Workflow (Stage 7):
- /app/mechanic Filament page filtered to master_id = auth user
- Kanban 4 columns (in_work / awaiting_parts / ready / recent), each card
  shows WO#, plate, client, complaint summary, photo presence
- 2 KPI tiles (active now / closed today)
- Mobile-responsive grid (auto-fit, minmax 260px)

WarehouseService:
- issueNow(WorkOrderPart) — consume reservations immediately scoped to one
  line, without closing the WO (mechanic physically takes part now)
- returnPart(WorkOrderPart, qty?, notes?) — refund to stock as new batch
  at original buy_price, writes `return` event, capped at consumed total

WO PartsRelationManager:
- "Eliberează" action — visible when active reservation exists
- "Restituire" action — visible when consumed reservation exists, with qty
  modal + notes

Scan Center (Stage 14):
- PartResource "QR" action — per-part SVG QR with payload PART:<article|id>
- BulkAction "Tipărește etichete QR" → /parts/labels?ids=N,M (HTML A4 sheet,
  3-col grid, print CSS hides toolbar)
- /app/scan Filament page using html5-qrcode 2.3.8 (CDN), auto-picks back
  camera, decodes → Livewire dispatches scanner-decoded → resolveAndRedirect
- Lookup matches PART:N prefix, parts.article, parts.barcode, or numeric id
- Manual input fallback for browsers without camera

Tests (6 new):
- WarehouseIssueReturnTest (3): issueNow consumes immediately; returnPart
  creates positive batch + return event; over-return is capped
- ScannerLookupTest (3): PART: prefix lookup, raw barcode lookup, unknown miss

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

530 lines
19 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();
});
}
/**
* Issue parts NOW for a single WO line — used when the mechanic physically
* takes the part from the shelf before the WO is closed. Same logic as
* consume() but scoped to one work_order_part_id.
*/
public function issueNow(WorkOrderPart $wop): int
{
return DB::transaction(function () use ($wop) {
$active = PartReservation::with(['batch', 'part'])
->where('work_order_part_id', $wop->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,
$wop->workOrder, "WO part #{$wop->id} (issue now)"
);
$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();
}
}
if ($wop->part) $this->syncPartCachedQty($wop->part);
return $active->count();
});
}
/**
* Return parts physically taken back to stock. Creates a NEW batch with
* the same buy_price as the original (FIFO-correct re-entry) and writes
* a `return` event referencing the WO part.
*/
public function returnPart(WorkOrderPart $wop, ?float $qty = null, ?string $notes = null): ?PartBatch
{
if (! $wop->part_id) return null;
return DB::transaction(function () use ($wop, $qty, $notes) {
$consumed = PartReservation::where('work_order_part_id', $wop->id)
->where('status', PartReservation::STATUS_CONSUMED)
->orderBy('consumed_at', 'desc')
->get();
$totalConsumed = (float) $consumed->sum('qty');
$qtyReturn = $qty !== null ? min((float) $qty, $totalConsumed) : $totalConsumed;
if ($qtyReturn <= 0) return null;
$unitCost = (float) ($consumed->first()?->batch?->buy_price ?? $wop->buy_price);
$warehouse = $consumed->first()?->batch?->warehouse
?? $this->defaultWarehouse($wop->company_id);
$batch = PartBatch::create([
'company_id' => $wop->company_id,
'part_id' => $wop->part_id,
'warehouse_id' => $warehouse->id,
'qty_in' => $qtyReturn,
'qty_remaining' => $qtyReturn,
'buy_price' => $unitCost,
'received_at' => now(),
'notes' => $notes ?? "Retur de la WO part #{$wop->id}",
]);
$this->logEvent(
$wop->part, $batch, $warehouse,
'return', $qtyReturn, $unitCost,
$wop->workOrder, $notes ?? "Retur WO #{$wop->workOrder?->number}"
);
$this->syncPartCachedQty($wop->part);
return $batch;
});
}
/**
* 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();
}
}