Skip to content

Commit 29ab535

Browse files
committed
feat: Manufacturing Scheduling Engine + E-commerce Storefront — 35 tests passing
Manufacturing Scheduling Engine (10 tests): - ProductionSchedule: assigns work orders to work centers by time slot, conflict detection (overlapsWithWorkCenter), confirm/start/complete lifecycle - WorkCenterCapacity: per-day-of-week available hours configuration - ScrapOrder: record material waste linked to manufacturing orders - ShopFloor: real-time board of in_progress MOs with work order start/finish - SchedulingController: Gantt view, schedule/confirm/start/complete, capacity CRUD - ScrapController + ShopFloorController - Gantt.tsx: weekly Gantt chart with colored bars by status + conflict indicator - Capacity.tsx: work center capacity per day table - ShopFloor/Index.tsx: live MO board with progress bars - Scrap/Index.tsx: scrap recording form - 3 migrations: production_schedules, work_center_capacities, scrap_orders E-commerce Storefront (25 tests): - StoreCoupon: fixed/percentage discount, validity window, usage limits, isValid(), applyTo(), redeem() - StoreReview: rating 1-5, approve flow, linked to product + order - StoreCart: session-based cart (no auth required), multi-item support - CartController: add/update/remove/clear + Cart.tsx page - CouponController: admin CRUD + public validate endpoint (JSON response) - ReviewController: public submit + admin list/approve/delete - Checkout updated: cart items + coupon code input with Apply button - Product page updated: review section with star selector + approved reviews list - Coupons/Index.tsx + Reviews/Index.tsx admin pages - 3 migrations: store_coupons, store_reviews, store_carts https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent b60b8f6 commit 29ab535

31 files changed

Lines changed: 2907 additions & 2 deletions
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
namespace App\Modules\Ecommerce\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Ecommerce\Models\StoreCart;
7+
use App\Modules\Ecommerce\Models\StoreProduct;
8+
use App\Modules\Ecommerce\Models\StoreSettings;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class CartController extends Controller
15+
{
16+
private function getSessionKey(Request $request): string
17+
{
18+
return $request->session()->getId();
19+
}
20+
21+
public function index(Request $request, string $slug): Response
22+
{
23+
$store = StoreSettings::where('store_slug', $slug)->where('is_active', true)->firstOrFail();
24+
25+
$sessionKey = $this->getSessionKey($request);
26+
$cartItems = StoreCart::getOrCreateForSession($sessionKey)
27+
->filter(fn ($item) => $item->tenant_id === null || $item->tenant_id === $store->tenant_id)
28+
->map(fn ($item) => [
29+
'id' => $item->id,
30+
'quantity' => $item->quantity,
31+
'store_product_id' => $item->store_product_id,
32+
'product_name' => $item->product?->product?->name ?? 'Product',
33+
'product_sku' => $item->product?->product?->sku,
34+
'unit_price' => $item->product?->store_price ?? 0,
35+
'line_total' => ($item->product?->store_price ?? 0) * $item->quantity,
36+
])->values();
37+
38+
return Inertia::render('Ecommerce/Storefront/Cart', [
39+
'store' => $store,
40+
'cartItems' => $cartItems,
41+
]);
42+
}
43+
44+
public function add(Request $request, string $slug): RedirectResponse
45+
{
46+
$store = StoreSettings::where('store_slug', $slug)->where('is_active', true)->firstOrFail();
47+
48+
$data = $request->validate([
49+
'store_product_id' => 'required|exists:store_products,id',
50+
'quantity' => 'required|integer|min:1',
51+
]);
52+
53+
$product = StoreProduct::where('id', $data['store_product_id'])
54+
->where('tenant_id', $store->tenant_id)
55+
->where('is_visible', true)
56+
->firstOrFail();
57+
58+
$sessionKey = $this->getSessionKey($request);
59+
60+
$existing = StoreCart::where('session_key', $sessionKey)
61+
->where('store_product_id', $product->id)
62+
->first();
63+
64+
if ($existing) {
65+
$existing->increment('quantity', $data['quantity']);
66+
} else {
67+
StoreCart::create([
68+
'tenant_id' => $store->tenant_id,
69+
'session_key' => $sessionKey,
70+
'store_product_id' => $product->id,
71+
'quantity' => $data['quantity'],
72+
]);
73+
}
74+
75+
return back()->with('success', 'Item added to cart.');
76+
}
77+
78+
public function update(Request $request, string $slug, StoreCart $cartItem): RedirectResponse
79+
{
80+
$data = $request->validate([
81+
'quantity' => 'required|integer|min:0',
82+
]);
83+
84+
if ($data['quantity'] === 0) {
85+
$cartItem->delete();
86+
} else {
87+
$cartItem->update(['quantity' => $data['quantity']]);
88+
}
89+
90+
return back()->with('success', 'Cart updated.');
91+
}
92+
93+
public function remove(string $slug, StoreCart $cartItem): RedirectResponse
94+
{
95+
$cartItem->delete();
96+
97+
return back()->with('success', 'Item removed from cart.');
98+
}
99+
100+
public function clear(Request $request, string $slug): RedirectResponse
101+
{
102+
$sessionKey = $this->getSessionKey($request);
103+
StoreCart::where('session_key', $sessionKey)->delete();
104+
105+
return back()->with('success', 'Cart cleared.');
106+
}
107+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace App\Modules\Ecommerce\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Ecommerce\Models\StoreCoupon;
7+
use App\Modules\Ecommerce\Models\StoreSettings;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class CouponController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$tenantId = auth()->user()->tenant_id;
19+
20+
$coupons = StoreCoupon::where('tenant_id', $tenantId)
21+
->orderByDesc('created_at')
22+
->paginate(20);
23+
24+
return Inertia::render('Ecommerce/Coupons/Index', [
25+
'coupons' => $coupons,
26+
]);
27+
}
28+
29+
public function store(Request $request): RedirectResponse
30+
{
31+
$tenantId = auth()->user()->tenant_id;
32+
33+
$data = $request->validate([
34+
'code' => ['required', 'string', 'max:50',
35+
\Illuminate\Validation\Rule::unique('store_coupons')->where(fn ($q) => $q->where('tenant_id', $tenantId))->whereNull('deleted_at'),
36+
],
37+
'type' => 'required|in:fixed,percentage',
38+
'value' => 'required|numeric|min:0',
39+
'min_order_amount' => 'nullable|numeric|min:0',
40+
'max_uses' => 'nullable|integer|min:1',
41+
'valid_from' => 'nullable|date',
42+
'valid_until' => 'nullable|date|after_or_equal:valid_from',
43+
'is_active' => 'boolean',
44+
]);
45+
46+
StoreCoupon::create(array_merge($data, ['tenant_id' => $tenantId]));
47+
48+
return back()->with('success', 'Coupon created.');
49+
}
50+
51+
public function destroy(StoreCoupon $coupon): RedirectResponse
52+
{
53+
$coupon->delete();
54+
55+
return back()->with('success', 'Coupon deleted.');
56+
}
57+
58+
public function validate(Request $request, string $slug): JsonResponse
59+
{
60+
$store = StoreSettings::where('store_slug', $slug)->where('is_active', true)->firstOrFail();
61+
62+
$data = $request->validate([
63+
'code' => 'required|string',
64+
'subtotal' => 'nullable|numeric|min:0',
65+
]);
66+
67+
$coupon = StoreCoupon::where('tenant_id', $store->tenant_id)
68+
->where('code', strtoupper($data['code']))
69+
->first();
70+
71+
if (! $coupon) {
72+
$coupon = StoreCoupon::where('tenant_id', $store->tenant_id)
73+
->where('code', $data['code'])
74+
->first();
75+
}
76+
77+
if (! $coupon || ! $coupon->isValid()) {
78+
return response()->json([
79+
'valid' => false,
80+
'discount_amount' => 0,
81+
'message' => 'Invalid or expired coupon code.',
82+
]);
83+
}
84+
85+
$subtotal = (float) ($data['subtotal'] ?? 0);
86+
87+
if ($subtotal < $coupon->min_order_amount) {
88+
return response()->json([
89+
'valid' => false,
90+
'discount_amount' => 0,
91+
'message' => 'Order does not meet minimum amount for this coupon.',
92+
]);
93+
}
94+
95+
$discount = $coupon->applyTo($subtotal);
96+
97+
return response()->json([
98+
'valid' => true,
99+
'discount_amount' => $discount,
100+
'message' => 'Coupon applied successfully.',
101+
]);
102+
}
103+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
namespace App\Modules\Ecommerce\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Ecommerce\Models\StoreProduct;
7+
use App\Modules\Ecommerce\Models\StoreReview;
8+
use App\Modules\Ecommerce\Models\StoreSettings;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class ReviewController extends Controller
15+
{
16+
public function store(Request $request, string $slug, StoreProduct $storeProduct): RedirectResponse
17+
{
18+
$store = StoreSettings::where('store_slug', $slug)->where('is_active', true)->firstOrFail();
19+
20+
$data = $request->validate([
21+
'reviewer_name' => 'required|string|max:255',
22+
'reviewer_email' => 'nullable|email|max:255',
23+
'rating' => 'required|integer|min:1|max:5',
24+
'title' => 'nullable|string|max:255',
25+
'body' => 'nullable|string',
26+
]);
27+
28+
StoreReview::create(array_merge($data, [
29+
'tenant_id' => $store->tenant_id,
30+
'store_product_id' => $storeProduct->id,
31+
'is_approved' => false,
32+
]));
33+
34+
return back()->with('success', 'Review submitted. It will appear once approved.');
35+
}
36+
37+
public function index(Request $request): Response
38+
{
39+
$tenantId = auth()->user()->tenant_id;
40+
41+
$reviews = StoreReview::with('product.product')
42+
->where('tenant_id', $tenantId)
43+
->when($request->filled('rating'), fn ($q) => $q->where('rating', $request->rating))
44+
->when($request->filled('is_approved'), fn ($q) => $q->where('is_approved', $request->boolean('is_approved')))
45+
->orderByDesc('created_at')
46+
->paginate(20)
47+
->through(fn ($r) => [
48+
'id' => $r->id,
49+
'reviewer_name' => $r->reviewer_name,
50+
'rating' => $r->rating,
51+
'title' => $r->title,
52+
'body' => $r->body,
53+
'is_approved' => $r->is_approved,
54+
'created_at' => $r->created_at,
55+
'product' => $r->product ? [
56+
'id' => $r->product->id,
57+
'name' => $r->product->product?->name ?? 'Product',
58+
] : null,
59+
]);
60+
61+
return Inertia::render('Ecommerce/Reviews/Index', [
62+
'reviews' => $reviews,
63+
'filters' => $request->only(['rating', 'is_approved']),
64+
]);
65+
}
66+
67+
public function approve(StoreReview $review): RedirectResponse
68+
{
69+
$review->approve();
70+
71+
return back()->with('success', 'Review approved.');
72+
}
73+
74+
public function destroy(StoreReview $review): RedirectResponse
75+
{
76+
$review->delete();
77+
78+
return back()->with('success', 'Review deleted.');
79+
}
80+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace App\Modules\Ecommerce\Models;
4+
5+
use Illuminate\Database\Eloquent\Collection;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class StoreCart extends Model
10+
{
11+
protected $table = 'store_carts';
12+
13+
protected $fillable = [
14+
'tenant_id',
15+
'session_key',
16+
'store_product_id',
17+
'quantity',
18+
];
19+
20+
public function product(): BelongsTo
21+
{
22+
return $this->belongsTo(StoreProduct::class, 'store_product_id');
23+
}
24+
25+
public static function getOrCreateForSession(string $sessionKey): Collection
26+
{
27+
return static::where('session_key', $sessionKey)->with('product.product')->get();
28+
}
29+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
namespace App\Modules\Ecommerce\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\SoftDeletes;
8+
use Illuminate\Support\Carbon;
9+
10+
class StoreCoupon extends Model
11+
{
12+
use BelongsToTenant;
13+
use SoftDeletes;
14+
15+
protected $table = 'store_coupons';
16+
17+
protected $fillable = [
18+
'tenant_id',
19+
'code',
20+
'type',
21+
'value',
22+
'min_order_amount',
23+
'max_uses',
24+
'uses_count',
25+
'valid_from',
26+
'valid_until',
27+
'is_active',
28+
];
29+
30+
protected $casts = [
31+
'is_active' => 'boolean',
32+
'valid_from' => 'date',
33+
'valid_until' => 'date',
34+
];
35+
36+
public function isValid(): bool
37+
{
38+
if (! $this->is_active) {
39+
return false;
40+
}
41+
42+
$today = Carbon::today();
43+
44+
if ($this->valid_from !== null && $this->valid_from->gt($today)) {
45+
return false;
46+
}
47+
48+
if ($this->valid_until !== null && $this->valid_until->lt($today)) {
49+
return false;
50+
}
51+
52+
if ($this->max_uses !== null && $this->uses_count >= $this->max_uses) {
53+
return false;
54+
}
55+
56+
return true;
57+
}
58+
59+
public function applyTo(float $subtotal): float
60+
{
61+
if ($this->type === 'percentage') {
62+
return round($subtotal * $this->value / 100, 2);
63+
}
64+
65+
return min((float) $this->value, $subtotal);
66+
}
67+
68+
public function redeem(): void
69+
{
70+
$this->increment('uses_count');
71+
}
72+
}

0 commit comments

Comments
 (0)