diff --git a/app/Enums/OrderChannel.php b/app/Enums/OrderChannel.php new file mode 100644 index 0000000..e1c88cb --- /dev/null +++ b/app/Enums/OrderChannel.php @@ -0,0 +1,32 @@ + 'Toko', + self::SHOPEE => 'Shopee', + self::TIKTOK => 'TikTok', + }; + } + + public function defaultPriceType(): ?PriceType + { + return match ($this) { + self::STORE => null, + self::SHOPEE => PriceType::SHOPEE, + self::TIKTOK => PriceType::TIKTOK, + }; + } +} diff --git a/app/Enums/OrderStatus.php b/app/Enums/OrderStatus.php new file mode 100644 index 0000000..570ed4e --- /dev/null +++ b/app/Enums/OrderStatus.php @@ -0,0 +1,92 @@ + 'Menunggu', + self::PROCESSING => 'Diproses', + self::COMPLETED => 'Selesai', + self::CANCELLED => 'Dibatalkan', + }; + } + + public function isEditable(): bool + { + return in_array($this, [self::PENDING, self::PROCESSING], true); + } + + public function canTransitionTo(self $status): bool + { + return match ($this) { + self::PENDING => in_array($status, [self::PROCESSING, self::CANCELLED], true), + self::PROCESSING => in_array($status, [self::COMPLETED, self::CANCELLED], true), + self::COMPLETED, self::CANCELLED => false, + }; + } + + public function transitionPermission(): Permission + { + return match ($this) { + self::PROCESSING => Permission::ORDERS_SEND, + self::COMPLETED => Permission::ORDERS_COMPLETE, + self::CANCELLED => Permission::ORDERS_CANCEL, + default => throw new InvalidArgumentException('Status tidak mendukung transisi.'), + }; + } + + /** + * @return list + */ + public function availableActions(): array + { + return match ($this) { + self::PENDING => [ + [ + 'status' => self::PROCESSING->value, + 'label' => 'Kirim', + 'destructive' => false, + 'permission' => Permission::ORDERS_SEND->value, + 'icon_only' => true, + ], + [ + 'status' => self::CANCELLED->value, + 'label' => 'Batalkan', + 'destructive' => true, + 'permission' => Permission::ORDERS_CANCEL->value, + 'icon_only' => true, + ], + ], + self::PROCESSING => [ + [ + 'status' => self::COMPLETED->value, + 'label' => 'Selesai', + 'destructive' => false, + 'permission' => Permission::ORDERS_COMPLETE->value, + 'icon_only' => false, + ], + [ + 'status' => self::CANCELLED->value, + 'label' => 'Batalkan', + 'destructive' => true, + 'permission' => Permission::ORDERS_CANCEL->value, + 'icon_only' => true, + ], + ], + default => [], + }; + } +} diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index eb03a87..975a7c9 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -60,6 +60,14 @@ enum Permission: string case PURCHASES_UPDATE = 'purchases.update'; case PURCHASES_DELETE = 'purchases.delete'; + case ORDERS_VIEW = 'orders.view'; + case ORDERS_CREATE = 'orders.create'; + case ORDERS_UPDATE = 'orders.update'; + case ORDERS_DELETE = 'orders.delete'; + case ORDERS_SEND = 'orders.send'; + case ORDERS_COMPLETE = 'orders.complete'; + case ORDERS_CANCEL = 'orders.cancel'; + case CASH_VIEW = 'cash.view'; case CASH_DEPOSIT = 'cash.deposit'; case CASH_UPDATE = 'cash.update'; @@ -140,6 +148,14 @@ public function label(): string self::PURCHASES_UPDATE => 'Ubah Belanja', self::PURCHASES_DELETE => 'Hapus Belanja', + self::ORDERS_VIEW => 'Lihat Pesanan', + self::ORDERS_CREATE => 'Tambah Pesanan', + self::ORDERS_UPDATE => 'Ubah Pesanan', + self::ORDERS_DELETE => 'Hapus Pesanan', + self::ORDERS_SEND => 'Kirim Pesanan', + self::ORDERS_COMPLETE => 'Selesaikan Pesanan', + self::ORDERS_CANCEL => 'Batalkan Pesanan', + self::CASH_VIEW => 'Lihat Kas', self::CASH_DEPOSIT => 'Setor Kas', self::CASH_UPDATE => 'Ubah Setor Kas', @@ -189,6 +205,9 @@ public function group(): string self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku', self::PURCHASES_VIEW, self::PURCHASES_CREATE, self::PURCHASES_UPDATE, self::PURCHASES_DELETE => 'Belanja', + self::ORDERS_VIEW, self::ORDERS_CREATE, self::ORDERS_UPDATE, + self::ORDERS_DELETE, self::ORDERS_SEND, self::ORDERS_COMPLETE, + self::ORDERS_CANCEL => 'Pesanan', self::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_UPDATE, self::CASH_DELETE => 'Kas', self::EXPENSES_VIEW, self::EXPENSES_CREATE, self::EXPENSES_UPDATE, self::EXPENSES_DELETE => 'Pengeluaran', diff --git a/app/Enums/Role.php b/app/Enums/Role.php index ee02d19..a1ca3c4 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -78,6 +78,13 @@ public function permissions(): array Permission::PURCHASES_CREATE, Permission::PURCHASES_UPDATE, Permission::PURCHASES_DELETE, + Permission::ORDERS_VIEW, + Permission::ORDERS_CREATE, + Permission::ORDERS_UPDATE, + Permission::ORDERS_DELETE, + Permission::ORDERS_SEND, + Permission::ORDERS_COMPLETE, + Permission::ORDERS_CANCEL, Permission::CASH_VIEW, Permission::CASH_DEPOSIT, Permission::CASH_UPDATE, @@ -128,6 +135,13 @@ public function permissions(): array Permission::PRODUCTS_UPDATE, Permission::PRODUCTS_DELETE, Permission::PRODUCTS_TOGGLE_STATUS, + Permission::ORDERS_VIEW, + Permission::ORDERS_CREATE, + Permission::ORDERS_UPDATE, + Permission::ORDERS_DELETE, + Permission::ORDERS_SEND, + Permission::ORDERS_COMPLETE, + Permission::ORDERS_CANCEL, Permission::CASH_VIEW, Permission::CASH_DEPOSIT, Permission::CASH_UPDATE, @@ -197,6 +211,12 @@ public function permissions(): array Permission::CUSTOMERS_UPDATE, Permission::CUSTOMERS_DELETE, Permission::PRODUCTS_VIEW, + Permission::ORDERS_VIEW, + Permission::ORDERS_CREATE, + Permission::ORDERS_UPDATE, + Permission::ORDERS_SEND, + Permission::ORDERS_COMPLETE, + Permission::ORDERS_CANCEL, ], self::NON_OPERATOR => [ Permission::DASHBOARD_VIEW, diff --git a/app/Http/Controllers/Admin/Manage/OrderController.php b/app/Http/Controllers/Admin/Manage/OrderController.php new file mode 100644 index 0000000..06c271e --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/OrderController.php @@ -0,0 +1,107 @@ +parseDataTableQuery($request); + + return Inertia::render('admin/manage/orders/Index', [ + 'orders' => $this->orderService->paginateForIndex($tableQuery, $request->user()), + 'filters' => $this->dataTableFilters($tableQuery), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/manage/orders/Create', [ + 'customers' => $this->orderService->customerOptions(), + 'catalog' => $this->orderService->catalogItems(), + 'channels' => OrderChannel::selectOptions(), + 'storePriceTypes' => $this->orderService->storePriceTypeOptions(), + ]); + } + + public function store(OrderRequest $request): RedirectResponse + { + $this->orderService->create($request->validated(), $request->user()); + + Inertia::flash('success', 'Pesanan berhasil ditambahkan.'); + + return redirect()->route('admin.manage.orders.index'); + } + + public function edit(Order $order): Response|RedirectResponse + { + if (! $order->status->isEditable()) { + Inertia::flash('error', 'Pesanan tidak dapat diubah.'); + + return redirect()->route('admin.manage.orders.index'); + } + + return Inertia::render('admin/manage/orders/Edit', [ + 'order' => $this->orderService->findForEdit($order), + 'customers' => $this->orderService->customerOptions(), + 'catalog' => $this->orderService->catalogItems($order), + 'channels' => OrderChannel::selectOptions(), + 'storePriceTypes' => $this->orderService->storePriceTypeOptions(), + ]); + } + + public function update(OrderRequest $request, Order $order): RedirectResponse + { + $this->orderService->update($order, $request->validated()); + + Inertia::flash('success', 'Pesanan berhasil diperbarui.'); + + return redirect()->route('admin.manage.orders.index'); + } + + public function destroy(Order $order): RedirectResponse + { + $this->orderService->delete($order); + + Inertia::flash('success', 'Pesanan berhasil dihapus.'); + + return redirect()->route('admin.manage.orders.index'); + } + + public function transitionStatus(OrderStatusTransitionRequest $request, Order $order): RedirectResponse + { + $status = OrderStatus::from($request->validated('status')); + + $this->orderService->transitionStatus($order, $status); + + $message = match ($status) { + OrderStatus::PROCESSING => 'Pesanan berhasil dikirim.', + OrderStatus::COMPLETED => 'Pesanan berhasil diselesaikan.', + OrderStatus::CANCELLED => 'Pesanan berhasil dibatalkan.', + default => 'Status pesanan berhasil diperbarui.', + }; + + Inertia::flash('success', $message); + + return redirect()->route('admin.manage.orders.index'); + } +} diff --git a/app/Http/Requests/Admin/Manage/OrderRequest.php b/app/Http/Requests/Admin/Manage/OrderRequest.php new file mode 100644 index 0000000..d88ec04 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/OrderRequest.php @@ -0,0 +1,63 @@ +isMethod('POST') + ? Permission::ORDERS_CREATE + : Permission::ORDERS_UPDATE; + + return $this->user()?->can($permission->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + $rules = [ + 'customer_id' => ['nullable', 'integer', Rule::exists('customers', 'id')->whereNull('deleted_at')], + 'channel' => ['required', 'string', Rule::in(array_column(OrderChannel::cases(), 'value'))], + 'price_type' => ['required', 'string', Rule::in(array_column(PriceType::cases(), 'value'))], + 'discount' => ['nullable', 'integer', 'min:0'], + 'marketplace_fee' => ['nullable', 'integer', 'min:0'], + 'notes' => ['nullable', 'string', 'max:100'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_variant_id' => [ + 'required', + 'integer', + Rule::exists('product_variants', 'id')->whereNull('deleted_at'), + ], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + ]; + + return $rules; + } + + public function withValidator(Validator $validator): void + { + if (! $this->isMethod('PUT') && ! $this->isMethod('PATCH')) { + return; + } + + $validator->after(function (Validator $validator): void { + /** @var Order $order */ + $order = $this->route('order'); + + if (! $order->status->isEditable()) { + $validator->errors()->add('status', 'Pesanan tidak dapat diubah.'); + } + }); + } +} diff --git a/app/Http/Requests/Admin/Manage/OrderStatusTransitionRequest.php b/app/Http/Requests/Admin/Manage/OrderStatusTransitionRequest.php new file mode 100644 index 0000000..1257d21 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/OrderStatusTransitionRequest.php @@ -0,0 +1,50 @@ +input('status')); + + if ($status === null) { + return false; + } + + return $this->user()?->can($status->transitionPermission()->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'status' => ['required', 'string', Rule::in(array_column(OrderStatus::cases(), 'value'))], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + /** @var Order $order */ + $order = $this->route('order'); + $status = OrderStatus::tryFrom((string) $this->input('status')); + + if ($status === null) { + return; + } + + if (! $order->status->canTransitionTo($status)) { + $validator->errors()->add('status', 'Status pesanan tidak dapat diubah.'); + } + }); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index f8bd732..7b830c3 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -4,10 +4,16 @@ use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; #[Guarded(['id'])] class Customer extends Model { use SoftDeletes; + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } } diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000..80804eb --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,119 @@ + OrderChannel::class, + 'price_type' => PriceType::class, + 'status' => OrderStatus::class, + 'subtotal' => 'integer', + 'discount' => 'integer', + 'marketplace_fee' => 'integer', + 'net_amount' => 'integer', + ]; + } + + public function subtotalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'), + ); + } + + public function discountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->discount, 0, ',', '.'), + ); + } + + public function marketplaceFeeFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->marketplace_fee, 0, ',', '.'), + ); + } + + public function netAmountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->net_amount, 0, ',', '.'), + ); + } + + public function createdAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function channelLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->channel->label(), + ); + } + + public function priceTypeLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->price_type->label(), + ); + } + + public function statusLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->status->label(), + ); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function items(): HasMany + { + return $this->hasMany(OrderItem::class); + } + + public function cashTransaction(): BelongsTo + { + return $this->belongsTo(CashTransaction::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_id'); + } +} diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php new file mode 100644 index 0000000..e797e23 --- /dev/null +++ b/app/Models/OrderItem.php @@ -0,0 +1,66 @@ + 'integer', + 'unit_price' => 'integer', + 'subtotal' => 'integer', + ]; + } + + public function quantityFormatted(): Attribute + { + return Attribute::make( + get: fn () => number_format($this->quantity, 0, ',', '.').' pcs', + ); + } + + public function quantityInput(): Attribute + { + return Attribute::make( + get: fn () => (string) $this->quantity, + ); + } + + public function unitPriceFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->unit_price, 0, ',', '.'), + ); + } + + public function subtotalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'), + ); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function productVariant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php index c228d58..7cb6972 100644 --- a/app/Models/ProductVariant.php +++ b/app/Models/ProductVariant.php @@ -42,4 +42,9 @@ public function prices(): HasMany { return $this->hasMany(ProductPrice::class, 'variant_id'); } + + public function orderItems(): HasMany + { + return $this->hasMany(OrderItem::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 78c797b..8fc5481 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -80,4 +80,9 @@ public function purchases(): HasMany { return $this->hasMany(Purchase::class, 'created_by_id'); } + + public function orders(): HasMany + { + return $this->hasMany(Order::class, 'created_by_id'); + } } diff --git a/app/Observers/OrderObserver.php b/app/Observers/OrderObserver.php new file mode 100644 index 0000000..e5d13ec --- /dev/null +++ b/app/Observers/OrderObserver.php @@ -0,0 +1,29 @@ +order_number !== null && $order->order_number !== '') { + return; + } + + $date = now()->format('Ymd'); + $prefix = "PSN-{$date}-"; + + $lastOrder = Order::withTrashed() + ->where('order_number', 'like', "{$prefix}%") + ->orderByDesc('order_number') + ->value('order_number'); + + $sequence = $lastOrder !== null + ? ((int) substr($lastOrder, -5)) + 1 + : 1; + + $order->order_number = $prefix.str_pad((string) $sequence, 5, '0', STR_PAD_LEFT); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 51747c7..3084cc8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,8 @@ namespace App\Providers; use App\Enums\Role; +use App\Models\Order; +use App\Observers\OrderObserver; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; @@ -27,6 +29,12 @@ public function boot(): void { $this->configureDefaults(); $this->configureAuthorization(); + $this->configureObservers(); + } + + protected function configureObservers(): void + { + Order::observe(OrderObserver::class); } protected function configureAuthorization(): void diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php new file mode 100644 index 0000000..e3acfda --- /dev/null +++ b/app/Services/Manage/OrderService.php @@ -0,0 +1,369 @@ +with([ + 'customer:id,name', + 'createdBy.profile', + 'items.productVariant.product:id,name', + 'items.productVariant:id,product_id,name', + ]) + ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { + $search = $tableQuery['search']; + $query->where(function (Builder $query) use ($search): void { + $query->where('order_number', 'like', "%{$search}%") + ->orWhere('notes', 'like', "%{$search}%") + ->orWhereHas('customer', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")) + ->orWhereHas('items.productVariant', function (Builder $query) use ($search): void { + $query->where('name', 'like', "%{$search}%") + ->orWhereHas('product', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")); + }); + }); + }); + + $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); + + return $query + ->paginate(10) + ->withQueryString() + ->through(function (Order $order) use ($user) { + $actions = collect($order->status->availableActions()) + ->filter(fn (array $action) => $user->can($action['permission'])) + ->values() + ->all(); + + $order->setAttribute('available_actions', $actions); + $order->setAttribute('is_editable', $order->status->isEditable()); + + return $order; + }); + } + + /** + * @return list + */ + public function customerOptions(): array + { + return Customer::query() + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (Customer $customer) => [ + 'value' => $customer->id, + 'label' => $customer->name, + ]) + ->all(); + } + + /** + * @return list + */ + public function storePriceTypeOptions(): array + { + return collect(PriceType::cases()) + ->reject(fn (PriceType $type) => in_array($type, [PriceType::SHOPEE, PriceType::TIKTOK], true)) + ->map(fn (PriceType $type) => [ + 'value' => $type->value, + 'label' => $type->label(), + ]) + ->values() + ->all(); + } + + /** + * @return Collection + */ + public function catalogItems(?Order $order = null): Collection + { + $orderVariantIds = $order + ? $order->items()->pluck('product_variant_id')->all() + : []; + + return Product::query() + ->with([ + 'variants' => fn ($query) => $query + ->with(['prices' => fn ($query) => $query->orderBy('type'), 'media']) + ->orderBy('created_at'), + ]) + ->where(function (Builder $query) use ($orderVariantIds): void { + $query->where('is_active', true); + + if ($orderVariantIds !== []) { + $query->orWhereHas( + 'variants', + fn (Builder $query) => $query->whereIn('id', $orderVariantIds), + ); + } + }) + ->orderBy('name') + ->get() + ->each(function (Product $product): void { + $product->variants->each(function (ProductVariant $variant): void { + $variant->setAttribute( + 'images', + MediaPresenter::collection($variant, 'images'), + ); + }); + }); + } + + public function findForEdit(Order $order): Order + { + $order->load([ + 'items.productVariant.product:id,name', + 'items.productVariant.prices', + 'items.productVariant.media', + ]); + + $order->items->each(function (OrderItem $item): void { + $variant = $item->productVariant; + + if ($variant) { + $item->setAttribute('product_name', $variant->product?->name); + $item->setAttribute('variant_name', $variant->name); + $variant->setAttribute( + 'images', + MediaPresenter::collection($variant, 'images'), + ); + } + }); + + return $order; + } + + /** + * @param array $validated + */ + public function create(array $validated, User $user): Order + { + return DB::transaction(function () use ($validated, $user): Order { + $priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']); + $lineItems = $this->buildLineItems($validated['items'], $priceType); + $subtotal = array_sum(array_column($lineItems, 'subtotal')); + $discount = (int) ($validated['discount'] ?? 0); + $marketplaceFee = (int) ($validated['marketplace_fee'] ?? 0); + $netAmount = max($subtotal - $discount - $marketplaceFee, 0); + + $order = Order::create([ + 'customer_id' => $validated['customer_id'] ?? null, + 'channel' => $validated['channel'], + 'price_type' => $priceType, + 'status' => OrderStatus::PENDING, + 'created_by_id' => $user->id, + 'subtotal' => $subtotal, + 'discount' => $discount, + 'marketplace_fee' => $marketplaceFee, + 'net_amount' => $netAmount, + 'notes' => $validated['notes'] ?? null, + ]); + + foreach ($lineItems as $itemData) { + $orderItem = $order->items()->create($itemData); + $this->decrementStock($orderItem); + } + + return $order; + }); + } + + /** + * @param array $validated + */ + public function update(Order $order, array $validated): void + { + if (! $order->status->isEditable()) { + throw ValidationException::withMessages([ + 'status' => 'Pesanan tidak dapat diubah.', + ]); + } + + DB::transaction(function () use ($order, $validated): void { + $order->load('items'); + + foreach ($order->items as $item) { + $this->incrementStock($item); + } + + $order->items()->delete(); + + $priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']); + $lineItems = $this->buildLineItems($validated['items'], $priceType); + $subtotal = array_sum(array_column($lineItems, 'subtotal')); + $discount = (int) ($validated['discount'] ?? 0); + $marketplaceFee = (int) ($validated['marketplace_fee'] ?? 0); + $netAmount = max($subtotal - $discount - $marketplaceFee, 0); + + $order->customer_id = $validated['customer_id'] ?? null; + $order->channel = $validated['channel']; + $order->price_type = $priceType; + $order->subtotal = $subtotal; + $order->discount = $discount; + $order->marketplace_fee = $marketplaceFee; + $order->net_amount = $netAmount; + $order->notes = $validated['notes'] ?? null; + $order->save(); + + foreach ($lineItems as $itemData) { + $orderItem = $order->items()->create($itemData); + $this->decrementStock($orderItem); + } + }); + } + + public function delete(Order $order): void + { + DB::transaction(function () use ($order): void { + $order->load('items'); + + if ($order->status->isEditable()) { + foreach ($order->items as $item) { + $this->incrementStock($item); + } + } + + $order->items()->delete(); + $order->delete(); + }); + } + + public function transitionStatus(Order $order, OrderStatus $status): void + { + if (! $order->status->canTransitionTo($status)) { + throw ValidationException::withMessages([ + 'status' => 'Status pesanan tidak dapat diubah.', + ]); + } + + DB::transaction(function () use ($order, $status): void { + if ($status === OrderStatus::CANCELLED) { + $order->load('items'); + + foreach ($order->items as $item) { + $this->incrementStock($item); + } + } + + $order->status = $status; + $order->save(); + }); + } + + /** + * @param list $items + * @return list + */ + private function buildLineItems(array $items, PriceType $priceType): array + { + return collect($items) + ->map(function (array $itemData, int $index) use ($priceType) { + $variant = ProductVariant::query()->find($itemData['product_variant_id']); + + if ($variant === null) { + throw ValidationException::withMessages([ + "items.{$index}.product_variant_id" => 'Varian produk tidak ditemukan.', + ]); + } + + $price = ProductPrice::query() + ->where('variant_id', $variant->id) + ->where('type', $priceType) + ->first(); + + if ($price === null) { + throw ValidationException::withMessages([ + "items.{$index}.product_variant_id" => 'Harga untuk tipe harga ini belum diatur.', + ]); + } + + $quantity = (int) $itemData['quantity']; + + if ($quantity < 1) { + throw ValidationException::withMessages([ + "items.{$index}.quantity" => 'Jumlah minimal 1 pcs.', + ]); + } + + $unitPrice = (int) $price->price; + $subtotal = $unitPrice * $quantity; + + return [ + 'product_variant_id' => $variant->id, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'subtotal' => $subtotal, + ]; + }) + ->all(); + } + + private function resolvePriceType(string $channel, string $priceType): PriceType + { + $channelEnum = OrderChannel::from($channel); + $priceTypeEnum = PriceType::from($priceType); + + $defaultPriceType = $channelEnum->defaultPriceType(); + + if ($defaultPriceType !== null && $priceTypeEnum !== $defaultPriceType) { + throw ValidationException::withMessages([ + 'price_type' => 'Tipe harga tidak sesuai dengan channel pesanan.', + ]); + } + + if ($channelEnum === OrderChannel::STORE && in_array($priceTypeEnum, [PriceType::SHOPEE, PriceType::TIKTOK], true)) { + throw ValidationException::withMessages([ + 'price_type' => 'Tipe harga marketplace tidak dapat digunakan untuk channel toko.', + ]); + } + + return $priceTypeEnum; + } + + private function decrementStock(OrderItem $item): void + { + ProductVariant::query() + ->whereKey($item->product_variant_id) + ->decrement('stock', $item->quantity); + } + + private function incrementStock(OrderItem $item): void + { + ProductVariant::query() + ->whereKey($item->product_variant_id) + ->increment('stock', $item->quantity); + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['created_at', 'net_amount', 'discount', 'subtotal', 'order_number'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->latest(); + } +} diff --git a/database/migrations/2026_06_12_100001_create_orders_table.php b/database/migrations/2026_06_12_100001_create_orders_table.php new file mode 100644 index 0000000..28aace8 --- /dev/null +++ b/database/migrations/2026_06_12_100001_create_orders_table.php @@ -0,0 +1,51 @@ +id(); + + $table->string('order_number', 30)->unique(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->string('channel', 20); + $table->string('price_type', 20); + $table->string('status', 20)->default('pending'); + + $table->unsignedBigInteger('subtotal'); + $table->unsignedBigInteger('discount')->default(0); + $table->unsignedBigInteger('marketplace_fee')->default(0); + $table->unsignedBigInteger('net_amount'); + + $table->text('notes')->nullable(); + + $table->foreignId('cash_transaction_id') + ->nullable() + ->unique() + ->constrained() + ->restrictOnDelete(); + + $table->foreignId('created_by_id')->constrained('users')->restrictOnDelete(); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + + $table->index('status'); + $table->index('customer_id'); + $table->index('channel'); + $table->index('price_type'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_06_12_100002_create_order_items_table.php b/database/migrations/2026_06_12_100002_create_order_items_table.php new file mode 100644 index 0000000..2cb47ed --- /dev/null +++ b/database/migrations/2026_06_12_100002_create_order_items_table.php @@ -0,0 +1,34 @@ +id(); + + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_variant_id')->constrained()->restrictOnDelete(); + + $table->unsignedInteger('quantity'); + $table->unsignedBigInteger('unit_price'); + $table->unsignedBigInteger('subtotal'); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + + $table->unique(['order_id', 'product_variant_id']); + $table->index('order_id'); + $table->index('product_variant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_items'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 2da818f..fde7893 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@