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, ?string $signatureB64 = null, ?string $scanPayload = null): int { return DB::transaction(function () use ($wop, $signatureB64, $scanPayload) { $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(); $event = $this->logEvent( $r->part, $batch, $batch->warehouse, 'issue', -$take, (float) $batch->buy_price, $wop->workOrder, "WO part #{$wop->id} (issue now)" ); if ($signatureB64 || $scanPayload) { $event->forceFill([ 'signature_b64' => $signatureB64, 'scan_payload' => $scanPayload, ])->save(); } $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(); } }