Compare commits

..

14 Commits

Author SHA1 Message Date
Vasyka d6a0bfb890 fix: repair 4 pre-existing failing tests
- WorkOrderCalcTest: vehicle fixture used `brand` (column is `make`, NOT NULL)
  and WorkOrderPart used `price`/manual total (column is `sell_price`, total is
  auto-computed). Switched to correct columns + valid status.
- ExampleTest: stock test expected 200 on `/` but the central domain redirects
  to /admin. Replaced with a deterministic central-root → /admin redirect check.

Full suite now green: 99 passed, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:14:20 +00:00
Vasyka 5e255b7b40 Stage 10 — Bodyshop / PDR / Detailing: damage map + insurance + photos
Completes the 18-stage roadmap (17/18 fully functional, 18 partial).

Schema:
- bodyshop_jobs (type body_repair/pdr/painting/detailing/ceramic/ppf/polishing,
  status workflow, insurance case fields, estimate/approved amounts)
- damage_points (zone, kind, severity) — the damage map

Models:
- BodyshopJob (HasMedia: photos_before/photos_after), auto number BS-YY-NNNN
- DamagePoint with ZONES/KINDS/SEVERITIES

Filament (new "Tinichigerie" nav group):
- BodyshopJobResource: type/status, collapsible insurance section (conditional
  fields), before/after photo upload, estimate/approved amounts
- DamagePointsRelationManager (zone + kind + colour-coded severity)
- Table with type badge, insurance flag, damage count; nav badge = open jobs

Tests (5 new):
- auto number; damage points relation; insurance fields persist;
  detailing types supported; tenant isolation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 06:49:47 +00:00
Vasyka e8078f157a Stage 9 — Subcontractor System: outsourced work with cost+markup
Schema:
- subcontractors (specialty, rating, contact)
- subcontract_jobs (work_order link, cost, markup_pct, client_price, status
  workflow, sent_at/eta/returned_at, paid_to_sub)

Models:
- SubcontractJob: auto number (SC-YY-NNNN), client_price = cost×(1+markup/100)
  when markup>0 (else manual), margin() helper, recalcs parent WO on save/delete
- WorkOrder.recalcTotal now includes non-cancelled subcontract job client_price

Filament (new "Subcontractare" nav group):
- SubcontractorResource (specialty/rating CRUD)
- SubcontractJobResource board with cost/client/margin columns + status filters,
  nav badge = open jobs
- SubcontractJobsRelationManager on WorkOrder

Tests (7 new):
- client_price from markup; manual price without markup; auto number;
  WO total includes jobs; cancelled excluded; delete recalcs; tenant isolation

Closes roadmap to 16/18 stages (only Stage 10 Bodyshop remains).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 06:43:15 +00:00
Vasyka 94938f24d7 Stage 11 — Tire Service: tire hotel + wheel sets
Schema:
- tire_sets (client/vehicle, season, size width/profile/diameter, brand/DOT,
  rims, tread JSON per position + tread_min cache, TPMS + sensor ids, photos)
- tire_storage (location, season_label, stored/retrieved, check-in/out, fee)

Models:
- TireSet (HasMedia): sizeLabel, isStored, currentStorage, auto tread_min
- TireStorage: durationDays, isActive

Filament (new "Anvelope" nav group):
- TireSetResource: specs form + per-position tread + TPMS + photo upload;
  table with size, season badge, min tread (red < 3mm), storage status
- Check-in (location + period + fee → stored) / Check-out (→ retrieved)
- StorageRelationManager (stay history); nav badge = sets currently stored

Tests (6 new):
- sizeLabel formatting; tread_min from positions; check-in active storage;
  check-out retrieved + duration; multiple stays per set; tenant isolation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 06:33:00 +00:00
Vasyka a1be01b0d5 Stage 4 — Labor Catalog: fixed price + default parts + service templates
Schema:
- labors.pricing_mode (hourly/fixed) + fixed_price
- labor_parts (default parts auto-added with a labor)
- service_templates + service_template_items (labor/part bundles)

ServiceComposer:
- addLabor(wo, labor, withParts) — hourly (hours×rate) or fixed (fixed_price),
  then auto-adds the labor's default parts
- addPart(wo, part, qty) — catalog price snapshot
- applyTemplate(wo, template) — adds all labor+part lines, recalcs total
- hourlyRate from settings.labor_rate

Filament:
- LaborResource: pricing_mode (live) toggles hours/fixed_price fields,
  DefaultPartsRelationManager
- ServiceTemplateResource (Service group) with ItemsRelationManager
- WorkOrder edit "Aplică șablon" action → applyTemplate
- WorksRelationManager CreateAction auto-adds labor default parts

Tests (6 new):
- hourly rate×hours; fixed uses fixed_price; default parts auto-added;
  withParts=false skips; applyTemplate adds lines + recalcs total;
  templates tenant-isolated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 06:16:50 +00:00
Vasyka c90c35d930 Stage 8 — Smart Pricing Engine: contextual coefficients
Contextual multipliers layered on top of base MarkupRule pricing, applied
per work-order line based on vehicle, client and urgency.

Schema:
- pricing_coefficients (multiplier, conditions JSON, priority, stackable)
- vehicles.vehicle_class (sedan/suv/commercial/hybrid/ev/premium)
- clients.is_vip
- work_orders.urgency (normal/urgent/express)

PricingEngine::quote(Part, Vehicle?, Client?, urgency):
- base = MarkupRule on buy_price (fallback sell_price or buy×1.30)
- context: class (explicit or inferred hybrid/ev from fuel), age, vip, urgency
- stackable coefficients all multiply; non-stackable take only the highest
- returns {base, final, applied[]} breakdown

PricingCoefficient::matches(ctx) — classes/age range/vip/urgency conditions
(empty = always applies).

Filament:
- PricingCoefficientResource with condition builder (classes, age, vip, urgency)
- vehicle_class select, client is_vip toggle, WO urgency select
- "Preț inteligent" action on WO parts shows breakdown + applies sell_price

Tests (6 new):
- base-only without coefficients; age coefficient gating; VIP; express urgency;
  stackable multiply vs non-stackable highest-wins; hybrid inferred from fuel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:40:27 +00:00
Vasyka 954ba8f059 Stage 12 — Online Store: public catalog + cart + orders
Schema:
- online_orders (token-tracked, status workflow, delivery method/fee)
- online_order_items (price snapshot, fulfilled flag)
- part_cross_refs (OEM/equivalent codes for search)
- parts.is_published (shop visibility)

Storefront (ShopController, tenant subdomain, /shop):
- Catalog with search across name/article/brand/cross-refs, category +
  in-stock filters, live stock, white-label themed layout
- Part detail page with cross-ref codes
- VIN search → VinDecoder → guided catalog search
- Session cart (per-tenant key), guest checkout, order confirmation page
- Respects settings.shop.enabled (404 when off); tenant-guarded

Part::searchPublished matches cross-ref articles via whereHas.

Order notifications (ShopOrderNotifier, best-effort):
- Staff: Web Push to active users
- Customer: Telegram if phone matches a linked client

Filament (tenant):
- OnlineOrderResource under "Magazin" nav group, status workflow,
  items relation, "Onorează" action issues stock via WarehouseService (FIFO)
- PartResource: is_published toggle + column + bulk publish/unpublish +
  CrossRefsRelationManager
- Settings: shop section (enable, delivery methods, fee, free-over)
- Landing page: shop button when enabled

Tests (6 new):
- catalog 404 when disabled; lists published only; cross-ref search;
  order placement (token + items + total); fulfill issues stock;
  cross-tenant token isolation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:27:51 +00:00
Vasyka c413004930 Stage 15 — PWA complete: install prompt + Web Push notifications
Dependency:
- minishlink/web-push v10 (VAPID JWT + aes128gcm payload encryption)
- Dockerfile: add curl, mbstring, gmp extensions (web-push needs ext-curl)

VAPID:
- config/webpush.php from env; `php artisan push:vapid` generates keypair
- Shared platform keypair; .env.example has empty placeholders

Schema:
- push_subscriptions (user/company, endpoint unique, p256dh, auth, encoding)

WebPushService:
- send / sendToUser / dispatch via WebPush::flush
- Auto-prunes subscriptions reported expired (404/410)

Subscribe flow:
- POST /push/subscribe + /push/unsubscribe (auth, tenant)
- Tenant panel JS subscribes after SW registration with VAPID public key

Service worker (/sw.js):
- Cache v2, push listener → showNotification, notificationclick → focus/open

Install prompt:
- Floating "Instalează aplicația" button wired to beforeinstallprompt

Staff push:
- WorkOrder master_id change → push to assigned mechanic
- Settings "Test notificare push" action

Tests (6 new):
- subscribe stores + upserts; requires auth (401); validation (422);
  service configured; sendToUser with no subs returns zero

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:11:18 +00:00
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
Vasyka 1ff888131f Stage 16 — AI Layer: VIN decoder + diagnostic / parts / price helpers
VinDecoder (deterministic, no API):
- ISO 3779/3780 parsing: WMI manufacturer (~60 brands), year (cyclical with
  post-2010 disambiguation via position 7), region, plant, NA checksum
- Strip non-VIN chars, accept dashes/spaces, reject I/O/Q per spec

AiAssistantService:
- Refactored provider HTTP into postClaude/postOpenAI/postGemini so both
  chat history and one-shot calls share the same transport
- singleShot(system, userPrompt, provider?) for fire-and-forget calls
- 4 specialized helpers with tight prompts:
  - suggestDiagnosis(WO) — diagnostician based on complaint + VIN info
  - suggestParts(WO, task) — OEM parts list for an operation
  - suggestPrice(Part) — markup recommendation with justification
  - vinRecommendations(vin, mileage) — scheduled maintenance from decoded VIN
- monthlyUsage() — token spend MTD by provider

Filament:
- VehicleResource: "Decode VIN" + "AI: recomandări" actions
- WorkOrderResource Edit: "AI: sugerează diagnostic" header action
- PartResource: "AI: preț recomandat" action
- Shared views: filament.tenant.ai-reply, filament.tenant.vin-decode
- AiAssistant page shows monthly token usage banner

Tests (13 new):
- 8 VinDecoder unit tests with real VIN samples (Honda 2003, VW 1999, Audi
  2014, Dacia, unknown WMI, lowercase/dashes, forbidden chars)
- 5 AiHelpers feature tests with Http::fake covering all providers + no-key
  fallback + token usage aggregation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:24:09 +00:00
Vasyka 85ef2f6e00 Stage 13 — Notifications: Telegram bot + multi-channel + service reminders
Schema:
- clients.telegram_chat_id (linked via /start contact-share)
- clients.notify_prefs (per-client channel order override)
- service_reminders_sent (dedup ledger for the daily cron)

Telegram (per tenant):
- TelegramService (sendMessage, getMe, setWebhook with auto-generated secret)
- Bot token stored in companies.settings.telegram.bot_token
- Webhook /telegram/webhook/{slug} validates X-Telegram-Bot-Api-Secret-Token,
  matches client by last 9 digits of phone, persists chat_id, replies confirm
- /start prompts share-contact; /stop unlinks chat_id

NotificationDispatcher refactor:
- Multi-channel: telegram first if chat_id + bot configured, then email
- Backwards-compat with legacy boolean notify.{type} flags
- 4 HTML-formatted Telegram messages (wo_ready with tracking link, payment,
  appointment, reminder)

Service reminders:
- `reminders:send` artisan command with --slug / --dry-run
- Policy: vehicles whose last closed WO is older than reminder.after_days
  (default 365). Skips if sent within reminder.cooldown_days (default 30).
- Schedule daily 09:00

Filament UI:
- Settings page: Telegram bot token field + "Test bot" + "Set webhook" actions
- Settings page: reminder_after_days + reminder_cooldown_days inputs
- ClientResource: telegram_chat_id readonly badge

Tests (6 new, all pass):
- webhook links client via shared contact
- webhook rejects wrong secret → 401
- dispatcher uses telegram when chat_id present (Http::fake)
- dispatcher falls back to email otherwise
- dispatcher returns false when no channel available
- reminder cron respects 30-day cooldown

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:14:17 +00:00
Vasyka a2026f640a Stage 6 — Purchase System: partial receipt + supplier analytics
Schema:
- purchase_items.qty_received (backfilled from `received` boolean)
- purchases.warehouse_id (target warehouse FK)
- supplier_part_prices (price history per supplier/part with purchase ref)
- New status `partial` between ordered and received

Purchase ↔ Warehouse integration:
- Purchase::receiveItem(item, qty, warehouse?) — routes through
  WarehouseService::receive: creates batch + receipt event + supplier price row
- Purchase::receiveAllRemaining(warehouse?) — receives all outstanding lines
- Purchase::recomputeStatus() — auto: ordered → partial → received

Old flat markReceived() removed — every receipt now writes batches + ledger.

Filament:
- Purchase list: progress %, partial badge, warehouse picker on form
- ItemsRelationManager: per-line "Recepționează" with qty + warehouse modal,
  qty_received shown as "X.XX / Y.YY" with colour
- PartResource: new PriceHistoryRelationManager (supplier price history)
- SupplierResource: derived columns onTimeRate / avgDeliveryDays / spend(90d)
  + "Rerating" action

Analytics:
- App\Services\Warehouse\SupplierAnalytics (onTimeRate, avgDeliveryDays,
  spend, count, computedRating)
- `suppliers:rate` artisan command + weekly schedule (Mon 04:00)
- Computed rating: 70% on-time + 20% volume + 10% speed bonus

Tests (6 new, all pass):
- Partial receipt of 3/10 → status=partial + 1 batch + 1 price row
- receiveAllRemaining → status=received with received_at set
- Over-receive throws InvalidArgumentException
- Two partial receipts (4+6) → 2 batches FIFO + status=received
- onTimeRate 50% with 1 on-time + 1 late
- computedRating null when <2 deliveries

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:37:12 +00:00
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
Vasyka edcdba9d53 Stage 3 — WO photos + ETA + QR + public tracking page
- HasMedia (Spatie) on WorkOrder with `photos` collection
- eta_at + tracking_token columns; token auto-generated on create
- Public /t/{token} page — tenant-scoped via subdomain, white-label themed
- QR code SVG via chillerlan/php-qrcode (inline modal + download)
- Filament: SpatieMediaLibraryFileUpload + ETA picker + tracking section
- EditWorkOrder header action "Link client (QR)" modal
- Fix: Auditable::dontSubmitEmptyLogs() → dontLogEmptyChanges() (removed in activitylog)
- Tests: TrackingPageTest (4 pass) covering token gen + cross-tenant isolation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:21:23 +00:00
158 changed files with 10486 additions and 121 deletions
+5
View File
@@ -58,6 +58,11 @@ MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="noreply@service.mir.md"
MAIL_FROM_NAME="${APP_NAME}"
# Web Push (VAPID) — generate with: php artisan push:vapid
VAPID_SUBJECT=mailto:admin@service.mir.md
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Storage — local pentru MVP, S3-compatible mai târziu
FILESYSTEM_DISK=local
+4 -1
View File
@@ -41,7 +41,10 @@ RUN install-php-extensions \
opcache \
pcntl \
sockets \
exif
exif \
curl \
mbstring \
gmp
# System tools
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Minishlink\WebPush\VAPID;
class GenerateVapidKeysCommand extends Command
{
protected $signature = 'push:vapid';
protected $description = 'Generate a VAPID keypair for Web Push and print the .env lines.';
public function handle(): int
{
$keys = VAPID::createVapidKeys();
$this->info('VAPID keys generated. Add these to your .env:');
$this->newLine();
$this->line('VAPID_SUBJECT=mailto:admin@service.mir.md');
$this->line('VAPID_PUBLIC_KEY=' . $keys['publicKey']);
$this->line('VAPID_PRIVATE_KEY=' . $keys['privateKey']);
$this->newLine();
$this->warn('Keep the private key secret. Re-generating invalidates existing subscriptions.');
return self::SUCCESS;
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use App\Models\Central\Company;
use App\Models\Tenant\Supplier;
use App\Services\Warehouse\SupplierAnalytics;
use App\Tenancy\TenantManager;
use Illuminate\Console\Command;
class RateSuppliersCommand extends Command
{
protected $signature = 'suppliers:rate
{--days=90 : Look-back window in days}
{--slug= : Only one tenant by slug}';
protected $description = 'Recompute auto-rating for every supplier based on on-time deliveries, speed and volume.';
public function handle(SupplierAnalytics $analytics): int
{
$query = Company::query()->where('status', '!=', 'archived');
if ($slug = $this->option('slug')) {
$query->where('slug', $slug);
}
$companies = $query->get();
$days = (int) $this->option('days');
$totalUpdated = 0;
foreach ($companies as $company) {
app(TenantManager::class)->setCurrent($company);
$suppliers = Supplier::where('is_active', true)->get();
$changed = 0;
foreach ($suppliers as $supplier) {
$score = $analytics->computedRating($supplier, $days);
if ($score !== null && $score !== (int) $supplier->rating) {
$supplier->rating = $score;
$supplier->saveQuietly();
$changed++;
}
}
$this->info(sprintf('[%s] suppliers rated, %d updated', $company->slug, $changed));
$totalUpdated += $changed;
}
$this->info("Total suppliers updated: {$totalUpdated}");
return self::SUCCESS;
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Console\Commands;
use App\Models\Central\Company;
use App\Models\Tenant\ServiceReminderSent;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Services\NotificationDispatcher;
use App\Tenancy\TenantManager;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class SendServiceRemindersCommand extends Command
{
protected $signature = 'reminders:send
{--slug= : Only one tenant by slug}
{--dry-run : Show candidates without sending}';
protected $description = 'Scan vehicles for due service reminders and send via configured channels.';
public function handle(NotificationDispatcher $dispatcher): int
{
$query = Company::query()->where('status', '!=', 'archived');
if ($slug = $this->option('slug')) {
$query->where('slug', $slug);
}
$companies = $query->get();
$dry = (bool) $this->option('dry-run');
$totalSent = 0;
foreach ($companies as $company) {
app(TenantManager::class)->setCurrent($company);
$settings = (array) ($company->settings ?? []);
$reminderDays = (int) data_get($settings, 'reminder.after_days', 365);
$cooldownDays = (int) data_get($settings, 'reminder.cooldown_days', 30);
$cutoff = Carbon::now()->subDays($reminderDays);
$cooldown = Carbon::now()->subDays($cooldownDays);
// Pick vehicles whose last *closed* WO was before $cutoff (or never).
$vehicles = Vehicle::with('client')
->whereHas('client', fn ($q) => $q->where('status', 'active'))
->get();
$sentThisTenant = 0;
foreach ($vehicles as $v) {
$lastClosedAt = WorkOrder::where('vehicle_id', $v->id)
->whereNotNull('closed_at')
->max('closed_at');
if (! $lastClosedAt) continue; // never serviced — skip (handled by other logic)
if (Carbon::parse($lastClosedAt)->gt($cutoff)) continue;
$recent = ServiceReminderSent::where('vehicle_id', $v->id)
->where('sent_at', '>=', $cooldown)
->exists();
if ($recent) continue;
if ($dry) {
$this->line(" - [{$company->slug}] Vehicle #{$v->id} {$v->plate} last serviced {$lastClosedAt}");
continue;
}
$ok = $dispatcher->serviceReminder($v, 'general');
if ($ok) {
ServiceReminderSent::create([
'company_id' => $company->id,
'vehicle_id' => $v->id,
'client_id' => $v->client_id,
'channel' => $v->client?->telegram_chat_id ? 'telegram' : 'email',
'type' => 'general',
'sent_at' => now(),
]);
$sentThisTenant++;
}
}
$this->info(sprintf('[%s] reminders sent: %d', $company->slug, $sentThisTenant));
$totalSent += $sentThisTenant;
}
$this->info("Total reminders sent: {$totalSent}" . ($dry ? ' (dry run)' : ''));
return self::SUCCESS;
}
}
@@ -60,6 +60,11 @@ class AiAssistant extends Page
->get();
}
public function getUsage(): array
{
return app(AiAssistantService::class)->monthlyUsage();
}
public function newChat(): void
{
$chat = AiChat::create([
@@ -0,0 +1,85 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\WorkOrder;
use Filament\Pages\Page;
/**
* Mobile-first dashboard for a single mechanic shows ONLY work orders
* assigned to the currently logged-in user (master_id = auth()->id()).
* Kanban-style grouped by status.
*/
class MechanicBoard extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench';
protected static ?string $navigationLabel = 'Atelierul meu';
protected static string|\UnitEnum|null $navigationGroup = 'Service';
protected static ?int $navigationSort = 25;
protected static ?string $title = 'Atelierul meu';
protected string $view = 'filament.tenant.pages.mechanic-board';
public function getColumns(): array
{
$userId = auth()->id();
if (! $userId) return [];
$all = WorkOrder::with(['client', 'vehicle'])
->where('master_id', $userId)
->whereIn('status', ['in_work', 'awaiting_parts', 'ready', 'done', 'approved', 'diagnosis'])
->orderBy('opened_at', 'desc')
->get();
return [
[
'key' => 'in_work',
'label' => 'În lucru',
'color' => '#f59e0b',
'items' => $all->where('status', 'in_work')->values(),
],
[
'key' => 'awaiting_parts',
'label' => 'Așteaptă piese',
'color' => '#8b5cf6',
'items' => $all->whereIn('status', ['awaiting_parts'])->values(),
],
[
'key' => 'ready',
'label' => 'Gata',
'color' => '#10b981',
'items' => $all->where('status', 'ready')->values(),
],
[
'key' => 'recent',
'label' => 'Recente / restul',
'color' => '#64748b',
'items' => $all->whereIn('status', ['done', 'approved', 'diagnosis'])
->take(20)
->values(),
],
];
}
public function getCounts(): array
{
$userId = auth()->id();
return [
'active' => $userId
? WorkOrder::where('master_id', $userId)
->whereIn('status', ['in_work', 'awaiting_parts', 'ready'])
->count()
: 0,
'closed_today' => $userId
? WorkOrder::where('master_id', $userId)
->where('status', 'done')
->whereDate('closed_at', today())
->count()
: 0,
];
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\Part;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Livewire\Attributes\On;
/**
* Mobile scanner: opens camera in the browser, decodes QR/barcode, looks up
* Part by:
* - `PART:<article|id>` payload (our own QR labels)
* - exact barcode match on parts.barcode
* - exact article match on parts.article
* On match redirect to Part edit page.
*/
class Scanner extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-qr-code';
protected static ?string $navigationLabel = 'Scaner';
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
protected static ?int $navigationSort = 39;
protected static ?string $title = 'Scaner cod QR / Bare';
protected string $view = 'filament.tenant.pages.scanner';
public string $manual = '';
#[On('scanner-decoded')]
public function decoded(string $text): void
{
$this->resolveAndRedirect(trim($text));
}
public function submitManual(): void
{
if (trim($this->manual) === '') return;
$this->resolveAndRedirect(trim($this->manual));
}
protected function resolveAndRedirect(string $code): void
{
$clean = $code;
if (str_starts_with($clean, 'PART:')) {
$clean = substr($clean, 5);
}
$part = Part::where(function ($q) use ($clean, $code) {
$q->where('article', $clean)
->orWhere('barcode', $clean)
->orWhere('barcode', $code);
if (ctype_digit($clean)) $q->orWhere('id', (int) $clean);
})
->first();
if (! $part) {
Notification::make()
->title('Cod necunoscut')
->body('Nu am găsit nicio piesă pentru: ' . $code)
->warning()
->send();
return;
}
Notification::make()
->title('Piesă găsită: ' . $part->name)
->success()
->send();
$this->redirect(
route('filament.tenant.resources.parts.edit', ['record' => $part->id])
);
}
}
+137 -2
View File
@@ -2,7 +2,9 @@
namespace App\Filament\Tenant\Pages;
use App\Services\Notifications\TelegramService;
use App\Tenancy\TenantManager;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
@@ -50,6 +52,13 @@ class Settings extends Page
'notify_payment' => $notify['payment'] ?? true,
'notify_appointment' => $notify['appointment'] ?? true,
'notify_reminder' => $notify['reminder'] ?? true,
'telegram_bot_token' => data_get($settings, 'telegram.bot_token'),
'reminder_after_days' => data_get($settings, 'reminder.after_days', 365),
'reminder_cooldown_days' => data_get($settings, 'reminder.cooldown_days', 30),
'shop_enabled' => data_get($settings, 'shop.enabled', false),
'shop_delivery_methods' => data_get($settings, 'shop.delivery_methods', ['pickup']),
'shop_delivery_fee' => data_get($settings, 'shop.delivery_fee', 0),
'shop_free_delivery_over' => data_get($settings, 'shop.free_delivery_over', 0),
'ai_default_provider' => $settings['ai']['default_provider'] ?? 'claude',
'ai_claude_key' => $settings['ai']['claude_key'] ?? null,
'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null,
@@ -126,8 +135,8 @@ class Settings extends Page
->maxSize(512)
->helperText('PNG/ICO, max 512 KB.'),
]),
Schemas\Components\Section::make('Notificări email')
->description('Activează / dezactivează emailurile auto către clienți.')
Schemas\Components\Section::make('Notificări')
->description('Activează / dezactivează notificările auto către clienți. Telegram are prioritate dacă clientul are cont legat.')
->columns(2)
->schema([
Forms\Components\Toggle::make('notify_wo_ready')->label('Mașina e gata de ridicat')->default(true),
@@ -135,6 +144,46 @@ class Settings extends Page
Forms\Components\Toggle::make('notify_appointment')->label('Programare confirmată')->default(true),
Forms\Components\Toggle::make('notify_reminder')->label('Reminder ITP / revizie')->default(true),
]),
Schemas\Components\Section::make('Telegram bot')
->description('Creează un bot la @BotFather, lipește token-ul aici și apasă „Setează webhook". Clienții îți scriu la bot, partajează telefonul, iar codul se leagă automat de fișa lor.')
->columns(1)
->schema([
Forms\Components\TextInput::make('telegram_bot_token')
->label('Bot token')
->password()
->revealable()
->placeholder('123456:ABC-XYZ...')
->helperText(fn () => 'Webhook URL: ' .
app(\App\Services\Notifications\TelegramService::class)
->webhookUrlFor(app(\App\Tenancy\TenantManager::class)->current())),
]),
Schemas\Components\Section::make('Reminder service auto')
->columns(2)
->schema([
Forms\Components\TextInput::make('reminder_after_days')
->label('Trimite reminder după X zile fără vizită')
->numeric()
->minValue(30)
->default(365),
Forms\Components\TextInput::make('reminder_cooldown_days')
->label('Nu re-trimite mai des de X zile')
->numeric()
->minValue(7)
->default(30),
]),
Schemas\Components\Section::make('Magazin online')
->description('Activează magazinul public la <slug>.service.mir.md/shop. Piesele apar doar dacă sunt marcate „Publicat".')
->columns(2)
->schema([
Forms\Components\Toggle::make('shop_enabled')->label('Magazin activ')->columnSpanFull(),
Forms\Components\CheckboxList::make('shop_delivery_methods')
->label('Metode de livrare')
->options(\App\Models\Tenant\OnlineOrder::DELIVERY)
->default(['pickup'])
->columnSpanFull(),
Forms\Components\TextInput::make('shop_delivery_fee')->label('Taxă livrare')->numeric()->default(0),
Forms\Components\TextInput::make('shop_free_delivery_over')->label('Livrare gratuită peste')->numeric()->default(0)->helperText('0 = dezactivat'),
]),
Schemas\Components\Section::make('Asistent AI')
->description('Adaugă chei API ca să activezi asistentul. Cheile rămân la voi — nu sunt partajate.')
->columns(2)
@@ -178,6 +227,20 @@ class Settings extends Page
'appointment' => (bool) ($data['notify_appointment'] ?? true),
'reminder' => (bool) ($data['notify_reminder'] ?? true),
],
'telegram' => array_replace(
(array) data_get($company->settings, 'telegram', []),
['bot_token' => $data['telegram_bot_token'] ?? null]
),
'reminder' => [
'after_days' => (int) ($data['reminder_after_days'] ?? 365),
'cooldown_days' => (int) ($data['reminder_cooldown_days'] ?? 30),
],
'shop' => [
'enabled' => (bool) ($data['shop_enabled'] ?? false),
'delivery_methods' => array_values((array) ($data['shop_delivery_methods'] ?? ['pickup'])),
'delivery_fee' => (float) ($data['shop_delivery_fee'] ?? 0),
'free_delivery_over' => (float) ($data['shop_free_delivery_over'] ?? 0),
],
'ai' => [
'default_provider' => $data['ai_default_provider'] ?? 'claude',
'claude_key' => $data['ai_claude_key'] ?? null,
@@ -201,4 +264,76 @@ class Settings extends Page
Notification::make()->title('Setări salvate')->success()->send();
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('push_test')
->label('Test notificare push')
->icon('heroicon-m-bell-alert')
->color('gray')
->action(function () {
$svc = app(\App\Services\Notifications\WebPushService::class);
if (! $svc->configured()) {
Notification::make()
->title('Web Push neconfigurat')
->body('Rulează `php artisan push:vapid` și adaugă cheile în .env.')
->warning()->send();
return;
}
$r = $svc->sendToUser(
(int) auth()->id(),
'Test AutoCRM',
'Notificările push funcționează ✅',
'/app',
);
Notification::make()
->title($r['sent'] > 0 ? "Trimis pe {$r['sent']} dispozitiv(e)" : 'Niciun dispozitiv abonat')
->body($r['sent'] > 0 ? null : 'Deschide panoul pe telefon și acceptă notificările întâi.')
->{$r['sent'] > 0 ? 'success' : 'warning'}()
->send();
}),
Actions\Action::make('telegram_test')
->label('Testează bot Telegram')
->icon('heroicon-m-bolt')
->color('gray')
->action(function () {
$company = app(TenantManager::class)->current();
if (! $company) return;
$r = app(TelegramService::class)->getMe($company);
if (! ($r['ok'] ?? false)) {
Notification::make()
->title('Bot Telegram nu răspunde')
->body($r['error'] ?? 'Verifică token-ul.')
->danger()->send();
return;
}
$name = data_get($r, 'response.result.username', '?');
Notification::make()
->title("Bot OK: @{$name}")
->success()->send();
}),
Actions\Action::make('telegram_webhook')
->label('Setează webhook')
->icon('heroicon-m-link')
->color('primary')
->requiresConfirmation()
->modalDescription('Telegram va trimite mesajele primite la URL-ul webhook de mai jos.')
->action(function () {
$company = app(TenantManager::class)->current();
if (! $company) return;
$r = app(TelegramService::class)->setWebhook($company);
if (! ($r['ok'] ?? false)) {
Notification::make()
->title('Webhook eșuat')
->body($r['error'] ?? json_encode($r['response'] ?? []))
->danger()->send();
return;
}
Notification::make()
->title('Webhook setat — botul e gata')
->success()->send();
}),
];
}
}
@@ -0,0 +1,145 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
use App\Filament\Tenant\Resources\BodyshopJobResource\RelationManagers;
use App\Models\Tenant\BodyshopJob;
use App\Models\Tenant\Client;
use App\Models\Tenant\Vehicle;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class BodyshopJobResource extends Resource
{
protected static ?string $model = BodyshopJob::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-paint-brush';
protected static ?string $navigationLabel = 'Tinichigerie / Detailing';
protected static string|\UnitEnum|null $navigationGroup = 'Tinichigerie';
protected static ?string $modelLabel = 'lucrare caroserie';
protected static ?string $pluralModelLabel = 'lucrări caroserie';
protected static ?int $navigationSort = 80;
public static function getNavigationBadge(): ?string
{
$open = static::getModel()::query()
->whereNotIn('status', ['delivered', 'cancelled'])->count();
return $open > 0 ? (string) $open : null;
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Lucrare')
->columns(3)
->schema([
Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false)->placeholder('Generat automat'),
Forms\Components\Select::make('type')->label('Tip')->options(BodyshopJob::TYPES)->default('body_repair')->required(),
Forms\Components\Select::make('status')->label('Status')->options(BodyshopJob::STATUSES)->default('estimate')->required(),
Forms\Components\Select::make('client_id')
->label('Client')
->options(fn () => Client::pluck('name', 'id'))
->searchable()->live(),
Forms\Components\Select::make('vehicle_id')
->label('Auto')
->options(fn (Get $get) => $get('client_id')
? Vehicle::where('client_id', $get('client_id'))->get()
->mapWithKeys(fn ($v) => [$v->id => "{$v->make} {$v->model} {$v->plate}"])->toArray()
: [])
->searchable(),
Forms\Components\TextInput::make('estimate_amount')->label('Deviz')->numeric()->default(0),
Forms\Components\TextInput::make('approved_amount')->label('Aprobat')->numeric()->default(0),
]),
Schemas\Components\Section::make('Asigurare')
->collapsible()
->columns(3)
->schema([
Forms\Components\Toggle::make('is_insurance')->label('Caz de asigurare')->live()->columnSpanFull(),
Forms\Components\TextInput::make('insurer')->label('Asigurător')
->visible(fn (Get $get) => $get('is_insurance')),
Forms\Components\TextInput::make('policy_no')->label('Nr. poliță')
->visible(fn (Get $get) => $get('is_insurance')),
Forms\Components\TextInput::make('claim_no')->label('Nr. dosar daună')
->visible(fn (Get $get) => $get('is_insurance')),
Forms\Components\Select::make('insurance_status')->label('Status dosar')
->options(BodyshopJob::INSURANCE_STATUSES)
->visible(fn (Get $get) => $get('is_insurance')),
]),
Schemas\Components\Section::make('Foto înainte / după')
->columns(2)
->schema([
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos_before')
->label('Înainte')->collection('photos_before')->multiple()->image()->reorderable()->maxFiles(20),
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos_after')
->label('După')->collection('photos_after')->multiple()->image()->reorderable()->maxFiles(20),
]),
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('number')->label('Nr.')->searchable()->sortable(),
Tables\Columns\TextColumn::make('client.name')->label('Client')->searchable()->placeholder('—'),
Tables\Columns\TextColumn::make('vehicle.plate')->label('Auto')->placeholder('—'),
Tables\Columns\TextColumn::make('type')
->formatStateUsing(fn ($s) => BodyshopJob::TYPES[$s] ?? $s)
->badge()->color('info'),
Tables\Columns\IconColumn::make('is_insurance')->label('Asig.')->boolean()->toggleable(),
Tables\Columns\TextColumn::make('damage_points_count')->counts('damagePoints')->label('Daune')->alignRight(),
Tables\Columns\TextColumn::make('approved_amount')->label('Aprobat')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('status')
->formatStateUsing(fn ($s) => BodyshopJob::STATUSES[$s] ?? $s)
->badge()
->colors([
'gray' => ['estimate'],
'info' => ['approved', 'in_progress'],
'success' => ['done', 'delivered'],
'danger' => ['cancelled'],
]),
])
->filters([
Tables\Filters\SelectFilter::make('type')->options(BodyshopJob::TYPES),
Tables\Filters\SelectFilter::make('status')->options(BodyshopJob::STATUSES),
Tables\Filters\TernaryFilter::make('is_insurance')->label('Caz asigurare'),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading('Nicio lucrare de caroserie')
->emptyStateDescription('Înregistrează lucrări de tinichigerie, vopsitorie, PDR, detailing, ceramică, PPF sau polish. Hartă daune, dosar asigurare și arhivă foto înainte/după.')
->emptyStateIcon('heroicon-o-paint-brush')
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
RelationManagers\DamagePointsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListBodyshopJobs::route('/'),
'create' => Pages\CreateBodyshopJob::route('/create'),
'edit' => Pages\EditBodyshopJob::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
use App\Filament\Tenant\Resources\BodyshopJobResource;
use Filament\Resources\Pages\CreateRecord;
class CreateBodyshopJob extends CreateRecord
{
protected static string $resource = BodyshopJobResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
use App\Filament\Tenant\Resources\BodyshopJobResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditBodyshopJob extends EditRecord
{
protected static string $resource = BodyshopJobResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
use App\Filament\Tenant\Resources\BodyshopJobResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListBodyshopJobs extends ListRecords
{
protected static string $resource = BodyshopJobResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Filament\Tenant\Resources\BodyshopJobResource\RelationManagers;
use App\Models\Tenant\DamagePoint;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class DamagePointsRelationManager extends RelationManager
{
protected static string $relationship = 'damagePoints';
protected static ?string $title = 'Hartă daune';
public function form(Schema $schema): Schema
{
return $schema->components([
Forms\Components\Select::make('zone')
->label('Zonă')
->options(array_combine(DamagePoint::ZONES, DamagePoint::ZONES))
->searchable()
->required(),
Forms\Components\Select::make('kind')
->label('Tip daună')
->options(array_combine(DamagePoint::KINDS, DamagePoint::KINDS))
->required(),
Forms\Components\Select::make('severity')
->label('Gravitate')
->options(DamagePoint::SEVERITIES)
->default('minor')
->required(),
Forms\Components\Textarea::make('notes')->label('Observații')->rows(2)->columnSpanFull(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('zone')
->columns([
Tables\Columns\TextColumn::make('zone')->label('Zonă')->badge()->color('gray'),
Tables\Columns\TextColumn::make('kind')->label('Tip'),
Tables\Columns\TextColumn::make('severity')
->label('Gravitate')
->formatStateUsing(fn ($s) => DamagePoint::SEVERITIES[$s] ?? $s)
->badge()
->colors(['gray' => ['minor'], 'warning' => ['medium'], 'danger' => ['severe']]),
Tables\Columns\TextColumn::make('notes')->limit(40)->placeholder('—'),
])
->headerActions([Actions\CreateAction::make()])
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()])
->emptyStateHeading('Nicio daună marcată')
->emptyStateDescription('Adaugă punctele de daună pe zone (capotă, ușă, aripă) cu tip și gravitate — formează harta de daune a mașinii.');
}
}
@@ -65,6 +65,9 @@ class ClientResource extends Resource
])
->default('active')
->required(),
Forms\Components\Toggle::make('is_vip')
->label('Client VIP')
->helperText('Activează coeficienții de preț VIP pe fișele acestui client.'),
]),
Schemas\Components\Section::make('Contacte')
->columns(2)
@@ -73,6 +76,14 @@ class ClientResource extends Resource
Forms\Components\TextInput::make('phone_alt')->label('Telefon alternativ')->tel()->maxLength(40),
Forms\Components\TextInput::make('email')->email()->maxLength(120),
Forms\Components\TextInput::make('telegram')->maxLength(60),
Forms\Components\TextInput::make('telegram_chat_id')
->label('Telegram chat ID')
->disabled()
->dehydrated(false)
->placeholder('Se completează automat când clientul scrie la bot')
->helperText(fn ($record) => $record?->telegram_chat_id
? '✅ Telegram legat — notificările vor merge prin bot'
: null),
Forms\Components\TextInput::make('whatsapp')->maxLength(60),
Forms\Components\TextInput::make('viber')->maxLength(60),
]),
@@ -3,6 +3,7 @@
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\LaborResource\Pages;
use App\Filament\Tenant\Resources\LaborResource\RelationManagers;
use App\Models\Tenant\Labor;
use Filament\Actions;
use Filament\Forms;
@@ -42,8 +43,17 @@ class LaborResource extends Resource
Forms\Components\TextInput::make('code')->label('Cod')->maxLength(32),
Forms\Components\TextInput::make('name_ro')->label('Nume (RO)')->required()->maxLength(160),
Forms\Components\TextInput::make('name_ru')->label('Nume (RU)')->maxLength(160),
Forms\Components\TextInput::make('hours')->label('Ore')->numeric()->default(1)->required(),
Forms\Components\TextInput::make('price')->label('Preț (MDL)')->numeric()->default(0),
Forms\Components\Select::make('pricing_mode')
->label('Mod tarifare')
->options(Labor::PRICING_MODES)
->default('hourly')
->live()
->required(),
Forms\Components\TextInput::make('hours')->label('Ore (normă)')->numeric()->default(1)
->visible(fn (Schemas\Components\Utilities\Get $get) => $get('pricing_mode') !== 'fixed'),
Forms\Components\TextInput::make('fixed_price')->label('Preț fix (MDL)')->numeric()->default(0)
->visible(fn (Schemas\Components\Utilities\Get $get) => $get('pricing_mode') === 'fixed'),
Forms\Components\TextInput::make('price')->label('Preț orientativ (MDL)')->numeric()->default(0),
Forms\Components\Toggle::make('is_active')->label('Activă')->default(true),
]),
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
@@ -56,8 +66,15 @@ class LaborResource extends Resource
->columns([
Tables\Columns\TextColumn::make('category')->label('Categorie')->badge()->sortable(),
Tables\Columns\TextColumn::make('name_ro')->label('Manoperă')->searchable()->sortable(),
Tables\Columns\TextColumn::make('pricing_mode')
->label('Tarifare')
->formatStateUsing(fn ($s) => $s === 'fixed' ? 'Fix' : 'Pe oră')
->badge()
->color(fn ($s) => $s === 'fixed' ? 'info' : 'gray'),
Tables\Columns\TextColumn::make('hours')->label('Ore')->numeric(decimalPlaces: 2)->alignRight(),
Tables\Columns\TextColumn::make('price')->label('Preț')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('fixed_price')->label('Preț fix')->money('MDL')->alignRight()
->placeholder('—')->toggleable(),
Tables\Columns\TextColumn::make('laborParts_count')->counts('laborParts')->label('Piese impl.')->alignRight()->toggleable(),
Tables\Columns\IconColumn::make('is_active')->label('Activă')->boolean(),
])
->filters([
@@ -73,6 +90,13 @@ class LaborResource extends Resource
->defaultGroup('category');
}
public static function getRelations(): array
{
return [
RelationManagers\DefaultPartsRelationManager::class,
];
}
public static function getPages(): array
{
return [
@@ -0,0 +1,57 @@
<?php
namespace App\Filament\Tenant\Resources\LaborResource\RelationManagers;
use App\Models\Tenant\Part;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class DefaultPartsRelationManager extends RelationManager
{
protected static string $relationship = 'laborParts';
protected static ?string $title = 'Piese implicite';
public function form(Schema $schema): Schema
{
return $schema->components([
Forms\Components\Select::make('part_id')
->label('Piesă')
->options(fn () => Part::where('is_active', true)
->get()
->mapWithKeys(fn ($p) => [$p->id => "{$p->name} " . ($p->article ? "[{$p->article}]" : '')])
->toArray())
->searchable()
->required()
->live()
->afterStateUpdated(function ($state, Set $set) {
if ($state && $p = Part::find($state)) {
$set('unit', $p->unit);
}
}),
Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(1)->required(),
Forms\Components\TextInput::make('unit')->label('UM')->default('buc')->maxLength(16),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('part.name')
->columns([
Tables\Columns\TextColumn::make('part.name')->label('Piesă')->wrap(),
Tables\Columns\TextColumn::make('part.article')->label('Cod')->placeholder('—'),
Tables\Columns\TextColumn::make('qty')->label('Cant.')->alignRight(),
Tables\Columns\TextColumn::make('unit')->label('UM'),
])
->headerActions([Actions\CreateAction::make()])
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()])
->emptyStateHeading('Nicio piesă implicită')
->emptyStateDescription('Adaugă piesele care se montează de obicei la această manoperă — se adaugă automat în fișă când selectezi manopera.');
}
}
@@ -0,0 +1,142 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\OnlineOrderResource\Pages;
use App\Filament\Tenant\Resources\OnlineOrderResource\RelationManagers;
use App\Models\Tenant\OnlineOrder;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class OnlineOrderResource extends Resource
{
protected static ?string $model = OnlineOrder::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shopping-bag';
protected static ?string $navigationLabel = 'Comenzi online';
protected static string|\UnitEnum|null $navigationGroup = 'Magazin';
protected static ?string $modelLabel = 'comandă';
protected static ?string $pluralModelLabel = 'comenzi online';
protected static ?int $navigationSort = 50;
public static function getNavigationBadge(): ?string
{
$new = static::getModel()::query()->where('status', 'new')->count();
return $new > 0 ? (string) $new : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'warning';
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Comandă')
->columns(3)
->schema([
Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false),
Forms\Components\Select::make('status')->options(OnlineOrder::STATUSES)->required(),
Forms\Components\Select::make('delivery_method')->label('Livrare')->options(OnlineOrder::DELIVERY)->required(),
Forms\Components\TextInput::make('customer_name')->label('Client')->required(),
Forms\Components\TextInput::make('customer_phone')->label('Telefon')->required(),
Forms\Components\TextInput::make('customer_email')->label('Email'),
Forms\Components\TextInput::make('address')->label('Adresă')->columnSpan(2),
Forms\Components\TextInput::make('delivery_fee')->label('Taxă livrare')->numeric(),
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('number')->label('Nr.')->searchable()->sortable(),
Tables\Columns\TextColumn::make('created_at')->label('Data')->dateTime('d.m.Y H:i')->sortable(),
Tables\Columns\TextColumn::make('customer_name')->label('Client')->searchable(),
Tables\Columns\TextColumn::make('customer_phone')->label('Telefon')->copyable(),
Tables\Columns\TextColumn::make('delivery_method')
->label('Livrare')
->formatStateUsing(fn ($s) => OnlineOrder::DELIVERY[$s] ?? $s),
Tables\Columns\TextColumn::make('status')
->formatStateUsing(fn ($s) => OnlineOrder::STATUSES[$s] ?? $s)
->badge()
->colors([
'warning' => ['new'],
'info' => ['confirmed', 'packed'],
'primary' => ['shipped'],
'success' => ['delivered'],
'danger' => ['cancelled'],
]),
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight()->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')->options(OnlineOrder::STATUSES),
])
->actions([
Actions\Action::make('fulfill')
->label('Onorează (scade stoc)')
->icon('heroicon-m-check-badge')
->color('success')
->visible(fn (OnlineOrder $r) => ! in_array($r->status, ['delivered', 'cancelled'], true))
->requiresConfirmation()
->modalDescription('Scade din stoc piesele legate de catalog (FIFO) și marchează comanda confirmată.')
->action(function (OnlineOrder $r) {
$svc = app(\App\Services\Warehouse\WarehouseService::class);
$issued = 0; $skipped = 0;
foreach ($r->items as $item) {
if ($item->fulfilled) continue;
if (! $item->part_id) { $skipped++; continue; }
$part = \App\Models\Tenant\Part::find($item->part_id);
if (! $part) { $skipped++; continue; }
try {
$svc->issue($part, (float) $item->qty, null, $r, "Comandă online #{$r->number}");
$item->fulfilled = true;
$item->save();
$issued++;
} catch (\App\Services\Warehouse\InsufficientStockException $e) {
$skipped++;
}
}
if ($r->status === 'new') {
$r->status = 'confirmed';
$r->save();
}
Notification::make()
->title("Onorat: {$issued} linii scăzute" . ($skipped ? ", {$skipped} sărite (stoc/lipsă link)" : ''))
->{$skipped ? 'warning' : 'success'}()
->send();
}),
Actions\EditAction::make(),
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
RelationManagers\ItemsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListOnlineOrders::route('/'),
'edit' => Pages\EditOnlineOrder::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\OnlineOrderResource\Pages;
use App\Filament\Tenant\Resources\OnlineOrderResource;
use Filament\Resources\Pages\EditRecord;
class EditOnlineOrder extends EditRecord
{
protected static string $resource = OnlineOrderResource::class;
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\OnlineOrderResource\Pages;
use App\Filament\Tenant\Resources\OnlineOrderResource;
use Filament\Resources\Pages\ListRecords;
class ListOnlineOrders extends ListRecords
{
protected static string $resource = OnlineOrderResource::class;
}
@@ -0,0 +1,28 @@
<?php
namespace App\Filament\Tenant\Resources\OnlineOrderResource\RelationManagers;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class ItemsRelationManager extends RelationManager
{
protected static string $relationship = 'items';
protected static ?string $title = 'Produse';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('name')->label('Piesă')->wrap(),
Tables\Columns\TextColumn::make('article')->label('Cod')->placeholder('—'),
Tables\Columns\TextColumn::make('qty')->label('Cant.')->alignRight(),
Tables\Columns\TextColumn::make('price')->label('Preț')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
Tables\Columns\IconColumn::make('fulfilled')->label('Onorat')->boolean(),
]);
}
}
@@ -3,6 +3,7 @@
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\PartResource\Pages;
use App\Filament\Tenant\Resources\PartResource\RelationManagers;
use App\Models\Tenant\Part;
use App\Models\Tenant\Supplier;
use Filament\Actions;
@@ -79,6 +80,10 @@ class PartResource extends Resource
Forms\Components\TextInput::make('unit')->label('UM')->default('buc')->maxLength(16),
Forms\Components\TextInput::make('min_qty')->label('Minim')->numeric()->default(0),
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
Forms\Components\Toggle::make('is_published')
->label('Publicat în magazin')
->helperText('Apare în magazinul online public.')
->default(false),
]),
Schemas\Components\Section::make('Prețuri')
->columns(2)
@@ -112,9 +117,16 @@ class PartResource extends Resource
->alignRight()
->color(fn ($state, $record) => $record->qty <= 0 ? 'danger' : ($record->qty <= $record->min_qty ? 'warning' : null))
->weight(fn ($state, $record) => $record->qty <= $record->min_qty ? 'bold' : null),
Tables\Columns\TextColumn::make('qty_reserved')
->label('Rezervat')
->numeric(decimalPlaces: 2)
->alignRight()
->color(fn ($state) => (float) $state > 0 ? 'info' : null)
->toggleable(),
Tables\Columns\TextColumn::make('unit')->label('UM'),
Tables\Columns\TextColumn::make('location')->label('Loc.')->placeholder('—'),
Tables\Columns\TextColumn::make('sell_price')->label('Preț vz.')->money('MDL')->alignRight(),
Tables\Columns\IconColumn::make('is_published')->label('Magazin')->boolean()->toggleable(),
Tables\Columns\TextColumn::make('preferredSupplier.name')->label('Furnizor')->placeholder('—')->toggleable(),
])
->filters([
@@ -128,15 +140,115 @@ class PartResource extends Resource
->query(fn ($q) => $q->where('qty', '<=', 0)),
])
->actions([
Actions\Action::make('qr')
->label('QR')
->icon('heroicon-m-qr-code')
->color('gray')
->modalHeading(fn (Part $r) => 'QR pentru ' . $r->name)
->modalSubmitAction(false)
->modalCancelActionLabel('Închide')
->modalContent(function (Part $r) {
$payload = 'PART:' . ($r->article ?: $r->id);
$svg = (new \chillerlan\QRCode\QRCode(new \chillerlan\QRCode\QROptions([
'outputType' => \chillerlan\QRCode\QRCode::OUTPUT_MARKUP_SVG,
'eccLevel' => \chillerlan\QRCode\QRCode::ECC_M,
'scale' => 8,
'imageBase64' => false,
'addQuietzone' => true,
])))->render($payload);
return view('filament.tenant.part-qr', [
'part' => $r, 'svg' => $svg, 'payload' => $payload,
]);
}),
Actions\Action::make('ai_price')
->label('AI: preț recomandat')
->icon('heroicon-m-sparkles')
->color('primary')
->modalHeading(fn (Part $r) => "AI: preț pentru {$r->name}")
->modalSubmitAction(false)
->modalCancelActionLabel('Închide')
->modalContent(function (Part $r) {
[$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class)
->suggestPrice($r);
return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]);
}),
Actions\Action::make('receive')
->label('Recepție')
->icon('heroicon-m-arrow-down-tray')
->color('success')
->schema([
Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->required()->minValue(0.001),
Forms\Components\TextInput::make('buy_price')->label('Preț unitar')->numeric()->required(),
Forms\Components\Select::make('supplier_id')
->label('Furnizor')
->options(fn () => \App\Models\Tenant\Supplier::pluck('name', 'id')),
Forms\Components\Select::make('warehouse_id')
->label('Depozit')
->options(fn () => \App\Models\Tenant\Warehouse::where('is_active', true)->pluck('name', 'id'))
->default(fn () => \App\Models\Tenant\Warehouse::where('is_default', true)->value('id')),
Forms\Components\TextInput::make('batch_ref')->label('Ref. lot/factură')->maxLength(64),
])
->action(function (Part $record, array $data) {
$warehouse = $data['warehouse_id']
? \App\Models\Tenant\Warehouse::find($data['warehouse_id'])
: null;
$supplier = $data['supplier_id']
? \App\Models\Tenant\Supplier::find($data['supplier_id'])
: null;
app(\App\Services\Warehouse\WarehouseService::class)->receive(
part: $record,
qty: (float) $data['qty'],
buyPrice: (float) $data['buy_price'],
warehouse: $warehouse,
supplier: $supplier,
batchRef: $data['batch_ref'] ?? null,
);
\Filament\Notifications\Notification::make()
->title('Stoc adăugat')
->success()
->send();
}),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->bulkActions([
Actions\BulkAction::make('print_labels')
->label('Tipărește etichete QR')
->icon('heroicon-m-printer')
->color('gray')
->action(function ($records) {
$ids = collect($records)->pluck('id')->implode(',');
return redirect()->away('/parts/labels?ids=' . $ids);
})
->deselectRecordsAfterCompletion(),
Actions\BulkAction::make('publish')
->label('Publică în magazin')
->icon('heroicon-m-globe-alt')
->color('success')
->action(fn ($records) => collect($records)->each->update(['is_published' => true]))
->deselectRecordsAfterCompletion(),
Actions\BulkAction::make('unpublish')
->label('Scoate din magazin')
->icon('heroicon-m-eye-slash')
->color('gray')
->action(fn ($records) => collect($records)->each->update(['is_published' => false]))
->deselectRecordsAfterCompletion(),
])
->emptyStateHeading('Depozit gol')
->emptyStateDescription('Adaugă piese manual, sau folosește Achiziții ca să le adaugi prin recepție de la furnizor (cu prețuri și stoc auto). Procentaj poate seta automat prețul de vânzare.')
->emptyStateIcon('heroicon-o-cube')
->defaultSort('name');
}
public static function getRelations(): array
{
return [
RelationManagers\BatchesRelationManager::class,
RelationManagers\PriceHistoryRelationManager::class,
RelationManagers\CrossRefsRelationManager::class,
];
}
public static function getPages(): array
{
return [
@@ -0,0 +1,45 @@
<?php
namespace App\Filament\Tenant\Resources\PartResource\RelationManagers;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class BatchesRelationManager extends RelationManager
{
protected static string $relationship = 'batches';
protected static ?string $title = 'Loturi (FIFO)';
public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('received_at')
->label('Recepție')
->dateTime('d.m.Y H:i')
->sortable(),
Tables\Columns\TextColumn::make('warehouse.code')->label('Depozit')->placeholder('—'),
Tables\Columns\TextColumn::make('batch_ref')->label('Ref.')->placeholder('—'),
Tables\Columns\TextColumn::make('supplier.name')->label('Furnizor')->placeholder('—'),
Tables\Columns\TextColumn::make('qty_in')
->label('Intrat')
->numeric(decimalPlaces: 2)
->alignRight(),
Tables\Columns\TextColumn::make('qty_remaining')
->label('Rămas')
->numeric(decimalPlaces: 2)
->alignRight()
->weight('bold')
->color(fn ($state) => (float) $state <= 0 ? 'gray' : 'success'),
Tables\Columns\TextColumn::make('buy_price')
->label('Preț unit.')
->money('MDL')
->alignRight(),
])
->defaultSort('received_at')
->emptyStateHeading('Niciun lot înregistrat')
->emptyStateDescription('Apasă „Recepție" pe lista de piese pentru a înregistra prima intrare în depozit.');
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Filament\Tenant\Resources\PartResource\RelationManagers;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class CrossRefsRelationManager extends RelationManager
{
protected static string $relationship = 'crossRefs';
protected static ?string $title = 'Coduri cross (OEM/echivalente)';
public function form(Schema $schema): Schema
{
return $schema->components([
Forms\Components\TextInput::make('cross_article')->label('Cod echivalent')->required()->maxLength(64),
Forms\Components\TextInput::make('brand')->label('Brand')->maxLength(64),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('cross_article')
->columns([
Tables\Columns\TextColumn::make('cross_article')->label('Cod')->searchable(),
Tables\Columns\TextColumn::make('brand')->placeholder('—'),
])
->headerActions([Actions\CreateAction::make()])
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()])
->emptyStateHeading('Niciun cod cross')
->emptyStateDescription('Adaugă coduri echivalente OEM/aftermarket ca să fie găsite în căutarea din magazin.');
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Filament\Tenant\Resources\PartResource\RelationManagers;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class PriceHistoryRelationManager extends RelationManager
{
protected static string $relationship = 'priceHistory';
protected static ?string $title = 'Istoric prețuri furnizori';
public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('observed_at')
->label('Data')
->dateTime('d.m.Y H:i')
->sortable(),
Tables\Columns\TextColumn::make('supplier.name')->label('Furnizor')->searchable(),
Tables\Columns\TextColumn::make('purchase.number')->label('PO')->placeholder('—'),
Tables\Columns\TextColumn::make('price')
->money('MDL')
->alignRight()
->sortable(),
Tables\Columns\TextColumn::make('currency')->label('Val.'),
])
->defaultSort('observed_at', 'desc')
->emptyStateHeading('Niciun preț înregistrat')
->emptyStateDescription('Prețurile se înregistrează automat la fiecare recepție de PO.');
}
}
@@ -0,0 +1,108 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
use App\Models\Tenant\PricingCoefficient;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class PricingCoefficientResource extends Resource
{
protected static ?string $model = PricingCoefficient::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-adjustments-horizontal';
protected static ?string $navigationLabel = 'Coeficienți preț';
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
protected static ?string $modelLabel = 'coeficient';
protected static ?string $pluralModelLabel = 'coeficienți preț';
protected static ?int $navigationSort = 46;
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Coeficient')
->columns(2)
->schema([
Forms\Components\TextInput::make('name')->label('Denumire')->required()
->placeholder('ex: Mașină veche, Client VIP, Express')->columnSpanFull(),
Forms\Components\TextInput::make('multiplier')
->label('Multiplicator')
->numeric()
->required()
->default(1.10)
->helperText('1.15 = +15% peste prețul de bază. 0.95 = -5%.'),
Forms\Components\TextInput::make('priority')->label('Prioritate')->numeric()->default(100),
Forms\Components\Toggle::make('stackable')
->label('Cumulabil')
->default(true)
->helperText('Cumulabil = se înmulțește cu alți coeficienți. Necumulabil = doar cel mai mare necumulabil se aplică.'),
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
]),
Schemas\Components\Section::make('Condiții (toate trebuie îndeplinite)')
->description('Lasă gol = se aplică mereu. Combină condițiile pentru a ținti situații specifice.')
->columns(2)
->schema([
Forms\Components\CheckboxList::make('conditions.classes')
->label('Clase auto')
->options(PricingCoefficient::VEHICLE_CLASSES)
->columns(2)
->columnSpanFull(),
Forms\Components\TextInput::make('conditions.age_min')->label('Vârstă min (ani)')->numeric(),
Forms\Components\TextInput::make('conditions.age_max')->label('Vârstă max (ani)')->numeric(),
Forms\Components\Toggle::make('conditions.client_vip')->label('Doar clienți VIP'),
Forms\Components\CheckboxList::make('conditions.urgency')
->label('Urgență')
->options(PricingCoefficient::URGENCY)
->columns(3)
->columnSpanFull(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('priority')->label('Prio')->sortable()->alignRight(),
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('multiplier')
->label('Multiplicator')
->formatStateUsing(fn ($s) => '×' . rtrim(rtrim(number_format((float) $s, 3), '0'), '.'))
->alignRight()
->color(fn ($s) => (float) $s >= 1 ? 'success' : 'warning'),
Tables\Columns\IconColumn::make('stackable')->label('Cumul.')->boolean(),
Tables\Columns\IconColumn::make('is_active')->label('Activ')->boolean(),
])
->filters([
Tables\Filters\TernaryFilter::make('is_active')->label('Active'),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading('Niciun coeficient')
->emptyStateDescription('Adaugă reguli care ajustează prețul în funcție de vârsta mașinii, clasă (SUV, comercial, hibrid), client VIP sau urgență. Se aplică peste markup-ul de bază pe fișele de lucru.')
->emptyStateIcon('heroicon-o-adjustments-horizontal')
->defaultSort('priority');
}
public static function getPages(): array
{
return [
'index' => Pages\ListPricingCoefficients::route('/'),
'create' => Pages\CreatePricingCoefficient::route('/create'),
'edit' => Pages\EditPricingCoefficient::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
use App\Filament\Tenant\Resources\PricingCoefficientResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePricingCoefficient extends CreateRecord
{
protected static string $resource = PricingCoefficientResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
use App\Filament\Tenant\Resources\PricingCoefficientResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPricingCoefficient extends EditRecord
{
protected static string $resource = PricingCoefficientResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
use App\Filament\Tenant\Resources\PricingCoefficientResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListPricingCoefficients extends ListRecords
{
protected static string $resource = PricingCoefficientResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -6,6 +6,7 @@ use App\Filament\Tenant\Resources\PurchaseResource\Pages;
use App\Filament\Tenant\Resources\PurchaseResource\RelationManagers;
use App\Models\Tenant\Purchase;
use App\Models\Tenant\Supplier;
use App\Models\Tenant\Warehouse;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
@@ -43,6 +44,11 @@ class PurchaseResource extends Resource
->options(fn () => Supplier::where('is_active', true)->pluck('name', 'id'))
->searchable()
->required(),
Forms\Components\Select::make('warehouse_id')
->label('Depozit țintă')
->options(fn () => Warehouse::where('is_active', true)->pluck('name', 'id'))
->default(fn () => Warehouse::where('is_default', true)->value('id'))
->required(),
Forms\Components\Select::make('status')
->options(Purchase::STATUSES)
->default('draft')
@@ -71,9 +77,19 @@ class PurchaseResource extends Resource
->colors([
'gray' => ['draft'],
'warning' => ['ordered'],
'info' => ['partial'],
'success' => ['received'],
'danger' => ['cancelled'],
]),
Tables\Columns\TextColumn::make('received_progress')
->label('Progres')
->state(function (Purchase $r) {
$items = $r->items;
$ord = (float) $items->sum('qty');
$rec = (float) $items->sum('qty_received');
return $ord > 0 ? sprintf('%d%%', (int) round($rec / $ord * 100)) : '—';
})
->alignRight(),
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
])
->filters([
@@ -83,19 +99,27 @@ class PurchaseResource extends Resource
->options(fn () => Supplier::pluck('name', 'id')),
])
->actions([
Actions\Action::make('receive')
->label('Recepționează')
Actions\Action::make('receive_all')
->label('Recepție totală')
->icon('heroicon-m-check-circle')
->color('success')
->visible(fn (Purchase $r) => $r->status !== 'received' && $r->status !== 'cancelled')
->visible(fn (Purchase $r) => ! in_array($r->status, ['received', 'cancelled', 'draft'], true))
->requiresConfirmation()
->modalDescription('Se va incrementa stocul pieselor legate.')
->modalDescription('Se vor crea batch-uri pentru toate restanțele rămase în depozitul țintă.')
->action(function (Purchase $r) {
$r->markReceived();
Notification::make()
->title('Recepționat — stoc actualizat')
->success()
->send();
try {
$r->receiveAllRemaining();
Notification::make()
->title('Recepție completă — batch-uri create')
->success()
->send();
} catch (\Throwable $e) {
Notification::make()
->title('Eroare')
->body($e->getMessage())
->danger()
->send();
}
}),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
@@ -3,8 +3,11 @@
namespace App\Filament\Tenant\Resources\PurchaseResource\RelationManagers;
use App\Models\Tenant\Part;
use App\Models\Tenant\PurchaseItem;
use App\Models\Tenant\Warehouse;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
@@ -52,16 +55,58 @@ class ItemsRelationManager extends RelationManager
->columns([
Tables\Columns\TextColumn::make('name')->wrap(),
Tables\Columns\TextColumn::make('article')->placeholder('—'),
Tables\Columns\TextColumn::make('qty')->alignRight(),
Tables\Columns\TextColumn::make('qty')->label('Comandat')->alignRight(),
Tables\Columns\TextColumn::make('qty_received')
->label('Recepționat')
->alignRight()
->color(fn ($state, $record) => $record->isFullyReceived() ? 'success' : ((float) $state > 0 ? 'warning' : 'gray'))
->formatStateUsing(fn ($state, $record) => sprintf('%.2f / %.2f', (float) $state, (float) $record->qty)),
Tables\Columns\TextColumn::make('unit')->label('UM'),
Tables\Columns\TextColumn::make('buy_price')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
Tables\Columns\IconColumn::make('received')->boolean()->label('Recepț.'),
])
->headerActions([
Actions\CreateAction::make(),
])
->actions([
Actions\Action::make('receive_item')
->label('Recepționează')
->icon('heroicon-m-arrow-down-tray')
->color('success')
->visible(fn (PurchaseItem $r) => ! $r->isFullyReceived())
->schema([
Forms\Components\Placeholder::make('outstanding')
->label('Restanță')
->content(fn (PurchaseItem $r) => sprintf('%.2f %s', $r->outstanding(), $r->unit ?? 'buc')),
Forms\Components\TextInput::make('qty')
->label('Cantitate recepționată')
->numeric()
->required()
->minValue(0.001)
->default(fn (PurchaseItem $r) => $r->outstanding()),
Forms\Components\Select::make('warehouse_id')
->label('Depozit țintă')
->options(fn () => Warehouse::where('is_active', true)->pluck('name', 'id'))
->default(fn (PurchaseItem $r) => $r->purchase?->warehouse_id
?? Warehouse::where('is_default', true)->value('id'))
->required(),
])
->action(function (PurchaseItem $r, array $data) {
$wh = $data['warehouse_id'] ? Warehouse::find($data['warehouse_id']) : null;
try {
$r->purchase->receiveItem($r, (float) $data['qty'], $wh);
Notification::make()
->title('Recepționat — batch creat')
->success()
->send();
} catch (\Throwable $e) {
Notification::make()
->title('Eroare la recepție')
->body($e->getMessage())
->danger()
->send();
}
}),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
]);
@@ -0,0 +1,88 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\ServiceTemplateResource\Pages;
use App\Filament\Tenant\Resources\ServiceTemplateResource\RelationManagers;
use App\Models\Tenant\Labor;
use App\Models\Tenant\ServiceTemplate;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class ServiceTemplateResource extends Resource
{
protected static ?string $model = ServiceTemplate::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static ?string $navigationLabel = 'Șabloane servicii';
protected static string|\UnitEnum|null $navigationGroup = 'Service';
protected static ?string $modelLabel = 'șablon';
protected static ?string $pluralModelLabel = 'șabloane servicii';
protected static ?int $navigationSort = 33;
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make()
->columns(2)
->schema([
Forms\Components\TextInput::make('name')->label('Denumire')->required()
->placeholder('ex: Revizie completă 15.000 km')->columnSpanFull(),
Forms\Components\Select::make('category')
->label('Categorie')
->options(array_combine(Labor::CATEGORIES, Labor::CATEGORIES))
->searchable(),
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'),
Tables\Columns\TextColumn::make('items_count')->counts('items')->label('Linii')->alignRight(),
Tables\Columns\IconColumn::make('is_active')->label('Activ')->boolean(),
])
->filters([
Tables\Filters\TernaryFilter::make('is_active')->label('Active'),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading('Niciun șablon')
->emptyStateDescription('Grupează manopere + piese frecvente într-un șablon (ex: „Schimb ulei complet") și aplică-l pe o fișă cu un click.')
->emptyStateIcon('heroicon-o-clipboard-document-list')
->defaultSort('name');
}
public static function getRelations(): array
{
return [
RelationManagers\ItemsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListServiceTemplates::route('/'),
'create' => Pages\CreateServiceTemplate::route('/create'),
'edit' => Pages\EditServiceTemplate::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\ServiceTemplateResource\Pages;
use App\Filament\Tenant\Resources\ServiceTemplateResource;
use Filament\Resources\Pages\CreateRecord;
class CreateServiceTemplate extends CreateRecord
{
protected static string $resource = ServiceTemplateResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\ServiceTemplateResource\Pages;
use App\Filament\Tenant\Resources\ServiceTemplateResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditServiceTemplate extends EditRecord
{
protected static string $resource = ServiceTemplateResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\ServiceTemplateResource\Pages;
use App\Filament\Tenant\Resources\ServiceTemplateResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListServiceTemplates extends ListRecords
{
protected static string $resource = ServiceTemplateResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -0,0 +1,79 @@
<?php
namespace App\Filament\Tenant\Resources\ServiceTemplateResource\RelationManagers;
use App\Models\Tenant\Labor;
use App\Models\Tenant\Part;
use App\Models\Tenant\ServiceTemplateItem;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class ItemsRelationManager extends RelationManager
{
protected static string $relationship = 'items';
protected static ?string $title = 'Conținut șablon';
public function form(Schema $schema): Schema
{
return $schema->components([
Forms\Components\Select::make('kind')
->label('Tip')
->options(ServiceTemplateItem::KINDS)
->default('labor')
->live()
->required(),
Forms\Components\Select::make('labor_id')
->label('Manoperă')
->options(fn () => Labor::where('is_active', true)->pluck('name_ro', 'id'))
->searchable()
->visible(fn (Get $get) => $get('kind') === 'labor')
->live()
->afterStateUpdated(function ($state, Set $set) {
if ($state && $l = Labor::find($state)) {
$set('name', $l->name_ro);
$set('hours', $l->hours);
}
}),
Forms\Components\Select::make('part_id')
->label('Piesă')
->options(fn () => Part::where('is_active', true)
->get()->mapWithKeys(fn ($p) => [$p->id => "{$p->name} " . ($p->article ? "[{$p->article}]" : '')])->toArray())
->searchable()
->visible(fn (Get $get) => $get('kind') === 'part')
->live()
->afterStateUpdated(function ($state, Set $set) {
if ($state && $p = Part::find($state)) $set('name', $p->name);
}),
Forms\Components\TextInput::make('name')->label('Denumire')->required()->columnSpanFull(),
Forms\Components\TextInput::make('hours')->label('Ore')->numeric()
->visible(fn (Get $get) => $get('kind') === 'labor'),
Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(1)
->visible(fn (Get $get) => $get('kind') === 'part'),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('kind')
->label('Tip')
->formatStateUsing(fn ($s) => ServiceTemplateItem::KINDS[$s] ?? $s)
->badge()
->color(fn ($s) => $s === 'labor' ? 'info' : 'gray'),
Tables\Columns\TextColumn::make('name')->wrap(),
Tables\Columns\TextColumn::make('hours')->label('Ore')->placeholder('—')->alignRight(),
Tables\Columns\TextColumn::make('qty')->label('Cant.')->placeholder('—')->alignRight(),
])
->headerActions([Actions\CreateAction::make()])
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()]);
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\SubcontractJobResource\Pages;
use App\Models\Tenant\Subcontractor;
use App\Models\Tenant\SubcontractJob;
use App\Models\Tenant\WorkOrder;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class SubcontractJobResource extends Resource
{
protected static ?string $model = SubcontractJob::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-arrow-top-right-on-square';
protected static ?string $navigationLabel = 'Lucrări terți';
protected static string|\UnitEnum|null $navigationGroup = 'Subcontractare';
protected static ?string $modelLabel = 'lucrare terți';
protected static ?string $pluralModelLabel = 'lucrări terți';
protected static ?int $navigationSort = 71;
public static function getNavigationBadge(): ?string
{
$open = static::getModel()::query()->whereNotIn('status', ['done', 'returned', 'cancelled'])->count();
return $open > 0 ? (string) $open : null;
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Lucrare')
->columns(2)
->schema([
Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false)->placeholder('Generat automat'),
Forms\Components\Select::make('status')->options(SubcontractJob::STATUSES)->default('sent')->required(),
Forms\Components\Select::make('subcontractor_id')
->label('Subcontractor')
->options(fn () => Subcontractor::where('is_active', true)->pluck('name', 'id'))
->searchable(),
Forms\Components\Select::make('work_order_id')
->label('Fișă asociată')
->options(fn () => WorkOrder::whereNotIn('status', ['done', 'cancelled'])
->get()->mapWithKeys(fn ($w) => [$w->id => "#{$w->number} · " . ($w->vehicle?->plate ?? '')])->toArray())
->searchable(),
Forms\Components\Select::make('category')
->label('Categorie')
->options(array_combine(Subcontractor::SPECIALTIES, Subcontractor::SPECIALTIES))
->searchable(),
Forms\Components\Textarea::make('description')->label('Descriere')->rows(2)->columnSpanFull(),
]),
Schemas\Components\Section::make('Cost & marjă')
->columns(3)
->schema([
Forms\Components\TextInput::make('cost')->label('Cost (de la terț)')->numeric()->default(0)->required(),
Forms\Components\TextInput::make('markup_pct')->label('Markup %')->numeric()->default(0)
->helperText('> 0 calculează automat prețul client.'),
Forms\Components\TextInput::make('client_price')->label('Preț client')->numeric()->default(0)
->helperText('Setat manual dacă markup = 0.'),
Forms\Components\Toggle::make('paid_to_sub')->label('Plătit către terț'),
]),
Schemas\Components\Section::make('Termene')
->columns(3)
->schema([
Forms\Components\DatePicker::make('sent_at')->label('Trimis')->default(today()),
Forms\Components\DatePicker::make('eta')->label('ETA'),
Forms\Components\DatePicker::make('returned_at')->label('Returnat'),
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('number')->label('Nr.')->searchable()->sortable(),
Tables\Columns\TextColumn::make('subcontractor.name')->label('Terț')->placeholder('—'),
Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'),
Tables\Columns\TextColumn::make('workOrder.number')->label('Fișă')->placeholder('—'),
Tables\Columns\TextColumn::make('cost')->label('Cost')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('client_price')->label('Preț client')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('margin')
->label('Marjă')
->state(fn (SubcontractJob $r) => $r->margin())
->money('MDL')
->alignRight()
->color(fn ($state) => (float) $state > 0 ? 'success' : ((float) $state < 0 ? 'danger' : 'gray')),
Tables\Columns\TextColumn::make('status')
->formatStateUsing(fn ($s) => SubcontractJob::STATUSES[$s] ?? $s)
->badge()
->colors([
'warning' => ['sent', 'in_progress'],
'success' => ['done', 'returned'],
'danger' => ['cancelled'],
]),
Tables\Columns\IconColumn::make('paid_to_sub')->label('Plătit terț')->boolean()->toggleable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')->options(SubcontractJob::STATUSES),
Tables\Filters\SelectFilter::make('subcontractor_id')
->label('Subcontractor')
->options(fn () => Subcontractor::pluck('name', 'id')),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading('Nicio lucrare la terți')
->emptyStateDescription('Înregistrează lucrările trimise la ateliere externe (turbo, cutii, vopsitorie). Costul terțului + markup intră automat în totalul fișei asociate.')
->emptyStateIcon('heroicon-o-arrow-top-right-on-square')
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListSubcontractJobs::route('/'),
'create' => Pages\CreateSubcontractJob::route('/create'),
'edit' => Pages\EditSubcontractJob::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\SubcontractJobResource\Pages;
use App\Filament\Tenant\Resources\SubcontractJobResource;
use Filament\Resources\Pages\CreateRecord;
class CreateSubcontractJob extends CreateRecord
{
protected static string $resource = SubcontractJobResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\SubcontractJobResource\Pages;
use App\Filament\Tenant\Resources\SubcontractJobResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditSubcontractJob extends EditRecord
{
protected static string $resource = SubcontractJobResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\SubcontractJobResource\Pages;
use App\Filament\Tenant\Resources\SubcontractJobResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListSubcontractJobs extends ListRecords
{
protected static string $resource = SubcontractJobResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\SubcontractorResource\Pages;
use App\Models\Tenant\Subcontractor;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class SubcontractorResource extends Resource
{
protected static ?string $model = Subcontractor::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
protected static ?string $navigationLabel = 'Subcontractori';
protected static string|\UnitEnum|null $navigationGroup = 'Subcontractare';
protected static ?string $modelLabel = 'subcontractor';
protected static ?string $pluralModelLabel = 'subcontractori';
protected static ?int $navigationSort = 70;
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make()->columns(2)->schema([
Forms\Components\TextInput::make('name')->label('Nume')->required()->maxLength(160),
Forms\Components\Select::make('specialty')
->label('Specialitate')
->options(array_combine(Subcontractor::SPECIALTIES, Subcontractor::SPECIALTIES))
->searchable(),
Forms\Components\TextInput::make('phone')->label('Telefon')->tel()->maxLength(40),
Forms\Components\TextInput::make('email')->email()->maxLength(120),
Forms\Components\Select::make('rating')
->label('Rating')
->options([1 => '★', 2 => '★★', 3 => '★★★', 4 => '★★★★', 5 => '★★★★★'])
->default(3),
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('specialty')->badge()->placeholder('—'),
Tables\Columns\TextColumn::make('phone')->copyable()->placeholder('—'),
Tables\Columns\TextColumn::make('rating')->formatStateUsing(fn ($s) => str_repeat('★', (int) $s)),
Tables\Columns\TextColumn::make('jobs_count')->counts('jobs')->label('Lucrări')->alignRight(),
Tables\Columns\IconColumn::make('is_active')->boolean(),
])
->filters([
Tables\Filters\TernaryFilter::make('is_active')->label('Activi'),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading('Niciun subcontractor')
->emptyStateDescription('Adaugă atelierele terțe la care trimiți lucrări (turbo, cutii, vopsitorie, PDR) și urmărește costul + marja.')
->emptyStateIcon('heroicon-o-user-group')
->defaultSort('name');
}
public static function getPages(): array
{
return [
'index' => Pages\ListSubcontractors::route('/'),
'create' => Pages\CreateSubcontractor::route('/create'),
'edit' => Pages\EditSubcontractor::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\SubcontractorResource\Pages;
use App\Filament\Tenant\Resources\SubcontractorResource;
use Filament\Resources\Pages\CreateRecord;
class CreateSubcontractor extends CreateRecord
{
protected static string $resource = SubcontractorResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\SubcontractorResource\Pages;
use App\Filament\Tenant\Resources\SubcontractorResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditSubcontractor extends EditRecord
{
protected static string $resource = SubcontractorResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\SubcontractorResource\Pages;
use App\Filament\Tenant\Resources\SubcontractorResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListSubcontractors extends ListRecords
{
protected static string $resource = SubcontractorResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -68,15 +68,56 @@ class SupplierResource extends Resource
Tables\Columns\TextColumn::make('rating')
->label('Rating')
->formatStateUsing(fn ($s) => str_repeat('★', (int) $s)),
Tables\Columns\TextColumn::make('delivery_days')->label('Livrare (zile)')->alignRight(),
Tables\Columns\TextColumn::make('on_time_pct')
->label('La timp 90d')
->state(fn (Supplier $r) => app(\App\Services\Warehouse\SupplierAnalytics::class)->onTimeRate($r))
->formatStateUsing(fn ($s) => $s === null ? '—' : "{$s}%")
->color(fn ($s) => $s === null ? 'gray' : ($s >= 90 ? 'success' : ($s >= 70 ? 'warning' : 'danger')))
->alignRight()
->toggleable(),
Tables\Columns\TextColumn::make('avg_delivery_days')
->label('Avg zile')
->state(fn (Supplier $r) => app(\App\Services\Warehouse\SupplierAnalytics::class)->avgDeliveryDays($r))
->formatStateUsing(fn ($s) => $s === null ? '—' : (string) $s)
->alignRight()
->toggleable(),
Tables\Columns\TextColumn::make('spend_90d')
->label('Cheltuit 90d')
->state(fn (Supplier $r) => app(\App\Services\Warehouse\SupplierAnalytics::class)->spend($r))
->money('MDL')
->alignRight()
->toggleable(),
Tables\Columns\TextColumn::make('delivery_days')->label('Livrare (zile)')->alignRight()->toggleable(),
Tables\Columns\TextColumn::make('discount_pct')->label('Discount')
->formatStateUsing(fn ($s) => $s . '%')->alignRight(),
->formatStateUsing(fn ($s) => $s . '%')->alignRight()->toggleable(),
Tables\Columns\IconColumn::make('is_active')->boolean(),
])
->filters([
Tables\Filters\TernaryFilter::make('is_active')->label('Activi'),
])
->actions([
Actions\Action::make('rate')
->label('Rerating')
->icon('heroicon-m-arrow-path')
->color('gray')
->action(function (Supplier $r) {
$score = app(\App\Services\Warehouse\SupplierAnalytics::class)
->computedRating($r);
if ($score === null) {
\Filament\Notifications\Notification::make()
->title('Date insuficiente')
->body('Necesită cel puțin 2 recepții complete cu data așteptată setată.')
->warning()
->send();
return;
}
$r->rating = $score;
$r->saveQuietly();
\Filament\Notifications\Notification::make()
->title("Rating actualizat → {$score}")
->success()
->send();
}),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
@@ -0,0 +1,196 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\TireSetResource\Pages;
use App\Filament\Tenant\Resources\TireSetResource\RelationManagers;
use App\Models\Tenant\Client;
use App\Models\Tenant\TireSet;
use App\Models\Tenant\Vehicle;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class TireSetResource extends Resource
{
protected static ?string $model = TireSet::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-lifebuoy';
protected static ?string $navigationLabel = 'Seturi anvelope';
protected static string|\UnitEnum|null $navigationGroup = 'Anvelope';
protected static ?string $modelLabel = 'set anvelope';
protected static ?string $pluralModelLabel = 'seturi anvelope';
protected static ?int $navigationSort = 60;
public static function getNavigationBadge(): ?string
{
$stored = \App\Models\Tenant\TireStorage::where('status', 'stored')->count();
return $stored > 0 ? (string) $stored : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'info';
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Proprietar')
->columns(2)
->schema([
Forms\Components\Select::make('client_id')
->label('Client')
->options(fn () => Client::pluck('name', 'id'))
->searchable()
->live()
->required(),
Forms\Components\Select::make('vehicle_id')
->label('Auto')
->options(fn (Get $get) => $get('client_id')
? Vehicle::where('client_id', $get('client_id'))->get()
->mapWithKeys(fn ($v) => [$v->id => "{$v->make} {$v->model} {$v->plate}"])->toArray()
: [])
->searchable(),
Forms\Components\TextInput::make('label')->label('Etichetă')->placeholder('ex: Iarnă Michelin'),
Forms\Components\Select::make('season')->label('Sezon')->options(TireSet::SEASONS)->default('winter')->required(),
]),
Schemas\Components\Section::make('Specificații')
->columns(3)
->schema([
Forms\Components\TextInput::make('width')->label('Lățime')->numeric()->placeholder('205'),
Forms\Components\TextInput::make('profile')->label('Profil')->numeric()->placeholder('55'),
Forms\Components\TextInput::make('diameter')->label('Diametru R')->numeric()->placeholder('16'),
Forms\Components\TextInput::make('brand')->maxLength(64),
Forms\Components\TextInput::make('model')->maxLength(64),
Forms\Components\TextInput::make('dot_year')->label('DOT')->maxLength(8)->placeholder('3621'),
Forms\Components\Toggle::make('has_rims')->label('Cu jante'),
Forms\Components\Select::make('rim_type')->label('Tip jante')->options(['steel' => 'Tablă', 'alloy' => 'Aliaj']),
Forms\Components\Select::make('condition')->label('Stare')->options(TireSet::CONDITIONS),
]),
Schemas\Components\Section::make('Uzură (mm) per poziție')
->columns(4)
->schema([
Forms\Components\TextInput::make('tread.fl')->label('Față-Stânga')->numeric(),
Forms\Components\TextInput::make('tread.fr')->label('Față-Dreapta')->numeric(),
Forms\Components\TextInput::make('tread.rl')->label('Spate-Stânga')->numeric(),
Forms\Components\TextInput::make('tread.rr')->label('Spate-Dreapta')->numeric(),
]),
Schemas\Components\Section::make('TPMS & foto')
->columns(2)
->schema([
Forms\Components\Toggle::make('tpms')->label('Senzori TPMS'),
Forms\Components\TextInput::make('notes')->label('Observații'),
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos')
->label('Fotografii')
->collection('photos')
->multiple()
->image()
->maxFiles(8)
->columnSpanFull(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('client.name')->label('Client')->searchable()->sortable(),
Tables\Columns\TextColumn::make('label')->label('Etichetă')->placeholder('—'),
Tables\Columns\TextColumn::make('size')
->label('Dimensiune')
->state(fn (TireSet $r) => $r->sizeLabel()),
Tables\Columns\TextColumn::make('season')
->label('Sezon')
->formatStateUsing(fn ($s) => TireSet::SEASONS[$s] ?? $s)
->badge()
->colors(['warning' => ['summer'], 'info' => ['winter'], 'gray' => ['allseason']]),
Tables\Columns\TextColumn::make('tread_min')->label('Uzură min')
->formatStateUsing(fn ($s) => $s ? $s . ' mm' : '—')
->color(fn ($s) => $s !== null && (float) $s < 3 ? 'danger' : null)
->alignRight(),
Tables\Columns\IconColumn::make('tpms')->label('TPMS')->boolean()->toggleable(),
Tables\Columns\TextColumn::make('storage_status')
->label('Depozit')
->state(fn (TireSet $r) => $r->isStored() ? ($r->currentStorage()?->location ?? 'da') : '—')
->badge()
->color(fn ($state) => $state === '—' ? 'gray' : 'success'),
])
->filters([
Tables\Filters\SelectFilter::make('season')->options(TireSet::SEASONS),
Tables\Filters\Filter::make('stored')
->label('În depozit')
->query(fn ($q) => $q->whereHas('storage', fn ($s) => $s->where('status', 'stored'))),
])
->actions([
Actions\Action::make('check_in')
->label('Check-in depozit')
->icon('heroicon-m-arrow-down-on-square')
->color('success')
->visible(fn (TireSet $r) => ! $r->isStored())
->schema([
Forms\Components\TextInput::make('location')->label('Locație (raft)')->required()->placeholder('A1-03'),
Forms\Components\TextInput::make('season_label')->label('Perioadă')->placeholder('Iarnă 2025-2026'),
Forms\Components\TextInput::make('fee')->label('Taxă depozitare')->numeric()->default(0),
])
->action(function (TireSet $r, array $data) {
\App\Models\Tenant\TireStorage::create([
'tire_set_id' => $r->id,
'location' => $data['location'],
'season_label' => $data['season_label'] ?? null,
'fee' => (float) ($data['fee'] ?? 0),
'status' => 'stored',
'checked_in_at' => now(),
]);
\Filament\Notifications\Notification::make()->title('Set primit în depozit')->success()->send();
}),
Actions\Action::make('check_out')
->label('Eliberează')
->icon('heroicon-m-arrow-up-on-square')
->color('warning')
->visible(fn (TireSet $r) => $r->isStored())
->requiresConfirmation()
->modalDescription('Marchează setul ca ridicat de client.')
->action(function (TireSet $r) {
$storage = $r->currentStorage();
if ($storage) {
$storage->update(['status' => 'retrieved', 'checked_out_at' => now()]);
}
\Filament\Notifications\Notification::make()->title('Set eliberat din depozit')->success()->send();
}),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading('Niciun set de anvelope')
->emptyStateDescription('Înregistrează seturile de anvelope ale clienților și gestionează depozitarea sezonieră (tire hotel). Urmărește uzura, TPMS și locația în depozit.')
->emptyStateIcon('heroicon-o-lifebuoy')
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
RelationManagers\StorageRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTireSets::route('/'),
'create' => Pages\CreateTireSet::route('/create'),
'edit' => Pages\EditTireSet::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\TireSetResource\Pages;
use App\Filament\Tenant\Resources\TireSetResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTireSet extends CreateRecord
{
protected static string $resource = TireSetResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\TireSetResource\Pages;
use App\Filament\Tenant\Resources\TireSetResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTireSet extends EditRecord
{
protected static string $resource = TireSetResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\TireSetResource\Pages;
use App\Filament\Tenant\Resources\TireSetResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTireSets extends ListRecords
{
protected static string $resource = TireSetResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Filament\Tenant\Resources\TireSetResource\RelationManagers;
use App\Models\Tenant\TireStorage;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class StorageRelationManager extends RelationManager
{
protected static string $relationship = 'storage';
protected static ?string $title = 'Istoric depozitare';
public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('season_label')->label('Perioadă')->placeholder('—'),
Tables\Columns\TextColumn::make('location')->label('Locație')->placeholder('—'),
Tables\Columns\TextColumn::make('checked_in_at')->label('Primit')->dateTime('d.m.Y'),
Tables\Columns\TextColumn::make('checked_out_at')->label('Ridicat')->dateTime('d.m.Y')->placeholder('—'),
Tables\Columns\TextColumn::make('status')
->formatStateUsing(fn ($s) => TireStorage::STATUSES[$s] ?? $s)
->badge()
->colors(['success' => ['stored'], 'gray' => ['retrieved']]),
Tables\Columns\TextColumn::make('fee')->money('MDL')->alignRight(),
Tables\Columns\IconColumn::make('paid')->label('Plătit')->boolean(),
])
->defaultSort('checked_in_at', 'desc')
->emptyStateHeading('Niciun istoric')
->emptyStateDescription('Folosește „Check-in depozit" pe set pentru a înregistra prima depozitare.');
}
}
@@ -72,6 +72,10 @@ class VehicleResource extends Resource
'Benzină' => 'Benzină', 'Diesel' => 'Diesel', 'Hybrid' => 'Hybrid',
'EV' => 'Electric', 'GPL' => 'GPL', 'GNC' => 'GNC',
]),
Forms\Components\Select::make('vehicle_class')
->label('Clasă (pentru pricing)')
->options(\App\Models\Tenant\PricingCoefficient::VEHICLE_CLASSES)
->helperText('Folosită de coeficienții de preț. Hibrid/EV se deduc și din combustibil.'),
Forms\Components\TextInput::make('mileage')->label('Kilometraj')->numeric()->default(0),
Forms\Components\TextInput::make('color')->maxLength(40),
]),
@@ -93,6 +97,31 @@ class VehicleResource extends Resource
Tables\Columns\TextColumn::make('created_at')->date()->sortable(),
])
->actions([
Actions\Action::make('decode_vin')
->label('Decode VIN')
->icon('heroicon-m-cpu-chip')
->color('gray')
->visible(fn (\App\Models\Tenant\Vehicle $r) => ! empty($r->vin) && strlen($r->vin) === 17)
->modalHeading(fn (\App\Models\Tenant\Vehicle $r) => 'Decode VIN: ' . $r->vin)
->modalSubmitAction(false)
->modalCancelActionLabel('Închide')
->modalContent(function (\App\Models\Tenant\Vehicle $r) {
$info = app(\App\Services\Ai\VinDecoder::class)->decode($r->vin);
return view('filament.tenant.vin-decode', ['info' => $info, 'vehicle' => $r]);
}),
Actions\Action::make('ai_recommend')
->label('AI: recomandări')
->icon('heroicon-m-sparkles')
->color('primary')
->visible(fn (\App\Models\Tenant\Vehicle $r) => ! empty($r->vin))
->modalHeading('Recomandări AI')
->modalSubmitAction(false)
->modalCancelActionLabel('Închide')
->modalContent(function (\App\Models\Tenant\Vehicle $r) {
[$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class)
->vinRecommendations($r->vin, (int) $r->mileage);
return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]);
}),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
@@ -0,0 +1,76 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\WarehouseResource\Pages;
use App\Models\Tenant\Warehouse;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class WarehouseResource extends Resource
{
protected static ?string $model = Warehouse::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-storefront';
protected static ?string $navigationLabel = 'Depozite';
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
protected static ?string $modelLabel = 'depozit';
protected static ?string $pluralModelLabel = 'depozite';
protected static ?int $navigationSort = 38;
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make()->columns(2)->schema([
Forms\Components\TextInput::make('code')->label('Cod')->required()->maxLength(32),
Forms\Components\TextInput::make('name')->label('Denumire')->required()->maxLength(120),
Forms\Components\TextInput::make('address')->label('Adresă')->columnSpanFull()->maxLength(200),
Forms\Components\Toggle::make('is_default')->label('Depozit implicit'),
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('code')->searchable()->sortable(),
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('address')->placeholder('—')->toggleable(),
Tables\Columns\IconColumn::make('is_default')->label('Implicit')->boolean(),
Tables\Columns\IconColumn::make('is_active')->label('Activ')->boolean(),
Tables\Columns\TextColumn::make('batches_count')
->counts('batches')
->label('Loturi')
->alignRight(),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading('Niciun depozit')
->emptyStateDescription('Un depozit implicit a fost creat la migrare. Adaugă altele dacă ai locații fizice separate (sucursală, hală, mobil).')
->emptyStateIcon('heroicon-o-building-storefront')
->defaultSort('code');
}
public static function getPages(): array
{
return [
'index' => Pages\ListWarehouses::route('/'),
'create' => Pages\CreateWarehouse::route('/create'),
'edit' => Pages\EditWarehouse::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\WarehouseResource\Pages;
use App\Filament\Tenant\Resources\WarehouseResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWarehouse extends CreateRecord
{
protected static string $resource = WarehouseResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\WarehouseResource\Pages;
use App\Filament\Tenant\Resources\WarehouseResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditWarehouse extends EditRecord
{
protected static string $resource = WarehouseResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\WarehouseResource\Pages;
use App\Filament\Tenant\Resources\WarehouseResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListWarehouses extends ListRecords
{
protected static string $resource = WarehouseResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -72,6 +72,11 @@ class WorkOrderResource extends Resource
->options(WorkOrder::STATUSES)
->default('new')
->required(),
Forms\Components\Select::make('urgency')
->label('Urgență')
->options(\App\Models\Tenant\PricingCoefficient::URGENCY)
->default('normal')
->required(),
Forms\Components\Select::make('client_id')
->label('Client')
->options(fn () => Client::pluck('name', 'id'))
@@ -101,6 +106,35 @@ class WorkOrderResource extends Resource
Forms\Components\Textarea::make('diagnosis')->label('Diagnostic')->rows(3)->columnSpanFull(),
Forms\Components\Textarea::make('recommendations')->label('Recomandări')->rows(2)->columnSpanFull(),
]),
Schemas\Components\Section::make('Foto')
->collapsible()
->schema([
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos')
->label('Fotografii')
->collection('photos')
->multiple()
->reorderable()
->image()
->imageEditor()
->maxFiles(20)
->columnSpanFull(),
]),
Schemas\Components\Section::make('Tracking & ETA')
->columns(3)
->collapsible()
->schema([
Forms\Components\DateTimePicker::make('eta_at')
->label('Gata estimat (ETA)')
->seconds(false),
Forms\Components\TextInput::make('tracking_token')
->label('Token public')
->disabled()
->dehydrated(false)
->columnSpan(2)
->helperText(fn (?WorkOrder $record) => $record?->tracking_token
? 'Link client: ' . $record->trackingUrl()
: 'Se generează la salvare'),
]),
Schemas\Components\Section::make('Plată & total')
->columns(3)
->schema([
@@ -180,6 +214,7 @@ class WorkOrderResource extends Resource
return [
RelationManagers\WorksRelationManager::class,
RelationManagers\PartsRelationManager::class,
RelationManagers\SubcontractJobsRelationManager::class,
RelationManagers\PaymentsRelationManager::class,
];
}
@@ -15,6 +15,49 @@ class EditWorkOrder extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\Action::make('apply_template')
->label('Aplică șablon')
->icon('heroicon-m-clipboard-document-list')
->color('gray')
->schema([
\Filament\Forms\Components\Select::make('template_id')
->label('Șablon serviciu')
->options(fn () => \App\Models\Tenant\ServiceTemplate::where('is_active', true)->pluck('name', 'id'))
->searchable()
->required(),
])
->action(function (array $data) {
$template = \App\Models\Tenant\ServiceTemplate::with('items')->find($data['template_id']);
if (! $template) return;
$r = app(\App\Services\ServiceComposer::class)->applyTemplate($this->record, $template);
$this->fillForm();
\Filament\Notifications\Notification::make()
->title("Șablon aplicat: {$r['labor']} manopere, {$r['parts']} piese")
->success()->send();
}),
Actions\Action::make('ai_diagnose')
->label('AI: sugerează diagnostic')
->icon('heroicon-m-sparkles')
->color('primary')
->visible(fn () => ! empty($this->record->complaint))
->modalHeading('Diagnostic AI bazat pe plângerea clientului')
->modalSubmitAction(false)
->modalCancelActionLabel('Închide')
->modalContent(function () {
[$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class)
->suggestDiagnosis($this->record);
return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]);
}),
Actions\Action::make('tracking')
->label('Link client (QR)')
->icon('heroicon-m-qr-code')
->color('primary')
->modalHeading(fn () => 'Tracking client — WO #' . $this->record->number)
->modalSubmitAction(false)
->modalCancelActionLabel('Închide')
->modalContent(fn () => view('filament.tenant.tracking-qr', [
'wo' => $this->record,
])),
Actions\Action::make('pdf')
->label('Descarcă PDF')
->icon('heroicon-m-document-arrow-down')
@@ -3,9 +3,12 @@
namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers;
use App\Models\Tenant\Part;
use App\Models\Tenant\PartReservation;
use App\Models\Tenant\WorkOrderPart;
use App\Services\Warehouse\WarehouseService;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
@@ -82,6 +85,74 @@ class PartsRelationManager extends RelationManager
Actions\CreateAction::make(),
])
->actions([
Actions\Action::make('smart_price')
->label('Preț inteligent')
->icon('heroicon-m-sparkles')
->color('primary')
->visible(fn (WorkOrderPart $r) => (bool) $r->part_id)
->modalHeading('Preț contextual')
->modalSubmitActionLabel('Aplică prețul')
->modalContent(function (WorkOrderPart $r) {
$wo = $r->workOrder;
$part = $r->part;
$quote = app(\App\Services\Pricing\PricingEngine::class)->quote(
$part, $wo?->vehicle, $wo?->client, $wo?->urgency ?? 'normal'
);
return view('filament.tenant.smart-price', ['quote' => $quote, 'item' => $r]);
})
->action(function (WorkOrderPart $r) {
$wo = $r->workOrder;
$quote = app(\App\Services\Pricing\PricingEngine::class)->quote(
$r->part, $wo?->vehicle, $wo?->client, $wo?->urgency ?? 'normal'
);
$r->sell_price = $quote['final'];
$r->save();
Notification::make()
->title('Preț actualizat: ' . number_format($quote['final'], 2) . ' MDL')
->success()->send();
}),
Actions\Action::make('issue_now')
->label('Eliberează')
->icon('heroicon-m-arrow-up-on-square')
->color('warning')
->visible(fn (WorkOrderPart $r) => $r->part_id
&& PartReservation::where('work_order_part_id', $r->id)
->where('status', PartReservation::STATUS_ACTIVE)
->exists())
->requiresConfirmation()
->modalDescription('Confirmă că mecanicul ia fizic piesa din depozit. Stocul scade acum, fără să aștepți închiderea fișei.')
->action(function (WorkOrderPart $r) {
$n = app(WarehouseService::class)->issueNow($r);
Notification::make()
->title("Eliberat: {$n} rezervări consumate")
->success()->send();
}),
Actions\Action::make('return_part')
->label('Restituire')
->icon('heroicon-m-arrow-uturn-left')
->color('gray')
->visible(fn (WorkOrderPart $r) => $r->part_id
&& PartReservation::where('work_order_part_id', $r->id)
->where('status', PartReservation::STATUS_CONSUMED)
->exists())
->schema([
Forms\Components\TextInput::make('qty')
->label('Cantitate restituită')
->numeric()
->required()
->minValue(0.001)
->default(fn (WorkOrderPart $r) => (float) $r->qty),
Forms\Components\Textarea::make('notes')->rows(2)->label('Observații'),
])
->action(function (WorkOrderPart $r, array $data) {
$batch = app(WarehouseService::class)->returnPart(
$r, (float) $data['qty'], $data['notes'] ?? null
);
Notification::make()
->title($batch ? 'Piesa returnată în stoc' : 'Nimic de restituit')
->{$batch ? 'success' : 'warning'}()
->send();
}),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
]);
@@ -0,0 +1,65 @@
<?php
namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers;
use App\Models\Tenant\Subcontractor;
use App\Models\Tenant\SubcontractJob;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class SubcontractJobsRelationManager extends RelationManager
{
protected static string $relationship = 'subcontractJobs';
protected static ?string $title = 'Lucrări la terți';
public function form(Schema $schema): Schema
{
return $schema->components([
Forms\Components\Select::make('subcontractor_id')
->label('Subcontractor')
->options(fn () => Subcontractor::where('is_active', true)->pluck('name', 'id'))
->searchable()
->columnSpanFull(),
Forms\Components\Select::make('category')
->label('Categorie')
->options(array_combine(Subcontractor::SPECIALTIES, Subcontractor::SPECIALTIES))
->searchable(),
Forms\Components\Select::make('status')->options(SubcontractJob::STATUSES)->default('sent')->required(),
Forms\Components\Textarea::make('description')->label('Descriere')->rows(2)->columnSpanFull(),
Forms\Components\TextInput::make('cost')->label('Cost (terț)')->numeric()->default(0)->required(),
Forms\Components\TextInput::make('markup_pct')->label('Markup %')->numeric()->default(0),
Forms\Components\TextInput::make('client_price')->label('Preț client')->numeric()->default(0)
->helperText('Folosit dacă markup = 0.'),
Forms\Components\DatePicker::make('eta')->label('ETA'),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('number')
->columns([
Tables\Columns\TextColumn::make('number')->label('Nr.'),
Tables\Columns\TextColumn::make('subcontractor.name')->label('Terț')->placeholder('—'),
Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'),
Tables\Columns\TextColumn::make('cost')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('client_price')->label('Preț client')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('margin')
->label('Marjă')
->state(fn (SubcontractJob $r) => $r->margin())
->money('MDL')->alignRight()
->color(fn ($s) => (float) $s > 0 ? 'success' : ((float) $s < 0 ? 'danger' : 'gray')),
Tables\Columns\TextColumn::make('status')
->formatStateUsing(fn ($s) => SubcontractJob::STATUSES[$s] ?? $s)
->badge()
->colors(['warning' => ['sent', 'in_progress'], 'success' => ['done', 'returned'], 'danger' => ['cancelled']]),
])
->headerActions([Actions\CreateAction::make()])
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()]);
}
}
@@ -69,7 +69,23 @@ class WorksRelationManager extends RelationManager
->colors(['gray' => ['todo'], 'warning' => ['in_progress'], 'success' => ['done']]),
])
->headerActions([
Actions\CreateAction::make(),
Actions\CreateAction::make()
->after(function (WorkOrderWork $record) {
// Auto-add the labor's default parts to the parent WO.
if (! $record->labor_id) return;
$labor = Labor::with('laborParts.part')->find($record->labor_id);
$wo = $record->workOrder;
if (! $labor || ! $wo || $labor->laborParts->isEmpty()) return;
$composer = app(\App\Services\ServiceComposer::class);
foreach ($labor->laborParts as $lp) {
if ($lp->part) {
$composer->addPart($wo, $lp->part, (float) $lp->qty, $lp->unit);
}
}
\Filament\Notifications\Notification::make()
->title('Piese implicite adăugate (' . $labor->laborParts->count() . ')')
->success()->send();
}),
])
->actions([
Actions\EditAction::make(),
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant\Part;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use Illuminate\Http\Request;
class PartLabelsController extends Controller
{
public function sheet(Request $request)
{
$ids = array_filter(array_map('intval', explode(',', (string) $request->query('ids', ''))));
if (empty($ids)) abort(400, 'No parts selected.');
$parts = Part::whereIn('id', $ids)->orderBy('name')->get();
$opts = new QROptions([
'outputType' => QRCode::OUTPUT_MARKUP_SVG,
'eccLevel' => QRCode::ECC_M,
'scale' => 4,
'imageBase64' => false,
'addQuietzone' => true,
]);
$labels = $parts->map(function (Part $p) use ($opts) {
$payload = 'PART:' . ($p->article ?: $p->id);
return [
'part' => $p,
'svg' => (new QRCode($opts))->render($payload),
'payload' => $payload,
];
});
return view('parts.labels', ['labels' => $labels]);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant\PushSubscription;
use Illuminate\Http\Request;
class PushSubscriptionController extends Controller
{
public function subscribe(Request $request)
{
$data = $request->validate([
'endpoint' => 'required|string|max:500',
'keys.p256dh' => 'required|string',
'keys.auth' => 'required|string',
'contentEncoding' => 'nullable|string|max:32',
]);
$user = $request->user();
PushSubscription::updateOrCreate(
['endpoint' => $data['endpoint']],
[
'company_id' => $user?->company_id,
'user_id' => $user?->id,
'public_key' => $data['keys']['p256dh'],
'auth_token' => $data['keys']['auth'],
'content_encoding' => $data['contentEncoding'] ?? 'aesgcm',
'user_agent' => substr((string) $request->userAgent(), 0, 255),
]
);
return response()->json(['ok' => true]);
}
public function unsubscribe(Request $request)
{
$endpoint = $request->input('endpoint');
if ($endpoint) {
PushSubscription::where('endpoint', $endpoint)->delete();
}
return response()->json(['ok' => true]);
}
}
+242
View File
@@ -0,0 +1,242 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant\OnlineOrder;
use App\Models\Tenant\OnlineOrderItem;
use App\Models\Tenant\Part;
use App\Services\Ai\VinDecoder;
use App\Tenancy\TenantManager;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ShopController extends Controller
{
private function tenantOrFail()
{
$tenant = app(TenantManager::class)->current();
if (! $tenant) {
throw new NotFoundHttpException('Magazinul e disponibil doar pe subdomeniul service-ului.');
}
if (! data_get($tenant->settings, 'shop.enabled')) {
throw new NotFoundHttpException('Magazinul online nu este activ.');
}
return $tenant;
}
public function catalog(Request $request)
{
$tenant = $this->tenantOrFail();
$term = $request->query('q');
$category = $request->query('cat');
$inStock = $request->boolean('in_stock');
$query = Part::searchPublished($term);
if ($category) $query->where('category', $category);
if ($inStock) $query->where('qty', '>', 0);
$parts = $query->orderBy('name')->paginate(24)->withQueryString();
$categories = Part::published()->distinct()->pluck('category')->filter()->sort()->values();
return view('shop.catalog', [
'tenant' => $tenant,
'parts' => $parts,
'categories' => $categories,
'term' => $term,
'category' => $category,
'inStock' => $inStock,
'cartCount' => $this->cartCount(),
]);
}
public function part(Request $request, int $id)
{
$tenant = $this->tenantOrFail();
$part = Part::published()->with('crossRefs')->find($id);
if (! $part) throw new NotFoundHttpException('Piesa nu există sau nu e publicată.');
return view('shop.part', [
'tenant' => $tenant,
'part' => $part,
'cartCount' => $this->cartCount(),
]);
}
public function vin(Request $request)
{
$tenant = $this->tenantOrFail();
$vin = strtoupper(trim((string) $request->query('vin', '')));
$decoded = null;
if ($vin !== '') {
$decoded = app(VinDecoder::class)->decode($vin);
}
return view('shop.vin', [
'tenant' => $tenant,
'vin' => $vin,
'decoded' => $decoded,
'cartCount' => $this->cartCount(),
]);
}
// ─── Cart (session) ───────────────────────────────────────────
private function cartKey(): string
{
$tenant = app(TenantManager::class)->current();
return 'shop_cart_' . ($tenant?->id ?? '0');
}
private function cart(): array
{
return (array) session($this->cartKey(), []);
}
private function cartCount(): int
{
return (int) collect($this->cart())->sum('qty');
}
public function addToCart(Request $request, int $id)
{
$this->tenantOrFail();
$part = Part::published()->findOrFail($id);
$qty = max(1, (int) $request->input('qty', 1));
$cart = $this->cart();
$cart[$id] = [
'part_id' => $part->id,
'name' => $part->name,
'article' => $part->article,
'price' => (float) $part->sell_price,
'qty' => ($cart[$id]['qty'] ?? 0) + $qty,
];
session([$this->cartKey() => $cart]);
return redirect('/shop/cart');
}
public function updateCart(Request $request)
{
$this->tenantOrFail();
$cart = $this->cart();
foreach ((array) $request->input('qty', []) as $id => $qty) {
$qty = (int) $qty;
if ($qty <= 0) {
unset($cart[$id]);
} elseif (isset($cart[$id])) {
$cart[$id]['qty'] = $qty;
}
}
session([$this->cartKey() => $cart]);
return redirect('/shop/cart');
}
public function showCart()
{
$tenant = $this->tenantOrFail();
$cart = $this->cart();
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
return view('shop.cart', [
'tenant' => $tenant,
'cart' => $cart,
'subtotal' => $subtotal,
'cartCount' => $this->cartCount(),
]);
}
public function checkout()
{
$tenant = $this->tenantOrFail();
$cart = $this->cart();
if (empty($cart)) return redirect('/shop');
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
return view('shop.checkout', [
'tenant' => $tenant,
'cart' => $cart,
'subtotal' => $subtotal,
'deliveryOptions' => (array) data_get($tenant->settings, 'shop.delivery_methods', ['pickup']),
'cartCount' => $this->cartCount(),
]);
}
public function placeOrder(Request $request)
{
$tenant = $this->tenantOrFail();
$cart = $this->cart();
if (empty($cart)) return redirect('/shop');
$data = $request->validate([
'customer_name' => 'required|string|max:160',
'customer_phone' => 'required|string|max:40',
'customer_email' => 'nullable|email|max:160',
'delivery_method' => 'required|in:pickup,courier,post',
'address' => 'nullable|string|max:255',
'notes' => 'nullable|string|max:1000',
]);
$deliveryFee = 0.0;
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
if ($data['delivery_method'] !== 'pickup') {
$fee = (float) data_get($tenant->settings, 'shop.delivery_fee', 0);
$freeOver = (float) data_get($tenant->settings, 'shop.free_delivery_over', 0);
$deliveryFee = ($freeOver > 0 && $subtotal >= $freeOver) ? 0.0 : $fee;
}
$order = DB::transaction(function () use ($tenant, $cart, $data, $deliveryFee) {
$order = OnlineOrder::create([
'number' => OnlineOrder::generateNumber($tenant->id),
'customer_name' => $data['customer_name'],
'customer_phone' => $data['customer_phone'],
'customer_email' => $data['customer_email'] ?? null,
'delivery_method' => $data['delivery_method'],
'address' => $data['address'] ?? null,
'notes' => $data['notes'] ?? null,
'status' => 'new',
'delivery_fee' => $deliveryFee,
]);
foreach ($cart as $item) {
OnlineOrderItem::create([
'online_order_id' => $order->id,
'part_id' => $item['part_id'] ?? null,
'name' => $item['name'],
'article' => $item['article'] ?? null,
'qty' => $item['qty'],
'price' => $item['price'],
]);
}
$order->refresh()->recalcTotal();
return $order;
});
session()->forget($this->cartKey());
// Notify (best-effort): customer + shop staff.
try {
app(\App\Services\Notifications\ShopOrderNotifier::class)->placed($order);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::debug('shop order notify skipped: ' . $e->getMessage());
}
return redirect('/shop/order/' . $order->tracking_token);
}
public function orderStatus(Request $request, string $token)
{
$tenant = $this->tenantOrFail();
$order = OnlineOrder::with('items')->where('tracking_token', $token)->first();
if (! $order) throw new NotFoundHttpException('Comanda nu a fost găsită.');
return view('shop.order', [
'tenant' => $tenant,
'order' => $order,
'cartCount' => $this->cartCount(),
]);
}
}
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers;
use App\Models\Central\Company;
use App\Models\Tenant\Client;
use App\Services\Notifications\TelegramService;
use App\Tenancy\TenantManager;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Receives Telegram updates per tenant. URL: /telegram/webhook/{slug}
*
* To link a Telegram account to a Client record, the bot expects the user
* to share their phone via Telegram's contact share button (Telegram lets
* users send their own phone with one tap). We match the shared phone (or
* the message text fallback) to clients.phone and persist chat_id.
*/
class TelegramWebhookController extends Controller
{
public function handle(Request $request, string $slug, TelegramService $telegram)
{
$company = Company::where('slug', $slug)->first();
if (! $company) return response()->json(['ok' => false], 404);
$expectedSecret = $telegram->webhookSecretFor($company);
$providedSecret = $request->header('X-Telegram-Bot-Api-Secret-Token');
if ($expectedSecret && $providedSecret !== $expectedSecret) {
Log::warning('telegram.webhook bad secret', ['tenant' => $slug]);
return response()->json(['ok' => false], 401);
}
app(TenantManager::class)->setCurrent($company);
$message = $request->input('message', []);
$chatId = (string) data_get($message, 'chat.id', '');
if (! $chatId) {
return response()->json(['ok' => true]);
}
$contact = data_get($message, 'contact');
$text = trim((string) data_get($message, 'text', ''));
$client = null;
$phoneRaw = null;
if ($contact) {
$phoneRaw = data_get($contact, 'phone_number');
} elseif (preg_match('/(\+?[0-9\-\s\(\)]{7,})/', $text, $m)) {
$phoneRaw = $m[1];
}
if ($phoneRaw) {
$needle = Client::normalizePhone($phoneRaw);
if ($needle) {
$client = Client::whereRaw(
"REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') LIKE ?",
['%' . substr($needle, -9) . '%']
)->first();
}
}
if (! $client && $text === '/start') {
$telegram->sendMessage($company, $chatId,
'Salut! Pentru a primi notificări despre mașina ta, ' .
'apasă butonul „Share contact" sau trimite numărul tău de telefon.'
);
return response()->json(['ok' => true]);
}
if ($client) {
$client->telegram_chat_id = $chatId;
$client->saveQuietly();
$name = $company->display_name ?? $company->name;
$telegram->sendMessage($company, $chatId,
"Te-am identificat — <b>{$client->name}</b>.\n" .
"Vei primi aici notificări despre fișele tale de la <b>{$name}</b>.\n\n" .
"Trimite /stop oricând ca să oprești notificările."
);
return response()->json(['ok' => true]);
}
if ($text === '/stop') {
Client::where('telegram_chat_id', $chatId)->update(['telegram_chat_id' => null]);
$telegram->sendMessage($company, $chatId, 'Notificările au fost oprite.');
return response()->json(['ok' => true]);
}
if ($phoneRaw) {
$telegram->sendMessage($company, $chatId,
"Nu am găsit un client cu acest număr la {$company->name}. " .
"Verifică telefonul sau contactează service-ul."
);
}
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant\WorkOrder;
use App\Tenancy\TenantManager;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TrackingController extends Controller
{
/**
* Public WO tracking page accessed via QR code or SMS link.
* Tenant is resolved by ResolveTenant from the host, so the global
* BelongsToTenant scope already filters to the correct tenant.
*/
public function show(Request $request, string $token)
{
$tenant = app(TenantManager::class)->current();
if (! $tenant) {
throw new NotFoundHttpException('Tracking only available on tenant subdomain.');
}
$wo = WorkOrder::with(['client', 'vehicle', 'master', 'media'])
->where('tracking_token', $token)
->first();
if (! $wo) {
throw new NotFoundHttpException('Fișa nu a fost găsită.');
}
return view('tracking.show', [
'wo' => $wo,
'tenant' => $tenant,
'photos' => $wo->getMedia('photos'),
]);
}
public function qr(Request $request, string $token)
{
$tenant = app(TenantManager::class)->current();
if (! $tenant) {
throw new NotFoundHttpException();
}
$wo = WorkOrder::where('tracking_token', $token)->first();
if (! $wo) {
throw new NotFoundHttpException();
}
$options = new \chillerlan\QRCode\QROptions([
'outputType' => \chillerlan\QRCode\QRCode::OUTPUT_MARKUP_SVG,
'eccLevel' => \chillerlan\QRCode\QRCode::ECC_M,
'scale' => 6,
'imageBase64' => false,
'svgViewBoxSize' => 200,
'addQuietzone' => true,
]);
$svg = (new \chillerlan\QRCode\QRCode($options))->render($wo->trackingUrl());
return response($svg, 200, [
'Content-Type' => 'image/svg+xml',
'Cache-Control' => 'public, max-age=3600',
]);
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ trait Auditable
return LogOptions::defaults()
->logFillable()
->logOnlyDirty()
->dontSubmitEmptyLogs()
->dontLogEmptyChanges()
->setDescriptionForEvent(fn (string $event) => match ($event) {
'created' => 'creat',
'updated' => 'modificat',
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class BodyshopJob extends Model implements HasMedia
{
use BelongsToTenant, InteractsWithMedia, SoftDeletes;
public const TYPES = [
'body_repair' => 'Tinichigerie',
'pdr' => 'PDR (fără vopsire)',
'painting' => 'Vopsitorie',
'detailing' => 'Detailing',
'ceramic' => 'Ceramică',
'ppf' => 'Folie PPF',
'polishing' => 'Polish',
];
public const STATUSES = [
'estimate' => 'Deviz',
'approved' => 'Aprobat',
'in_progress' => 'În lucru',
'done' => 'Finalizat',
'delivered' => 'Predat',
'cancelled' => 'Anulat',
];
public const INSURANCE_STATUSES = [
'submitted' => 'Depus',
'approved' => 'Aprobat',
'rejected' => 'Respins',
'paid' => 'Plătit',
];
protected $fillable = [
'company_id', 'work_order_id', 'client_id', 'vehicle_id',
'number', 'type', 'status',
'is_insurance', 'insurer', 'policy_no', 'claim_no', 'insurance_status',
'estimate_amount', 'approved_amount', 'notes',
];
protected $casts = [
'is_insurance' => 'boolean',
'estimate_amount' => 'decimal:2',
'approved_amount' => 'decimal:2',
];
public function registerMediaCollections(): void
{
$this->addMediaCollection('photos_before');
$this->addMediaCollection('photos_after');
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function vehicle(): BelongsTo
{
return $this->belongsTo(Vehicle::class);
}
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
}
public function damagePoints(): HasMany
{
return $this->hasMany(DamagePoint::class);
}
public static function generateNumber(int $companyId): string
{
$year = date('y');
$count = static::withoutGlobalScopes()
->where('company_id', $companyId)
->whereYear('created_at', date('Y'))
->count();
return sprintf('BS-%s-%04d', $year, $count + 1);
}
protected static function booted(): void
{
static::creating(function (self $job) {
if (empty($job->number)) {
$job->number = static::generateNumber(
$job->company_id ?: app(\App\Tenancy\TenantManager::class)->currentId()
);
}
});
}
}
+13 -2
View File
@@ -16,8 +16,9 @@ class Client extends Model
protected $fillable = [
'company_id', 'type', 'name', 'company_name',
'phone', 'phone_alt', 'email',
'telegram', 'whatsapp', 'viber',
'source', 'marketing_channel', 'status',
'telegram', 'telegram_chat_id', 'whatsapp', 'viber',
'notify_prefs',
'source', 'marketing_channel', 'status', 'is_vip',
'balance', 'discount_pct', 'notes',
'assigned_to', 'last_contact_at',
];
@@ -26,8 +27,18 @@ class Client extends Model
'balance' => 'decimal:2',
'discount_pct' => 'decimal:2',
'last_contact_at' => 'datetime',
'notify_prefs' => 'array',
'is_vip' => 'boolean',
];
/** Normalize a phone number to E.164-ish digits for matching. */
public static function normalizePhone(?string $phone): ?string
{
if (! $phone) return null;
$digits = preg_replace('/[^0-9]/', '', $phone);
return $digits ?: null;
}
public function vehicles(): HasMany
{
return $this->hasMany(Vehicle::class);
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DamagePoint extends Model
{
use BelongsToTenant;
public const ZONES = [
'Bară față', 'Capotă', 'Aripă FS', 'Aripă FD',
'Ușă FS', 'Ușă FD', 'Ușă SS', 'Ușă SD',
'Aripă SS', 'Aripă SD', 'Bară spate', 'Portbagaj',
'Plafon', 'Parbriz', 'Lunetă', 'Prag S', 'Prag D',
'Oglindă S', 'Oglindă D', 'Jantă',
];
public const KINDS = [
'Zgârietură', 'Lovitură', 'Fisură', 'Rugină', 'Vopsea sărită', 'Spart',
];
public const SEVERITIES = [
'minor' => 'Minoră',
'medium' => 'Medie',
'severe' => 'Gravă',
];
protected $fillable = [
'company_id', 'bodyshop_job_id', 'zone', 'kind', 'severity', 'notes',
];
public function job(): BelongsTo
{
return $this->belongsTo(BodyshopJob::class, 'bodyshop_job_id');
}
}
+22 -1
View File
@@ -4,6 +4,7 @@ namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Labor extends Model
@@ -15,14 +16,34 @@ class Labor extends Model
'Caroserie', 'Electrică', 'Climatizare', 'Eșapament', 'Altele',
];
public const PRICING_MODES = [
'hourly' => 'Pe oră (normă × tarif)',
'fixed' => 'Preț fix',
];
protected $fillable = [
'company_id', 'category', 'name_ro', 'name_ru', 'code',
'hours', 'price', 'is_active', 'notes',
'hours', 'pricing_mode', 'fixed_price', 'price', 'is_active', 'notes',
];
protected $casts = [
'hours' => 'decimal:2',
'fixed_price' => 'decimal:2',
'price' => 'decimal:2',
'is_active' => 'boolean',
];
public function laborParts(): HasMany
{
return $this->hasMany(LaborPart::class);
}
/** Effective line total for this labor given the tenant hourly rate. */
public function effectiveTotal(float $hourlyRate): float
{
if ($this->pricing_mode === 'fixed') {
return (float) $this->fixed_price;
}
return round((float) $this->hours * $hourlyRate, 2);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LaborPart extends Model
{
use BelongsToTenant;
protected $fillable = ['company_id', 'labor_id', 'part_id', 'qty', 'unit'];
protected $casts = ['qty' => 'decimal:2'];
public function labor(): BelongsTo
{
return $this->belongsTo(Labor::class);
}
public function part(): BelongsTo
{
return $this->belongsTo(Part::class);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class OnlineOrder extends Model
{
use BelongsToTenant, SoftDeletes;
public const STATUSES = [
'new' => 'Nouă',
'confirmed' => 'Confirmată',
'packed' => 'Pregătită',
'shipped' => 'Expediată',
'delivered' => 'Livrată',
'cancelled' => 'Anulată',
];
public const DELIVERY = [
'pickup' => 'Ridicare din service',
'courier' => 'Curier',
'post' => 'Poștă',
];
protected $fillable = [
'company_id', 'number', 'tracking_token', 'client_id',
'customer_name', 'customer_phone', 'customer_email',
'delivery_method', 'address', 'status',
'subtotal', 'delivery_fee', 'total', 'notes',
];
protected $casts = [
'subtotal' => 'decimal:2',
'delivery_fee' => 'decimal:2',
'total' => 'decimal:2',
];
public function items(): HasMany
{
return $this->hasMany(OnlineOrderItem::class);
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function trackingUrl(): string
{
return url('/shop/order/' . $this->tracking_token);
}
public function recalcTotal(): void
{
$this->subtotal = (float) $this->items()->sum('total');
$this->total = round((float) $this->subtotal + (float) $this->delivery_fee, 2);
$this->save();
}
public static function generateNumber(int $companyId): string
{
$year = date('y');
$count = static::withoutGlobalScopes()
->where('company_id', $companyId)
->whereYear('created_at', date('Y'))
->count();
return sprintf('SO-%s-%04d', $year, $count + 1);
}
protected static function booted(): void
{
static::creating(function (self $o) {
if (empty($o->tracking_token)) {
$o->tracking_token = Str::random(24);
}
});
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OnlineOrderItem extends Model
{
use BelongsToTenant;
protected $fillable = [
'company_id', 'online_order_id', 'part_id',
'name', 'article', 'qty', 'price', 'total', 'fulfilled',
];
protected $casts = [
'qty' => 'decimal:2',
'price' => 'decimal:2',
'total' => 'decimal:2',
'fulfilled' => 'boolean',
];
public function order(): BelongsTo
{
return $this->belongsTo(OnlineOrder::class, 'online_order_id');
}
public function part(): BelongsTo
{
return $this->belongsTo(Part::class);
}
protected static function booted(): void
{
static::saving(function (self $row) {
$row->total = round((float) $row->qty * (float) $row->price, 2);
});
static::saved(fn (self $row) => $row->order?->recalcTotal());
static::deleted(fn (self $row) => $row->order?->recalcTotal());
}
}
+73 -2
View File
@@ -5,6 +5,7 @@ namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Part extends Model
@@ -18,18 +19,20 @@ class Part extends Model
protected $fillable = [
'company_id', 'name', 'article', 'brand', 'category',
'qty', 'unit', 'min_qty',
'qty', 'qty_reserved', 'unit', 'min_qty',
'buy_price', 'sell_price',
'location', 'barcode', 'preferred_supplier_id',
'is_active', 'notes',
'is_active', 'is_published', 'notes',
];
protected $casts = [
'qty' => 'decimal:2',
'qty_reserved' => 'decimal:3',
'min_qty' => 'decimal:2',
'buy_price' => 'decimal:2',
'sell_price' => 'decimal:2',
'is_active' => 'boolean',
'is_published' => 'boolean',
];
public function preferredSupplier(): BelongsTo
@@ -37,6 +40,69 @@ class Part extends Model
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
}
public function batches(): HasMany
{
return $this->hasMany(PartBatch::class);
}
public function reservations(): HasMany
{
return $this->hasMany(PartReservation::class);
}
public function events(): HasMany
{
return $this->hasMany(WarehouseEvent::class);
}
public function priceHistory(): HasMany
{
return $this->hasMany(SupplierPartPrice::class);
}
public function crossRefs(): HasMany
{
return $this->hasMany(PartCrossRef::class);
}
public function scopePublished($q)
{
return $q->where('is_active', true)->where('is_published', true);
}
/**
* Search published parts by free text against name / article / brand and
* any registered cross-reference article. Returns a query builder.
*/
public static function searchPublished(?string $term)
{
$q = static::published();
if ($term = trim((string) $term)) {
$like = '%' . $term . '%';
$q->where(function ($w) use ($like, $term) {
$w->where('name', 'like', $like)
->orWhere('article', 'like', $like)
->orWhere('brand', 'like', $like)
->orWhereHas('crossRefs', fn ($c) => $c->where('cross_article', 'like', $like));
});
}
return $q;
}
/** Live total across all batches of all warehouses (source of truth). */
public function qtyOnHand(?int $warehouseId = null): float
{
$q = $this->batches()->newQuery()->where('part_id', $this->id);
if ($warehouseId) $q->where('warehouse_id', $warehouseId);
return (float) $q->sum('qty_remaining');
}
/** Available for new reservations = on hand already reserved. */
public function qtyAvailable(?int $warehouseId = null): float
{
return max(0.0, $this->qtyOnHand($warehouseId) - (float) $this->qty_reserved);
}
public function isLow(): bool
{
return (float) $this->qty <= (float) $this->min_qty;
@@ -47,6 +113,11 @@ class Part extends Model
return (float) $this->qty <= 0;
}
/**
* Legacy direct-stock adjustment.
* NOTE: this only moves the cached `qty` column. Real stock changes
* should go through WarehouseService so batches + events stay in sync.
*/
public function adjustStock(float $delta, ?string $reason = null): void
{
$this->qty = max(0, (float) $this->qty + $delta);
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PartBatch extends Model
{
use BelongsToTenant;
protected $fillable = [
'company_id', 'part_id', 'warehouse_id', 'supplier_id',
'batch_ref', 'qty_in', 'qty_remaining', 'buy_price',
'received_at', 'notes',
];
protected $casts = [
'qty_in' => 'decimal:3',
'qty_remaining' => 'decimal:3',
'buy_price' => 'decimal:2',
'received_at' => 'datetime',
];
public function part(): BelongsTo
{
return $this->belongsTo(Part::class);
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function supplier(): BelongsTo
{
return $this->belongsTo(Supplier::class);
}
public function reservations(): HasMany
{
return $this->hasMany(PartReservation::class, 'batch_id');
}
public function isDepleted(): bool
{
return (float) $this->qty_remaining <= 0;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PartCrossRef extends Model
{
use BelongsToTenant;
protected $fillable = ['company_id', 'part_id', 'cross_article', 'brand'];
public function part(): BelongsTo
{
return $this->belongsTo(Part::class);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PartReservation extends Model
{
use BelongsToTenant;
public const STATUS_ACTIVE = 'active';
public const STATUS_CONSUMED = 'consumed';
public const STATUS_RELEASED = 'released';
protected $fillable = [
'company_id', 'work_order_id', 'work_order_part_id',
'part_id', 'batch_id', 'qty', 'status',
'reserved_at', 'consumed_at',
];
protected $casts = [
'qty' => 'decimal:3',
'reserved_at' => 'datetime',
'consumed_at' => 'datetime',
];
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
}
public function workOrderPart(): BelongsTo
{
return $this->belongsTo(WorkOrderPart::class);
}
public function part(): BelongsTo
{
return $this->belongsTo(Part::class);
}
public function batch(): BelongsTo
{
return $this->belongsTo(PartBatch::class, 'batch_id');
}
public function isActive(): bool
{
return $this->status === self::STATUS_ACTIVE;
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class PricingCoefficient extends Model
{
use BelongsToTenant;
public const VEHICLE_CLASSES = [
'sedan' => 'Sedan / Hatchback',
'suv' => 'SUV / Crossover',
'commercial' => 'Comercial (van/camion)',
'hybrid' => 'Hibrid',
'ev' => 'Electric (EV)',
'premium' => 'Premium / Lux',
];
public const URGENCY = [
'normal' => 'Normal',
'urgent' => 'Urgent',
'express' => 'Express',
];
protected $fillable = [
'company_id', 'name', 'multiplier', 'conditions',
'priority', 'stackable', 'is_active',
];
protected $casts = [
'multiplier' => 'decimal:3',
'conditions' => 'array',
'stackable' => 'boolean',
'is_active' => 'boolean',
];
/**
* Does this coefficient apply to the given pricing context?
*
* @param array{class?:?string, age?:?int, vip?:bool, urgency?:string} $ctx
*/
public function matches(array $ctx): bool
{
$c = (array) $this->conditions;
// Vehicle class — if rule lists classes, context class must be among them.
$classes = (array) ($c['classes'] ?? []);
if (! empty($classes)) {
if (empty($ctx['class']) || ! in_array($ctx['class'], $classes, true)) {
return false;
}
}
// Vehicle age range.
if (isset($c['age_min']) && $c['age_min'] !== null && $c['age_min'] !== '') {
if (($ctx['age'] ?? null) === null || $ctx['age'] < (int) $c['age_min']) return false;
}
if (isset($c['age_max']) && $c['age_max'] !== null && $c['age_max'] !== '') {
if (($ctx['age'] ?? null) === null || $ctx['age'] > (int) $c['age_max']) return false;
}
// VIP requirement (true = only VIP, false/null = ignore).
if (! empty($c['client_vip'])) {
if (empty($ctx['vip'])) return false;
}
// Urgency — if rule lists urgencies, context must match.
$urg = (array) ($c['urgency'] ?? []);
if (! empty($urg)) {
if (empty($ctx['urgency']) || ! in_array($ctx['urgency'], $urg, true)) {
return false;
}
}
return true;
}
}
+97 -14
View File
@@ -15,12 +15,13 @@ class Purchase extends Model
public const STATUSES = [
'draft' => 'Ciornă',
'ordered' => 'Comandată',
'partial' => 'Parțial recepționată',
'received' => 'Recepționată',
'cancelled' => 'Anulată',
];
protected $fillable = [
'company_id', 'number', 'supplier_id',
'company_id', 'number', 'supplier_id', 'warehouse_id',
'order_date', 'expected_at', 'received_at', 'paid_at',
'status', 'total', 'notes',
];
@@ -38,6 +39,11 @@ class Purchase extends Model
return $this->belongsTo(Supplier::class);
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function items(): HasMany
{
return $this->hasMany(PurchaseItem::class);
@@ -60,24 +66,101 @@ class Purchase extends Model
}
/**
* Mark all items received and increment Part.qty for linked items.
* Receive a specific item qty of buy_price unit cost into target warehouse.
* Routes through WarehouseService so a batch is created + receipt event written.
* Also records the supplier price for analytics.
*/
public function markReceived(): void
public function receiveItem(PurchaseItem $item, float $qty, ?Warehouse $warehouse = null): void
{
\Illuminate\Support\Facades\DB::transaction(function () {
foreach ($this->items as $item) {
if (! $item->received) {
if ($item->part_id) {
$part = Part::find($item->part_id);
$part?->adjustStock((float) $item->qty);
if ($qty <= 0) {
throw new \InvalidArgumentException('Cantitatea de recepție trebuie să fie pozitivă.');
}
$outstanding = (float) $item->qty - (float) $item->qty_received;
if ($qty > $outstanding + 0.001) {
throw new \InvalidArgumentException(sprintf(
'Cantitate prea mare: cerut %.2f, restanță %.2f',
$qty, $outstanding
));
}
\Illuminate\Support\Facades\DB::transaction(function () use ($item, $qty, $warehouse) {
$warehouse ??= $this->warehouse;
if (! $warehouse) {
$warehouse = app(\App\Services\Warehouse\WarehouseService::class)
->defaultWarehouse($this->company_id);
}
if ($item->part_id) {
$part = Part::find($item->part_id);
if ($part) {
app(\App\Services\Warehouse\WarehouseService::class)->receive(
part: $part,
qty: $qty,
buyPrice: (float) $item->buy_price,
warehouse: $warehouse,
supplier: $this->supplier,
batchRef: $this->number,
ref: $this,
notes: "PO #{$this->number}",
);
if ($this->supplier_id) {
SupplierPartPrice::create([
'supplier_id' => $this->supplier_id,
'part_id' => $part->id,
'purchase_id' => $this->id,
'price' => (float) $item->buy_price,
'currency' => 'MDL',
'observed_at' => now(),
]);
}
$item->received = true;
$item->save();
}
}
$this->status = 'received';
$this->received_at = now();
$this->save();
$item->qty_received = (float) $item->qty_received + $qty;
if ((float) $item->qty_received >= (float) $item->qty) {
$item->received = true;
}
$item->save();
$this->recomputeStatus();
});
}
/** Convenience: receive every outstanding item in full. */
public function receiveAllRemaining(?Warehouse $warehouse = null): void
{
foreach ($this->items()->get() as $item) {
$outstanding = (float) $item->qty - (float) $item->qty_received;
if ($outstanding > 0) {
$this->receiveItem($item, $outstanding, $warehouse);
}
}
}
/** Recalculate status based on item qty_received vs qty. */
public function recomputeStatus(): void
{
if ($this->status === 'cancelled' || $this->status === 'draft') {
return;
}
$items = $this->items()->get();
if ($items->isEmpty()) return;
$totals = $items->reduce(function ($carry, $i) {
$carry['ordered'] += (float) $i->qty;
$carry['received'] += (float) $i->qty_received;
return $carry;
}, ['ordered' => 0.0, 'received' => 0.0]);
if ($totals['received'] <= 0) {
$this->status = 'ordered';
} elseif ($totals['received'] + 0.001 < $totals['ordered']) {
$this->status = 'partial';
} else {
$this->status = 'received';
if (! $this->received_at) $this->received_at = now();
}
$this->save();
}
}
+12 -1
View File
@@ -12,16 +12,27 @@ class PurchaseItem extends Model
protected $fillable = [
'company_id', 'purchase_id', 'part_id',
'name', 'article', 'qty', 'unit', 'buy_price', 'total', 'received',
'name', 'article', 'qty', 'qty_received', 'unit', 'buy_price', 'total', 'received',
];
protected $casts = [
'qty' => 'decimal:2',
'qty_received' => 'decimal:2',
'buy_price' => 'decimal:2',
'total' => 'decimal:2',
'received' => 'boolean',
];
public function isFullyReceived(): bool
{
return (float) $this->qty_received + 0.001 >= (float) $this->qty;
}
public function outstanding(): float
{
return max(0.0, (float) $this->qty - (float) $this->qty_received);
}
public function purchase(): BelongsTo
{
return $this->belongsTo(Purchase::class);
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PushSubscription extends Model
{
use BelongsToTenant;
protected $fillable = [
'company_id', 'user_id', 'endpoint',
'public_key', 'auth_token', 'content_encoding', 'user_agent',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ServiceReminderSent extends Model
{
use BelongsToTenant;
protected $table = 'service_reminders_sent';
protected $fillable = [
'company_id', 'vehicle_id', 'client_id',
'channel', 'type', 'sent_at',
];
protected $casts = [
'sent_at' => 'datetime',
];
public function vehicle(): BelongsTo
{
return $this->belongsTo(Vehicle::class);
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class ServiceTemplate extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = ['company_id', 'name', 'category', 'notes', 'is_active'];
protected $casts = ['is_active' => 'boolean'];
public function items(): HasMany
{
return $this->hasMany(ServiceTemplateItem::class);
}
public function laborItems(): HasMany
{
return $this->items()->where('kind', 'labor');
}
public function partItems(): HasMany
{
return $this->items()->where('kind', 'part');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ServiceTemplateItem extends Model
{
use BelongsToTenant;
public const KINDS = ['labor' => 'Manoperă', 'part' => 'Piesă'];
protected $fillable = [
'company_id', 'service_template_id', 'kind',
'labor_id', 'part_id', 'name', 'qty', 'hours',
];
protected $casts = [
'qty' => 'decimal:2',
'hours' => 'decimal:2',
];
public function template(): BelongsTo
{
return $this->belongsTo(ServiceTemplate::class, 'service_template_id');
}
public function labor(): BelongsTo
{
return $this->belongsTo(Labor::class);
}
public function part(): BelongsTo
{
return $this->belongsTo(Part::class);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class SubcontractJob extends Model
{
use BelongsToTenant, SoftDeletes;
public const STATUSES = [
'sent' => 'Trimis',
'in_progress' => 'În lucru',
'done' => 'Gata',
'returned' => 'Returnat',
'cancelled' => 'Anulat',
];
protected $fillable = [
'company_id', 'work_order_id', 'subcontractor_id',
'number', 'category', 'description',
'cost', 'markup_pct', 'client_price',
'status', 'sent_at', 'eta', 'returned_at', 'paid_to_sub', 'notes',
];
protected $casts = [
'cost' => 'decimal:2',
'markup_pct' => 'decimal:2',
'client_price' => 'decimal:2',
'sent_at' => 'date',
'eta' => 'date',
'returned_at' => 'date',
'paid_to_sub' => 'boolean',
];
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
}
public function subcontractor(): BelongsTo
{
return $this->belongsTo(Subcontractor::class);
}
/** Our margin = what we bill the client what the sub charges us. */
public function margin(): float
{
return round((float) $this->client_price - (float) $this->cost, 2);
}
public static function generateNumber(int $companyId): string
{
$year = date('y');
$count = static::withoutGlobalScopes()
->where('company_id', $companyId)
->whereYear('created_at', date('Y'))
->count();
return sprintf('SC-%s-%04d', $year, $count + 1);
}
protected static function booted(): void
{
static::creating(function (self $job) {
if (empty($job->number)) {
$job->number = static::generateNumber(
$job->company_id ?: app(\App\Tenancy\TenantManager::class)->currentId()
);
}
});
static::saving(function (self $job) {
// markup drives client_price unless markup is zero (then keep manual price).
if ((float) $job->markup_pct > 0) {
$job->client_price = round((float) $job->cost * (1 + (float) $job->markup_pct / 100), 2);
}
});
static::saved(fn (self $job) => $job->workOrder?->recalcTotal());
static::deleted(fn (self $job) => $job->workOrder?->recalcTotal());
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Subcontractor extends Model
{
use BelongsToTenant, SoftDeletes;
public const SPECIALTIES = [
'Turbo', 'Cutie viteze', 'Variator', 'Casetă direcție',
'PDR', 'Vopsitorie', 'Electronică', 'Injectoare', 'Altele',
];
protected $fillable = [
'company_id', 'name', 'specialty', 'phone', 'email',
'rating', 'is_active', 'notes',
];
protected $casts = [
'is_active' => 'boolean',
];
public function jobs(): HasMany
{
return $this->hasMany(SubcontractJob::class);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SupplierPartPrice extends Model
{
use BelongsToTenant;
protected $fillable = [
'company_id', 'supplier_id', 'part_id', 'purchase_id',
'price', 'currency', 'observed_at',
];
protected $casts = [
'price' => 'decimal:2',
'observed_at' => 'datetime',
];
public function supplier(): BelongsTo
{
return $this->belongsTo(Supplier::class);
}
public function part(): BelongsTo
{
return $this->belongsTo(Part::class);
}
public function purchase(): BelongsTo
{
return $this->belongsTo(Purchase::class);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class TireSet extends Model implements HasMedia
{
use BelongsToTenant, InteractsWithMedia, SoftDeletes;
public const SEASONS = [
'summer' => 'Vară',
'winter' => 'Iarnă',
'allseason' => 'All-season',
];
public const CONDITIONS = [
'nou' => 'Nou',
'bun' => 'Bun',
'uzat' => 'Uzat',
'critic' => 'Critic',
];
protected $fillable = [
'company_id', 'client_id', 'vehicle_id',
'label', 'season',
'width', 'profile', 'diameter', 'brand', 'model', 'dot_year',
'has_rims', 'rim_type',
'tread', 'tread_min', 'tpms', 'tpms_ids',
'condition', 'notes',
];
protected $casts = [
'tread' => 'array',
'tpms_ids' => 'array',
'tread_min' => 'decimal:1',
'has_rims' => 'boolean',
'tpms' => 'boolean',
];
public function registerMediaCollections(): void
{
$this->addMediaCollection('photos');
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function vehicle(): BelongsTo
{
return $this->belongsTo(Vehicle::class);
}
public function storage(): HasMany
{
return $this->hasMany(TireStorage::class);
}
public function currentStorage(): ?TireStorage
{
return $this->storage()->where('status', 'stored')->latest('checked_in_at')->first();
}
public function isStored(): bool
{
return $this->storage()->where('status', 'stored')->exists();
}
public function sizeLabel(): string
{
if (! $this->width || ! $this->profile || ! $this->diameter) {
return '—';
}
return "{$this->width}/{$this->profile} R{$this->diameter}";
}
/** Recompute tread_min from the per-position tread JSON. */
public function recomputeTreadMin(): void
{
$vals = array_filter(array_map('floatval', array_values((array) $this->tread)), fn ($v) => $v > 0);
$this->tread_min = $vals ? min($vals) : null;
}
protected static function booted(): void
{
static::saving(function (self $set) {
$set->recomputeTreadMin();
});
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TireStorage extends Model
{
use BelongsToTenant;
protected $table = 'tire_storage';
public const STATUSES = [
'stored' => 'În depozit',
'retrieved' => 'Ridicat',
];
protected $fillable = [
'company_id', 'tire_set_id',
'location', 'season_label', 'status',
'checked_in_at', 'checked_out_at', 'fee', 'paid', 'notes',
];
protected $casts = [
'checked_in_at' => 'datetime',
'checked_out_at' => 'datetime',
'fee' => 'decimal:2',
'paid' => 'boolean',
];
public function tireSet(): BelongsTo
{
return $this->belongsTo(TireSet::class);
}
public function isActive(): bool
{
return $this->status === 'stored';
}
public function durationDays(): int
{
$end = $this->checked_out_at ?? now();
return (int) $this->checked_in_at?->diffInDays($end);
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ class Vehicle extends Model
protected $fillable = [
'company_id', 'client_id',
'make', 'model', 'year', 'vin', 'plate',
'engine', 'gearbox', 'fuel', 'mileage', 'color', 'notes',
'engine', 'gearbox', 'fuel', 'vehicle_class', 'mileage', 'color', 'notes',
];
public function client(): BelongsTo
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Warehouse extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'company_id', 'code', 'name', 'address', 'is_default', 'is_active',
];
protected $casts = [
'is_default' => 'boolean',
'is_active' => 'boolean',
];
public function batches(): HasMany
{
return $this->hasMany(PartBatch::class);
}
public function events(): HasMany
{
return $this->hasMany(WarehouseEvent::class);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* Immutable ledger of every stock movement. The qty/cost reality of the
* warehouse can always be reconstructed by aggregating these events.
*/
class WarehouseEvent extends Model
{
use BelongsToTenant;
public const TYPES = [
'opening' => 'Stoc inițial',
'receipt' => 'Recepție',
'issue' => 'Consum',
'transfer_out' => 'Transfer (ieșire)',
'transfer_in' => 'Transfer (intrare)',
'adjustment' => 'Ajustare',
'write_off' => 'Casare',
'return' => 'Retur',
];
protected $fillable = [
'company_id', 'part_id', 'batch_id', 'warehouse_id',
'type', 'qty_delta', 'unit_cost',
'ref_type', 'ref_id', 'user_id',
'occurred_at', 'notes',
];
protected $casts = [
'qty_delta' => 'decimal:3',
'unit_cost' => 'decimal:2',
'occurred_at' => 'datetime',
];
public $timestamps = true;
public function part(): BelongsTo
{
return $this->belongsTo(Part::class);
}
public function batch(): BelongsTo
{
return $this->belongsTo(PartBatch::class, 'batch_id');
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function ref(): MorphTo
{
return $this->morphTo();
}
}
+61 -4
View File
@@ -8,10 +8,12 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class WorkOrder extends Model
class WorkOrder extends Model implements HasMedia
{
use Auditable, BelongsToTenant, SoftDeletes;
use Auditable, BelongsToTenant, InteractsWithMedia, SoftDeletes;
public const STATUSES = [
'new' => 'Nou',
@@ -36,19 +38,31 @@ class WorkOrder extends Model
'client_id', 'vehicle_id', 'master_id', 'deal_id', 'appointment_id',
'opened_at', 'closed_at', 'mileage_in', 'mileage_out',
'complaint', 'diagnosis', 'recommendations',
'status', 'pay_status', 'approved', 'approved_at',
'status', 'urgency', 'pay_status', 'approved', 'approved_at',
'discount_pct', 'total',
'eta_at', 'tracking_token',
];
protected $casts = [
'opened_at' => 'date',
'closed_at' => 'date',
'approved_at' => 'datetime',
'eta_at' => 'datetime',
'approved' => 'boolean',
'discount_pct' => 'decimal:2',
'total' => 'decimal:2',
];
public function registerMediaCollections(): void
{
$this->addMediaCollection('photos');
}
public function trackingUrl(): string
{
return url('/t/' . $this->tracking_token);
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
@@ -79,6 +93,11 @@ class WorkOrder extends Model
return $this->hasMany(Payment::class);
}
public function subcontractJobs(): HasMany
{
return $this->hasMany(SubcontractJob::class);
}
public function paidAmount(): float
{
return (float) $this->payments()->sum('amount');
@@ -93,7 +112,10 @@ class WorkOrder extends Model
{
$worksTotal = $this->works()->sum('total');
$partsTotal = $this->parts()->sum('total');
$sub = (float) $worksTotal + (float) $partsTotal;
$subcontractTotal = $this->subcontractJobs()
->where('status', '!=', 'cancelled')
->sum('client_price');
$sub = (float) $worksTotal + (float) $partsTotal + (float) $subcontractTotal;
$disc = (float) $this->discount_pct;
$this->total = round($sub * (1 - $disc / 100), 2);
$this->save();
@@ -112,6 +134,12 @@ class WorkOrder extends Model
/** Auto-send 'ready' email + broadcast WS event on status change. */
protected static function booted(): void
{
static::creating(function (self $wo) {
if (empty($wo->tracking_token)) {
$wo->tracking_token = \Illuminate\Support\Str::random(24);
}
});
static::updated(function (self $wo) {
if (
$wo->wasChanged('status')
@@ -121,6 +149,35 @@ class WorkOrder extends Model
app(\App\Services\NotificationDispatcher::class)->workOrderReady($wo);
}
// Push the assigned mechanic when a WO gets assigned to them.
if ($wo->wasChanged('master_id') && $wo->master_id) {
try {
app(\App\Services\Notifications\WebPushService::class)->sendToUser(
(int) $wo->master_id,
'Fișă nouă atribuită',
"Fișa #{$wo->number} · " . ($wo->vehicle?->plate ?? ''),
'/app/resources/work-orders/' . $wo->id . '/edit',
'wo-assign-' . $wo->id,
);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::debug('WO assign push skipped: ' . $e->getMessage());
}
}
// Warehouse lifecycle: status=done → consume reservations into issues;
// status=cancelled → release reservations.
if ($wo->wasChanged('status')) {
$svc = app(\App\Services\Warehouse\WarehouseService::class);
if ($wo->status === 'done' && $wo->getOriginal('status') !== 'done') {
$svc->consume($wo);
}
if ($wo->status === 'cancelled' && $wo->getOriginal('status') !== 'cancelled') {
foreach ($wo->parts as $wop) {
$svc->release($wop);
}
}
}
// Broadcast real-time update on any field change (skip if broadcasting=log).
if (config('broadcasting.default') !== 'log') {
try {
+27 -11
View File
@@ -52,21 +52,37 @@ class WorkOrderPart extends Model
$row->total = round($sub * (1 - $disc / 100), 2);
});
// When a part is marked installed, decrement catalog stock once.
static::updating(function (self $row) {
$wasInstalled = $row->getOriginal('status') === 'installed';
$isInstalled = $row->status === 'installed';
if (! $wasInstalled && $isInstalled && $row->part_id) {
$part = Part::find($row->part_id);
$part?->adjustStock(-(float) $row->qty);
// Reserve batches as soon as a catalog-linked part line is created.
// Reservations don't reduce on-hand qty, only block other reservations.
static::created(function (self $row) {
if ($row->part_id) {
try {
app(\App\Services\Warehouse\WarehouseService::class)->reserve($row);
} catch (\App\Services\Warehouse\InsufficientStockException $e) {
\Illuminate\Support\Facades\Log::warning('WO part reservation skipped: ' . $e->getMessage());
}
}
// If reverting from installed → restore stock
if ($wasInstalled && ! $isInstalled && $row->part_id) {
$part = Part::find($row->part_id);
$part?->adjustStock((float) $row->qty);
});
// If qty / part link changes, release old reservation and re-reserve.
static::updated(function (self $row) {
if ($row->wasChanged(['qty', 'part_id'])) {
$svc = app(\App\Services\Warehouse\WarehouseService::class);
$svc->release($row);
if ($row->part_id) {
try {
$svc->reserve($row);
} catch (\App\Services\Warehouse\InsufficientStockException $e) {
\Illuminate\Support\Facades\Log::warning('WO part re-reservation skipped: ' . $e->getMessage());
}
}
}
});
static::deleted(function (self $row) {
app(\App\Services\Warehouse\WarehouseService::class)->release($row);
});
static::saved(fn (self $row) => $row->workOrder?->recalcTotal());
static::deleted(fn (self $row) => $row->workOrder?->recalcTotal());
}
+52 -2
View File
@@ -222,13 +222,63 @@ class TenantPanelProvider extends PanelProvider
$reverbPort = config('broadcasting.connections.reverb.options.port');
$reverbScheme = config('broadcasting.connections.reverb.options.scheme', 'https');
$broadcastEnabled = config('broadcasting.default') === 'reverb' && $reverbKey && $reverbHost;
$vapidPublic = config('webpush.vapid.public_key');
$csrf = csrf_token();
@endphp
<button id="autocrm-install" type="button" style="display:none;position:fixed;bottom:16px;right:16px;z-index:60;background:#3b82f6;color:#fff;border:0;border-radius:24px;padding:10px 18px;font-size:13px;font-weight:600;box-shadow:0 4px 12px rgba(0,0,0,.2);cursor:pointer;">
Instalează aplicația
</button>
<script>
// Service worker + Web Push subscription.
const AUTOCRM_VAPID = @json($vapidPublic);
function urlBase64ToUint8Array(b64) {
const pad = '='.repeat((4 - b64.length % 4) % 4);
const base64 = (b64 + pad).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js');
if (AUTOCRM_VAPID && 'PushManager' in window) {
const perm = await Notification.requestPermission().catch(() => 'default');
if (perm === 'granted') {
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(AUTOCRM_VAPID),
});
}
await fetch('/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ $csrf }}' },
body: JSON.stringify(sub.toJSON()),
});
}
}
} catch (e) { /* push optional */ }
});
}
// PWA install prompt.
let deferredPrompt = null;
const installBtn = document.getElementById('autocrm-install');
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
if (installBtn) installBtn.style.display = 'block';
});
if (installBtn) {
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
await deferredPrompt.userChoice;
deferredPrompt = null;
installBtn.style.display = 'none';
});
}
window.addEventListener('appinstalled', () => { if (installBtn) installBtn.style.display = 'none'; });
</script>
@if ($broadcastEnabled && $tenant)
<script src="https://js.pusher.com/8.4/pusher.min.js"></script>
+233
View File
@@ -7,8 +7,12 @@ use App\Models\Tenant\AiChat;
use App\Models\Tenant\AiMessage;
use App\Models\Tenant\Client;
use App\Models\Tenant\Lead;
use App\Models\Tenant\Part;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Tenancy\TenantManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
/**
@@ -175,6 +179,235 @@ TXT;
];
}
// ─── One-shot AI helpers ─────────────────────────────────────────
/**
* Single-prompt call without persisting a chat. Used by quick-action buttons
* (diagnose / suggest parts / suggest price / VIN recommendations).
*
* @return array{0:string, 1:array} [reply, meta]
*/
public function singleShot(string $systemPrompt, string $userPrompt, ?string $provider = null): array
{
$company = $this->currentCompany();
if (! $company) return ['Niciun tenant rezolvat.', ['error' => 'no_tenant']];
$aiCfg = (array) ($company->settings['ai'] ?? []);
$provider ??= $aiCfg['default_provider'] ?? 'claude';
$key = $aiCfg["{$provider}_key"] ?? null;
if (! $key) {
return ['⚠️ API key pentru ' . strtoupper($provider) . ' lipsește în Setări → AI.',
['error' => 'no_api_key', 'provider' => $provider]];
}
$start = microtime(true);
try {
[$reply, $meta] = match ($provider) {
'claude' => $this->postClaude($key, $systemPrompt, [['role' => 'user', 'content' => $userPrompt]]),
'gpt' => $this->postOpenAI($key, $systemPrompt, [['role' => 'user', 'content' => $userPrompt]]),
'gemini' => $this->postGemini($key, $systemPrompt, [['role' => 'user', 'content' => $userPrompt]]),
default => ['Provider necunoscut: ' . $provider, []],
};
} catch (\Throwable $e) {
$reply = '❌ Eroare API: ' . $e->getMessage();
$meta = ['error' => $e->getMessage()];
}
$meta['latency_ms'] = (int) ((microtime(true) - $start) * 1000);
$meta['provider'] = $provider;
return [$reply, $meta];
}
public function suggestDiagnosis(WorkOrder $wo): array
{
$vehicle = $wo->vehicle;
$vinInfo = '';
if ($vehicle?->vin) {
$d = app(VinDecoder::class)->decode($vehicle->vin);
if (! empty($d['manufacturer'])) {
$vinInfo = " (VIN decode: {$d['manufacturer']} {$d['year']})";
}
}
$sys = "Ești diagnostician auto cu 20 de ani experiență. Răspunzi scurt, structurat, "
. "în română. Listează în ordine de probabilitate cauzele posibile + verificările "
. "necesare. Nu inventezi date — dacă plângerea e vagă, sugerezi întrebări de clarificat.";
$user = sprintf(
"Mașina: %s %s %s%s, %s km.\nPlângere client: %s\n\nDă-mi top 3 cauze probabile și ce trebuie verificat la fiecare.",
(string) ($vehicle->make ?? '?'),
(string) ($vehicle->model ?? '?'),
(string) ($vehicle->year ?? ''),
$vinInfo,
number_format((float) ($vehicle->mileage ?? 0), 0, '.', ' '),
$wo->complaint ?: '(nu e completată)',
);
return $this->singleShot($sys, $user);
}
public function suggestParts(WorkOrder $wo, string $task): array
{
$vehicle = $wo->vehicle;
$sys = "Ești expert piese auto. Pentru mașina dată și operațiunea solicitată, "
. "listează piesele necesare cu coduri OEM tipice când le știi (sau familie generică). "
. "Răspunde în română, format listă.";
$user = sprintf(
"Mașina: %s %s %s.\nOperațiune: %s.\nListează piesele necesare + coduri OEM dacă există + cantități.",
(string) ($vehicle->make ?? '?'),
(string) ($vehicle->model ?? '?'),
(string) ($vehicle->year ?? ''),
$task,
);
return $this->singleShot($sys, $user);
}
public function suggestPrice(Part $part): array
{
$company = $this->currentCompany();
$currency = (string) data_get($company?->settings, 'currency', 'MDL');
$sys = "Ești manager comercial pentru un magazin de piese auto din Moldova. "
. "Sugerezi preț de vânzare bazat pe costul de achiziție, categorie și brand. "
. "Răspunde scurt cu: preț recomandat, markup %, justificare 1-2 fraze.";
$user = sprintf(
"Piesa: %s\nBrand: %s\nCategorie: %s\nCost achiziție: %.2f %s\nPreț actual: %.2f %s",
$part->name,
$part->brand ?? '?',
$part->category ?? '?',
(float) $part->buy_price, $currency,
(float) $part->sell_price, $currency,
);
return $this->singleShot($sys, $user);
}
public function vinRecommendations(string $vin, ?int $mileage = null): array
{
$decoded = app(VinDecoder::class)->decode($vin);
if (empty($decoded['manufacturer'])) {
return ["VIN nu poate fi decodat — verifică formatul (17 caractere).", ['error' => 'invalid_vin']];
}
$sys = "Ești expert service auto. Pe baza datelor mașinii și kilometrajului, "
. "sugerezi mentenanța programată recomandată de producător + verificările "
. "tipice pentru vârsta mașinii. Format listă scurtă.";
$user = sprintf(
"Mașina decodată din VIN: %s din %d (%s).\n%sCe verificări și mentenanță programată recomandăm acum?",
$decoded['manufacturer'],
$decoded['year'],
$decoded['country'] ?? '?',
$mileage ? "Kilometraj actual: " . number_format($mileage, 0, '.', ' ') . " km.\n" : '',
);
[$reply, $meta] = $this->singleShot($sys, $user);
$meta['vin_decoded'] = $decoded;
return [$reply, $meta];
}
// ─── Token usage tracking ────────────────────────────────────────
/**
* Aggregate token spend for current month, grouped by provider.
*
* @return array<string, array{tokens_in:int, tokens_out:int, calls:int}>
*/
public function monthlyUsage(): array
{
$start = Carbon::now()->startOfMonth();
$rows = AiMessage::where('role', 'assistant')
->where('created_at', '>=', $start)
->get(['meta']);
$out = [];
foreach ($rows as $r) {
$meta = (array) $r->meta;
$provider = (string) ($meta['provider'] ?? 'unknown');
$out[$provider] ??= ['tokens_in' => 0, 'tokens_out' => 0, 'calls' => 0];
$out[$provider]['tokens_in'] += (int) ($meta['tokens_in'] ?? 0);
$out[$provider]['tokens_out'] += (int) ($meta['tokens_out'] ?? 0);
$out[$provider]['calls'] += 1;
}
return $out;
}
// ─── Provider HTTP — common form, used by both chat + single-shot ─
protected function postClaude(string $key, string $system, array $messages): array
{
$r = Http::withHeaders([
'x-api-key' => $key,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])
->timeout(60)
->post('https://api.anthropic.com/v1/messages', [
'model' => 'claude-sonnet-4-5',
'max_tokens' => 1024,
'system' => $system,
'messages' => $messages,
]);
if (! $r->successful()) {
return ['❌ ' . ($r->json('error.message') ?? 'Anthropic ' . $r->status()), ['status' => $r->status()]];
}
$body = $r->json();
$text = collect($body['content'] ?? [])
->where('type', 'text')->pluck('text')->implode("\n");
return [$text ?: '(răspuns gol)', [
'model' => $body['model'] ?? null,
'tokens_in' => $body['usage']['input_tokens'] ?? null,
'tokens_out' => $body['usage']['output_tokens'] ?? null,
]];
}
protected function postOpenAI(string $key, string $system, array $messages): array
{
$r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json'])
->timeout(60)
->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o-mini',
'messages' => array_merge([['role' => 'system', 'content' => $system]], $messages),
'max_tokens' => 1024,
]);
if (! $r->successful()) {
return ['❌ ' . ($r->json('error.message') ?? 'OpenAI ' . $r->status()), ['status' => $r->status()]];
}
$body = $r->json();
return [
$body['choices'][0]['message']['content'] ?? '(răspuns gol)',
[
'model' => $body['model'] ?? null,
'tokens_in' => $body['usage']['prompt_tokens'] ?? null,
'tokens_out' => $body['usage']['completion_tokens'] ?? null,
],
];
}
protected function postGemini(string $key, string $system, array $messages): array
{
$contents = [];
foreach ($messages as $m) {
$contents[] = [
'role' => $m['role'] === 'assistant' ? 'model' : 'user',
'parts' => [['text' => $m['content']]],
];
}
$r = Http::withHeaders(['content-type' => 'application/json'])
->timeout(60)
->post('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' . $key, [
'systemInstruction' => ['parts' => [['text' => $system]]],
'contents' => $contents,
'generationConfig' => ['maxOutputTokens' => 1024],
]);
if (! $r->successful()) {
return ['❌ Gemini ' . $r->status(), ['status' => $r->status()]];
}
$body = $r->json();
$text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)';
return [$text, ['model' => 'gemini-1.5-flash', 'tokens' => $body['usageMetadata'] ?? null]];
}
protected function currentCompany(): ?Company
{
$id = app(TenantManager::class)->currentId();
if (! $id) return null;
return Company::withoutGlobalScopes()->find($id);
}
protected function callGemini(string $key, AiChat $chat, string $msg, Company $company): array
{
$contents = [];
+171
View File
@@ -0,0 +1,171 @@
<?php
namespace App\Services\Ai;
/**
* Deterministic VIN decoder. Extracts year, country and manufacturer from the
* 17-character Vehicle Identification Number per ISO 3779/3780. No external API.
*
* Reliable signals:
* - position 1 world region (geographic prefix)
* - positions 1-3 WMI (world manufacturer identifier)
* - position 10 model year (cyclical 30-year mapping)
* - position 11 assembly plant code
* - position 9 check digit (NA-spec checksum, optional verification)
*
* Granular model/trim info requires a licensed database (TecDoc / NHTSA API);
* those are out of scope here. We return a "best-effort" identification.
*/
class VinDecoder
{
private const YEAR_CODES = [
'A' => [1980, 2010], 'B' => [1981, 2011], 'C' => [1982, 2012], 'D' => [1983, 2013],
'E' => [1984, 2014], 'F' => [1985, 2015], 'G' => [1986, 2016], 'H' => [1987, 2017],
'J' => [1988, 2018], 'K' => [1989, 2019], 'L' => [1990, 2020], 'M' => [1991, 2021],
'N' => [1992, 2022], 'P' => [1993, 2023], 'R' => [1994, 2024], 'S' => [1995, 2025],
'T' => [1996, 2026], 'V' => [1997, 2027], 'W' => [1998, 2028], 'X' => [1999, 2029],
'Y' => [2000, 2030], '1' => [2001, 2031], '2' => [2002, 2032], '3' => [2003, 2033],
'4' => [2004, 2034], '5' => [2005, 2035], '6' => [2006, 2036], '7' => [2007, 2037],
'8' => [2008, 2038], '9' => [2009, 2039],
];
// Region by first char (ISO 3779 broad regions).
private const REGIONS = [
'A' => 'Africa', 'B' => 'Africa', 'C' => 'Africa', 'D' => 'Africa',
'E' => 'Africa', 'F' => 'Africa', 'G' => 'Africa', 'H' => 'Africa',
'J' => 'Asia', 'K' => 'Asia', 'L' => 'Asia', 'M' => 'Asia',
'N' => 'Asia', 'P' => 'Asia', 'R' => 'Asia',
'S' => 'Europe', 'T' => 'Europe', 'U' => 'Europe', 'V' => 'Europe',
'W' => 'Europe', 'X' => 'Europe', 'Y' => 'Europe', 'Z' => 'Europe',
'1' => 'North America', '2' => 'North America', '3' => 'North America',
'4' => 'North America', '5' => 'North America',
'6' => 'Oceania', '7' => 'Oceania',
'8' => 'South America', '9' => 'South America',
];
// Selected WMI → manufacturer/country. Covers most common European/Asian/US
// brands relevant for a Moldova service shop.
private const WMI = [
// Volkswagen group
'WVW' => ['VW', 'Germany'], 'WV1' => ['VW Commercial', 'Germany'], 'WV2' => ['VW Bus', 'Germany'],
'WAU' => ['Audi', 'Germany'], 'WA1' => ['Audi SUV', 'Germany'],
'TRU' => ['Audi', 'Hungary'], 'WUA' => ['Audi Sport', 'Germany'],
'VWV' => ['VW', 'Spain'], 'VSS' => ['SEAT', 'Spain'], 'TMB' => ['Škoda', 'Czechia'],
// BMW
'WBA' => ['BMW', 'Germany'], 'WBS' => ['BMW M', 'Germany'], 'WBY' => ['BMW i', 'Germany'],
'WBX' => ['BMW X SUV', 'USA'], 'NM0' => ['BMW Mini', 'Turkey'],
// Mercedes
'WDB' => ['Mercedes-Benz', 'Germany'], 'WDC' => ['Mercedes-Benz SUV', 'USA'],
'WDD' => ['Mercedes-Benz', 'Germany'], 'WDF' => ['Mercedes-Benz Van', 'Germany'],
// Porsche
'WP0' => ['Porsche', 'Germany'], 'WP1' => ['Porsche SUV', 'Germany'],
// Opel / Vauxhall
'W0L' => ['Opel', 'Germany'], 'W0V' => ['Opel/Vauxhall', 'Germany'],
// Ford
'1FA' => ['Ford', 'USA'], '1FT' => ['Ford Truck', 'USA'], '1FM' => ['Ford SUV', 'USA'],
'WF0' => ['Ford Europe', 'Germany'],
// Honda
'1HG' => ['Honda', 'USA'], 'JHM' => ['Honda', 'Japan'], 'JHL' => ['Honda SUV', 'Japan'],
// Toyota
'JT2' => ['Toyota', 'Japan'], 'JTD' => ['Toyota', 'Japan'], 'JTE' => ['Toyota', 'Japan'],
'4T1' => ['Toyota', 'USA'], '5TD' => ['Toyota', 'USA'],
// Hyundai/Kia
'KMH' => ['Hyundai', 'Korea'], 'KNA' => ['Kia', 'Korea'], 'KND' => ['Kia SUV', 'Korea'],
// Renault/Dacia
'VF1' => ['Renault', 'France'], 'VF6' => ['Renault Trucks', 'France'],
'UU1' => ['Dacia', 'Romania'], 'UU3' => ['Dacia Pickup', 'Romania'],
// Peugeot/Citroën
'VF3' => ['Peugeot', 'France'], 'VF7' => ['Citroën', 'France'],
// Fiat group
'ZFA' => ['Fiat', 'Italy'], 'ZAR' => ['Alfa Romeo', 'Italy'], 'ZFF' => ['Ferrari', 'Italy'],
// Volvo
'YV1' => ['Volvo Cars', 'Sweden'], 'YV4' => ['Volvo SUV', 'Sweden'],
// Nissan
'JN1' => ['Nissan', 'Japan'], 'JN8' => ['Nissan SUV', 'Japan'], '1N4' => ['Nissan', 'USA'],
// Mazda
'JM1' => ['Mazda', 'Japan'], 'JMZ' => ['Mazda', 'Japan'],
// Subaru
'JF1' => ['Subaru', 'Japan'], 'JF2' => ['Subaru SUV', 'Japan'],
// Mitsubishi
'JMB' => ['Mitsubishi', 'Japan'], 'JA3' => ['Mitsubishi', 'Japan'],
// Lada / Russian
'XTA' => ['Lada/AvtoVAZ', 'Russia'], 'X4X' => ['UAZ', 'Russia'],
// Tesla
'5YJ' => ['Tesla', 'USA'], 'LRW' => ['Tesla', 'China'],
// Chinese brands
'LGW' => ['Great Wall', 'China'], 'LJV' => ['JAC', 'China'], 'LSJ' => ['MG/SAIC', 'China'],
'LB1' => ['Geely', 'China'],
];
public function decode(string $raw): array
{
$vin = preg_replace('/[^A-HJ-NPR-Z0-9]/', '', strtoupper($raw));
if (strlen($vin) !== 17) {
return [
'vin' => $vin,
'valid_length' => false,
'reason' => 'VIN must be exactly 17 characters (no I, O, Q allowed).',
];
}
$wmi = substr($vin, 0, 3);
$yearCode = $vin[9];
$plant = $vin[10];
[$manufacturer, $country] = self::WMI[$wmi] ?? [null, null];
$region = self::REGIONS[$vin[0]] ?? null;
$year = $this->resolveYear($yearCode, $vin);
return [
'vin' => $vin,
'valid_length' => true,
'wmi' => $wmi,
'region' => $region,
'country' => $country,
'manufacturer' => $manufacturer,
'year' => $year,
'plant' => $plant,
'checksum_valid' => $this->validateChecksum($vin),
];
}
/**
* Position 10 encodes year cyclically. Use position 7 as A-Z 2010+, 0-9 pre-2010
* disambiguator for new-spec VINs (since 2010 NHTSA spec).
*/
private function resolveYear(string $code, string $vin): ?int
{
if (! isset(self::YEAR_CODES[$code])) return null;
[$old, $new] = self::YEAR_CODES[$code];
// Position 7 alpha → post-2010 cycle; numeric → pre-2010
$p7 = $vin[6];
return ctype_alpha($p7) ? $new : $old;
}
/**
* ISO 3779 / NA-spec checksum. Position 9 = mod-11 check digit (X = 10).
* Optional many European/Asian manufacturers don't follow the spec.
*/
private function validateChecksum(string $vin): bool
{
$weights = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2];
$values = [
'A' => 1, 'B' => 2, 'C' => 3, 'D' => 4, 'E' => 5, 'F' => 6, 'G' => 7, 'H' => 8,
'J' => 1, 'K' => 2, 'L' => 3, 'M' => 4, 'N' => 5, 'P' => 7, 'R' => 9,
'S' => 2, 'T' => 3, 'U' => 4, 'V' => 5, 'W' => 6, 'X' => 7, 'Y' => 8, 'Z' => 9,
];
$sum = 0;
for ($i = 0; $i < 17; $i++) {
$c = $vin[$i];
$v = ctype_digit($c) ? (int) $c : ($values[$c] ?? null);
if ($v === null) return false;
$sum += $v * $weights[$i];
}
$check = $sum % 11;
$expected = $check === 10 ? 'X' : (string) $check;
return $vin[8] === $expected;
}
}
+177 -50
View File
@@ -8,85 +8,220 @@ use App\Mail\ServiceReminderMail;
use App\Mail\WorkOrderReadyMail;
use App\Models\Central\Company;
use App\Models\Tenant\Appointment;
use App\Models\Tenant\Client;
use App\Models\Tenant\Payment;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Services\Notifications\TelegramService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
* Centralizes outbound email notifications, with per-tenant feature toggles
* stored in companies.settings.notify (e.g., 'wo_ready' => true).
* Multi-channel outbound notifications with per-tenant + per-client opt-in/out.
*
* Usage:
* app(NotificationDispatcher::class)->workOrderReady($workOrder);
* Channels tried in order:
* 1. Telegram if client has telegram_chat_id AND tenant bot token AND
* tenant has notify.{type}.telegram = true (default true when bot configured)
* 2. Email if client has email AND tenant has notify.{type}.email = true
* (default true preserves legacy behaviour)
*
* Per-client overrides via clients.notify_prefs JSON (array of channel keys).
*
* Public methods return TRUE when at least one channel succeeded.
*/
class NotificationDispatcher
{
public function __construct(private TelegramService $telegram)
{
}
public function workOrderReady(WorkOrder $wo): bool
{
$company = $this->companyFor($wo);
if (! $this->isEnabled($company, 'wo_ready')) return false;
$client = $wo->client;
if (! $client) return false;
$email = $wo->client?->email;
if (! $email) return false;
try {
Mail::to($email)->send(new WorkOrderReadyMail($wo, $company));
return true;
} catch (\Throwable $e) {
Log::warning('workOrderReady mail failed', ['wo' => $wo->id, 'err' => $e->getMessage()]);
return false;
}
return $this->dispatch($company, $client, 'wo_ready', [
'telegram' => fn () => $this->tgWorkOrderReady($wo, $company, $client),
'email' => fn () => $this->emailSafe(
fn () => Mail::to($client->email)->send(new WorkOrderReadyMail($wo, $company)),
'workOrderReady', ['wo' => $wo->id]
),
]);
}
public function paymentReceived(Payment $payment): bool
{
$company = $this->companyFor($payment);
if (! $this->isEnabled($company, 'payment')) return false;
$client = $payment->client;
if (! $client) return false;
$email = $payment->client?->email;
if (! $email) return false;
try {
Mail::to($email)->send(new PaymentReceivedMail($payment, $company));
return true;
} catch (\Throwable $e) {
Log::warning('paymentReceived mail failed', ['payment' => $payment->id, 'err' => $e->getMessage()]);
return false;
}
return $this->dispatch($company, $client, 'payment', [
'telegram' => fn () => $this->tgPaymentReceived($payment, $company, $client),
'email' => fn () => $this->emailSafe(
fn () => Mail::to($client->email)->send(new PaymentReceivedMail($payment, $company)),
'paymentReceived', ['payment' => $payment->id]
),
]);
}
public function appointmentConfirmed(Appointment $a): bool
{
$company = $this->companyFor($a);
if (! $this->isEnabled($company, 'appointment')) return false;
$client = $a->client;
if (! $client) return false;
$email = $a->client?->email;
if (! $email) return false;
try {
Mail::to($email)->send(new AppointmentConfirmedMail($a, $company));
return true;
} catch (\Throwable $e) {
Log::warning('appointmentConfirmed mail failed', ['appt' => $a->id, 'err' => $e->getMessage()]);
return false;
}
return $this->dispatch($company, $client, 'appointment', [
'telegram' => fn () => $this->tgAppointmentConfirmed($a, $company, $client),
'email' => fn () => $this->emailSafe(
fn () => Mail::to($client->email)->send(new AppointmentConfirmedMail($a, $company)),
'appointmentConfirmed', ['appt' => $a->id]
),
]);
}
public function serviceReminder(Vehicle $v, string $type = 'general', ?string $note = null): bool
{
$company = $this->companyFor($v);
if (! $this->isEnabled($company, 'reminder')) return false;
$client = $v->client;
if (! $client) return false;
$email = $v->client?->email;
if (! $email) return false;
return $this->dispatch($company, $client, 'reminder', [
'telegram' => fn () => $this->tgServiceReminder($v, $type, $note, $company, $client),
'email' => fn () => $this->emailSafe(
fn () => Mail::to($client->email)->send(new ServiceReminderMail($v, $type, $note, $company)),
'serviceReminder', ['vehicle' => $v->id]
),
]);
}
// ─── Channel dispatch ─────────────────────────────────────────
/**
* @param array<string, callable(): bool> $senders channel-key sender callback
* @return bool Returns the channel name that delivered, or null on full miss.
*/
protected function dispatch(Company $company, Client $client, string $key, array $senders): bool
{
$any = false;
foreach ($this->channelsFor($company, $client, $key) as $channel) {
if (! isset($senders[$channel])) continue;
try {
if (($senders[$channel])() === true) {
$any = true;
// Try only one channel — first that succeeds is enough.
break;
}
} catch (\Throwable $e) {
Log::warning("notify.{$key} {$channel} threw", ['err' => $e->getMessage()]);
}
}
return $any;
}
/**
* Resolve which channels to try and in what order, applying per-client
* preference if set, otherwise the tenant default.
*/
protected function channelsFor(Company $company, Client $client, string $key): array
{
$prefs = (array) ($client->notify_prefs ?? []);
// Per-client list takes precedence.
if (! empty($prefs)) {
$candidates = array_values(array_intersect(['telegram', 'email'], $prefs));
} else {
$candidates = ['telegram', 'email'];
}
return array_values(array_filter(
$candidates,
fn (string $ch) => $this->channelEnabled($company, $client, $key, $ch)
));
}
protected function channelEnabled(Company $company, Client $client, string $key, string $channel): bool
{
$notify = (array) data_get($company->settings, 'notify', []);
// Tenant-level: notify.{type}.{channel} — accept legacy boolean notify.{type}=true as email-only flag.
$tenantValue = $notify[$key] ?? null;
if (is_bool($tenantValue)) {
// legacy: email default true, telegram default true if bot configured
if ($channel === 'email') $tenantValueEnabled = $tenantValue;
elseif ($channel === 'telegram') $tenantValueEnabled = $tenantValue && (bool) $this->telegram->tokenFor($company);
else $tenantValueEnabled = false;
} elseif (is_array($tenantValue)) {
$tenantValueEnabled = (bool) ($tenantValue[$channel] ?? true);
} else {
$tenantValueEnabled = true;
}
if (! $tenantValueEnabled) return false;
// Per-client field presence
if ($channel === 'telegram') {
return ! empty($client->telegram_chat_id) && (bool) $this->telegram->tokenFor($company);
}
if ($channel === 'email') {
return ! empty($client->email);
}
return false;
}
// ─── Telegram message builders ────────────────────────────────
protected function tgWorkOrderReady(WorkOrder $wo, Company $company, Client $client): bool
{
$brand = htmlspecialchars($company->display_name ?? $company->name);
$no = htmlspecialchars((string) $wo->number);
$plate = htmlspecialchars((string) ($wo->vehicle->plate ?? ''));
$text = "✅ <b>Mașina e gata de ridicat</b>\n"
. "Fișa #{$no} · {$plate}\n"
. "Total: <b>" . number_format((float) $wo->total, 2) . " " . ($company->settings['currency'] ?? 'MDL') . "</b>\n\n"
. "🔗 Detalii: " . $wo->trackingUrl() . "\n\n"
. "{$brand}";
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
}
protected function tgPaymentReceived(Payment $payment, Company $company, Client $client): bool
{
$amt = number_format((float) $payment->amount, 2);
$cur = $company->settings['currency'] ?? 'MDL';
$text = "💳 <b>Plată primită</b>\n"
. "Suma: <b>{$amt} {$cur}</b>\n"
. "Mulțumim!";
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
}
protected function tgAppointmentConfirmed(Appointment $a, Company $company, Client $client): bool
{
$when = $a->starts_at?->isoFormat('D MMM YYYY, HH:mm') ?? '?';
$text = "📅 <b>Programare confirmată</b>\n"
. "Data: <b>{$when}</b>\n"
. ($a->vehicle?->plate ? "Auto: " . htmlspecialchars($a->vehicle->plate) . "\n" : '')
. "Te așteptăm.";
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
}
protected function tgServiceReminder(Vehicle $v, string $type, ?string $note, Company $company, Client $client): bool
{
$brand = htmlspecialchars($company->display_name ?? $company->name);
$plate = htmlspecialchars((string) ($v->plate ?? ''));
$text = "🔧 <b>Reminder service</b>\n"
. "{$brand}: " . htmlspecialchars($v->make . ' ' . $v->model) . " · {$plate}\n"
. ($note ?: 'A trecut ceva timp de la ultima vizită — recomandăm o verificare.');
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
}
// ─── Helpers ──────────────────────────────────────────────────
protected function emailSafe(callable $fn, string $tag, array $ctx = []): bool
{
try {
Mail::to($email)->send(new ServiceReminderMail($v, $type, $note, $company));
$fn();
return true;
} catch (\Throwable $e) {
Log::warning('serviceReminder mail failed', ['vehicle' => $v->id, 'err' => $e->getMessage()]);
Log::warning("{$tag} mail failed", $ctx + ['err' => $e->getMessage()]);
return false;
}
}
@@ -95,12 +230,4 @@ class NotificationDispatcher
{
return Company::withoutGlobalScopes()->findOrFail($model->company_id);
}
protected function isEnabled(Company $company, string $key): bool
{
$settings = (array) ($company->settings ?? []);
$notify = (array) ($settings['notify'] ?? []);
// default: enabled (toate notificările active by default)
return ($notify[$key] ?? true) === true;
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Services\Notifications;
use App\Models\Central\Company;
use App\Models\Tenant\Client;
use App\Models\Tenant\OnlineOrder;
use App\Models\Tenant\User;
/**
* Notifies staff of a new online order (Web Push) and confirms to the customer
* via Telegram if they have a linked chat. Best-effort never throws.
*/
class ShopOrderNotifier
{
public function __construct(
private WebPushService $push,
private TelegramService $telegram,
) {
}
public function placed(OnlineOrder $order): void
{
$company = Company::withoutGlobalScopes()->find($order->company_id);
if (! $company) return;
// ── Staff: Web Push to active users of this tenant ──
$title = 'Comandă nouă #' . $order->number;
$body = $order->customer_name . ' · ' . number_format((float) $order->total, 2) . ' '
. ($company->settings['currency'] ?? 'MDL');
$url = '/app/resources/online-orders/' . $order->id . '/edit';
$userIds = User::where('status', 'active')->pluck('id');
foreach ($userIds as $uid) {
$this->push->sendToUser((int) $uid, $title, $body, $url, 'shop-order-' . $order->id);
}
// ── Customer: Telegram if their phone is linked ──
$needle = Client::normalizePhone($order->customer_phone);
if ($needle) {
$client = Client::whereNotNull('telegram_chat_id')
->whereRaw(
"REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') LIKE ?",
['%' . substr($needle, -9) . '%']
)
->first();
if ($client && $client->telegram_chat_id) {
$brand = htmlspecialchars($company->display_name ?? $company->name);
$text = "🛒 <b>Comanda #{$order->number} primită</b>\n"
. "Total: <b>" . number_format((float) $order->total, 2) . " "
. ($company->settings['currency'] ?? 'MDL') . "</b>\n\n"
. "Urmărește statusul: " . $order->trackingUrl() . "\n\n{$brand}";
$this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
}
}
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Services\Notifications;
use App\Models\Central\Company;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Thin client over Bot API. Tenant-aware: bot token comes from
* companies.settings.telegram.bot_token. Webhook secret used to verify
* incoming updates (Telegram sends it back as X-Telegram-Bot-Api-Secret-Token).
*/
class TelegramService
{
private const API = 'https://api.telegram.org/bot';
public function tokenFor(Company $company): ?string
{
return data_get($company->settings, 'telegram.bot_token');
}
public function webhookSecretFor(Company $company): ?string
{
return data_get($company->settings, 'telegram.webhook_secret');
}
public function webhookUrlFor(Company $company): string
{
$central = config('app.central_domain') ?: 'service.mir.md';
// We expose the webhook on the central domain so Telegram does not
// need to know about subdomain wildcards. Slug routes to tenant.
return "https://{$central}/telegram/webhook/{$company->slug}";
}
public function sendMessage(Company $company, string $chatId, string $text, array $options = []): bool
{
$token = $this->tokenFor($company);
if (! $token || ! $chatId) return false;
try {
$resp = Http::asJson()
->timeout(10)
->post(self::API . $token . '/sendMessage', array_merge([
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
], $options));
if (! $resp->ok()) {
Log::warning('telegram.send failed', [
'tenant' => $company->slug,
'status' => $resp->status(),
'body' => $resp->body(),
]);
return false;
}
return true;
} catch (\Throwable $e) {
Log::warning('telegram.send exception', ['err' => $e->getMessage()]);
return false;
}
}
public function setWebhook(Company $company): array
{
$token = $this->tokenFor($company);
if (! $token) return ['ok' => false, 'error' => 'Lipsește bot token în setări.'];
$secret = $this->webhookSecretFor($company);
if (! $secret) {
$secret = \Illuminate\Support\Str::random(32);
$company->update([
'settings' => array_replace_recursive((array) $company->settings, [
'telegram' => ['webhook_secret' => $secret],
]),
]);
}
try {
$resp = Http::asJson()->post(self::API . $token . '/setWebhook', [
'url' => $this->webhookUrlFor($company),
'secret_token' => $secret,
'allowed_updates' => ['message', 'callback_query'],
]);
return ['ok' => $resp->ok(), 'response' => $resp->json()];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
}
public function getMe(Company $company): array
{
$token = $this->tokenFor($company);
if (! $token) return ['ok' => false, 'error' => 'no_token'];
try {
$resp = Http::timeout(10)->get(self::API . $token . '/getMe');
return ['ok' => $resp->ok(), 'response' => $resp->json()];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
}
}

Some files were not shown because too many files have changed in this diff Show More