Skip to content

Commit de9c1e9

Browse files
committed
feat(finance): Phase 69 — Price Lists & Customer Pricing with tiered pricing
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent cd005e2 commit de9c1e9

11 files changed

Lines changed: 493 additions & 300 deletions

File tree

erp/app/Modules/Finance/Http/Controllers/PriceListController.php

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public function index(): Response
2121

2222
$priceLists = PriceList::withCount(['items', 'contacts'])
2323
->orderBy('name')
24-
->get();
24+
->paginate(15);
2525

2626
return Inertia::render('Finance/PriceLists/Index', [
2727
'priceLists' => $priceLists,
@@ -53,29 +53,46 @@ public function store(Request $request): RedirectResponse
5353
$data = $request->validate([
5454
'name' => 'required|string|max:191',
5555
'description' => 'nullable|string',
56-
'currency_code' => 'nullable|string|size:3',
56+
'currency_code' => 'required|string|size:3',
5757
'discount_percent' => 'nullable|numeric|min:0|max:100',
5858
'is_active' => 'boolean',
59+
'is_default' => 'boolean',
60+
'valid_from' => 'nullable|date',
61+
'valid_to' => 'nullable|date',
5962
'items' => 'nullable|array',
6063
'items.*.product_id' => 'required|exists:products,id',
6164
'items.*.unit_price' => 'required|numeric|min:0',
6265
]);
6366

64-
$priceList = DB::transaction(function () use ($data, $request) {
67+
$tenantId = auth()->user()->tenant_id;
68+
69+
$priceList = DB::transaction(function () use ($data, $tenantId) {
70+
// If setting as default, clear existing defaults
71+
if (!empty($data['is_default'])) {
72+
PriceList::where('tenant_id', $tenantId)
73+
->where('is_default', true)
74+
->update(['is_default' => false]);
75+
}
76+
6577
$list = PriceList::create([
66-
'tenant_id' => auth()->user()->tenant_id,
78+
'tenant_id' => $tenantId,
6779
'name' => $data['name'],
6880
'description' => $data['description'] ?? null,
69-
'currency_code' => $data['currency_code'] ?? 'USD',
81+
'currency_code' => $data['currency_code'],
7082
'discount_percent' => $data['discount_percent'] ?? 0,
7183
'is_active' => $data['is_active'] ?? true,
84+
'is_default' => $data['is_default'] ?? false,
85+
'valid_from' => $data['valid_from'] ?? null,
86+
'valid_to' => $data['valid_to'] ?? null,
7287
]);
7388

7489
foreach ($data['items'] ?? [] as $item) {
7590
PriceListItem::create([
91+
'tenant_id' => $tenantId,
7692
'price_list_id' => $list->id,
7793
'product_id' => $item['product_id'],
7894
'unit_price' => $item['unit_price'],
95+
'min_quantity' => $item['min_quantity'] ?? 1,
7996
]);
8097
}
8198

@@ -90,10 +107,11 @@ public function show(PriceList $priceList): Response
90107
{
91108
$this->authorize('view', $priceList);
92109

93-
$priceList->load(['items.product', 'contacts']);
110+
$items = $priceList->items()->with('product')->paginate(15);
94111

95112
return Inertia::render('Finance/PriceLists/Show', [
96-
'priceList' => $priceList,
113+
'priceList' => $priceList->load('contacts'),
114+
'items' => $items,
97115
'breadcrumbs' => [
98116
['label' => 'Finance'],
99117
['label' => 'Price Lists', 'href' => route('finance.price-lists.index')],
@@ -112,6 +130,9 @@ public function update(Request $request, PriceList $priceList): RedirectResponse
112130
'currency_code' => 'nullable|string|size:3',
113131
'discount_percent' => 'nullable|numeric|min:0|max:100',
114132
'is_active' => 'boolean',
133+
'is_default' => 'boolean',
134+
'valid_from' => 'nullable|date',
135+
'valid_to' => 'nullable|date',
115136
'items' => 'nullable|array',
116137
'items.*.product_id' => 'required|exists:products,id',
117138
'items.*.unit_price' => 'required|numeric|min:0',
@@ -124,15 +145,20 @@ public function update(Request $request, PriceList $priceList): RedirectResponse
124145
'currency_code' => $data['currency_code'] ?? 'USD',
125146
'discount_percent' => $data['discount_percent'] ?? 0,
126147
'is_active' => $data['is_active'] ?? true,
148+
'is_default' => $data['is_default'] ?? false,
149+
'valid_from' => $data['valid_from'] ?? null,
150+
'valid_to' => $data['valid_to'] ?? null,
127151
]);
128152

129153
// Sync items
130154
$priceList->items()->delete();
131155
foreach ($data['items'] ?? [] as $item) {
132156
PriceListItem::create([
157+
'tenant_id' => $priceList->tenant_id,
133158
'price_list_id' => $priceList->id,
134159
'product_id' => $item['product_id'],
135160
'unit_price' => $item['unit_price'],
161+
'min_quantity' => $item['min_quantity'] ?? 1,
136162
]);
137163
}
138164
});
@@ -151,6 +177,36 @@ public function destroy(PriceList $priceList): RedirectResponse
151177
->with('success', 'Price list deleted.');
152178
}
153179

180+
public function addItem(Request $request, PriceList $priceList): RedirectResponse
181+
{
182+
$this->authorize('create', PriceList::class);
183+
184+
$data = $request->validate([
185+
'product_id' => 'required|exists:products,id',
186+
'unit_price' => 'required|numeric|min:0',
187+
'min_quantity' => 'required|integer|min:1',
188+
]);
189+
190+
PriceListItem::create([
191+
'tenant_id' => $priceList->tenant_id,
192+
'price_list_id' => $priceList->id,
193+
'product_id' => $data['product_id'],
194+
'unit_price' => $data['unit_price'],
195+
'min_quantity' => $data['min_quantity'],
196+
]);
197+
198+
return redirect()->back()->with('success', 'Item added to price list.');
199+
}
200+
201+
public function removeItem(PriceList $priceList, PriceListItem $item): RedirectResponse
202+
{
203+
$this->authorize('delete', $priceList);
204+
205+
$item->delete();
206+
207+
return redirect()->back()->with('success', 'Item removed from price list.');
208+
}
209+
154210
public function priceForContact(Request $request)
155211
{
156212
$this->authorize('viewAny', PriceList::class);

erp/app/Modules/Finance/Models/PriceList.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@ class PriceList extends Model
1313
use SoftDeletes;
1414

1515
protected $fillable = [
16-
'tenant_id', 'name', 'description', 'currency_code', 'discount_percent', 'is_active',
16+
'tenant_id', 'name', 'description', 'currency_code',
17+
'discount_percent', 'is_active',
18+
'is_default', 'valid_from', 'valid_to',
1719
];
1820

1921
protected $casts = [
2022
'discount_percent' => 'float',
2123
'is_active' => 'boolean',
24+
'is_default' => 'boolean',
25+
'valid_from' => 'date',
26+
'valid_to' => 'date',
2227
];
2328

2429
public function items(): HasMany
@@ -31,6 +36,24 @@ public function contacts(): HasMany
3136
return $this->hasMany(Contact::class, 'price_list_id');
3237
}
3338

39+
public static function getDefault(int $tenantId): ?self
40+
{
41+
return static::where('is_default', true)
42+
->where('tenant_id', $tenantId)
43+
->first();
44+
}
45+
46+
public function getPriceForProduct(int $productId, int $quantity = 1): ?float
47+
{
48+
$item = $this->items()
49+
->where('product_id', $productId)
50+
->where('min_quantity', '<=', $quantity)
51+
->orderBy('min_quantity', 'desc')
52+
->first();
53+
54+
return $item ? (float) $item->unit_price : null;
55+
}
56+
3457
public static function priceFor(int $priceListId, int $productId, float $defaultPrice): float
3558
{
3659
// First check for a product-specific override

erp/app/Modules/Finance/Models/PriceListItem.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
namespace App\Modules\Finance\Models;
44

5+
use App\Modules\Core\Traits\BelongsToTenant;
56
use Illuminate\Database\Eloquent\Model;
67
use Illuminate\Database\Eloquent\Relations\BelongsTo;
78

89
class PriceListItem extends Model
910
{
11+
use BelongsToTenant;
12+
1013
protected $fillable = [
11-
'price_list_id', 'product_id', 'unit_price',
14+
'tenant_id', 'price_list_id', 'product_id', 'unit_price', 'min_quantity',
1215
];
1316

1417
protected $casts = [
15-
'unit_price' => 'float',
18+
'unit_price' => 'decimal:4',
19+
'min_quantity' => 'integer',
1620
];
1721

1822
public function priceList(): BelongsTo

erp/app/Modules/Finance/Providers/FinanceServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use App\Modules\Finance\Models\Budget;
1919
use App\Modules\Finance\Models\BudgetLine;
2020
use App\Modules\Finance\Models\PriceList;
21+
use App\Modules\Finance\Models\PriceListItem;
2122
use App\Modules\Finance\Models\DepreciationEntry;
2223
use App\Modules\Finance\Models\FixedAsset;
2324
use App\Modules\Finance\Models\Attachment;
@@ -85,6 +86,7 @@ public function boot(): void
8586
Gate::policy(FixedAsset::class, FixedAssetPolicy::class);
8687
Gate::policy(DepreciationEntry::class, FixedAssetPolicy::class);
8788
Gate::policy(PriceList::class, PriceListPolicy::class);
89+
Gate::policy(PriceListItem::class, PriceListPolicy::class);
8890
Gate::policy(Project::class, ProjectPolicy::class);
8991
Gate::policy(Attachment::class, AttachmentPolicy::class);
9092
Gate::policy(BatchPayment::class, BatchPaymentPolicy::class);

erp/app/Modules/Finance/routes/finance.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@
169169

170170
// Price Lists
171171
Route::get('price-lists/price-for-contact', [PriceListController::class, 'priceForContact'])->name('price-lists.price-for-contact');
172+
Route::post('price-lists/{priceList}/items', [PriceListController::class, 'addItem'])->name('price-lists.items.add');
173+
Route::delete('price-lists/{priceList}/items/{item}', [PriceListController::class, 'removeItem'])->name('price-lists.items.remove');
172174
Route::resource('price-lists', PriceListController::class)->except(['edit']);
173175

174176
// Projects
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::table('price_lists', function (Blueprint $table) {
12+
$table->boolean('is_default')->default(false)->after('is_active');
13+
$table->date('valid_from')->nullable()->after('is_default');
14+
$table->date('valid_to')->nullable()->after('valid_from');
15+
});
16+
17+
Schema::table('price_list_items', function (Blueprint $table) {
18+
$table->unsignedBigInteger('tenant_id')->default(0)->after('id');
19+
$table->unsignedInteger('min_quantity')->default(1)->after('unit_price');
20+
});
21+
22+
// Drop the old unique constraint and add new one including min_quantity
23+
Schema::table('price_list_items', function (Blueprint $table) {
24+
$table->dropUnique(['price_list_id', 'product_id']);
25+
$table->unique(['price_list_id', 'product_id', 'min_quantity']);
26+
$table->index('tenant_id');
27+
});
28+
}
29+
30+
public function down(): void
31+
{
32+
Schema::table('price_list_items', function (Blueprint $table) {
33+
$table->dropUnique(['price_list_id', 'product_id', 'min_quantity']);
34+
$table->dropIndex(['tenant_id']);
35+
$table->unique(['price_list_id', 'product_id']);
36+
$table->dropColumn(['tenant_id', 'min_quantity']);
37+
});
38+
39+
Schema::table('price_lists', function (Blueprint $table) {
40+
$table->dropColumn(['is_default', 'valid_from', 'valid_to']);
41+
});
42+
}
43+
};

0 commit comments

Comments
 (0)