Files
autocrm/app/Services/Pricing/PricingEngine.php
T
Vasyka 03e030d6d2 feat: tier 3 polish — M12/13/14/15 deep cleanup
Closes the remaining ~50h of items from CONFORMITY-12-15.md across all
four modules. Single umbrella migration (2026_06_05_000004) lands four
tables + 5 column additions, no downtime risk.

== M12 — body_type + transmission + pricing audit log ==

Vehicle gains body_type (12 values: sedan/hatchback/suv/crossover/pickup/
van/truck/coupe/wagon/convertible/minivan/moto) and transmission_type
(6 values: manual/automatic/cvt/dsg/dct/amt). These are separate from
vehicle_class so admin can configure DSG-only coefficients without
contaminating the SUV detection.

PricingCoefficient.matches() now also tests:
  - conditions.body_types[] against ctx.body_type
  - conditions.transmissions[] against ctx.transmission

PricingEngine builds the richer ctx and exposes it on the quote return
under quote.context.

New pricing_application_logs table (append-only) — call
PricingEngine::logApplication($quote, $subject, $vehicle, $client, $part)
after applying a price to a WO line. Stores base, final, full
applied[] array, and the ctx snapshot so the question "why was this
priced at 218 lei in March?" stays answerable forever.

PricingCoefficientResource form gains CheckboxList for body_types and
transmissions (3-column layouts, full-width). Both are optional —
empty list = applies to anything.

== M13 — Mechanic REST API + KPI ==

New MechanicApiController with 7 endpoints under /api/v1/mechanic/:
  GET    /board               — own non-done WOs with their works expanded
  GET    /kpi?period=YYYY-MM  — own aggregates for the period
  POST   /tasks/{w}/start
  POST   /tasks/{w}/pause
  POST   /tasks/{w}/resume
  POST   /tasks/{w}/done
  POST   /tasks/{w}/block     — validates reason from BLOCK_REASONS enum

Every endpoint authorizes ownership: $work->workOrder->master_id ===
auth()->id() else 403. board() returns null pending_works so native
apps don't make round-trips. workPayload() emits efficiency_pct and
efficiency_class on every response.

New MechanicKpi Filament page at /app/mechanic-kpi (Service group). Same
aggregation logic but tenant-wide: groups WorkOrderWork rows by
master_id for the selected period, computes totals + efficiency_pct +
revenue. Period navigation via ◀/▶ buttons, default = current month.
Color-coded efficiency badges (green ≤100%, amber ≤130%, red >130%).
Rows sort by revenue descending — easy "top earners this month" view.

== M14 — OCR async via Laravel queue ==

New ocr_jobs table (id, supplier_id?, source_type, file_path, status,
result JSON, error_message, ai_provider, tokens_used, purchase_id?,
processed_at). Idempotent migration.

New OcrJob model + ProcessOcrJob queueable job. Job re-establishes
tenant context inside the worker (Company::find + TenantManager::setCurrent)
since queue workers don't inherit middleware-resolved tenants.

handle() walks: status=pending → processing, calls OcrInvoiceService::extract,
on success → status=done + result + ai_provider; on throw → status=failed
+ error_message. Failed jobs auto-retry once (tries=2) with 120s timeout.

The existing synchronous OcrInvoiceService stays for inline use cases
(tests, quick imports). The job is now the canonical path for the
admin UI to keep requests sub-100ms.

== M15 — eta_promised + JSON tracking + notifications log ==

Three new wo columns: eta_promised (initial commitment, never changes),
eta_change_reason (text for "așteptăm piesă"), eta_updated_at (when
the current eta was last touched). Existing eta_at remains as "current"
ETA so the UI can render both side-by-side.

New /api/track/{token} JSON endpoint (public, tenant-scoped via subdomain):
  number, status, status_label, progress %, client, vehicle, plate, master,
  eta_promised, eta_current, eta_change_reason, total, pay_status,
  pending_approvals[] (each with kind/id/name/amount/approve_url —
  signed URLs ready for native app webview),
  timeline[] (from activity_log, last 20 events).

NotificationDispatcher::dispatch() gains optional workOrderId param.
Every send call (success or failure) now writes one row to the new
client_notifications_log table with channel/template_key/status (sent
or failed)/error_detail/sent_at. Failures of logging are swallowed
so a missing activity_log never breaks notifications. workOrderReady
and paymentReceived pass the WO id through; others can be wired in
future commits without schema change.

New tables tracked:
  client_notifications_log — every push to client, append-only
  pricing_application_logs — every pricing decision, append-only
  ocr_jobs — async OCR job queue

== Tests ==

PolishTier3Test (11):
- M12: body_type condition match/no-match; transmission DSG match;
  pricing_log row persists base/final/applied/ctx
- M13: mechanic API board scoped to own WOs; start task on foreign
  work returns 403; KPI endpoint computes 2.5/3 = 83% efficiency
  across 2 done works in period
- M14: ocr_job queueable + Queue::fake assertion
- M15: tracking JSON returns ETA promised/current/reason + pending
  approvals with correctly-signed approve_url; dispatcher writes
  ClientNotificationLog row on workOrderReady
- M12: vehicle body_type + transmission_type round-trip through save

Suite: 269 passed (761 assertions). Was 258.

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

126 lines
4.3 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\Pricing;
use App\Models\Tenant\Client;
use App\Models\Tenant\MarkupRule;
use App\Models\Tenant\Part;
use App\Models\Tenant\PricingCoefficient;
use App\Models\Tenant\Vehicle;
/**
* Computes a contextual sell price for a part:
* base = MarkupRule applied to buy_price (or current sell_price fallback)
* final = base × product(matching coefficient multipliers)
*
* Coefficient stacking:
* - stackable coefficients all multiply together
* - among non-stackable matches, only the single highest multiplier applies
*/
class PricingEngine
{
/**
* @return array{base: float, final: float, applied: array<int, array{name:string, multiplier:float}>}
*/
public function quote(
Part $part,
?Vehicle $vehicle = null,
?Client $client = null,
string $urgency = 'normal',
): array {
$base = $this->basePrice($part);
$ctx = [
'class' => $this->vehicleClass($vehicle),
'age' => $this->vehicleAge($vehicle),
'body_type' => $vehicle?->body_type,
'transmission' => $vehicle?->transmission_type,
'vip' => (bool) ($client?->is_vip),
'urgency' => $urgency ?: 'normal',
];
$coefficients = PricingCoefficient::where('is_active', true)
->orderBy('priority')
->get()
->filter(fn (PricingCoefficient $c) => $c->matches($ctx));
$applied = [];
$factor = 1.0;
// Stackable: multiply all.
foreach ($coefficients->where('stackable', true) as $c) {
$factor *= (float) $c->multiplier;
$applied[] = ['name' => $c->name, 'multiplier' => (float) $c->multiplier];
}
// Non-stackable: take only the strongest one.
$nonStack = $coefficients->where('stackable', false)
->sortByDesc(fn ($c) => (float) $c->multiplier)
->first();
if ($nonStack) {
$factor *= (float) $nonStack->multiplier;
$applied[] = ['name' => $nonStack->name, 'multiplier' => (float) $nonStack->multiplier];
}
$result = [
'base' => round($base, 2),
'final' => round($base * $factor, 2),
'applied' => $applied,
'context' => $ctx,
];
return $result;
}
/**
* Persist a quote to pricing_application_logs — appends one immutable row
* per pricing decision. Caller passes the subject (WO part/work line) so
* we can later answer "why was this line priced at X?".
*/
public function logApplication(array $quote, $subject, ?Vehicle $vehicle = null, ?Client $client = null, ?Part $part = null): \App\Models\Tenant\PricingApplicationLog
{
return \App\Models\Tenant\PricingApplicationLog::create([
'subject_type' => get_class($subject),
'subject_id' => $subject->id ?? 0,
'part_id' => $part?->id,
'vehicle_id' => $vehicle?->id,
'client_id' => $client?->id,
'base_price' => $quote['base'],
'final_price' => $quote['final'],
'applied_coefficients' => $quote['applied'],
'context' => $quote['context'] ?? [],
'calculated_at' => now(),
]);
}
private function basePrice(Part $part): float
{
$rule = MarkupRule::bestForPart($part);
if ($rule) {
return (float) $part->buy_price * (1 + (float) $rule->markup_pct / 100);
}
// Fall back to existing sell_price, or buy_price + 30%.
if ((float) $part->sell_price > 0) return (float) $part->sell_price;
return (float) $part->buy_price * 1.30;
}
/** Explicit vehicle_class, else inferred from fuel (hybrid/EV). */
private function vehicleClass(?Vehicle $vehicle): ?string
{
if (! $vehicle) return null;
if ($vehicle->vehicle_class) return $vehicle->vehicle_class;
$fuel = mb_strtolower((string) $vehicle->fuel);
if (str_contains($fuel, 'hybrid') || str_contains($fuel, 'hibrid')) return 'hybrid';
if (str_contains($fuel, 'electric') || $fuel === 'ev' || str_contains($fuel, 'electr')) return 'ev';
return null;
}
private function vehicleAge(?Vehicle $vehicle): ?int
{
if (! $vehicle || ! $vehicle->year) return null;
return max(0, (int) date('Y') - (int) $vehicle->year);
}
}