move into sonardesk
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
<?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,12 @@
|
||||
<?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,12 @@
|
||||
<?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,8 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Commerce\Contracts;
|
||||
|
||||
interface ShippingProvider
|
||||
{
|
||||
public function quote(array $address, float $subtotal): float;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Commerce\Contracts;
|
||||
|
||||
interface TaxProvider
|
||||
{
|
||||
public function calculate(float $subtotal, array $address = []): float;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?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,53 @@
|
||||
<?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,20 @@
|
||||
<?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,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Commerce\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class Category extends Model
|
||||
{
|
||||
protected $table = 'commerce_categories';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Commerce\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
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,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Commerce\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class InventoryMovement extends Model
|
||||
{
|
||||
protected $table = 'commerce_inventory_movements';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Commerce\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
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,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Commerce\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
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,20 @@
|
||||
<?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,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Commerce\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
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,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Commerce\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
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,3 @@
|
||||
<?php
|
||||
|
||||
// Relationship reference: Product belongsTo Category and hasMany ProductVariant; Order hasMany OrderItem and belongsTo Customer.
|
||||
@@ -0,0 +1,32 @@
|
||||
<?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\ConfiguredTaxProvider;
|
||||
use Modules\Commerce\Services\DatabaseInventoryProvider;
|
||||
use Modules\Commerce\Services\FlatRateShippingProvider;
|
||||
use Modules\Commerce\Services\ManualPaymentProvider;
|
||||
|
||||
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,15 @@
|
||||
<?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,7 @@
|
||||
<?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,12 @@
|
||||
<?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,13 @@
|
||||
<?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,24 @@
|
||||
<?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,13 @@
|
||||
<?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,19 @@
|
||||
<?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,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Commerce\Tests\Feature;
|
||||
|
||||
use Modules\Commerce\Contracts\InventoryProvider;
|
||||
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(InventoryProvider::class, app(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"]}
|
||||
Reference in New Issue
Block a user