Skip to content

Commit 1af2ec6

Browse files
committed
feat(product-prices): implement prices
1 parent d23cf63 commit 1af2ec6

7 files changed

Lines changed: 303 additions & 14 deletions

File tree

database/migrations/2025_08_18_104934_create_catalog_product_prices_table.php renamed to database/migrations/2025_08_18_104934_create_catalogue_product_prices_table.php

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,22 @@
1111
*/
1212
public function up(): void
1313
{
14-
Schema::create('catalog_product_prices', function (Blueprint $table) {
15-
/*
16-
* @todo: remove these comments before PR is made
17-
*/
14+
Schema::create('catalogue_product_prices', function (Blueprint $table) {
1815
$table->id();
1916
$table->foreignId('product_id')
20-
->constrained('catalog_products')
17+
->constrained('catalogue_products')
2118
->cascadeOnUpdate()
2219
->cascadeOnDelete();
2320
$table->foreignId('price_list_id')
2421
->constrained('pim_price_lists')
2522
->cascadeOnUpdate()
2623
->cascadeOnDelete();
27-
// Dates are inclusive, valid_to is optional
2824
$table->date('valid_from');
2925
$table->date('valid_to')->nullable();
30-
// Price should be decimal(20,5)
31-
$table->decimal('price');
32-
// Automatically copied from the price list but can be overridden by user when editing the price
26+
$table->decimal('price', 20, 5);
3327
$table->boolean('tax_included');
3428
$table->timestamps();
35-
36-
// This should also be enforced by form validation
37-
$table->unique(['product_id', 'price_list_id', 'valid_from']);
29+
$table->unique(['product_id', 'price_list_id', 'valid_from'], 'uq_cpp_pid_plid_vf');
3830
});
3931
}
4032

@@ -43,6 +35,6 @@ public function up(): void
4335
*/
4436
public function down(): void
4537
{
46-
Schema::dropIfExists('catalog_product_prices');
38+
Schema::dropIfExists('catalogue_product_prices');
4739
}
4840
};

resources/lang/en/product.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,23 @@
5050
'available_from_date' => 'Date/time when the product becomes available',
5151
'sorting_label' => 'Optional label used to influence sorting within lists',
5252
],
53+
54+
'price' => [
55+
'section' => 'Product price list',
56+
'list' => 'Price list',
57+
'fields' => [
58+
'price_list' => 'Price list',
59+
'price' => 'Price',
60+
'tax_included' => 'Incl. tax',
61+
'valid_from' => 'Valid from',
62+
'valid_to' => 'Valid to',
63+
],
64+
'actions' => [
65+
'add' => 'Add price',
66+
],
67+
'validation' => [
68+
'unique_title' => 'Duplicate price',
69+
'unique_body' => 'A price with the same Price List, Price and Valid From already exists in the form.',
70+
],
71+
],
5372
];

resources/lang/sl/product.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,23 @@
5050
'available_from_date' => 'Datum/čas, ko bo izdelek na voljo',
5151
'sorting_label' => 'Neobvezna oznaka, ki vpliva na razvrščanje v seznamih',
5252
],
53+
54+
'price' => [
55+
'section' => 'Cenik izdelka',
56+
'list' => 'Cenik',
57+
'fields' => [
58+
'price_list' => 'Cenik',
59+
'price' => 'Cena',
60+
'tax_included' => 'Z DDV',
61+
'valid_from' => 'Velja od',
62+
'valid_to' => 'Velja do',
63+
],
64+
'actions' => [
65+
'add' => 'Dodaj ceno',
66+
],
67+
'validation' => [
68+
'unique_title' => 'Podvojen vnos cene',
69+
'unique_body' => 'Cena z enakim cenikom, ceno in datumom "Velja od" že obstaja v obrazcu.',
70+
],
71+
],
5372
];

src/Filament/Resources/ProductResource.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,22 @@
77
use Eclipse\Catalogue\Filament\Resources\ProductResource\Pages;
88
use Eclipse\Catalogue\Forms\Components\GenericTenantFieldsComponent;
99
use Eclipse\Catalogue\Models\Category;
10+
use Eclipse\Catalogue\Models\PriceList;
1011
use Eclipse\Catalogue\Models\Product;
1112
use Eclipse\Catalogue\Traits\HandlesTenantData;
1213
use Eclipse\Catalogue\Traits\HasTenantFields;
1314
use Eclipse\World\Models\Country;
15+
use Filament\Forms\Components\Checkbox;
16+
use Filament\Forms\Components\DatePicker;
17+
use Filament\Forms\Components\Hidden;
18+
use Filament\Forms\Components\Repeater;
1419
use Filament\Forms\Components\RichEditor;
1520
use Filament\Forms\Components\Section;
1621
use Filament\Forms\Components\Select;
1722
use Filament\Forms\Components\Tabs;
1823
use Filament\Forms\Components\TextInput;
1924
use Filament\Forms\Form;
25+
use Filament\Forms\Get;
2026
use Filament\Resources\Concerns\Translatable;
2127
use Filament\Resources\Resource;
2228
use Filament\Tables\Actions\ActionGroup;
@@ -183,6 +189,76 @@ function ($query) {
183189
),
184190
]),
185191

192+
Tabs\Tab::make('Prices')
193+
->schema([
194+
Section::make(__('eclipse-catalogue::product.price.section'))
195+
->schema([
196+
Repeater::make('prices')
197+
->hiddenLabel()
198+
->relationship('prices')
199+
->schema([
200+
Hidden::make('id'),
201+
202+
Select::make('price_list_id')
203+
->label(__('eclipse-catalogue::product.price.fields.price_list'))
204+
->relationship('priceList', 'name')
205+
->required()
206+
->preload()
207+
->searchable()
208+
->live()
209+
->afterStateUpdated(function ($state, callable $set) {
210+
if (! $state) {
211+
return;
212+
}
213+
$pl = PriceList::query()->select('id', 'tax_included')->find($state);
214+
if ($pl) {
215+
$set('tax_included', (bool) $pl->tax_included);
216+
}
217+
}),
218+
219+
TextInput::make('price')
220+
->label(__('eclipse-catalogue::product.price.fields.price'))
221+
->numeric()
222+
->rule('decimal:0,5')
223+
->required(),
224+
225+
Checkbox::make('tax_included')
226+
->label(__('eclipse-catalogue::product.price.fields.tax_included'))
227+
->inline(false)
228+
->default(false),
229+
230+
DatePicker::make('valid_from')
231+
->label(__('eclipse-catalogue::product.price.fields.valid_from'))
232+
->native(false)
233+
->required(),
234+
235+
DatePicker::make('valid_to')
236+
->label(__('eclipse-catalogue::product.price.fields.valid_to'))
237+
->native(false)
238+
->nullable(),
239+
])
240+
->minItems(0)
241+
->reorderable(false)
242+
->columns(5)
243+
->createItemButtonLabel(__('eclipse-catalogue::product.price.actions.add'))
244+
->rule(function (Get $get) {
245+
return function (string $attribute, $value, $fail) {
246+
$seen = [];
247+
foreach ($value as $row) {
248+
if (! $row['price_list_id'] || ! $row['valid_from']) {
249+
continue;
250+
}
251+
$key = $row['price_list_id'].'_'.$row['valid_from'];
252+
if (isset($seen[$key])) {
253+
$fail(__('eclipse-catalogue::product.price.validation.unique_body'));
254+
}
255+
$seen[$key] = true;
256+
}
257+
};
258+
}),
259+
])
260+
->compact(),
261+
]),
186262
Tabs\Tab::make('Images')
187263
->schema([
188264
ImageManager::make('images')

src/Models/Product.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ public function productData(): HasMany
9999
return $this->hasMany(ProductData::class, 'product_id');
100100
}
101101

102+
/**
103+
* Prices relationship.
104+
*/
105+
public function prices(): HasMany
106+
{
107+
return $this->hasMany(\Eclipse\Catalogue\Models\Product\Price::class);
108+
}
109+
102110
public function getIsActiveAttribute(): bool
103111
{
104112
return $this->getTenantFlagValue('is_active');

src/Models/Product/Price.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,17 @@
99

1010
class Price extends Model
1111
{
12-
protected $table = 'catalog_product_prices';
12+
protected $table = 'catalogue_product_prices';
13+
14+
protected $fillable = [
15+
'id',
16+
'product_id',
17+
'price_list_id',
18+
'valid_from',
19+
'valid_to',
20+
'price',
21+
'tax_included',
22+
];
1323

1424
public function product(): BelongsTo
1525
{

tests/Feature/ProductPriceTest.php

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
3+
use Eclipse\Catalogue\Models\PriceList;
4+
use Eclipse\Catalogue\Models\Product;
5+
use Eclipse\Catalogue\Models\Product\Price as ProductPrice;
6+
use Eclipse\World\Models\Currency;
7+
use Illuminate\Database\QueryException;
8+
9+
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
10+
11+
beforeEach(function () {
12+
Currency::create(['id' => 'USD', 'name' => 'US Dollar', 'is_active' => true]);
13+
Currency::create(['id' => 'EUR', 'name' => 'Euro', 'is_active' => true]);
14+
});
15+
16+
function makeProduct(): Product
17+
{
18+
return Product::factory()->create();
19+
}
20+
21+
function makePriceList(array $attrs = []): PriceList
22+
{
23+
return PriceList::factory()->create(array_merge([
24+
'currency_id' => 'USD',
25+
'tax_included' => false,
26+
], $attrs));
27+
}
28+
29+
test('can create a product price', function () {
30+
$product = makeProduct();
31+
$priceList = makePriceList();
32+
33+
$price = ProductPrice::create([
34+
'product_id' => $product->id,
35+
'price_list_id' => $priceList->id,
36+
'valid_from' => '2025-08-18',
37+
'valid_to' => null,
38+
'price' => 12.34567,
39+
'tax_included' => true,
40+
]);
41+
42+
expect($price)->toBeInstanceOf(ProductPrice::class);
43+
expect($price->price)->toBeFloat();
44+
expect($price->tax_included)->toBeTrue();
45+
expect($price->valid_from)->toBeInstanceOf(\Illuminate\Support\Carbon::class);
46+
expect($price->valid_to)->toBeNull();
47+
});
48+
49+
test('price has product and price list relations', function () {
50+
$product = makeProduct();
51+
$priceList = makePriceList();
52+
53+
$price = ProductPrice::create([
54+
'product_id' => $product->id,
55+
'price_list_id' => $priceList->id,
56+
'valid_from' => '2025-08-18',
57+
'price' => 9.99,
58+
'tax_included' => false,
59+
]);
60+
61+
expect($price->product)->toBeInstanceOf(Product::class)
62+
->and($price->priceList)->toBeInstanceOf(PriceList::class);
63+
});
64+
65+
test('cannot create duplicate price for same product, price list and date', function () {
66+
$product = makeProduct();
67+
$priceList = makePriceList();
68+
69+
ProductPrice::create([
70+
'product_id' => $product->id,
71+
'price_list_id' => $priceList->id,
72+
'valid_from' => '2025-08-18',
73+
'price' => 10,
74+
'tax_included' => false,
75+
]);
76+
77+
// 2nd insert with the same triple key must fail at DB level
78+
expect(fn () => ProductPrice::create([
79+
'product_id' => $product->id,
80+
'price_list_id' => $priceList->id,
81+
'valid_from' => '2025-08-18',
82+
'price' => 11,
83+
'tax_included' => true,
84+
]))->toThrow(QueryException::class);
85+
});
86+
87+
test('same date is allowed on different price lists', function () {
88+
$product = makeProduct();
89+
$pl1 = makePriceList(['currency_id' => 'USD']);
90+
$pl2 = makePriceList(['currency_id' => 'USD']);
91+
92+
ProductPrice::create([
93+
'product_id' => $product->id,
94+
'price_list_id' => $pl1->id,
95+
'valid_from' => '2025-08-18',
96+
'price' => 10,
97+
'tax_included' => false,
98+
]);
99+
100+
$p2 = ProductPrice::create([
101+
'product_id' => $product->id,
102+
'price_list_id' => $pl2->id,
103+
'valid_from' => '2025-08-18',
104+
'price' => 12,
105+
'tax_included' => false,
106+
]);
107+
108+
expect($p2->exists)->toBeTrue();
109+
});
110+
111+
test('same date is allowed on same price list for different products', function () {
112+
$p1 = makeProduct();
113+
$p2 = makeProduct();
114+
$priceList = makePriceList();
115+
116+
ProductPrice::create([
117+
'product_id' => $p1->id,
118+
'price_list_id' => $priceList->id,
119+
'valid_from' => '2025-08-18',
120+
'price' => 10,
121+
'tax_included' => false,
122+
]);
123+
124+
$ok = ProductPrice::create([
125+
'product_id' => $p2->id,
126+
'price_list_id' => $priceList->id,
127+
'valid_from' => '2025-08-18',
128+
'price' => 13,
129+
'tax_included' => true,
130+
]);
131+
132+
expect($ok->exists)->toBeTrue();
133+
});
134+
135+
test('stores price with up to 5 decimal places', function () {
136+
$product = makeProduct();
137+
$priceList = makePriceList();
138+
139+
$p = ProductPrice::create([
140+
'product_id' => $product->id,
141+
'price_list_id' => $priceList->id,
142+
'valid_from' => '2025-08-18',
143+
'price' => 123.45678,
144+
'tax_included' => true,
145+
])->fresh();
146+
147+
expect($p->price)->toEqual(123.45678);
148+
});
149+
150+
test('(soft) deleting product does not remove prices', function () {
151+
$product = makeProduct();
152+
$priceList = makePriceList();
153+
154+
$price = ProductPrice::create([
155+
'product_id' => $product->id,
156+
'price_list_id' => $priceList->id,
157+
'valid_from' => '2025-08-18',
158+
'price' => 5,
159+
'tax_included' => false,
160+
]);
161+
162+
$product->delete();
163+
164+
expect(ProductPrice::find($price->id))->not->toBeNull();
165+
});

0 commit comments

Comments
 (0)