diff --git a/app/Filament/Tenant/Resources/PartResource.php b/app/Filament/Tenant/Resources/PartResource.php new file mode 100644 index 0000000..e08084e --- /dev/null +++ b/app/Filament/Tenant/Resources/PartResource.php @@ -0,0 +1,132 @@ +where('is_active', true) + ->whereColumn('qty', '<=', 'min_qty') + ->count(); + return $low > 0 ? (string) $low : null; + } + + public static function getNavigationBadgeColor(): ?string + { + return 'warning'; + } + + public static function form(Schema $schema): Schema + { + return $schema->components([ + Schemas\Components\Section::make('Identificare') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('name')->label('Denumire')->required()->columnSpan(3)->maxLength(200), + Forms\Components\TextInput::make('article')->label('Cod articol')->maxLength(64), + Forms\Components\TextInput::make('brand')->maxLength(64), + Forms\Components\Select::make('category') + ->label('Categorie') + ->options(array_combine(Part::CATEGORIES, Part::CATEGORIES)) + ->searchable(), + Forms\Components\TextInput::make('barcode')->label('Cod bare')->maxLength(64), + Forms\Components\TextInput::make('location')->label('Locație rack/bin')->maxLength(64), + ]), + Schemas\Components\Section::make('Stoc') + ->columns(4) + ->schema([ + Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(0)->required(), + 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), + ]), + Schemas\Components\Section::make('Prețuri') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('buy_price')->label('Preț achiziție')->numeric()->default(0), + Forms\Components\TextInput::make('sell_price')->label('Preț vânzare')->numeric()->default(0), + ]), + Schemas\Components\Section::make('Furnizor preferat') + ->columns(1) + ->schema([ + Forms\Components\Select::make('preferred_supplier_id') + ->label('Furnizor') + ->options(fn () => Supplier::pluck('name', 'id')) + ->searchable(), + ]), + 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()->wrap(), + Tables\Columns\TextColumn::make('article')->label('Cod')->searchable()->copyable()->placeholder('—'), + Tables\Columns\TextColumn::make('brand')->placeholder('—'), + Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'), + Tables\Columns\TextColumn::make('qty') + ->label('Stoc') + ->numeric(decimalPlaces: 2) + ->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('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\TextColumn::make('preferredSupplier.name')->label('Furnizor')->placeholder('—')->toggleable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('category') + ->options(array_combine(Part::CATEGORIES, Part::CATEGORIES)), + Tables\Filters\Filter::make('low_stock') + ->label('Stoc minim') + ->query(fn ($q) => $q->whereColumn('qty', '<=', 'min_qty')), + Tables\Filters\Filter::make('out_of_stock') + ->label('Lipsă') + ->query(fn ($q) => $q->where('qty', '<=', 0)), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('name'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListParts::route('/'), + 'create' => Pages\CreatePart::route('/create'), + 'edit' => Pages\EditPart::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/PartResource/Pages/CreatePart.php b/app/Filament/Tenant/Resources/PartResource/Pages/CreatePart.php new file mode 100644 index 0000000..89395da --- /dev/null +++ b/app/Filament/Tenant/Resources/PartResource/Pages/CreatePart.php @@ -0,0 +1,14 @@ +components([ + Schemas\Components\Section::make('Antet') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false)->placeholder('Generat automat'), + Forms\Components\Select::make('supplier_id') + ->label('Furnizor') + ->options(fn () => Supplier::where('is_active', true)->pluck('name', 'id')) + ->searchable() + ->required(), + Forms\Components\Select::make('status') + ->options(Purchase::STATUSES) + ->default('draft') + ->required(), + Forms\Components\DatePicker::make('order_date')->label('Data comandă')->default(today())->required(), + Forms\Components\DatePicker::make('expected_at')->label('Așteptată'), + Forms\Components\DatePicker::make('received_at')->label('Recepționată'), + Forms\Components\DatePicker::make('paid_at')->label('Plătită')->columnSpanFull(), + ]), + 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('supplier.name')->label('Furnizor')->searchable(), + Tables\Columns\TextColumn::make('order_date')->label('Comandată')->date('d.m.Y'), + Tables\Columns\TextColumn::make('expected_at')->label('Așteptată')->date('d.m.Y')->placeholder('—'), + Tables\Columns\TextColumn::make('received_at')->label('Recepționată')->date('d.m.Y')->placeholder('—'), + Tables\Columns\TextColumn::make('status') + ->formatStateUsing(fn ($s) => Purchase::STATUSES[$s] ?? $s) + ->badge() + ->colors([ + 'gray' => ['draft'], + 'warning' => ['ordered'], + 'success' => ['received'], + 'danger' => ['cancelled'], + ]), + Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status')->options(Purchase::STATUSES), + Tables\Filters\SelectFilter::make('supplier_id') + ->label('Furnizor') + ->options(fn () => Supplier::pluck('name', 'id')), + ]) + ->actions([ + Actions\Action::make('receive') + ->label('Recepționează') + ->icon('heroicon-m-check-circle') + ->color('success') + ->visible(fn (Purchase $r) => $r->status !== 'received' && $r->status !== 'cancelled') + ->requiresConfirmation() + ->modalDescription('Se va incrementa stocul pieselor legate.') + ->action(function (Purchase $r) { + $r->markReceived(); + Notification::make() + ->title('Recepționat — stoc actualizat') + ->success() + ->send(); + }), + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('order_date', 'desc'); + } + + public static function getRelations(): array + { + return [ + RelationManagers\ItemsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPurchases::route('/'), + 'create' => Pages\CreatePurchase::route('/create'), + 'edit' => Pages\EditPurchase::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/PurchaseResource/Pages/CreatePurchase.php b/app/Filament/Tenant/Resources/PurchaseResource/Pages/CreatePurchase.php new file mode 100644 index 0000000..9c2bc5e --- /dev/null +++ b/app/Filament/Tenant/Resources/PurchaseResource/Pages/CreatePurchase.php @@ -0,0 +1,20 @@ +currentId(); + $data['number'] = Purchase::generateNumber($companyId); + return $data; + } +} diff --git a/app/Filament/Tenant/Resources/PurchaseResource/Pages/EditPurchase.php b/app/Filament/Tenant/Resources/PurchaseResource/Pages/EditPurchase.php new file mode 100644 index 0000000..d51c93e --- /dev/null +++ b/app/Filament/Tenant/Resources/PurchaseResource/Pages/EditPurchase.php @@ -0,0 +1,17 @@ +components([ + Forms\Components\Select::make('part_id') + ->label('Piesă din catalog') + ->options(fn () => Part::where('is_active', true) + ->get() + ->mapWithKeys(fn ($p) => [$p->id => "{$p->name} " . ($p->article ? "[{$p->article}]" : '')]) + ->toArray()) + ->searchable() + ->live() + ->afterStateUpdated(function ($state, Set $set) { + if ($state && $part = Part::find($state)) { + $set('name', $part->name); + $set('article', $part->article); + $set('unit', $part->unit); + $set('buy_price', $part->buy_price); + } + }) + ->columnSpanFull(), + Forms\Components\TextInput::make('name')->label('Denumire')->required()->columnSpanFull(), + Forms\Components\TextInput::make('article')->label('Cod articol'), + Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(1)->required(), + Forms\Components\TextInput::make('unit')->label('UM')->default('buc'), + Forms\Components\TextInput::make('buy_price')->label('Preț achiziție')->numeric()->required(), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('name') + ->columns([ + Tables\Columns\TextColumn::make('name')->wrap(), + Tables\Columns\TextColumn::make('article')->placeholder('—'), + Tables\Columns\TextColumn::make('qty')->alignRight(), + 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\EditAction::make(), + Actions\DeleteAction::make(), + ]); + } +} diff --git a/app/Filament/Tenant/Resources/SupplierResource.php b/app/Filament/Tenant/Resources/SupplierResource.php new file mode 100644 index 0000000..d1f957e --- /dev/null +++ b/app/Filament/Tenant/Resources/SupplierResource.php @@ -0,0 +1,94 @@ +components([ + Schemas\Components\Section::make('Identificare') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('name')->label('Nume')->required()->maxLength(160), + Forms\Components\TextInput::make('contact_name')->label('Persoană contact')->maxLength(120), + Forms\Components\TextInput::make('phone')->tel()->maxLength(40), + Forms\Components\TextInput::make('email')->email()->maxLength(120), + Forms\Components\TextInput::make('website')->url()->maxLength(160), + ]), + Schemas\Components\Section::make('Comercial') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('pay_terms')->label('Termeni plată')->placeholder('Net 30 / Avans')->maxLength(60), + Forms\Components\TextInput::make('delivery_days')->label('Zile livrare')->numeric()->default(0), + Forms\Components\Select::make('rating') + ->label('Rating') + ->options([1 => '★', 2 => '★★', 3 => '★★★', 4 => '★★★★', 5 => '★★★★★']) + ->default(3) + ->required(), + Forms\Components\TextInput::make('discount_pct')->label('Discount %')->numeric()->default(0), + Forms\Components\TagsInput::make('categories')->label('Categorii')->placeholder('Frâne, Ulei, ...')->columnSpan(2), + 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('contact_name')->label('Contact')->placeholder('—'), + Tables\Columns\TextColumn::make('phone')->copyable()->placeholder('—'), + 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('discount_pct')->label('Discount') + ->formatStateUsing(fn ($s) => $s . '%')->alignRight(), + Tables\Columns\IconColumn::make('is_active')->boolean(), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('is_active')->label('Activi'), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('name'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListSuppliers::route('/'), + 'create' => Pages\CreateSupplier::route('/create'), + 'edit' => Pages\EditSupplier::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/SupplierResource/Pages/CreateSupplier.php b/app/Filament/Tenant/Resources/SupplierResource/Pages/CreateSupplier.php new file mode 100644 index 0000000..97ade82 --- /dev/null +++ b/app/Filament/Tenant/Resources/SupplierResource/Pages/CreateSupplier.php @@ -0,0 +1,14 @@ +components([ + Forms\Components\Select::make('part_id') + ->label('Din catalog (lasă gol pentru text liber)') + ->options(fn () => Part::where('is_active', true) + ->get() + ->mapWithKeys(fn ($p) => [$p->id => "{$p->name} " . ($p->article ? "[{$p->article}] " : '') . "(stoc: {$p->qty})"]) + ->toArray()) + ->searchable() + ->live() + ->afterStateUpdated(function ($state, Set $set) { + if ($state && $part = Part::find($state)) { + $set('name', $part->name); + $set('article', $part->article); + $set('brand', $part->brand); + $set('unit', $part->unit); + $set('buy_price', $part->buy_price); + $set('sell_price', $part->sell_price); + } + }) + ->columnSpanFull(), Forms\Components\TextInput::make('name')->label('Denumire')->required()->columnSpanFull(), Forms\Components\TextInput::make('article')->label('Cod articol')->maxLength(64), - Forms\Components\TextInput::make('brand')->label('Brand')->maxLength(64), + Forms\Components\TextInput::make('brand')->maxLength(64), Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(1)->required(), Forms\Components\TextInput::make('unit')->label('UM')->maxLength(16)->default('buc'), Forms\Components\TextInput::make('buy_price')->label('Preț achiziție')->numeric()->default(0), @@ -30,7 +51,8 @@ class PartsRelationManager extends RelationManager Forms\Components\Select::make('status') ->options(WorkOrderPart::STATUSES) ->default('needed') - ->required(), + ->required() + ->helperText('La trecere pe „Montată" se scade automat din stoc.'), Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2), ]); } diff --git a/app/Filament/Tenant/Widgets/LowStockTable.php b/app/Filament/Tenant/Widgets/LowStockTable.php new file mode 100644 index 0000000..c73c761 --- /dev/null +++ b/app/Filament/Tenant/Widgets/LowStockTable.php @@ -0,0 +1,39 @@ +query( + Part::query() + ->where('is_active', true) + ->whereColumn('qty', '<=', 'min_qty') + ->orderBy('qty') + ) + ->columns([ + Tables\Columns\TextColumn::make('name')->label('Piesă')->wrap()->searchable(), + Tables\Columns\TextColumn::make('article')->label('Cod')->placeholder('—'), + Tables\Columns\TextColumn::make('qty')->label('Stoc')->alignRight() + ->color(fn ($state) => $state <= 0 ? 'danger' : 'warning') + ->weight('bold'), + Tables\Columns\TextColumn::make('min_qty')->label('Min.')->alignRight(), + Tables\Columns\TextColumn::make('preferredSupplier.name')->label('Furnizor')->placeholder('—'), + ]) + ->paginated(false) + ->defaultSort('qty'); + } +} diff --git a/app/Models/Tenant/Part.php b/app/Models/Tenant/Part.php new file mode 100644 index 0000000..76ade95 --- /dev/null +++ b/app/Models/Tenant/Part.php @@ -0,0 +1,55 @@ + 'decimal:2', + 'min_qty' => 'decimal:2', + 'buy_price' => 'decimal:2', + 'sell_price' => 'decimal:2', + 'is_active' => 'boolean', + ]; + + public function preferredSupplier(): BelongsTo + { + return $this->belongsTo(Supplier::class, 'preferred_supplier_id'); + } + + public function isLow(): bool + { + return (float) $this->qty <= (float) $this->min_qty; + } + + public function isOut(): bool + { + return (float) $this->qty <= 0; + } + + public function adjustStock(float $delta, ?string $reason = null): void + { + $this->qty = max(0, (float) $this->qty + $delta); + $this->save(); + } +} diff --git a/app/Models/Tenant/Purchase.php b/app/Models/Tenant/Purchase.php new file mode 100644 index 0000000..cc4ef96 --- /dev/null +++ b/app/Models/Tenant/Purchase.php @@ -0,0 +1,83 @@ + 'Ciornă', + 'ordered' => 'Comandată', + 'received' => 'Recepționată', + 'cancelled' => 'Anulată', + ]; + + protected $fillable = [ + 'company_id', 'number', 'supplier_id', + 'order_date', 'expected_at', 'received_at', 'paid_at', + 'status', 'total', 'notes', + ]; + + protected $casts = [ + 'order_date' => 'date', + 'expected_at' => 'date', + 'received_at' => 'date', + 'paid_at' => 'date', + 'total' => 'decimal:2', + ]; + + public function supplier(): BelongsTo + { + return $this->belongsTo(Supplier::class); + } + + public function items(): HasMany + { + return $this->hasMany(PurchaseItem::class); + } + + public function recalcTotal(): void + { + $this->total = (float) $this->items()->sum('total'); + $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('P-%s-%04d', $year, $count + 1); + } + + /** + * Mark all items received and increment Part.qty for linked items. + */ + public function markReceived(): 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); + } + $item->received = true; + $item->save(); + } + } + $this->status = 'received'; + $this->received_at = now(); + $this->save(); + }); + } +} diff --git a/app/Models/Tenant/PurchaseItem.php b/app/Models/Tenant/PurchaseItem.php new file mode 100644 index 0000000..2d06c89 --- /dev/null +++ b/app/Models/Tenant/PurchaseItem.php @@ -0,0 +1,43 @@ + 'decimal:2', + 'buy_price' => 'decimal:2', + 'total' => 'decimal:2', + 'received' => 'boolean', + ]; + + public function purchase(): BelongsTo + { + return $this->belongsTo(Purchase::class); + } + + 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->buy_price, 2); + }); + static::saved(fn (self $row) => $row->purchase?->recalcTotal()); + static::deleted(fn (self $row) => $row->purchase?->recalcTotal()); + } +} diff --git a/app/Models/Tenant/Supplier.php b/app/Models/Tenant/Supplier.php new file mode 100644 index 0000000..5182768 --- /dev/null +++ b/app/Models/Tenant/Supplier.php @@ -0,0 +1,35 @@ + 'array', + 'is_active' => 'boolean', + 'discount_pct' => 'decimal:2', + ]; + + public function purchases(): HasMany + { + return $this->hasMany(Purchase::class); + } + + public function parts(): HasMany + { + return $this->hasMany(Part::class, 'preferred_supplier_id'); + } +} diff --git a/app/Models/Tenant/WorkOrderPart.php b/app/Models/Tenant/WorkOrderPart.php index ebd496c..a3a756b 100644 --- a/app/Models/Tenant/WorkOrderPart.php +++ b/app/Models/Tenant/WorkOrderPart.php @@ -20,7 +20,7 @@ class WorkOrderPart extends Model ]; protected $fillable = [ - 'company_id', 'work_order_id', + 'company_id', 'work_order_id', 'part_id', 'name', 'article', 'brand', 'qty', 'unit', 'buy_price', 'sell_price', 'discount_pct', 'total', 'status', 'notes', @@ -39,6 +39,11 @@ class WorkOrderPart extends Model return $this->belongsTo(WorkOrder::class); } + public function part(): BelongsTo + { + return $this->belongsTo(Part::class); + } + protected static function booted(): void { static::saving(function (self $row) { @@ -46,6 +51,22 @@ class WorkOrderPart extends Model $disc = (float) $row->discount_pct; $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); + } + // If reverting from installed → restore stock + if ($wasInstalled && ! $isInstalled && $row->part_id) { + $part = Part::find($row->part_id); + $part?->adjustStock((float) $row->qty); + } + }); + static::saved(fn (self $row) => $row->workOrder?->recalcTotal()); static::deleted(fn (self $row) => $row->workOrder?->recalcTotal()); } diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php index 2b0eb54..8068e2e 100644 --- a/app/Providers/Filament/TenantPanelProvider.php +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -44,6 +44,7 @@ class TenantPanelProvider extends PanelProvider ->discoverWidgets(in: app_path('Filament/Tenant/Widgets'), for: 'App\\Filament\\Tenant\\Widgets') ->widgets([ \App\Filament\Tenant\Widgets\StatsOverview::class, + \App\Filament\Tenant\Widgets\LowStockTable::class, ]) ->middleware([ // CRITICAL: tenant resolution must run BEFORE Filament's diff --git a/database/migrations/2026_05_06_190001_create_suppliers_table.php b/database/migrations/2026_05_06_190001_create_suppliers_table.php new file mode 100644 index 0000000..45e16e1 --- /dev/null +++ b/database/migrations/2026_05_06_190001_create_suppliers_table.php @@ -0,0 +1,41 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + + $t->string('name'); + $t->string('contact_name')->nullable(); + $t->string('phone')->nullable(); + $t->string('email')->nullable(); + $t->string('website')->nullable(); + + $t->string('pay_terms')->nullable(); // "Net 30", "Avans", "La livrare" + $t->unsignedSmallInteger('delivery_days')->default(0); + $t->unsignedTinyInteger('rating')->default(3); // 1-5 + $t->decimal('discount_pct', 5, 2)->default(0); + $t->json('categories')->nullable(); // ["Frâne","Ulei"...] + + $t->boolean('is_active')->default(true); + $t->text('notes')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('suppliers'); + } +}; diff --git a/database/migrations/2026_05_06_190002_create_parts_table.php b/database/migrations/2026_05_06_190002_create_parts_table.php new file mode 100644 index 0000000..b4dfe7c --- /dev/null +++ b/database/migrations/2026_05_06_190002_create_parts_table.php @@ -0,0 +1,47 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + + $t->string('name'); + $t->string('article', 64)->nullable(); + $t->string('brand', 64)->nullable(); + $t->string('category')->nullable(); // Ulei / Filtre / Frâne / Lichide / ... + + $t->decimal('qty', 10, 2)->default(0); + $t->string('unit', 16)->default('buc'); + $t->decimal('min_qty', 10, 2)->default(0); + + $t->decimal('buy_price', 10, 2)->default(0); + $t->decimal('sell_price', 10, 2)->default(0); + + $t->string('location', 64)->nullable(); // ex "A1-M1" rack/bin + $t->string('barcode', 64)->nullable(); + $t->foreignId('preferred_supplier_id')->nullable()->constrained('suppliers')->nullOnDelete(); + + $t->boolean('is_active')->default(true); + $t->text('notes')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'category']); + $t->index(['company_id', 'is_active']); + $t->unique(['company_id', 'article']); + }); + } + + public function down(): void + { + Schema::dropIfExists('parts'); + } +}; diff --git a/database/migrations/2026_05_06_190003_create_purchases_tables.php b/database/migrations/2026_05_06_190003_create_purchases_tables.php new file mode 100644 index 0000000..a2400aa --- /dev/null +++ b/database/migrations/2026_05_06_190003_create_purchases_tables.php @@ -0,0 +1,57 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->string('number', 32); // P-26-0001 + $t->foreignId('supplier_id')->nullable()->constrained()->nullOnDelete(); + $t->date('order_date')->default(now()); + $t->date('expected_at')->nullable(); + $t->date('received_at')->nullable(); + $t->date('paid_at')->nullable(); + $t->string('status')->default('draft'); // draft / ordered / received / cancelled + $t->decimal('total', 12, 2)->default(0); + $t->text('notes')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->unique(['company_id', 'number']); + $t->index(['company_id', 'status']); + $t->index(['company_id', 'order_date']); + }); + + Schema::create('purchase_items', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('purchase_id')->constrained()->cascadeOnDelete(); + $t->foreignId('part_id')->nullable()->constrained()->nullOnDelete(); + + $t->string('name'); + $t->string('article', 64)->nullable(); + $t->decimal('qty', 10, 2)->default(1); + $t->string('unit', 16)->default('buc'); + $t->decimal('buy_price', 10, 2)->default(0); + $t->decimal('total', 12, 2)->default(0); + $t->boolean('received')->default(false); + + $t->timestamps(); + + $t->index(['company_id', 'purchase_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('purchase_items'); + Schema::dropIfExists('purchases'); + } +}; diff --git a/database/migrations/2026_05_06_190004_add_part_id_to_wo_parts.php b/database/migrations/2026_05_06_190004_add_part_id_to_wo_parts.php new file mode 100644 index 0000000..ba320db --- /dev/null +++ b/database/migrations/2026_05_06_190004_add_part_id_to_wo_parts.php @@ -0,0 +1,23 @@ +foreignId('part_id')->nullable()->after('work_order_id')->constrained('parts')->nullOnDelete(); + $t->index(['company_id', 'part_id']); + }); + } + + public function down(): void + { + Schema::table('wo_parts', function (Blueprint $t) { + $t->dropConstrainedForeignId('part_id'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index bbc6258..f25c27e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,6 +13,10 @@ use App\Models\Tenant\Lead; use App\Models\Tenant\Post; use App\Models\Tenant\User; use App\Models\Tenant\Vehicle; +use App\Models\Tenant\Part; +use App\Models\Tenant\Purchase; +use App\Models\Tenant\PurchaseItem; +use App\Models\Tenant\Supplier; use App\Models\Tenant\WorkOrder; use App\Models\Tenant\WorkOrderPart; use App\Models\Tenant\WorkOrderWork; @@ -316,6 +320,101 @@ class DatabaseSeeder extends Seeder ); $wo->refresh()->recalcTotal(); + // ─── Furnizori demo ─────────────────────────────────────── + $sup1 = Supplier::firstOrCreate( + ['company_id' => $psauto->id, 'name' => 'AutoParts Moldova SRL'], + [ + 'contact_name' => 'Ion Popescu', 'phone' => '+373 22 123456', + 'email' => 'sales@autoparts.md', 'website' => 'autoparts.md', + 'pay_terms' => 'Net 30', 'delivery_days' => 1, 'rating' => 5, + 'discount_pct' => 10, 'categories' => ['Frâne', 'Filtre', 'Ulei'], + 'notes' => 'Furnizor principal', + ] + ); + $sup2 = Supplier::firstOrCreate( + ['company_id' => $psauto->id, 'name' => 'Inter Cars Moldova'], + [ + 'contact_name' => 'Maria Lupu', 'phone' => '+373 22 654321', + 'email' => 'md@intercars.eu', 'website' => 'intercars.eu', + 'pay_terms' => 'Net 14', 'delivery_days' => 2, 'rating' => 4, + 'discount_pct' => 8, 'categories' => ['Toate'], + 'notes' => 'Catalog european larg', + ] + ); + + // ─── Piese demo ─────────────────────────────────────────── + Part::firstOrCreate( + ['company_id' => $psauto->id, 'article' => 'SHU5W40'], + [ + 'name' => 'Ulei motor Shell Helix Ultra 5W-40 1L', 'brand' => 'Shell', + 'category' => 'Ulei', 'qty' => 12, 'unit' => 'L', 'min_qty' => 5, + 'buy_price' => 65, 'sell_price' => 85, 'location' => 'A1-M1', + 'preferred_supplier_id' => $sup1->id, + ] + ); + Part::firstOrCreate( + ['company_id' => $psauto->id, 'article' => 'W81180'], + [ + 'name' => 'Filtru ulei Mann W811/80', 'brand' => 'MANN', + 'category' => 'Filtre', 'qty' => 8, 'unit' => 'buc', 'min_qty' => 3, + 'buy_price' => 32, 'sell_price' => 45, 'location' => 'A2-M3', + 'preferred_supplier_id' => $sup1->id, + ] + ); + Part::firstOrCreate( + ['company_id' => $psauto->id, 'article' => 'P85020'], + [ + 'name' => 'Plăcuțe frână Brembo P85020', 'brand' => 'Brembo', + 'category' => 'Frâne', 'qty' => 4, 'unit' => 'set', 'min_qty' => 2, + 'buy_price' => 280, 'sell_price' => 350, 'location' => 'B2-M1', + 'preferred_supplier_id' => $sup1->id, + ] + ); + Part::firstOrCreate( + ['company_id' => $psauto->id, 'article' => 'AF12P'], + [ + 'name' => 'Antigel G12+ 1L', 'brand' => 'Bosch', + 'category' => 'Lichide', 'qty' => 0, 'unit' => 'L', 'min_qty' => 10, + 'buy_price' => 28, 'sell_price' => 35, 'location' => 'A3-M2', + 'preferred_supplier_id' => $sup2->id, + ] + ); + Part::firstOrCreate( + ['company_id' => $psauto->id, 'article' => 'BKR6E'], + [ + 'name' => 'Bujie NGK BKR6E', 'brand' => 'NGK', + 'category' => 'Electrică', 'qty' => 16, 'unit' => 'buc', 'min_qty' => 4, + 'buy_price' => 35, 'sell_price' => 45, 'location' => 'C1-M1', + 'preferred_supplier_id' => $sup2->id, + ] + ); + + // ─── Achiziție demo ─────────────────────────────────────── + $purchase = Purchase::firstOrCreate( + ['company_id' => $psauto->id, 'number' => 'P-26-0001'], + [ + 'supplier_id' => $sup1->id, 'order_date' => today()->subDays(7), + 'expected_at' => today()->subDays(5), 'received_at' => today()->subDays(5), + 'paid_at' => today()->subDays(5), 'status' => 'received', + 'notes' => 'Plan stoc curent', + ] + ); + $partOil = Part::where('company_id', $psauto->id)->where('article', 'SHU5W40')->first(); + if ($partOil) { + PurchaseItem::firstOrCreate( + ['company_id' => $psauto->id, 'purchase_id' => $purchase->id, 'part_id' => $partOil->id], + ['name' => $partOil->name, 'article' => $partOil->article, 'qty' => 12, 'unit' => 'L', 'buy_price' => 65, 'received' => true] + ); + } + $partFilter = Part::where('company_id', $psauto->id)->where('article', 'W81180')->first(); + if ($partFilter) { + PurchaseItem::firstOrCreate( + ['company_id' => $psauto->id, 'purchase_id' => $purchase->id, 'part_id' => $partFilter->id], + ['name' => $partFilter->name, 'article' => $partFilter->article, 'qty' => 8, 'unit' => 'buc', 'buy_price' => 32, 'received' => true] + ); + } + $purchase->refresh()->recalcTotal(); + app(TenantManager::class)->clear(); } }