diff --git a/database/factories/MeasureUnitFactory.php b/database/factories/MeasureUnitFactory.php new file mode 100644 index 0000000..996b2e9 --- /dev/null +++ b/database/factories/MeasureUnitFactory.php @@ -0,0 +1,31 @@ + + */ +class MeasureUnitFactory extends Factory +{ + protected $model = MeasureUnit::class; + + public function definition(): array + { + $units = ['kg', 'g', 'lbs', 'oz', 'liter', 'ml', 'pieces', 'pcs', 'm', 'cm', 'mm', 'ft', 'in']; + + return [ + 'name' => $this->faker->randomElement($units), + 'is_default' => false, + ]; + } + + public function default(): static + { + return $this->state(fn (array $attributes) => [ + 'is_default' => true, + ]); + } +} diff --git a/database/factories/ProductDataFactory.php b/database/factories/ProductDataFactory.php index 5961ac0..4590912 100644 --- a/database/factories/ProductDataFactory.php +++ b/database/factories/ProductDataFactory.php @@ -21,6 +21,9 @@ public function definition(): array 'is_active' => $this->faker->boolean(90), 'available_from_date' => $this->faker->optional()->dateTimeBetween('now', '+3 months'), 'has_free_delivery' => $this->faker->boolean(20), + 'stock' => $this->faker->optional(0.7)->randomFloat(5, 0, 10000), + 'min_stock' => $this->faker->optional(0.5)->randomFloat(5, 0, 100), + 'date_stocked' => $this->faker->optional(0.6)->date('Y-m-d', '-1 year'), ]; if (config('eclipse-catalogue.tenancy.foreign_key')) { @@ -46,4 +49,13 @@ public function inactive(): static 'is_active' => false, ]); } + + public function withStock(?float $stock = null, ?float $minStock = null): static + { + return $this->state(fn (array $attributes) => [ + 'stock' => $stock ?? $this->faker->randomFloat(5, 1, 1000), + 'min_stock' => $minStock ?? $this->faker->randomFloat(5, 0, 50), + 'date_stocked' => $this->faker->dateTimeBetween('-6 months', 'now'), + ]); + } } diff --git a/database/migrations/2025_09_28_110919_add_measure_unit_id_to_catalogue_products_table.php b/database/migrations/2025_09_28_110919_add_measure_unit_id_to_catalogue_products_table.php new file mode 100644 index 0000000..5fb56f4 --- /dev/null +++ b/database/migrations/2025_09_28_110919_add_measure_unit_id_to_catalogue_products_table.php @@ -0,0 +1,28 @@ +foreignId('measure_unit_id') + ->nullable() + ->after('product_type_id') + ->constrained('pim_measure_units') + ->nullOnDelete() + ->cascadeOnUpdate(); + }); + } + + public function down(): void + { + Schema::table('pim_products', function (Blueprint $table) { + $table->dropForeign(['measure_unit_id']); + $table->dropColumn('measure_unit_id'); + }); + } +}; diff --git a/database/migrations/2025_09_28_110920_add_stock_fields_to_catalogue_product_data_table.php b/database/migrations/2025_09_28_110920_add_stock_fields_to_catalogue_product_data_table.php new file mode 100644 index 0000000..1db780e --- /dev/null +++ b/database/migrations/2025_09_28_110920_add_stock_fields_to_catalogue_product_data_table.php @@ -0,0 +1,24 @@ +decimal('stock', 20, 5)->nullable()->after('has_free_delivery'); + $table->decimal('min_stock', 20, 5)->nullable()->after('stock'); + $table->date('date_stocked')->nullable()->after('min_stock'); + }); + } + + public function down(): void + { + Schema::table('pim_product_data', function (Blueprint $table) { + $table->dropColumn(['stock', 'min_stock', 'date_stocked']); + }); + } +}; diff --git a/resources/lang/en/product.php b/resources/lang/en/product.php index 9b47836..58b0cf4 100644 --- a/resources/lang/en/product.php +++ b/resources/lang/en/product.php @@ -6,6 +6,10 @@ 'fields' => [ 'product_type' => 'Product Type', + 'measure_unit' => 'Measure Unit', + 'stock' => 'Stock', + 'min_stock' => 'Min Stock', + 'date_stocked' => 'Date Stocked', 'origin_country_id' => 'Country of Origin', 'tariff_code_id' => 'Tariff code (CN)', 'meta_title' => 'Meta Title', @@ -19,6 +23,10 @@ 'placeholders' => [ 'product_type' => 'Select product type (optional)', + 'measure_unit' => 'Select measure unit (optional)', + 'stock' => 'Enter stock quantity', + 'min_stock' => 'Enter minimum stock threshold', + 'date_stocked' => 'Select date when stock was added', 'origin_country_id' => 'Select country of origin', 'tariff_code_id' => 'Select tariff code (CN)', 'meta_title' => 'SEO meta title', @@ -29,12 +37,17 @@ 'table' => [ 'columns' => [ 'type' => 'Type', + 'stock' => 'Stock', + 'measure_unit' => 'Unit', + 'min_stock' => 'Min Stock', + 'date_stocked' => 'Date Stocked', 'is_active' => 'Active', ], ], 'filters' => [ 'product_type' => 'Product Types', + 'measure_unit' => 'Measure Units', ], 'sections' => [ diff --git a/src/Filament/Resources/ProductResource.php b/src/Filament/Resources/ProductResource.php index 80caef9..097cc8f 100644 --- a/src/Filament/Resources/ProductResource.php +++ b/src/Filament/Resources/ProductResource.php @@ -111,6 +111,13 @@ public static function form(Schema $form): Schema TextInput::make('gross_weight') ->numeric() ->suffix('kg'), + + Select::make('measure_unit_id') + ->label(__('eclipse-catalogue::product.fields.measure_unit')) + ->relationship('measureUnit', 'name') + ->searchable() + ->preload() + ->placeholder(__('eclipse-catalogue::product.placeholders.measure_unit')), ]) ->columns(2), @@ -245,6 +252,22 @@ public static function form(Schema $form): Schema DateTimePicker::make("tenant_data.{$tenantId}.available_from_date") ->label(__('eclipse-catalogue::product.fields.available_from_date')), + + TextInput::make("tenant_data.{$tenantId}.stock") + ->label(__('eclipse-catalogue::product.fields.stock')) + ->numeric() + ->step(0.00001) + ->placeholder(__('eclipse-catalogue::product.placeholders.stock')), + + TextInput::make("tenant_data.{$tenantId}.min_stock") + ->label(__('eclipse-catalogue::product.fields.min_stock')) + ->numeric() + ->step(0.00001) + ->placeholder(__('eclipse-catalogue::product.placeholders.min_stock')), + + \Filament\Forms\Components\DatePicker::make("tenant_data.{$tenantId}.date_stocked") + ->label(__('eclipse-catalogue::product.fields.date_stocked')) + ->placeholder(__('eclipse-catalogue::product.placeholders.date_stocked')), ]; }, sectionTitle: __('eclipse-catalogue::product.sections.tenant_settings'), @@ -685,6 +708,44 @@ public static function table(Table $table): Table return is_array($category->name) ? ($category->name[app()->getLocale()] ?? reset($category->name)) : $category->name; }), + TextColumn::make('stock') + ->label(__('eclipse-catalogue::product.table.columns.stock')) + ->numeric(5) + ->getStateUsing(function (Product $record) { + return $record->currentTenantData()?->stock; + }) + ->suffix(function (Product $record) { + return $record->measureUnit?->name ? ' '.$record->measureUnit->name : ''; + }) + ->width('120px') + ->toggleable(false), + + TextColumn::make('measureUnit.name') + ->label(__('eclipse-catalogue::product.table.columns.measure_unit')) + ->width('100px') + ->toggleable(false), + + TextColumn::make('min_stock') + ->label(__('eclipse-catalogue::product.table.columns.min_stock')) + ->numeric(5) + ->getStateUsing(function (Product $record) { + return $record->currentTenantData()?->min_stock; + }) + ->suffix(function (Product $record) { + return $record->measureUnit?->name ? ' '.$record->measureUnit->name : ''; + }) + ->width('120px') + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('date_stocked') + ->label(__('eclipse-catalogue::product.table.columns.date_stocked')) + ->getStateUsing(function (Product $record) { + return $record->currentTenantData()?->date_stocked; + }) + ->date() + ->width('120px') + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('type.name') ->label(__('eclipse-catalogue::product.table.columns.type')), @@ -731,8 +792,7 @@ public static function table(Table $table): Table return $tariffCode->code.' — '.$name; }) - ->toggleable() - ->toggledHiddenByDefault() + ->toggleable(isToggledHiddenByDefault: true) ->searchable() ->copyable(), @@ -832,6 +892,12 @@ public static function table(Table $table): Table return $query->pluck('name', 'id')->toArray(); }), + SelectFilter::make('measure_unit_id') + ->label(__('eclipse-catalogue::product.filters.measure_unit')) + ->multiple() + ->relationship('measureUnit', 'name') + ->searchable() + ->preload(), SelectFilter::make('origin_country_id') ->label(__('eclipse-catalogue::product.fields.origin_country_id')) ->multiple() @@ -923,7 +989,8 @@ public static function table(Table $table): Table RestoreBulkAction::make(), ForceDeleteBulkAction::make(), ]), - ]); + ]) + ->deferColumnManager(false); } public static function getPages(): array diff --git a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php index ade8f0b..4d93eca 100644 --- a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php @@ -52,7 +52,7 @@ protected function getFormMutuallyExclusiveFlagSets(): array return []; } - public function form(Schema $schema): Schema + public function schema(Schema $schema): Schema { return $schema; } diff --git a/src/Filament/Resources/ProductResource/Pages/EditProduct.php b/src/Filament/Resources/ProductResource/Pages/EditProduct.php index c883989..1be8670 100644 --- a/src/Filament/Resources/ProductResource/Pages/EditProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/EditProduct.php @@ -5,7 +5,6 @@ use Eclipse\Catalogue\Filament\Resources\ProductResource; use Eclipse\Catalogue\Models\Group; use Eclipse\Catalogue\Models\Property; -use Eclipse\Catalogue\Models\PropertyValue; use Eclipse\Catalogue\Traits\HandlesTenantData; use Eclipse\Catalogue\Traits\HasTenantFields; use Eclipse\Core\Models\Locale; @@ -48,43 +47,7 @@ protected function getHeaderActions(): array protected function mutateFormDataBeforeFill(array $data): array { // Hydrate property values for the product - if ($this->record && $this->record->product_type_id) { - $properties = Property::where('is_active', true) - ->where(function ($query) { - $query->where('is_global', true) - ->orWhereHas('productTypes', function ($q) { - $q->where('pim_product_types.id', $this->record->product_type_id); - }); - }) - ->get(); - - foreach ($properties as $property) { - if ($property->isListType() || $property->isColorType()) { - $fieldName = "property_values_{$property->id}"; - $selectedValues = $this->record->propertyValues() - ->where('pim_property_value.property_id', $property->id) - ->pluck('pim_property_value.id') - ->toArray(); - - $data[$fieldName] = ($property->max_values === 1) - ? ($selectedValues[0] ?? null) - : $selectedValues; - } else { - $fieldName = "custom_property_{$property->id}"; - $customValue = $this->record->getCustomPropertyValue($property); - if ($customValue) { - $data[$fieldName] = $customValue->value; - } else { - if ($property->supportsMultilang()) { - $locales = $this->getAvailableLocales(); - $data[$fieldName] = array_fill_keys($locales, ''); - } else { - $data[$fieldName] = null; - } - } - } - } - } + $data = $this->hydratePropertyFields($data); // Hydrate tenant-scoped fields $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); @@ -98,6 +61,9 @@ protected function mutateFormDataBeforeFill(array $data): array $data['sorting_label'] = $recordData->sorting_label; $data['category_id'] = $recordData->category_id ?? null; $data['product_status_id'] = $recordData->product_status_id ?? null; + $data['stock'] = $recordData->stock; + $data['min_stock'] = $recordData->min_stock; + $data['date_stocked'] = $recordData->date_stocked; } $data['groups'] = $this->record->groups()->pluck('pim_group.id')->toArray(); @@ -105,26 +71,10 @@ protected function mutateFormDataBeforeFill(array $data): array return $data; } - $tenantData = []; - $dataRecords = $this->record->productData; - - foreach ($dataRecords as $tenantRecord) { - $tenantId = $tenantRecord->getAttribute($tenantFK); - $tenantData[$tenantId] = [ - 'is_active' => $tenantRecord->is_active, - 'has_free_delivery' => $tenantRecord->has_free_delivery, - 'available_from_date' => $tenantRecord->available_from_date, - 'sorting_label' => $tenantRecord->sorting_label, - 'category_id' => $tenantRecord->category_id ?? null, - 'product_status_id' => $tenantRecord->product_status_id ?? null, - 'groups' => $this->record->groups() - ->where('pim_group.'.config('eclipse-catalogue.tenancy.foreign_key', 'site_id'), $tenantId) - ->pluck('pim_group.id') - ->toArray(), - ]; - } + $tenantData = $this->buildTenantDataPayload($tenantFK); $data['tenant_data'] = $tenantData; + $data['all_tenant_data'] = $tenantData; $currentTenant = Filament::getTenant(); $data['selected_tenant'] = $currentTenant?->id; @@ -146,62 +96,167 @@ protected function afterSave(): void { if ($this->record) { $state = $this->form->getRawState(); - $propertyData = []; - foreach ($state as $key => $value) { - if (is_string($key) && str_starts_with($key, 'property_values_')) { - $propertyId = str_replace('property_values_', '', $key); - $propertyData[$propertyId] = $value; - } - } + $this->syncListPropertyValues($state); + $this->syncCustomPropertyValues($state); + } + } - foreach ($propertyData as $propertyId => $values) { - $idsToDetach = PropertyValue::query() - ->where('property_id', $propertyId) - ->pluck('id') - ->all(); + /** + * Build per-tenant payload for all tenants to prefill the form. + */ + private function buildTenantDataPayload(string $tenantFK): array + { + $tenantData = []; + $dataRecords = $this->record->productData()->get(); + + // Prefetch groups per tenant in one query by mapping group_id -> site_id + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key', 'site_id'); + $groupsByTenant = $this->record->groups() + ->select('pim_group.id', 'pim_group.'.$tenantFK) + ->get() + ->groupBy($tenantFK) + ->map(fn ($rows) => $rows->pluck('id')->toArray()) + ->toArray(); - if (! empty($idsToDetach)) { - $this->record->propertyValues()->detach($idsToDetach); - } + foreach ($dataRecords as $tenantRecord) { + $tenantId = $tenantRecord->getAttribute($tenantFK); + $tenantData[$tenantId] = [ + 'is_active' => $tenantRecord->is_active, + 'has_free_delivery' => $tenantRecord->has_free_delivery, + 'available_from_date' => $tenantRecord->available_from_date, + 'sorting_label' => $tenantRecord->sorting_label, + 'category_id' => $tenantRecord->category_id ?? null, + 'product_status_id' => $tenantRecord->product_status_id ?? null, + 'stock' => $tenantRecord->stock, + 'min_stock' => $tenantRecord->min_stock, + 'date_stocked' => $tenantRecord->date_stocked, + 'groups' => $groupsByTenant[$tenantId] ?? [], + ]; + } + + return $tenantData; + } - // Add new values - if ($values) { - $valuesToAttach = is_array($values) ? $values : [$values]; - $valuesToAttach = array_filter($valuesToAttach); // Remove null values + /** + * Populate property_values_* and custom_property_* into the provided data array. + */ + private function hydratePropertyFields(array $data): array + { + if (! ($this->record && $this->record->product_type_id)) { + return $data; + } - if (! empty($valuesToAttach)) { - $this->record->propertyValues()->attach($valuesToAttach); + $properties = Property::where('is_active', true) + ->where(function ($query) { + $query->where('is_global', true) + ->orWhereHas('productTypes', function ($q) { + $q->where('pim_product_types.id', $this->record->product_type_id); + }); + }) + ->get(); + + // Prefetch all selected list property values for this product in one query + $selectedValuesByProperty = $this->record->propertyValues() + ->select('pim_property_value.id', 'pim_property_value.property_id') + ->get() + ->groupBy('property_id') + ->map(fn ($rows) => $rows->pluck('id')->toArray()) + ->toArray(); + + // Prefetch all custom property values in one query + $customValuesByProperty = $this->record->customPropertyValues() + ->get() + ->keyBy('property_id'); + + foreach ($properties as $property) { + if ($property->isListType() || $property->isColorType()) { + $fieldName = "property_values_{$property->id}"; + $selectedValues = $selectedValuesByProperty[$property->id] ?? []; + $data[$fieldName] = ($property->max_values === 1) + ? ($selectedValues[0] ?? null) + : $selectedValues; + } else { + $fieldName = "custom_property_{$property->id}"; + $customValue = $customValuesByProperty->get($property->id); + if ($customValue) { + $data[$fieldName] = $customValue->value; + } else { + if ($property->supportsMultilang()) { + $locales = $this->getAvailableLocales(); + $data[$fieldName] = array_fill_keys($locales, ''); + } else { + $data[$fieldName] = null; } } } + } - $customPropertyData = []; - foreach ($state as $key => $value) { - if (is_string($key) && str_starts_with($key, 'custom_property_')) { - $propertyId = str_replace('custom_property_', '', $key); - $customPropertyData[$propertyId] = $value; - } + return $data; + } + + /** + * Sync many-to-many list property values based on form state. + */ + private function syncListPropertyValues(array $state): void + { + $propertyData = []; + foreach ($state as $key => $value) { + if (is_string($key) && str_starts_with($key, 'property_values_')) { + $propertyId = str_replace('property_values_', '', $key); + $propertyData[$propertyId] = $value; + } + } + + // Detach all existing property values in a single operation to avoid repeated queries + $allCurrentIds = \Eclipse\Catalogue\Models\PropertyValue::query()->pluck('id')->all(); + if (! empty($allCurrentIds)) { + $this->record->propertyValues()->detach($allCurrentIds); + } + + // Attach back the new selections per property + foreach ($propertyData as $propertyId => $values) { + if (empty($values)) { + continue; + } + $valuesToAttach = is_array($values) ? $values : [$values]; + $valuesToAttach = array_filter($valuesToAttach); + if (! empty($valuesToAttach)) { + $this->record->propertyValues()->attach($valuesToAttach); } + } + } - foreach ($customPropertyData as $propertyId => $value) { - $property = Property::find($propertyId); - if ($property && $property->isCustomType()) { - if ($property->supportsMultilang() && is_array($value)) { - $filteredValue = array_filter($value, fn ($v) => $v !== null && $v !== ''); - if (! empty($filteredValue)) { - $this->record->setCustomPropertyValue($property, $value); - } else { - $this->record->customPropertyValues() - ->where('property_id', $propertyId) - ->delete(); - } - } elseif ($value !== null && $value !== '') { + /** + * Upsert custom property single-value fields based on form state. + */ + private function syncCustomPropertyValues(array $state): void + { + $customPropertyData = []; + foreach ($state as $key => $value) { + if (is_string($key) && str_starts_with($key, 'custom_property_')) { + $propertyId = str_replace('custom_property_', '', $key); + $customPropertyData[$propertyId] = $value; + } + } + + foreach ($customPropertyData as $propertyId => $value) { + $property = Property::find($propertyId); + if ($property && $property->isCustomType()) { + if ($property->supportsMultilang() && is_array($value)) { + $filteredValue = array_filter($value, fn ($v) => $v !== null && $v !== ''); + if (! empty($filteredValue)) { $this->record->setCustomPropertyValue($property, $value); } else { $this->record->customPropertyValues() ->where('property_id', $propertyId) ->delete(); } + } elseif ($value !== null && $value !== '') { + $this->record->setCustomPropertyValue($property, $value); + } else { + $this->record->customPropertyValues() + ->where('property_id', $propertyId) + ->delete(); } } } @@ -217,7 +272,7 @@ protected function getFormMutuallyExclusiveFlagSets(): array return []; } - public function form(Schema $schema): Schema + public function schema(Schema $schema): Schema { return $schema; } diff --git a/src/Models/MeasureUnit.php b/src/Models/MeasureUnit.php index 417a4ba..bb3c8df 100644 --- a/src/Models/MeasureUnit.php +++ b/src/Models/MeasureUnit.php @@ -2,13 +2,14 @@ namespace Eclipse\Catalogue\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Validation\ValidationException; class MeasureUnit extends Model { - use SoftDeletes; + use HasFactory, SoftDeletes; protected $table = 'pim_measure_units'; @@ -62,4 +63,9 @@ public function isDefault(): bool { return $this->is_default; } + + protected static function newFactory() + { + return \Eclipse\Catalogue\Factories\MeasureUnitFactory::new(); + } } diff --git a/src/Models/Product.php b/src/Models/Product.php index 1e5f1d4..b067981 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -35,6 +35,7 @@ class Product extends Model implements HasMedia 'gross_weight', 'name', 'product_type_id', + 'measure_unit_id', 'category_id', 'short_description', 'description', @@ -87,6 +88,10 @@ class Product extends Model implements HasMedia 'available_from_date', 'category_id', 'product_status_id', + 'groups', + 'stock', + 'min_stock', + 'date_stocked', ]; public function status(): ?ProductStatus @@ -104,6 +109,11 @@ public function type(): BelongsTo return $this->belongsTo(ProductType::class, 'product_type_id'); } + public function measureUnit(): BelongsTo + { + return $this->belongsTo(MeasureUnit::class, 'measure_unit_id'); + } + public function propertyValues(): BelongsToMany { return $this->belongsToMany(PropertyValue::class, 'pim_product_has_property_value', 'product_id', 'property_value_id') @@ -174,6 +184,37 @@ public function getSortingLabelAttribute(): ?string return $this->currentTenantData()?->sorting_label; } + public function getStockAttribute(): ?float + { + return $this->currentTenantData()?->stock; + } + + public function getMinStockAttribute(): ?float + { + return $this->currentTenantData()?->min_stock; + } + + public function getDateStockedAttribute(): ?string + { + return $this->currentTenantData()?->date_stocked; + } + + /** + * Get tenant attributes for automatic form generation + */ + public function getTenantAttributes(): array + { + return static::$tenantAttributes; + } + + /** + * Get tenant flags for automatic form generation + */ + public function getTenantFlags(): array + { + return static::$tenantFlags; + } + public function getCustomPropertyValue(Property $property): ?CustomPropertyValue { return $this->customPropertyValues()->where('property_id', $property->id)->first(); diff --git a/src/Models/ProductData.php b/src/Models/ProductData.php index 32858ab..0c26b38 100644 --- a/src/Models/ProductData.php +++ b/src/Models/ProductData.php @@ -24,6 +24,9 @@ class ProductData extends Model 'is_active', 'available_from_date', 'has_free_delivery', + 'stock', + 'min_stock', + 'date_stocked', ]; /** @@ -68,6 +71,9 @@ protected function casts(): array 'is_active' => 'boolean', 'available_from_date' => 'datetime', 'has_free_delivery' => 'boolean', + 'stock' => 'decimal:5', + 'min_stock' => 'decimal:5', + 'date_stocked' => 'date', ]; } diff --git a/tests/Feature/ProductStockFieldsTest.php b/tests/Feature/ProductStockFieldsTest.php new file mode 100644 index 0000000..1e91737 --- /dev/null +++ b/tests/Feature/ProductStockFieldsTest.php @@ -0,0 +1,162 @@ +setUpSuperAdmin(); +}); + +it('can save and load stock fields in product data', function (): void { + $measureUnit = MeasureUnit::factory()->create(['name' => 'kg']); + $product = Product::factory()->create([ + 'name' => 'Test Product', + 'measure_unit_id' => $measureUnit->id, + ]); + + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantModel = config('eclipse-catalogue.tenancy.model'); + $currentTenantId = $tenantModel::first()->id; + + ProductData::factory()->create([ + 'product_id' => $product->id, + $tenantFK => $currentTenantId, + 'stock' => 150.75, + 'min_stock' => 10.5, + 'date_stocked' => '2024-01-15', + 'is_active' => true, + ]); + + $product->refresh(); + + expect($product->stock)->toBe(150.75); + expect($product->min_stock)->toBe(10.5); + expect($product->date_stocked)->toStartWith('2024-01-15'); + expect($product->measureUnit->name)->toBe('kg'); +}); + +it('can filter products by measure unit in table', function (): void { + $kgUnit = MeasureUnit::factory()->create(['name' => 'kg']); + $literUnit = MeasureUnit::factory()->create(['name' => 'liter']); + + $product1 = Product::factory()->create([ + 'name' => 'Product with kg', + 'measure_unit_id' => $kgUnit->id, + ]); + $product2 = Product::factory()->create([ + 'name' => 'Product with liter', + 'measure_unit_id' => $literUnit->id, + ]); + $product3 = Product::factory()->create([ + 'name' => 'Product without unit', + 'measure_unit_id' => null, + ]); + + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantModel = config('eclipse-catalogue.tenancy.model'); + $currentTenantId = $tenantModel::first()->id; + + // Create product data for all products + foreach ([$product1, $product2, $product3] as $product) { + ProductData::factory()->create([ + 'product_id' => $product->id, + $tenantFK => $currentTenantId, + 'is_active' => true, + ]); + } + + Livewire::test(ProductResource\Pages\ListProducts::class) + ->filterTable('measure_unit_id', [$kgUnit->id]) + ->assertCanSeeTableRecords([$product1]) + ->assertCanNotSeeTableRecords([$product2, $product3]); +}); + +it('displays stock columns with correct visibility and widths', function (): void { + $measureUnit = MeasureUnit::factory()->create(['name' => 'pieces']); + $product = Product::factory()->create([ + 'name' => 'Stock Test Product', + 'measure_unit_id' => $measureUnit->id, + ]); + + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantModel = config('eclipse-catalogue.tenancy.model'); + $currentTenantId = $tenantModel::first()->id; + + ProductData::factory()->withStock(100.5, 25.0)->create([ + 'product_id' => $product->id, + $tenantFK => $currentTenantId, + 'is_active' => true, + ]); + + $component = Livewire::test(ProductResource\Pages\ListProducts::class); + + $component->assertCanSeeTableRecords([$product]); + + expect($component->instance()->getTable()->getColumns())->toHaveKey('stock'); + expect($component->instance()->getTable()->getColumns())->toHaveKey('measureUnit.name'); + expect($component->instance()->getTable()->getColumns())->toHaveKey('min_stock'); + expect($component->instance()->getTable()->getColumns())->toHaveKey('date_stocked'); +}); + +it('handles nullable stock values correctly', function (): void { + $product = Product::factory()->create(['name' => 'Nullable Stock Product']); + + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantModel = config('eclipse-catalogue.tenancy.model'); + $currentTenantId = $tenantModel::first()->id; + + ProductData::factory()->create([ + 'product_id' => $product->id, + $tenantFK => $currentTenantId, + 'stock' => null, + 'min_stock' => null, + 'date_stocked' => null, + 'is_active' => true, + ]); + + $product->refresh(); + + expect($product->stock)->toBeNull(); + expect($product->min_stock)->toBeNull(); + expect($product->date_stocked)->toBeNull(); +}); + +it('can create product with measure unit and stock via form', function (): void { + $measureUnit = MeasureUnit::factory()->create(['name' => 'kg']); + + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenantModel = config('eclipse-catalogue.tenancy.model'); + $currentTenantId = $tenantModel::first()->id; + + $formData = [ + 'name' => ['en' => 'Form Test Product'], + 'measure_unit_id' => $measureUnit->id, + 'selected_tenant' => $currentTenantId, + 'tenant_data' => [ + $currentTenantId => [ + 'stock' => 75.25, + 'min_stock' => 15.5, + 'date_stocked' => '2024-02-20', + 'is_active' => true, + ], + ], + ]; + + Livewire::test(ProductResource\Pages\CreateProduct::class) + ->fillForm($formData) + ->call('create') + ->assertHasNoFormErrors(); + + $product = Product::where('name->en', 'Form Test Product')->first(); + expect($product)->not->toBeNull(); + expect($product->measure_unit_id)->toBe($measureUnit->id); + expect($product->stock)->toBe(75.25); + expect($product->min_stock)->toBe(15.5); + expect($product->date_stocked)->toStartWith('2024-02-20'); +});