Skip to content

Commit f921e31

Browse files
committed
feat: Phase 30 — Price Lists with product overrides and global discounts
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 06d55be commit f921e31

18 files changed

Lines changed: 954 additions & 14 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use App\Modules\Finance\Http\Requests\StoreContactRequest;
77
use App\Modules\Finance\Http\Resources\ContactResource;
88
use App\Modules\Finance\Models\Contact;
9+
use App\Modules\Finance\Models\PriceList;
910
use Illuminate\Http\RedirectResponse;
1011
use Illuminate\Http\Request;
1112
use Inertia\Inertia;
@@ -38,6 +39,7 @@ public function create(): Response
3839
$this->authorize('create', Contact::class);
3940

4041
return Inertia::render('Finance/Contacts/Create', [
42+
'priceLists' => PriceList::where('is_active', true)->orderBy('name')->get(['id', 'name']),
4143
'breadcrumbs' => [
4244
['label' => 'Finance'],
4345
['label' => 'Contacts', 'href' => route('finance.contacts.index')],
@@ -62,6 +64,7 @@ public function edit(Contact $contact): Response
6264

6365
return Inertia::render('Finance/Contacts/Edit', [
6466
'contact' => new ContactResource($contact),
67+
'priceLists' => PriceList::where('is_active', true)->orderBy('name')->get(['id', 'name']),
6568
'breadcrumbs' => [
6669
['label' => 'Finance'],
6770
['label' => 'Contacts', 'href' => route('finance.contacts.index')],
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\Contact;
7+
use App\Modules\Finance\Models\PriceList;
8+
use App\Modules\Finance\Models\PriceListItem;
9+
use App\Modules\Inventory\Models\Product;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Illuminate\Support\Facades\DB;
13+
use Inertia\Inertia;
14+
use Inertia\Response;
15+
16+
class PriceListController extends Controller
17+
{
18+
public function index(): Response
19+
{
20+
$this->authorize('viewAny', PriceList::class);
21+
22+
$priceLists = PriceList::withCount(['items', 'contacts'])
23+
->orderBy('name')
24+
->get();
25+
26+
return Inertia::render('Finance/PriceLists/Index', [
27+
'priceLists' => $priceLists,
28+
'breadcrumbs' => [
29+
['label' => 'Finance'],
30+
['label' => 'Price Lists', 'href' => route('finance.price-lists.index')],
31+
],
32+
]);
33+
}
34+
35+
public function create(): Response
36+
{
37+
$this->authorize('create', PriceList::class);
38+
39+
return Inertia::render('Finance/PriceLists/Create', [
40+
'products' => Product::orderBy('name')->get(['id', 'name', 'sku', 'sale_price']),
41+
'breadcrumbs' => [
42+
['label' => 'Finance'],
43+
['label' => 'Price Lists', 'href' => route('finance.price-lists.index')],
44+
['label' => 'New Price List'],
45+
],
46+
]);
47+
}
48+
49+
public function store(Request $request): RedirectResponse
50+
{
51+
$this->authorize('create', PriceList::class);
52+
53+
$data = $request->validate([
54+
'name' => 'required|string|max:191',
55+
'description' => 'nullable|string',
56+
'currency_code' => 'nullable|string|size:3',
57+
'discount_percent' => 'nullable|numeric|min:0|max:100',
58+
'is_active' => 'boolean',
59+
'items' => 'nullable|array',
60+
'items.*.product_id' => 'required|exists:products,id',
61+
'items.*.unit_price' => 'required|numeric|min:0',
62+
]);
63+
64+
$priceList = DB::transaction(function () use ($data, $request) {
65+
$list = PriceList::create([
66+
'tenant_id' => auth()->user()->tenant_id,
67+
'name' => $data['name'],
68+
'description' => $data['description'] ?? null,
69+
'currency_code' => $data['currency_code'] ?? 'USD',
70+
'discount_percent' => $data['discount_percent'] ?? 0,
71+
'is_active' => $data['is_active'] ?? true,
72+
]);
73+
74+
foreach ($data['items'] ?? [] as $item) {
75+
PriceListItem::create([
76+
'price_list_id' => $list->id,
77+
'product_id' => $item['product_id'],
78+
'unit_price' => $item['unit_price'],
79+
]);
80+
}
81+
82+
return $list;
83+
});
84+
85+
return redirect()->route('finance.price-lists.show', $priceList)
86+
->with('success', 'Price list created.');
87+
}
88+
89+
public function show(PriceList $priceList): Response
90+
{
91+
$this->authorize('view', $priceList);
92+
93+
$priceList->load(['items.product', 'contacts']);
94+
95+
return Inertia::render('Finance/PriceLists/Show', [
96+
'priceList' => $priceList,
97+
'breadcrumbs' => [
98+
['label' => 'Finance'],
99+
['label' => 'Price Lists', 'href' => route('finance.price-lists.index')],
100+
['label' => $priceList->name],
101+
],
102+
]);
103+
}
104+
105+
public function update(Request $request, PriceList $priceList): RedirectResponse
106+
{
107+
$this->authorize('update', $priceList);
108+
109+
$data = $request->validate([
110+
'name' => 'required|string|max:191',
111+
'description' => 'nullable|string',
112+
'currency_code' => 'nullable|string|size:3',
113+
'discount_percent' => 'nullable|numeric|min:0|max:100',
114+
'is_active' => 'boolean',
115+
'items' => 'nullable|array',
116+
'items.*.product_id' => 'required|exists:products,id',
117+
'items.*.unit_price' => 'required|numeric|min:0',
118+
]);
119+
120+
DB::transaction(function () use ($data, $priceList) {
121+
$priceList->update([
122+
'name' => $data['name'],
123+
'description' => $data['description'] ?? null,
124+
'currency_code' => $data['currency_code'] ?? 'USD',
125+
'discount_percent' => $data['discount_percent'] ?? 0,
126+
'is_active' => $data['is_active'] ?? true,
127+
]);
128+
129+
// Sync items
130+
$priceList->items()->delete();
131+
foreach ($data['items'] ?? [] as $item) {
132+
PriceListItem::create([
133+
'price_list_id' => $priceList->id,
134+
'product_id' => $item['product_id'],
135+
'unit_price' => $item['unit_price'],
136+
]);
137+
}
138+
});
139+
140+
return redirect()->route('finance.price-lists.show', $priceList)
141+
->with('success', 'Price list updated.');
142+
}
143+
144+
public function destroy(PriceList $priceList): RedirectResponse
145+
{
146+
$this->authorize('delete', $priceList);
147+
148+
$priceList->delete();
149+
150+
return redirect()->route('finance.price-lists.index')
151+
->with('success', 'Price list deleted.');
152+
}
153+
154+
public function priceForContact(Request $request)
155+
{
156+
$this->authorize('viewAny', PriceList::class);
157+
158+
$data = $request->validate([
159+
'contact_id' => 'required|exists:contacts,id',
160+
'product_id' => 'required|exists:products,id',
161+
]);
162+
163+
$contact = Contact::find($data['contact_id']);
164+
$product = Product::find($data['product_id']);
165+
166+
if (!$contact->price_list_id) {
167+
return response()->json(['price' => (float) $product->sale_price]);
168+
}
169+
170+
$price = PriceList::priceFor($contact->price_list_id, $data['product_id'], (float) $product->sale_price);
171+
return response()->json(['price' => $price]);
172+
}
173+
}

erp/app/Modules/Finance/Http/Requests/StoreContactRequest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ public function rules(): array
1818
'address' => ['nullable', 'string'],
1919
'type' => ['required', Rule::in(['customer', 'vendor', 'both'])],
2020
'notes' => ['nullable', 'string'],
21-
'is_active' => ['boolean'],
21+
'is_active' => ['boolean'],
22+
'price_list_id' => ['nullable', 'exists:price_lists,id'],
2223
];
2324
}
2425
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Modules\Core\Traits\BelongsToTenant;
66
use App\Modules\Core\Traits\HasAuditLog;
77
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
89
use Illuminate\Database\Eloquent\Relations\HasMany;
910
use Illuminate\Database\Eloquent\SoftDeletes;
1011

@@ -16,7 +17,7 @@ class Contact extends Model
1617

1718
protected $fillable = [
1819
'tenant_id', 'name', 'email', 'phone',
19-
'address', 'type', 'notes', 'is_active',
20+
'address', 'type', 'price_list_id', 'notes', 'is_active',
2021
];
2122

2223
protected $casts = ['is_active' => 'boolean'];
@@ -26,6 +27,11 @@ public function invoices(): HasMany
2627
return $this->hasMany(Invoice::class);
2728
}
2829

30+
public function priceList(): BelongsTo
31+
{
32+
return $this->belongsTo(PriceList::class);
33+
}
34+
2935
public function scopeCustomers($query)
3036
{
3137
return $query->whereIn('type', ['customer', 'both']);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\HasMany;
8+
use Illuminate\Database\Eloquent\SoftDeletes;
9+
10+
class PriceList extends Model
11+
{
12+
use BelongsToTenant;
13+
use SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id', 'name', 'description', 'currency_code', 'discount_percent', 'is_active',
17+
];
18+
19+
protected $casts = [
20+
'discount_percent' => 'float',
21+
'is_active' => 'boolean',
22+
];
23+
24+
public function items(): HasMany
25+
{
26+
return $this->hasMany(PriceListItem::class);
27+
}
28+
29+
public function contacts(): HasMany
30+
{
31+
return $this->hasMany(Contact::class, 'price_list_id');
32+
}
33+
34+
public static function priceFor(int $priceListId, int $productId, float $defaultPrice): float
35+
{
36+
// First check for a product-specific override
37+
$item = PriceListItem::where('price_list_id', $priceListId)
38+
->where('product_id', $productId)
39+
->first();
40+
if ($item) return (float) $item->unit_price;
41+
42+
// Fall back to global discount
43+
$list = static::find($priceListId);
44+
if ($list && $list->discount_percent > 0) {
45+
return round($defaultPrice * (1 - $list->discount_percent / 100), 4);
46+
}
47+
48+
return $defaultPrice;
49+
}
50+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class PriceListItem extends Model
9+
{
10+
protected $fillable = [
11+
'price_list_id', 'product_id', 'unit_price',
12+
];
13+
14+
protected $casts = [
15+
'unit_price' => 'float',
16+
];
17+
18+
public function priceList(): BelongsTo
19+
{
20+
return $this->belongsTo(PriceList::class);
21+
}
22+
23+
public function product(): BelongsTo
24+
{
25+
return $this->belongsTo(\App\Modules\Inventory\Models\Product::class);
26+
}
27+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\PriceList;
7+
8+
class PriceListPolicy
9+
{
10+
public function viewAny(User $user): bool { return $user->can('finance.view'); }
11+
public function view(User $user, PriceList $priceList): bool { return $user->can('finance.view'); }
12+
public function create(User $user): bool { return $user->can('finance.create'); }
13+
public function update(User $user, PriceList $priceList): bool { return $user->can('finance.create'); }
14+
public function delete(User $user, PriceList $priceList): bool { return $user->can('finance.delete'); }
15+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
use App\Modules\Finance\Models\RecurringInvoice;
1616
use App\Modules\Finance\Models\SalesOrder;
1717
use App\Modules\Finance\Models\Budget;
18+
use App\Modules\Finance\Models\PriceList;
1819
use App\Modules\Finance\Models\DepreciationEntry;
1920
use App\Modules\Finance\Models\FixedAsset;
2021
use App\Modules\Finance\Policies\AccountPolicy;
22+
use App\Modules\Finance\Policies\PriceListPolicy;
2123
use App\Modules\Finance\Policies\BudgetPolicy;
2224
use App\Modules\Finance\Policies\FixedAssetPolicy;
2325
use App\Modules\Finance\Policies\BankAccountPolicy;
@@ -57,6 +59,7 @@ public function boot(): void
5759
Gate::policy(BankTransaction::class, BankTransactionPolicy::class);
5860
Gate::policy(FixedAsset::class, FixedAssetPolicy::class);
5961
Gate::policy(DepreciationEntry::class, FixedAssetPolicy::class);
62+
Gate::policy(PriceList::class, PriceListPolicy::class);
6063

6164
if ($this->app->runningInConsole()) {
6265
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use App\Modules\Finance\Http\Controllers\ReportController;
1717
use App\Modules\Finance\Http\Controllers\SalesOrderController;
1818
use App\Modules\Finance\Http\Controllers\FixedAssetController;
19+
use App\Modules\Finance\Http\Controllers\PriceListController;
1920
use Illuminate\Support\Facades\Route;
2021

2122
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
@@ -141,4 +142,8 @@
141142
Route::post('fixed-assets/{fixedAsset}/depreciate', [FixedAssetController::class, 'depreciate'])->name('fixed-assets.depreciate');
142143
Route::post('fixed-assets/{fixedAsset}/dispose', [FixedAssetController::class, 'dispose'])->name('fixed-assets.dispose');
143144
Route::resource('fixed-assets', FixedAssetController::class)->except(['edit', 'update']);
145+
146+
// Price Lists
147+
Route::get('price-lists/price-for-contact', [PriceListController::class, 'priceForContact'])->name('price-lists.price-for-contact');
148+
Route::resource('price-lists', PriceListController::class)->except(['edit']);
144149
});

0 commit comments

Comments
 (0)