Skip to content

Commit e9af40d

Browse files
committed
Phases 151-155 complete + Phase 157 WIP: Odoo inventory features + Multi-Company
Phase 151 — Warranty Management: - ProductWarranty, WarrantyClaim models + controllers + React pages (Index/Create/Show) - WarrantyPolicy, migrations, routes Phase 152 — Put-Away Rules: - PutAwayRule model + controller + React pages (Index/Create/Edit/Show) - PutAwayRulePolicy, migration, activate/deactivate actions Phase 153 — Stock Pickings (Odoo stock.picking): - StockPicking, StockPickingLine models + controller + React pages (Index/Create/Edit/Show) - WH/IN/, WH/OUT/, WH/INT/, WH/RET/ picking number prefixes - confirm/start/validate/cancel workflow Phase 154 — Replenishment Orders: - ReplenishmentOrder model + controller + React pages (Index/Create/Show) - REP-YYYY-NNNNN numbering, buy/manufacture/resupply routes - confirm/start/complete/cancel workflow Phase 155 — Product Traceability: - TraceabilityController (read-only lot/serial movement history) - Traceability/Index.tsx with lot + serial dropdowns Phase 157 WIP — Multi-Company: - companies + company_user migrations - Company model (hierarchy, fullName accessor) - CompanyController, CompanyPolicy, Core routes Sidebar updated: Stock Pickings, Replenishments, Traceability, Put-Away Rules, Warranties, Warranty Claims added. Tests: 57 new tests. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 37be050 commit e9af40d

30 files changed

Lines changed: 3387 additions & 8 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
namespace App\Modules\Core\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Core\Models\Company;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class CompanyController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', Company::class);
17+
18+
$companies = Company::with('parent')
19+
->withCount('subsidiaries')
20+
->latest()
21+
->paginate(20)
22+
->withQueryString();
23+
24+
return Inertia::render('Core/Companies/Index', [
25+
'companies' => $companies,
26+
]);
27+
}
28+
29+
public function create(): Response
30+
{
31+
$this->authorize('create', Company::class);
32+
33+
$parents = Company::select('id', 'name', 'code')->get();
34+
35+
return Inertia::render('Core/Companies/Create', [
36+
'parents' => $parents,
37+
]);
38+
}
39+
40+
public function store(Request $request): RedirectResponse
41+
{
42+
$this->authorize('create', Company::class);
43+
44+
$validated = $request->validate([
45+
'name' => 'required|string|max:255',
46+
'code' => 'nullable|string|max:20',
47+
'tax_id' => 'nullable|string|max:100',
48+
'currency_code' => 'nullable|string|size:3',
49+
'fiscal_year_start' => 'nullable|integer|min:1|max:12',
50+
'address' => 'nullable|string',
51+
'phone' => 'nullable|string|max:50',
52+
'email' => 'nullable|email|max:255',
53+
'website' => 'nullable|string|max:255',
54+
'industry' => 'nullable|string|max:100',
55+
'is_active' => 'boolean',
56+
'parent_company_id' => 'nullable|exists:companies,id',
57+
]);
58+
59+
$validated['created_by'] = auth()->id();
60+
61+
Company::create($validated);
62+
63+
return redirect()->route('core.companies.index')
64+
->with('success', 'Company created successfully.');
65+
}
66+
67+
public function show(Company $company): Response
68+
{
69+
$this->authorize('view', $company);
70+
71+
$company->load(['parent', 'subsidiaries', 'users']);
72+
73+
return Inertia::render('Core/Companies/Show', [
74+
'company' => $company,
75+
]);
76+
}
77+
78+
public function edit(Company $company): Response
79+
{
80+
$this->authorize('update', $company);
81+
82+
$parents = Company::select('id', 'name', 'code')
83+
->where('id', '!=', $company->id)
84+
->get();
85+
86+
return Inertia::render('Core/Companies/Edit', [
87+
'company' => $company,
88+
'parents' => $parents,
89+
]);
90+
}
91+
92+
public function update(Request $request, Company $company): RedirectResponse
93+
{
94+
$this->authorize('update', $company);
95+
96+
$validated = $request->validate([
97+
'name' => 'required|string|max:255',
98+
'code' => 'nullable|string|max:20',
99+
'tax_id' => 'nullable|string|max:100',
100+
'currency_code' => 'nullable|string|size:3',
101+
'fiscal_year_start' => 'nullable|integer|min:1|max:12',
102+
'address' => 'nullable|string',
103+
'phone' => 'nullable|string|max:50',
104+
'email' => 'nullable|email|max:255',
105+
'website' => 'nullable|string|max:255',
106+
'industry' => 'nullable|string|max:100',
107+
'is_active' => 'boolean',
108+
'parent_company_id' => 'nullable|exists:companies,id',
109+
]);
110+
111+
$company->update($validated);
112+
113+
return redirect()->route('core.companies.show', $company)
114+
->with('success', 'Company updated successfully.');
115+
}
116+
117+
public function destroy(Company $company): RedirectResponse
118+
{
119+
$this->authorize('delete', $company);
120+
121+
$company->delete();
122+
123+
return redirect()->route('core.companies.index')
124+
->with('success', 'Company deleted successfully.');
125+
}
126+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
namespace App\Modules\Core\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Casts\Attribute;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
10+
use Illuminate\Database\Eloquent\Relations\HasMany;
11+
use Illuminate\Database\Eloquent\SoftDeletes;
12+
use App\Models\User;
13+
14+
class Company extends Model
15+
{
16+
use BelongsToTenant, SoftDeletes;
17+
18+
protected $fillable = [
19+
'tenant_id', 'name', 'code', 'tax_id', 'currency_code',
20+
'fiscal_year_start', 'logo_path', 'address', 'phone', 'email',
21+
'website', 'industry', 'is_active', 'parent_company_id', 'created_by',
22+
];
23+
24+
protected $casts = [
25+
'is_active' => 'boolean',
26+
'fiscal_year_start' => 'integer',
27+
];
28+
29+
public function parent(): BelongsTo
30+
{
31+
return $this->belongsTo(Company::class, 'parent_company_id');
32+
}
33+
34+
public function subsidiaries(): HasMany
35+
{
36+
return $this->hasMany(Company::class, 'parent_company_id');
37+
}
38+
39+
public function users(): BelongsToMany
40+
{
41+
return $this->belongsToMany(User::class)->withPivot('is_default')->withTimestamps();
42+
}
43+
44+
public function creator(): BelongsTo
45+
{
46+
return $this->belongsTo(User::class, 'created_by');
47+
}
48+
49+
protected function fullName(): Attribute
50+
{
51+
return Attribute::make(
52+
get: fn () => $this->code ? "{$this->name} ({$this->code})" : $this->name
53+
);
54+
}
55+
56+
protected function isParent(): Attribute
57+
{
58+
return Attribute::make(
59+
get: fn () => is_null($this->parent_company_id)
60+
);
61+
}
62+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\Core\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Models\Company;
7+
8+
class CompanyPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return true;
13+
}
14+
15+
public function view(User $user, Company $company): bool
16+
{
17+
return true;
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->hasPermissionTo('admin') || $user->hasRole(['super-admin', 'admin']);
23+
}
24+
25+
public function update(User $user, Company $company): bool
26+
{
27+
return $user->hasPermissionTo('admin') || $user->hasRole(['super-admin', 'admin']);
28+
}
29+
30+
public function delete(User $user, Company $company): bool
31+
{
32+
return $user->hasPermissionTo('admin') || $user->hasRole(['super-admin', 'admin']);
33+
}
34+
}

erp/app/Modules/Core/Providers/CoreServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
namespace App\Modules\Core\Providers;
44

55
use App\Modules\Core\Models\AuditLog;
6+
use App\Modules\Core\Models\Company;
67
use App\Modules\Core\Policies\AuditLogPolicy;
8+
use App\Modules\Core\Policies\CompanyPolicy;
79
use App\Modules\Finance\Providers\FinanceServiceProvider;
810
use App\Modules\HR\Providers\HRServiceProvider;
911
use App\Modules\Inventory\Providers\InventoryServiceProvider;
@@ -23,5 +25,6 @@ public function boot(): void
2325
{
2426
$this->loadRoutesFrom(__DIR__ . '/../routes/core.php');
2527
Gate::policy(AuditLog::class, AuditLogPolicy::class);
28+
Gate::policy(Company::class, CompanyPolicy::class);
2629
}
2730
}

erp/app/Modules/Core/routes/core.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22

33
use App\Modules\Core\Http\Controllers\AuditLogController;
4+
use App\Modules\Core\Http\Controllers\CompanyController;
45
use App\Http\Controllers\Admin\AuditLogController as AdminAuditLogController;
56
use App\Http\Controllers\Admin\UserController;
67
use App\Http\Controllers\AnalyticsController;
@@ -39,5 +40,6 @@
3940

4041
Route::middleware(['web', 'auth', 'verified'])->prefix('core')->name('core.')->group(function () {
4142
Route::resource('audit-logs', AuditLogController::class)->only(['index', 'show']);
43+
Route::resource('companies', CompanyController::class);
4244
});
4345
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Modules\Inventory\Models\LotNumber;
6+
use App\Modules\Inventory\Models\SerialNumber;
7+
use App\Modules\Inventory\Models\StockMovement;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
11+
class TraceabilityController
12+
{
13+
public function index(Request $request)
14+
{
15+
// Search by lot or serial
16+
$lotId = $request->get('lot_id');
17+
$serialId = $request->get('serial_id');
18+
$tenantId = auth()->user()->tenant_id;
19+
20+
$lots = LotNumber::where('tenant_id', $tenantId)->with('product')->orderBy('lot_number')->get(['id', 'lot_number', 'product_id']);
21+
$serials = SerialNumber::where('tenant_id', $tenantId)->with('product')->orderBy('serial_number')->get(['id', 'serial_number', 'product_id']);
22+
23+
$movements = collect();
24+
$trackedItem = null;
25+
26+
if ($lotId) {
27+
$trackedItem = LotNumber::with('product')->find($lotId);
28+
$movements = StockMovement::where('tenant_id', $tenantId)
29+
->where('lot_id', $lotId)
30+
->with(['product', 'warehouse'])
31+
->orderBy('created_at')
32+
->get();
33+
} elseif ($serialId) {
34+
$trackedItem = SerialNumber::with('product')->find($serialId);
35+
$movements = StockMovement::where('tenant_id', $tenantId)
36+
->where('serial_id', $serialId)
37+
->with(['product', 'warehouse'])
38+
->orderBy('created_at')
39+
->get();
40+
}
41+
42+
return Inertia::render('Inventory/Traceability/Index', compact('lots', 'serials', 'movements', 'trackedItem', 'lotId', 'serialId'));
43+
}
44+
}

erp/app/Modules/Inventory/Providers/InventoryServiceProvider.php

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,11 @@
8989
use App\Modules\Inventory\Models\RmaRequest;
9090
use App\Modules\Inventory\Models\RmaRequestItem;
9191
use App\Modules\Inventory\Policies\RmaRequestPolicy;
92-
use App\Modules\Inventory\Models\ProductWarranty;
93-
use App\Modules\Inventory\Models\WarrantyClaim;
94-
use App\Modules\Inventory\Policies\WarrantyPolicy;
95-
use App\Modules\Inventory\Models\PutAwayRule;
96-
use App\Modules\Inventory\Policies\PutAwayRulePolicy;
92+
use App\Modules\Inventory\Models\StockPicking;
93+
use App\Modules\Inventory\Models\StockPickingLine;
94+
use App\Modules\Inventory\Policies\StockPickingPolicy;
95+
use App\Modules\Inventory\Models\ReplenishmentOrder;
96+
use App\Modules\Inventory\Policies\ReplenishmentOrderPolicy;
9797
use Illuminate\Support\Facades\Gate;
9898
use Illuminate\Support\ServiceProvider;
9999

@@ -160,8 +160,8 @@ public function boot(): void
160160
Gate::policy(ShipmentItem::class, ShipmentPolicy::class);
161161
Gate::policy(RmaRequest::class, RmaRequestPolicy::class);
162162
Gate::policy(RmaRequestItem::class, RmaRequestPolicy::class);
163-
Gate::policy(ProductWarranty::class, WarrantyPolicy::class);
164-
Gate::policy(WarrantyClaim::class, WarrantyPolicy::class);
165-
Gate::policy(PutAwayRule::class, PutAwayRulePolicy::class);
163+
Gate::policy(StockPicking::class, StockPickingPolicy::class);
164+
Gate::policy(StockPickingLine::class, StockPickingPolicy::class);
165+
Gate::policy(ReplenishmentOrder::class, ReplenishmentOrderPolicy::class);
166166
}
167167
}

erp/app/Modules/Inventory/routes/inventory.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,29 @@
360360
Route::post('put-away-rules/{put_away_rule}/deactivate', [PutAwayRuleController::class,'deactivate'])->name('put-away-rules.deactivate');
361361
Route::resource('put-away-rules', PutAwayRuleController::class);
362362
});
363+
364+
// Stock Pickings
365+
use App\Modules\Inventory\Http\Controllers\StockPickingController;
366+
Route::middleware(['web','auth','verified'])->prefix('inventory')->name('inventory.')->group(function() {
367+
Route::post('stock-pickings/{stock_picking}/confirm', [StockPickingController::class,'confirm'])->name('stock-pickings.confirm');
368+
Route::post('stock-pickings/{stock_picking}/start', [StockPickingController::class,'startProcessing'])->name('stock-pickings.start');
369+
Route::post('stock-pickings/{stock_picking}/validate', [StockPickingController::class,'validate'])->name('stock-pickings.validate');
370+
Route::post('stock-pickings/{stock_picking}/cancel', [StockPickingController::class,'cancel'])->name('stock-pickings.cancel');
371+
Route::resource('stock-pickings', StockPickingController::class);
372+
});
373+
374+
// Replenishment Orders
375+
use App\Modules\Inventory\Http\Controllers\ReplenishmentOrderController;
376+
Route::middleware(['web','auth','verified'])->prefix('inventory')->name('inventory.')->group(function() {
377+
Route::post('replenishments/{replenishment}/confirm', [ReplenishmentOrderController::class,'confirm'])->name('replenishments.confirm');
378+
Route::post('replenishments/{replenishment}/start', [ReplenishmentOrderController::class,'markInProgress'])->name('replenishments.start');
379+
Route::post('replenishments/{replenishment}/complete', [ReplenishmentOrderController::class,'complete'])->name('replenishments.complete');
380+
Route::post('replenishments/{replenishment}/cancel', [ReplenishmentOrderController::class,'cancel'])->name('replenishments.cancel');
381+
Route::resource('replenishments', ReplenishmentOrderController::class)->except(['edit','update']);
382+
});
383+
384+
// Traceability
385+
use App\Modules\Inventory\Http\Controllers\TraceabilityController;
386+
Route::middleware(['web','auth','verified'])->prefix('inventory')->name('inventory.')->group(function() {
387+
Route::get('traceability', [TraceabilityController::class,'index'])->name('traceability.index');
388+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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::create('companies', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
14+
$table->string('name');
15+
$table->string('code', 20)->nullable();
16+
$table->string('tax_id')->nullable();
17+
$table->string('currency_code', 3)->default('USD');
18+
$table->unsignedTinyInteger('fiscal_year_start')->default(1);
19+
$table->string('logo_path')->nullable();
20+
$table->text('address')->nullable();
21+
$table->string('phone')->nullable();
22+
$table->string('email')->nullable();
23+
$table->string('website')->nullable();
24+
$table->string('industry')->nullable();
25+
$table->boolean('is_active')->default(true);
26+
$table->foreignId('parent_company_id')->nullable()->constrained('companies')->nullOnDelete();
27+
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
28+
$table->timestamps();
29+
$table->softDeletes();
30+
$table->unique(['tenant_id', 'code']);
31+
});
32+
}
33+
34+
public function down(): void
35+
{
36+
Schema::dropIfExists('companies');
37+
}
38+
};

0 commit comments

Comments
 (0)