Commerce administration dashboard
* Product catalogue * Product categories * Physical, digital, service, and bundle product types * Product variants and option storage * SKU management * Product publishing states * Pricing and comparison pricing * Inventory quantities * Low-stock thresholds * Inventory movement history * Customer records and addresses * Orders and order items * Payment and fulfilment statuses * Payment transaction storage * Promotions and coupon codes * Shipping methods * Tax rates * Payment, shipping, tax, and inventory provider contracts * Default manual payment provider * Default flat-rate shipping provider * Public product REST API * Administration navigation * Commerce permissions * Module registration * Module boot tests
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
return [
|
||||
'currency' => env('COMMERCE_CURRENCY', 'GBP'),
|
||||
'currency_symbol' => env('COMMERCE_CURRENCY_SYMBOL', '£'),
|
||||
'prices_include_tax' => (bool) env('COMMERCE_PRICES_INCLUDE_TAX', true),
|
||||
'low_stock_threshold' => (int) env('COMMERCE_LOW_STOCK_THRESHOLD', 5),
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Contracts;
|
||||
use Modules\Commerce\Models\Product;
|
||||
interface InventoryProvider { public function adjust(Product $product, int $quantity, string $reason, ?int $userId = null): void; public function available(Product $product): int; }
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Contracts;
|
||||
use Modules\Commerce\Models\Order;
|
||||
interface PaymentProvider { public function charge(Order $order, array $data = []): array; public function refund(Order $order, float $amount): array; }
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Contracts;
|
||||
interface ShippingProvider { public function quote(array $address, float $subtotal): float; }
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Contracts;
|
||||
interface TaxProvider { public function calculate(float $subtotal, array $address = []): float; }
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('commerce_categories', function (Blueprint $table) { $table->id(); $table->foreignId('parent_id')->nullable()->constrained('commerce_categories')->nullOnDelete(); $table->string('name'); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->boolean('is_active')->default(true); $table->unsignedInteger('sort_order')->default(0); $table->timestamps(); });
|
||||
Schema::create('commerce_products', function (Blueprint $table) { $table->id(); $table->foreignId('category_id')->nullable()->constrained('commerce_categories')->nullOnDelete(); $table->string('type')->default('physical'); $table->string('name'); $table->string('slug')->unique(); $table->string('sku')->nullable()->unique(); $table->text('summary')->nullable(); $table->longText('description')->nullable(); $table->decimal('price', 12, 2)->default(0); $table->decimal('compare_at_price', 12, 2)->nullable(); $table->decimal('cost_price', 12, 2)->nullable(); $table->string('currency', 3)->default('GBP'); $table->boolean('track_inventory')->default(true); $table->integer('stock_quantity')->default(0); $table->integer('low_stock_threshold')->default(5); $table->boolean('is_featured')->default(false); $table->string('status')->default('draft'); $table->timestamp('published_at')->nullable(); $table->json('metadata')->nullable(); $table->timestamps(); $table->softDeletes(); $table->index(['status','published_at']); });
|
||||
Schema::create('commerce_product_variants', function (Blueprint $table) { $table->id(); $table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete(); $table->string('name'); $table->string('sku')->unique(); $table->decimal('price', 12, 2)->nullable(); $table->integer('stock_quantity')->default(0); $table->json('options')->nullable(); $table->boolean('is_active')->default(true); $table->timestamps(); });
|
||||
Schema::create('commerce_customers', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('email')->index(); $table->string('first_name'); $table->string('last_name'); $table->string('company')->nullable(); $table->string('phone')->nullable(); $table->string('tax_number')->nullable(); $table->json('billing_address')->nullable(); $table->json('shipping_address')->nullable(); $table->string('status')->default('active'); $table->timestamps(); });
|
||||
Schema::create('commerce_orders', function (Blueprint $table) { $table->id(); $table->string('number')->unique(); $table->foreignId('customer_id')->nullable()->constrained('commerce_customers')->nullOnDelete(); $table->string('email'); $table->string('status')->default('pending'); $table->string('payment_status')->default('unpaid'); $table->string('fulfilment_status')->default('unfulfilled'); $table->string('currency',3)->default('GBP'); $table->decimal('subtotal',12,2)->default(0); $table->decimal('discount_total',12,2)->default(0); $table->decimal('shipping_total',12,2)->default(0); $table->decimal('tax_total',12,2)->default(0); $table->decimal('grand_total',12,2)->default(0); $table->json('billing_address')->nullable(); $table->json('shipping_address')->nullable(); $table->text('notes')->nullable(); $table->timestamp('placed_at')->nullable(); $table->timestamps(); $table->index(['status','payment_status']); });
|
||||
Schema::create('commerce_order_items', function (Blueprint $table) { $table->id(); $table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete(); $table->foreignId('product_id')->nullable()->constrained('commerce_products')->nullOnDelete(); $table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->nullOnDelete(); $table->string('name'); $table->string('sku')->nullable(); $table->unsignedInteger('quantity'); $table->decimal('unit_price',12,2); $table->decimal('tax_total',12,2)->default(0); $table->decimal('line_total',12,2); $table->json('metadata')->nullable(); $table->timestamps(); });
|
||||
Schema::create('commerce_inventory_movements', function (Blueprint $table) { $table->id(); $table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete(); $table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->cascadeOnDelete(); $table->integer('quantity'); $table->string('reason'); $table->string('reference_type')->nullable(); $table->unsignedBigInteger('reference_id')->nullable(); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->text('notes')->nullable(); $table->timestamps(); $table->index(['reference_type','reference_id']); });
|
||||
Schema::create('commerce_promotions', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('code')->nullable()->unique(); $table->string('type')->default('percentage'); $table->decimal('value',12,2); $table->decimal('minimum_order',12,2)->nullable(); $table->unsignedInteger('usage_limit')->nullable(); $table->unsignedInteger('times_used')->default(0); $table->timestamp('starts_at')->nullable(); $table->timestamp('ends_at')->nullable(); $table->boolean('is_active')->default(true); $table->json('conditions')->nullable(); $table->timestamps(); });
|
||||
Schema::create('commerce_shipping_methods', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('code')->unique(); $table->decimal('price',12,2)->default(0); $table->decimal('free_over',12,2)->nullable(); $table->boolean('is_active')->default(true); $table->json('settings')->nullable(); $table->timestamps(); });
|
||||
Schema::create('commerce_tax_rates', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('country_code',2)->nullable(); $table->decimal('rate',7,4)->default(0); $table->boolean('is_default')->default(false); $table->boolean('is_active')->default(true); $table->timestamps(); });
|
||||
Schema::create('commerce_payment_transactions', function (Blueprint $table) { $table->id(); $table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete(); $table->string('provider'); $table->string('provider_reference')->nullable()->index(); $table->string('type')->default('payment'); $table->string('status')->default('pending'); $table->decimal('amount',12,2); $table->string('currency',3); $table->json('payload')->nullable(); $table->timestamps(); });
|
||||
}
|
||||
public function down(): void
|
||||
{
|
||||
foreach (['commerce_payment_transactions','commerce_tax_rates','commerce_shipping_methods','commerce_promotions','commerce_inventory_movements','commerce_order_items','commerce_orders','commerce_customers','commerce_product_variants','commerce_products','commerce_categories'] as $table) Schema::dropIfExists($table);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Commerce\Models\Customer;
|
||||
use Modules\Commerce\Models\Order;
|
||||
use Modules\Commerce\Models\Product;
|
||||
use Modules\Commerce\Models\Promotion;
|
||||
final class CommerceController extends Controller
|
||||
{
|
||||
public function index(): View { return view('commerce::admin.index',['products'=>Product::latest()->paginate(20),'orders'=>Order::latest()->limit(10)->get(),'revenue'=>Order::where('payment_status','paid')->sum('grand_total'),'lowStock'=>Product::where('track_inventory',true)->whereColumn('stock_quantity','<=','low_stock_threshold')->count(),'customers'=>Customer::count(),'promotions'=>Promotion::where('is_active',true)->count()]); }
|
||||
public function storeProduct(Request $request): RedirectResponse
|
||||
{
|
||||
$data=$request->validate(['name'=>['required','string','max:255'],'slug'=>['nullable','string','max:255','unique:commerce_products,slug'],'sku'=>['nullable','string','max:120','unique:commerce_products,sku'],'type'=>['required','in:physical,digital,service,bundle'],'summary'=>['nullable','string'],'description'=>['nullable','string'],'price'=>['required','numeric','min:0'],'stock_quantity'=>['required','integer'],'status'=>['required','in:draft,active,archived']]);
|
||||
$data['slug']=$data['slug'] ?: Str::slug($data['name']); $data['currency']=config('commerce.currency','GBP'); $data['published_at']=$data['status']==='active'?now():null; Product::create($data); return back()->with('status','Product created.');
|
||||
}
|
||||
public function updateProduct(Request $request, Product $product): RedirectResponse { $product->update($request->validate(['name'=>['required','string','max:255'],'price'=>['required','numeric','min:0'],'stock_quantity'=>['required','integer'],'status'=>['required','in:draft,active,archived']])); return back()->with('status','Product updated.'); }
|
||||
public function destroyProduct(Product $product): RedirectResponse { $product->delete(); return back()->with('status','Product deleted.'); }
|
||||
public function updateOrder(Request $request, Order $order): RedirectResponse { $order->update($request->validate(['status'=>['required','in:pending,processing,completed,cancelled,refunded'],'payment_status'=>['required','in:unpaid,authorised,paid,partially_refunded,refunded,failed'],'fulfilment_status'=>['required','in:unfulfilled,partial,fulfilled,returned']])); return back()->with('status','Order updated.'); }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Commerce\Models\Product;
|
||||
final class ProductApiController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse { return response()->json(Product::query()->where('status','active')->where(fn($q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->latest()->paginate(24)); }
|
||||
public function show(string $product): JsonResponse { return response()->json(Product::where('status','active')->where(fn($q)=>$q->where('slug',$product)->orWhere('id',$product))->firstOrFail()); }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
final class Category extends Model
|
||||
{
|
||||
|
||||
protected $table = 'commerce_categories';
|
||||
protected $guarded = [];
|
||||
protected function casts(): array { return ['is_active'=>'boolean']; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
final class Customer extends Model
|
||||
{
|
||||
|
||||
protected $table = 'commerce_customers';
|
||||
protected $guarded = [];
|
||||
protected function casts(): array { return ['billing_address'=>'array','shipping_address'=>'array']; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
final class InventoryMovement extends Model
|
||||
{
|
||||
|
||||
protected $table = 'commerce_inventory_movements';
|
||||
protected $guarded = [];
|
||||
protected function casts(): array { return []; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
final class Order extends Model
|
||||
{
|
||||
|
||||
protected $table = 'commerce_orders';
|
||||
protected $guarded = [];
|
||||
protected function casts(): array { return ['subtotal'=>'decimal:2','discount_total'=>'decimal:2','shipping_total'=>'decimal:2','tax_total'=>'decimal:2','grand_total'=>'decimal:2','billing_address'=>'array','shipping_address'=>'array','placed_at'=>'datetime']; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
final class OrderItem extends Model
|
||||
{
|
||||
|
||||
protected $table = 'commerce_order_items';
|
||||
protected $guarded = [];
|
||||
protected function casts(): array { return ['unit_price'=>'decimal:2','tax_total'=>'decimal:2','line_total'=>'decimal:2','metadata'=>'array']; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
final class Product extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
protected $table = 'commerce_products';
|
||||
protected $guarded = [];
|
||||
protected function casts(): array { return ['price'=>'decimal:2','compare_at_price'=>'decimal:2','cost_price'=>'decimal:2','track_inventory'=>'boolean','is_featured'=>'boolean','published_at'=>'datetime','metadata'=>'array']; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
final class ProductVariant extends Model
|
||||
{
|
||||
|
||||
protected $table = 'commerce_product_variants';
|
||||
protected $guarded = [];
|
||||
protected function casts(): array { return ['price'=>'decimal:2','options'=>'array','is_active'=>'boolean']; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
final class Promotion extends Model
|
||||
{
|
||||
|
||||
protected $table = 'commerce_promotions';
|
||||
protected $guarded = [];
|
||||
protected function casts(): array { return ['value'=>'decimal:2','minimum_order'=>'decimal:2','starts_at'=>'datetime','ends_at'=>'datetime','is_active'=>'boolean','conditions'=>'array']; }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
// Relationship reference: Product belongsTo Category and hasMany ProductVariant; Order hasMany OrderItem and belongsTo Customer.
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Providers;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Commerce\Contracts\InventoryProvider;
|
||||
use Modules\Commerce\Contracts\PaymentProvider;
|
||||
use Modules\Commerce\Contracts\ShippingProvider;
|
||||
use Modules\Commerce\Contracts\TaxProvider;
|
||||
use Modules\Commerce\Services\DatabaseInventoryProvider;
|
||||
use Modules\Commerce\Services\ManualPaymentProvider;
|
||||
use Modules\Commerce\Services\FlatRateShippingProvider;
|
||||
use Modules\Commerce\Services\ConfiguredTaxProvider;
|
||||
final class CommerceServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->register(RouteServiceProvider::class);
|
||||
$this->mergeConfigFrom(module_path('Commerce', 'Config/config.php'), 'commerce');
|
||||
$this->app->bind(InventoryProvider::class, DatabaseInventoryProvider::class);
|
||||
$this->app->bind(PaymentProvider::class, ManualPaymentProvider::class);
|
||||
$this->app->bind(ShippingProvider::class, FlatRateShippingProvider::class);
|
||||
$this->app->bind(TaxProvider::class, ConfiguredTaxProvider::class);
|
||||
}
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadMigrationsFrom(module_path('Commerce', 'Database/Migrations'));
|
||||
$this->loadViewsFrom(module_path('Commerce', 'Resources/views'), 'commerce');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Providers;
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
final class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
Route::middleware('web')->group(module_path('Commerce', 'Routes/web.php'));
|
||||
Route::prefix('api/v1')->middleware('api')->group(module_path('Commerce', 'Routes/api.php'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<x-layouts.app :title="__('Commerce')"><div class="space-y-8"><div><h1 class="text-2xl font-semibold">Commerce</h1><p class="text-sm text-zinc-500">Catalogue, stock, customers, orders and promotions.</p></div>@if(session('status'))<div class="rounded border border-green-300 bg-green-50 p-3 text-green-800">{{ session('status') }}</div>@endif
|
||||
<div class="grid gap-4 md:grid-cols-5"><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Products</div><div class="text-2xl font-semibold">{{ $products->total() }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Orders</div><div class="text-2xl font-semibold">{{ \Modules\Commerce\Models\Order::count() }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Revenue</div><div class="text-2xl font-semibold">{{ config('commerce.currency_symbol','£') }}{{ number_format($revenue,2) }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Customers</div><div class="text-2xl font-semibold">{{ $customers }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Low stock</div><div class="text-2xl font-semibold">{{ $lowStock }}</div></div></div>
|
||||
<div class="grid gap-6 lg:grid-cols-3"><form method="POST" action="{{ route('administration.commerce.products.store') }}" class="space-y-4 rounded-xl border p-5 lg:col-span-1">@csrf<h2 class="font-semibold">Add product</h2><flux:input name="name" label="Name" required/><div class="grid gap-4 md:grid-cols-2"><flux:input name="sku" label="SKU"/><flux:select name="type" label="Type"><option value="physical">Physical</option><option value="digital">Digital</option><option value="service">Service</option><option value="bundle">Bundle</option></flux:select><flux:input name="price" type="number" step="0.01" min="0" label="Price" required/><flux:input name="stock_quantity" type="number" value="0" label="Stock" required/></div><flux:textarea name="summary" label="Summary"/><flux:select name="status" label="Status"><option value="draft">Draft</option><option value="active">Active</option><option value="archived">Archived</option></flux:select><flux:button type="submit" variant="primary">Create product</flux:button></form>
|
||||
<div class="overflow-hidden rounded-xl border lg:col-span-2"><table class="w-full text-sm"><thead class="bg-zinc-50"><tr><th class="p-3 text-left">Product</th><th class="p-3 text-left">SKU</th><th class="p-3 text-left">Price</th><th class="p-3 text-left">Stock</th><th class="p-3 text-left">Status</th><th class="p-3"></th></tr></thead><tbody>@forelse($products as $product)<tr class="border-t"><td class="p-3 font-medium">{{ $product->name }}</td><td class="p-3">{{ $product->sku ?: '—' }}</td><td class="p-3">{{ config('commerce.currency_symbol','£') }}{{ number_format($product->price,2) }}</td><td class="p-3">{{ $product->stock_quantity }}</td><td class="p-3">{{ ucfirst($product->status) }}</td><td class="p-3 text-right"><form method="POST" action="{{ route('administration.commerce.products.destroy',$product) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger" type="submit">Delete</flux:button></form></td></tr>@empty<tr><td colspan="6" class="p-6 text-center text-zinc-500">No products yet.</td></tr>@endforelse</tbody></table></div></div>{{ $products->links() }}
|
||||
<div class="rounded-xl border p-5"><h2 class="mb-4 font-semibold">Recent orders</h2><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr><th class="p-2 text-left">Order</th><th class="p-2 text-left">Customer</th><th class="p-2 text-left">Total</th><th class="p-2 text-left">Status</th><th class="p-2 text-left">Payment</th></tr></thead><tbody>@forelse($orders as $order)<tr class="border-t"><td class="p-2 font-medium">{{ $order->number }}</td><td class="p-2">{{ $order->email }}</td><td class="p-2">{{ $order->currency }} {{ number_format($order->grand_total,2) }}</td><td class="p-2">{{ ucfirst($order->status) }}</td><td class="p-2">{{ ucfirst($order->payment_status) }}</td></tr>@empty<tr><td colspan="5" class="p-5 text-center text-zinc-500">No orders yet.</td></tr>@endforelse</tbody></table></div></div></div></x-layouts.app>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Commerce\Http\Controllers\ProductApiController;
|
||||
Route::get('/commerce/products',[ProductApiController::class,'index']);
|
||||
Route::get('/commerce/products/{product}',[ProductApiController::class,'show']);
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Commerce\Http\Controllers\CommerceController;
|
||||
Route::prefix('administration/commerce')->name('administration.commerce.')->middleware(['auth','permission:commerce.view'])->group(function () {
|
||||
Route::get('/',[CommerceController::class,'index'])->name('index');
|
||||
Route::post('/products',[CommerceController::class,'storeProduct'])->middleware('permission:commerce.manage')->name('products.store');
|
||||
Route::put('/products/{product}',[CommerceController::class,'updateProduct'])->middleware('permission:commerce.manage')->name('products.update');
|
||||
Route::delete('/products/{product}',[CommerceController::class,'destroyProduct'])->middleware('permission:commerce.manage')->name('products.destroy');
|
||||
Route::put('/orders/{order}',[CommerceController::class,'updateOrder'])->middleware('permission:commerce.manage')->name('orders.update');
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Services;
|
||||
use Modules\Commerce\Contracts\TaxProvider;
|
||||
final class ConfiguredTaxProvider implements TaxProvider { public function calculate(float $subtotal,array $address=[]): float { return config('commerce.prices_include_tax') ? 0.0 : round($subtotal * 0.2,2); } }
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Services;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Commerce\Contracts\InventoryProvider;
|
||||
use Modules\Commerce\Models\InventoryMovement;
|
||||
use Modules\Commerce\Models\Product;
|
||||
final class DatabaseInventoryProvider implements InventoryProvider
|
||||
{
|
||||
public function adjust(Product $product, int $quantity, string $reason, ?int $userId = null): void
|
||||
{
|
||||
DB::transaction(function () use ($product,$quantity,$reason,$userId) { $product->increment('stock_quantity',$quantity); InventoryMovement::create(['product_id'=>$product->id,'quantity'=>$quantity,'reason'=>$reason,'created_by'=>$userId]); });
|
||||
}
|
||||
public function available(Product $product): int { return max(0,(int)$product->stock_quantity); }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Services;
|
||||
use Modules\Commerce\Contracts\ShippingProvider;
|
||||
final class FlatRateShippingProvider implements ShippingProvider { public function quote(array $address,float $subtotal): float { return $subtotal >= 50 ? 0.0 : 4.95; } }
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Services;
|
||||
use Modules\Commerce\Contracts\PaymentProvider;
|
||||
use Modules\Commerce\Models\Order;
|
||||
final class ManualPaymentProvider implements PaymentProvider
|
||||
{ public function charge(Order $order,array $data=[]): array { return ['status'=>'pending','provider'=>'manual','reference'=>null]; } public function refund(Order $order,float $amount): array { return ['status'=>'pending','provider'=>'manual','amount'=>$amount]; } }
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace Modules\Commerce\Tests\Feature;
|
||||
use Tests\TestCase;
|
||||
final class ModuleBootTest extends TestCase
|
||||
{
|
||||
public function test_commerce_routes_are_registered(): void { $this->assertTrue(app('router')->has('administration.commerce.index')); }
|
||||
public function test_commerce_contracts_are_bound(): void { $this->assertInstanceOf(\Modules\Commerce\Contracts\InventoryProvider::class, app(\Modules\Commerce\Contracts\InventoryProvider::class)); }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"Commerce","alias":"commerce","version":"1.0.0","description":"Products, inventory, customers, orders, promotions, tax, shipping and payments.","keywords":["commerce","catalog","inventory","orders","customers"],"priority":110,"providers":["Modules\\Commerce\\Providers\\CommerceServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Media","Sites"]}
|
||||
@@ -63,6 +63,10 @@ final class RolePermissionSeeder extends Seeder
|
||||
'api.manage',
|
||||
'enterprise.view',
|
||||
'enterprise.manage',
|
||||
'commerce.view',
|
||||
'commerce.manage',
|
||||
'commerce.orders.manage',
|
||||
'commerce.products.manage',
|
||||
];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
|
||||
@@ -37,4 +37,6 @@
|
||||
"Developer": true,
|
||||
"Api": true,
|
||||
"Enterprise": true
|
||||
,
|
||||
"Commerce": true
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<flux:sidebar.header><x-stylist-theme-common::app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate /><flux:sidebar.collapse class="lg:hidden" /></flux:sidebar.header>
|
||||
<flux:sidebar.nav>
|
||||
<flux:sidebar.group :heading="__('Business')" class="grid">
|
||||
<flux:sidebar.item icon="shopping-bag" :href="route('administration.commerce.index')" :current="request()->routeIs('administration.commerce.*')" wire:navigate>{{ __('Commerce') }}</flux:sidebar.item>
|
||||
<flux:sidebar.item icon="building-office-2" :href="route('administration.crm.index')" :current="request()->routeIs('administration.crm.*')" wire:navigate>{{ __('CRM') }}</flux:sidebar.item>
|
||||
<flux:sidebar.item icon="envelope" :href="route('administration.newsletter.index')" :current="request()->routeIs('administration.newsletter.*')" wire:navigate>{{ __('Newsletter') }}</flux:sidebar.item>
|
||||
<flux:sidebar.item icon="calendar-days" :href="route('administration.events.index')" :current="request()->routeIs('administration.events.*')" wire:navigate>{{ __('Events') }}</flux:sidebar.item>
|
||||
|
||||
Reference in New Issue
Block a user