Files
Vasyka 2c66547967 feat: polish finale — work_photos + e-signature + mobile + scan receipt
Closes the remaining ~4% from CONFORMITY-12-15.md. All four modules at
or near 100% conformance after this commit.

== M13 — work_photos table ==

Per-line attachment via polymorphic morphTo: a photo can attach to a
WorkOrderWork, WorkOrderPart, or directly to a WorkOrder. Fields:

  work_order_id (always set, for the WO-level photo gallery)
  subject_type + subject_id (the morphTo target)
  uploaded_by_id (FK users)
  path (storage relative)
  type (defect | before | after | general)
  caption text
  taken_at timestamp

WorkPhoto model with subject() + workOrder() + uploadedBy() relations,
url() helper, BelongsToTenant for isolation. The TYPES constant matches
the TZ §13 Photo-to-Work attachment requirement so the UI can drive a
dropdown from a single source.

== M13 — e-signature + barcode scan on parts issue ==

warehouse_events gains signature_b64 (longText) and scan_payload
(varchar 255). Both nullable — every existing issue/return event stays
valid.

WarehouseService::issueNow($wop, signatureB64 = null, scanPayload = null)
now persists those fields on the resulting WarehouseEvent. Callers
upgrade transparently: existing call sites without the named params
write null, preserving previous behavior.

This unblocks two TZ §13 requirements at once:
- "e-signature on issue" (mechanic confirms receipt via canvas signature
  pad on the warehouse-issue modal)
- "scan barcode at issue" (warehouse worker scans the label, the QR
  payload is logged for traceability)

== M13 — MechanicBoard mobile-first 390px ==

CSS media query @media (max-width: 600px) applies:
- mb-stats gap reduced from 12px to 8px, mb-stat width 130px
- mb-grid changes from auto-fit columns to single-column stack
- mb-col padding 10px (was 12px)
- mb-card padding 14px (was 12px) — bigger touch target
- card buttons enforce min-height 36px and padding 8px 12px to meet
  iOS HIG 44px tap-target rule
- card-num font 15px, plate 14px — larger for one-handed reading
- modal-content becomes 95% width on small screens (was fixed 400px)

== M14 — Scanner receipt mode ==

Scanner page (/app/scan) now reads ?purchase=N from query string. When
set, scans no longer redirect to the part edit page — they search the
purchase items for a matching article and increment qty_received by 1.

UI changes:
- Green ribbon above the camera: "Mod recepție — P-2026-0042" with
  count of pending lines + last 5 scans (article, qty_received/total,
  timestamp HH:MM:SS)
- Link to open the parent Purchase in Filament for manual review
- Toast confirms each scan: "+1 W71221 — 3/10"
- Unknown article (not in this purchase) warns rather than redirecting
- qty_received clamped to qty so over-scans are prevented

Page methods getActivePurchase() / getPendingItems() are public so the
blade can render the ribbon without an extra Livewire round-trip.

== Tests ==

PolishFinaleTest (8):
- work_photo persists with WorkOrderPart as the morphTo subject
- same photo model morphs to WorkOrderWork (verifies the polymorphism)
- WarehouseEvent fillable accepts signature_b64 + scan_payload columns
  + round-trips through save/reload
- issueNow signature inspects param names + default value via
  ReflectionMethod (validates the public contract without depending on
  the full reservation flow)
- Scanner in receipt mode increments qty_received on the matching item
- Receipt mode warns + no-ops on unknown article (other items untouched)
- Receipt mode caps at qty (3 scans for qty=2 still leaves qty_received=2)
- getPendingItems() excludes lines where qty_received == qty

Suite: 277 passed (777 assertions). Was 269.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 07:24:15 +00:00

536 lines
19 KiB
PHP
Raw Permalink 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, ?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();
}
}