From 62beb309a85e677588e1b3f654af74d6ee1900f4 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Mon, 13 Jul 2026 19:15:17 +0700 Subject: [PATCH] feat: implement restock management features including CRUD operations, permissions, and UI components for creating and editing restocks --- app/Enums/Permission.php | 12 + app/Enums/Role.php | 5 + .../Manage/Restock/RestockController.php | 96 +++ .../Restock/RestockDraftItemController.php | 31 + .../Admin/Manage/RestockDraftItemRequest.php | 31 + .../Requests/Admin/Manage/RestockRequest.php | 55 ++ app/Models/Restock.php | 98 +++ app/Models/RestockItem.php | 80 ++ .../Manage/OwnerVerificationService.php | 5 + app/Services/Manage/RestockService.php | 782 ++++++++++++++++++ app/Support/ActivityLog/ModelLabel.php | 4 + ...026_07_13_000001_create_restocks_table.php | 32 + ...7_13_000002_create_restock_items_table.php | 32 + resources/js/components/AppSidebar.vue | 3 +- .../js/pages/admin/manage/restocks/Create.vue | 42 + .../js/pages/admin/manage/restocks/Edit.vue | 59 ++ .../js/pages/admin/manage/restocks/Index.vue | 90 ++ .../form/RestockPosCartDetailDialog.vue | 123 +++ .../form/RestockPosCartSummaryItems.vue | 110 +++ .../restocks/form/RestockPosCatalogPanel.vue | 121 +++ .../form/RestockPosCheckoutSection.vue | 92 +++ .../manage/restocks/form/RestockPosForm.vue | 279 +++++++ .../manage/restocks/form/useRestockPosCart.ts | 196 +++++ .../restocks/table/RestockGroupedTable.vue | 194 +++++ .../restocks/table/data-table-actions.vue | 29 + resources/js/types/restock.ts | 92 +++ routes/web.php | 45 + tests/Feature/Admin/Manage/RestockTest.php | 486 +++++++++++ 28 files changed, 3223 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Admin/Manage/Restock/RestockController.php create mode 100644 app/Http/Controllers/Admin/Manage/Restock/RestockDraftItemController.php create mode 100644 app/Http/Requests/Admin/Manage/RestockDraftItemRequest.php create mode 100644 app/Http/Requests/Admin/Manage/RestockRequest.php create mode 100644 app/Models/Restock.php create mode 100644 app/Models/RestockItem.php create mode 100644 app/Services/Manage/RestockService.php create mode 100644 database/migrations/2026_07_13_000001_create_restocks_table.php create mode 100644 database/migrations/2026_07_13_000002_create_restock_items_table.php create mode 100644 resources/js/pages/admin/manage/restocks/Create.vue create mode 100644 resources/js/pages/admin/manage/restocks/Edit.vue create mode 100644 resources/js/pages/admin/manage/restocks/Index.vue create mode 100644 resources/js/pages/admin/manage/restocks/form/RestockPosCartDetailDialog.vue create mode 100644 resources/js/pages/admin/manage/restocks/form/RestockPosCartSummaryItems.vue create mode 100644 resources/js/pages/admin/manage/restocks/form/RestockPosCatalogPanel.vue create mode 100644 resources/js/pages/admin/manage/restocks/form/RestockPosCheckoutSection.vue create mode 100644 resources/js/pages/admin/manage/restocks/form/RestockPosForm.vue create mode 100644 resources/js/pages/admin/manage/restocks/form/useRestockPosCart.ts create mode 100644 resources/js/pages/admin/manage/restocks/table/RestockGroupedTable.vue create mode 100644 resources/js/pages/admin/manage/restocks/table/data-table-actions.vue create mode 100644 resources/js/types/restock.ts create mode 100644 tests/Feature/Admin/Manage/RestockTest.php diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index 57f4ac6..024f11b 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -85,6 +85,11 @@ enum Permission: string case PURCHASES_UPDATE = 'purchases.update'; case PURCHASES_DELETE = 'purchases.delete'; + case RESTOCKS_VIEW = 'restocks.view'; + case RESTOCKS_CREATE = 'restocks.create'; + case RESTOCKS_UPDATE = 'restocks.update'; + case RESTOCKS_DELETE = 'restocks.delete'; + case ORDERS_VIEW = 'orders.view'; case ORDERS_CREATE = 'orders.create'; case ORDERS_UPDATE = 'orders.update'; @@ -233,6 +238,11 @@ public function label(): string self::PURCHASES_UPDATE => 'Ubah Belanja', self::PURCHASES_DELETE => 'Hapus Belanja', + self::RESTOCKS_VIEW => 'Lihat Restock', + self::RESTOCKS_CREATE => 'Catat Restock', + self::RESTOCKS_UPDATE => 'Ubah Restock', + self::RESTOCKS_DELETE => 'Hapus Restock', + self::ORDERS_VIEW => 'Lihat Pesanan', self::ORDERS_CREATE => 'Tambah Pesanan', self::ORDERS_UPDATE => 'Ubah Pesanan', @@ -333,6 +343,8 @@ 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::RESTOCKS_VIEW, self::RESTOCKS_CREATE, self::RESTOCKS_UPDATE, + self::RESTOCKS_DELETE => 'Restock', self::ORDERS_VIEW, self::ORDERS_CREATE, self::ORDERS_UPDATE, self::ORDERS_DELETE, self::ORDERS_SEND, self::ORDERS_COMPLETE, self::ORDERS_CANCEL => 'Pesanan', diff --git a/app/Enums/Role.php b/app/Enums/Role.php index 4295970..8ee6494 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -152,6 +152,11 @@ public function permissions(): array Permission::LEAVE_REQUESTS_UPDATE, Permission::LEAVE_REQUESTS_DELETE, + Permission::RESTOCKS_VIEW, + Permission::RESTOCKS_CREATE, + Permission::RESTOCKS_UPDATE, + Permission::RESTOCKS_DELETE, + Permission::CUSTOMERS_VIEW, Permission::CUSTOMERS_CREATE, Permission::CUSTOMERS_UPDATE, diff --git a/app/Http/Controllers/Admin/Manage/Restock/RestockController.php b/app/Http/Controllers/Admin/Manage/Restock/RestockController.php new file mode 100644 index 0000000..dea5157 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/Restock/RestockController.php @@ -0,0 +1,96 @@ +parseDataTableQuery($request); + $tableQuery['search_id'] = $request->string('search_id')->trim()->toString(); + + return Inertia::render('admin/manage/restocks/Index', [ + 'restocks' => $this->restockService->paginateForIndex($tableQuery), + 'filters' => $this->dataTableFilters($tableQuery, [ + 'search_id' => $tableQuery['search_id'], + ]), + ]); + } + + public function create(Request $request): Response + { + $user = $request->user(); + + $this->restockService->clearDraftItemsForUser($user); + + return Inertia::render('admin/manage/restocks/Create', [ + 'productCatalog' => $this->restockService->productCatalog(user: $user), + 'draftItems' => [], + ]); + } + + public function store(RestockRequest $request): RedirectResponse + { + $this->restockService->create($request->validated(), $request->user()); + + if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { + $this->flashCreated('Restock'); + } else { + $this->flashSuccess('Restock berhasil diajukan dan menunggu verifikasi owner.'); + } + + return redirect()->route('admin.manage.restocks.index'); + } + + public function edit(Restock $restock): Response + { + return Inertia::render('admin/manage/restocks/Edit', [ + 'restock' => $this->restockService->findForEdit($restock), + 'productCatalog' => $this->restockService->productCatalog($restock), + ]); + } + + public function update(RestockRequest $request, Restock $restock): RedirectResponse + { + $this->restockService->update($restock, $request->validated(), $request->user()); + + if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { + $this->flashUpdated('Restock'); + } else { + $this->flashSuccess('Perubahan restock berhasil diajukan dan menunggu verifikasi owner.'); + } + + return redirect()->route('admin.manage.restocks.index'); + } + + public function destroy(Request $request, Restock $restock): RedirectResponse + { + $this->restockService->delete($restock, $request->user()); + + if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { + $this->flashDeleted('Restock'); + } else { + $this->flashSuccess('Penghapusan restock berhasil diajukan dan menunggu verifikasi owner.'); + } + + return redirect()->route('admin.manage.restocks.index'); + } +} diff --git a/app/Http/Controllers/Admin/Manage/Restock/RestockDraftItemController.php b/app/Http/Controllers/Admin/Manage/Restock/RestockDraftItemController.php new file mode 100644 index 0000000..962c597 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/Restock/RestockDraftItemController.php @@ -0,0 +1,31 @@ +restockService->syncDraftItem($request->validated(), $request->user()); + + return response()->json(['item' => $item]); + } + + public function destroy(Request $request, ProductVariant $productVariant): JsonResponse + { + $this->restockService->removeDraftItem($request->user(), $productVariant); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Requests/Admin/Manage/RestockDraftItemRequest.php b/app/Http/Requests/Admin/Manage/RestockDraftItemRequest.php new file mode 100644 index 0000000..5c48a7e --- /dev/null +++ b/app/Http/Requests/Admin/Manage/RestockDraftItemRequest.php @@ -0,0 +1,31 @@ +user()?->can(Permission::RESTOCKS_CREATE->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'product_variant_id' => [ + 'required', + 'integer', + Rule::exists('product_variants', 'id')->whereNull('deleted_at'), + ], + 'quantity' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], + 'unit_price' => ['required', 'integer', 'gt:0'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/RestockRequest.php b/app/Http/Requests/Admin/Manage/RestockRequest.php new file mode 100644 index 0000000..2f1b3b6 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/RestockRequest.php @@ -0,0 +1,55 @@ +isMethod('POST') + ? Permission::RESTOCKS_CREATE + : Permission::RESTOCKS_UPDATE; + + return $this->user()?->can($permission->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'notes' => ['nullable', 'string', 'max:100'], + 'stock_type' => ['required', 'string', 'in:'.implode(',', ProductStockQuality::values())], + ...$this->photoRules('photos', 1), + + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_variant_id' => ['required', 'integer'], + 'items.*.quantity' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], + 'items.*.unit_price' => ['required', 'integer', 'gt:0'], + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'notes' => 'keterangan', + 'stock_type' => 'tipe stok', + 'items' => 'produk', + 'items.*.product_variant_id' => 'varian produk', + 'items.*.quantity' => 'jumlah', + 'items.*.unit_price' => 'harga', + ...$this->photoUploadAttributes('bukti restock', 'photos'), + ]; + } +} diff --git a/app/Models/Restock.php b/app/Models/Restock.php new file mode 100644 index 0000000..2cc847f --- /dev/null +++ b/app/Models/Restock.php @@ -0,0 +1,98 @@ + 'integer', + 'total' => 'integer', + 'stock_type' => ProductStockQuality::class, + ]; + } + + // 3. Attribute + public function createdAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function subtotalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'), + ); + } + + public function totalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->total, 0, ',', '.'), + ); + } + + // 4. Other Methods + public static function mediaModuleName(): string + { + return 'restock'; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('photos'); + } + + // 5. Relation + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_id'); + } + + public function items(): HasMany + { + return $this->hasMany(RestockItem::class); + } + + public function ownerVerificationRequests(): MorphMany + { + return $this->morphMany(OwnerVerificationRequest::class, 'subject'); + } + + public function pendingOwnerVerificationRequest(): MorphOne + { + return $this->morphOne(OwnerVerificationRequest::class, 'subject') + ->where('status', OwnerVerificationStatus::PENDING) + ->latestOfMany(); + } +} diff --git a/app/Models/RestockItem.php b/app/Models/RestockItem.php new file mode 100644 index 0000000..5e2de0f --- /dev/null +++ b/app/Models/RestockItem.php @@ -0,0 +1,80 @@ + 'integer', + 'subtotal' => 'integer', + 'unit_price' => 'integer', + ]; + } + + // 3. Attribute + public function quantityFormatted(): Attribute + { + return Attribute::make( + get: fn () => "{$this->quantity} pcs", + ); + } + + public function quantityInput(): Attribute + { + return Attribute::make( + get: fn () => (string) $this->quantity, + ); + } + + public function subtotalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'), + ); + } + + public function unitPriceFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->unit_price, 0, ',', '.'), + ); + } + + // 4. Relation + public function restock(): BelongsTo + { + return $this->belongsTo(Restock::class)->withTrashed(); + } + + public function productVariant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class)->withTrashed(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class)->withTrashed(); + } +} diff --git a/app/Services/Manage/OwnerVerificationService.php b/app/Services/Manage/OwnerVerificationService.php index dd045d2..a7d7527 100644 --- a/app/Services/Manage/OwnerVerificationService.php +++ b/app/Services/Manage/OwnerVerificationService.php @@ -11,6 +11,7 @@ use App\Models\ProductVariant; use App\Models\Purchase; use App\Models\RawMaterial; +use App\Models\Restock; use App\Models\User; use App\Services\Concerns\CachesQuery; use App\Services\Master\ProductService; @@ -37,6 +38,7 @@ public function __construct( private readonly ProductService $productService, private readonly RawMaterialService $rawMaterialService, private readonly PurchaseService $purchaseService, + private readonly RestockService $restockService, private readonly PushNotificationService $pushNotificationService, private readonly MarketplaceService $marketplaceService, private readonly RetailStockService $retailStockService, @@ -291,6 +293,7 @@ private function rejectVerificationRequest(OwnerVerificationRequest $request): v Product::class => $this->productService->rejectVerificationRequest($request), RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request), Purchase::class => $this->purchaseService->rejectVerificationRequest($request), + Restock::class => $this->restockService->rejectVerificationRequest($request), MarketplaceSettings::class => null, default => throw ValidationException::withMessages([ 'subject_type' => 'Tipe data verifikasi tidak didukung.', @@ -304,6 +307,7 @@ private function applyVerificationRequest(OwnerVerificationRequest $request): vo Product::class => $this->productService->applyVerificationRequest($request), RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request), Purchase::class => $this->purchaseService->applyVerificationRequest($request), + Restock::class => $this->restockService->applyVerificationRequest($request), MarketplaceSettings::class => $this->marketplaceService->applyVerificationRequest($request), default => throw ValidationException::withMessages([ 'subject_type' => 'Tipe data verifikasi tidak didukung.', @@ -317,6 +321,7 @@ private function clearVerificationRequestMedia(OwnerVerificationRequest $request Product::class => $this->productService->clearVerificationRequestMedia($request), RawMaterial::class => $this->rawMaterialService->clearVerificationRequestMedia($request), Purchase::class => $this->purchaseService->clearVerificationRequestMedia($request), + Restock::class => $this->restockService->clearVerificationRequestMedia($request), default => null, }; } diff --git a/app/Services/Manage/RestockService.php b/app/Services/Manage/RestockService.php new file mode 100644 index 0000000..cd42e6d --- /dev/null +++ b/app/Services/Manage/RestockService.php @@ -0,0 +1,782 @@ +with([ + 'createdBy.profile', + 'pendingOwnerVerificationRequest.submittedBy.profile', + 'items.productVariant.product:id,name', + 'items.productVariant.media', + 'media', + ]) + ->when(($tableQuery['search_id'] ?? '') !== '', function (Builder $query) use ($tableQuery): void { + $query->where('restocks.id', $tableQuery['search_id']); + }) + ->when(($tableQuery['search_id'] ?? '') === '' && $tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { + $search = $tableQuery['search']; + $query->where(function (Builder $query) use ($search): void { + $query->where('notes', '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(25) + ->withQueryString() + ->through(function (Restock $restock) { + $restock->setAttribute( + 'photos', + MediaPresenter::first($restock, 'photos'), + ); + + $pendingRequest = $restock->pendingOwnerVerificationRequest; + + $restock->setAttribute('has_pending_request', $pendingRequest !== null); + $restock->setAttribute('pending_request_id', $pendingRequest?->id); + $restock->setAttribute('pending_request_action', $pendingRequest?->action->value); + $restock->setAttribute('pending_request_action_label', $pendingRequest?->action->label()); + $restock->setAttribute('pending_request_submitted_by_name', $pendingRequest?->submittedBy?->profile?->full_name ?? $pendingRequest?->submittedBy?->username); + $restock->setAttribute('stock_type_label', $restock->stock_type->label()); + + $restock->items->each(fn (RestockItem $item) => $this->breakItemCircularReference($item)); + + return $restock; + }); + } + + public function productCatalog(?Restock $restock = null, ?User $user = null): Collection + { + $selectedVariantIds = $restock + ? $restock->items()->pluck('product_variant_id')->all() + : ($user ? $this->draftItemsQuery($user)->pluck('product_variant_id')->all() : []); + + return Product::query() + ->with([ + 'variants' => fn ($query) => $query + ->with(['media', 'prices']) + ->orderBy('created_at'), + ]) + ->where(function (Builder $query) use ($selectedVariantIds): void { + $query->active(); + + if ($selectedVariantIds !== []) { + $query->orWhereHas( + 'variants', + fn (Builder $query) => $query->whereIn('id', $selectedVariantIds), + ); + } + }) + ->orderBy('name') + ->get() + ->each(function (Product $product): void { + $product->variants->each(function (ProductVariant $variant): void { + $variant->setAttribute( + 'images', + MediaPresenter::collection($variant, 'images'), + ); + + $hargaModal = $variant->prices + ->firstWhere('type', PriceType::HARGA_MODAL); + $variant->setAttribute( + 'harga_modal', + $hargaModal?->price ?? 0, + ); + $variant->unsetRelation('prices'); + }); + }); + } + + public function findForEdit(Restock $restock): Restock + { + $restock->load([ + 'items.productVariant.product:id,name', + 'items.productVariant.media', + 'media', + ]); + + $restock->setAttribute( + 'photos', + MediaPresenter::first($restock, 'photos'), + ); + + $restock->items->each(function (RestockItem $item): void { + $variant = $item->productVariant; + + if ($variant) { + $variant->setAttribute('images', MediaPresenter::collection($variant, 'images')); + } + + $this->breakItemCircularReference($item); + }); + + return $restock; + } + + public function draftItemsForUser(User $user): array + { + return $this->draftItemsQuery($user) + ->with([ + 'productVariant.product:id,name', + 'productVariant.media', + ]) + ->get() + ->map(function (RestockItem $item) { + $result = $this->presentDraftItem($item); + $this->breakItemCircularReference($item); + + return $result; + }) + ->values() + ->all(); + } + + public function clearDraftItemsForUser(User $user): void + { + $this->draftItemsQuery($user)->delete(); + } + + public function syncDraftItem(array $validated, User $user): array + { + $variant = ProductVariant::query() + ->with('product:id,name') + ->findOrFail($validated['product_variant_id']); + + $quantity = (float) $validated['quantity']; + $unitPrice = (int) $validated['unit_price']; + $subtotal = (int) round($quantity * $unitPrice); + + $item = RestockItem::query()->updateOrCreate( + [ + 'user_id' => $user->id, + 'product_variant_id' => $variant->id, + 'restock_id' => null, + ], + [ + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'subtotal' => $subtotal, + ], + ); + + $item->load([ + 'productVariant.product:id,name', + 'productVariant.media', + ]); + + $result = $this->presentDraftItem($item); + $this->breakItemCircularReference($item); + + return $result; + } + + public function removeDraftItem(User $user, ProductVariant $productVariant): void + { + RestockItem::query() + ->whereNull('restock_id') + ->where('user_id', $user->id) + ->where('product_variant_id', $productVariant->id) + ->delete(); + } + + public function create(array $validated, User $user): Restock + { + $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); + + $restock = $this->runInTransaction( + function () use ($validated, $user, $isOwner): Restock { + $resolvedItems = $this->processRequestItems($validated['items'] ?? []); + + $subtotal = array_sum(array_column($resolvedItems, 'subtotal')); + + $restock = Restock::create([ + 'created_by_id' => $user->id, + 'subtotal' => $subtotal, + 'total' => $subtotal, + 'notes' => $validated['notes'] ?? null, + 'stock_type' => $validated['stock_type'] ?? 'good', + ]); + + foreach ($resolvedItems as $itemData) { + $restock->items()->create([ + 'product_variant_id' => $itemData['product_variant_id'], + 'quantity' => $itemData['quantity'], + 'unit_price' => $itemData['unit_price'], + 'subtotal' => $itemData['subtotal'], + ]); + } + + $this->syncPhotos($restock, $validated); + + $restock->load(['items.productVariant.product:id,name']); + + $stockType = $restock->stock_type; + + if ($isOwner) { + foreach ($restock->items as $item) { + $this->incrementStock($item, $stockType); + } + } else { + OwnerVerificationRequest::create([ + 'action' => OwnerVerificationAction::CREATE, + 'status' => OwnerVerificationStatus::PENDING, + 'subject_type' => Restock::class, + 'subject_id' => $restock->id, + 'submitted_by_id' => $user->id, + 'payload' => [ + 'old' => null, + 'new' => $this->snapshotRestock($restock), + ], + ]); + } + + return $restock; + }, + 'Gagal membuat restock', + ); + + if (! $isOwner) { + $this->notifyForPendingRequest( + $user, + 'Tambah Restock', + "Pengajuan restock senilai {$restock->total_formatted} menunggu verifikasi owner.", + route('admin.manage.restocks.index', ['search_id' => $restock->id]), + (string) $restock->id, + ); + } + + $this->cacheForgetByPattern('manage:restocks:*'); + + return $restock; + } + + public function update(Restock $restock, array $validated, User $user): void + { + $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); + $restock->load(['items.productVariant.product:id,name']); + + $this->runInTransaction( + function () use ($restock, $validated, $user, $isOwner): void { + if ($isOwner) { + $payload = $this->buildPayloadFromValidated($validated); + $this->applyPayloadToRestock($restock, $payload); + + if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) { + $this->syncPhotos($restock, $validated); + } + } else { + $verificationRequest = OwnerVerificationRequest::create([ + 'action' => OwnerVerificationAction::UPDATE, + 'status' => OwnerVerificationStatus::PENDING, + 'subject_type' => Restock::class, + 'subject_id' => $restock->id, + 'submitted_by_id' => $user->id, + 'payload' => [ + 'old' => $this->snapshotRestock($restock), + 'new' => $this->buildPayloadFromValidated($validated), + ], + ]); + + if (($validated['photos'] ?? null) !== null || ($validated['remove_media_ids'] ?? []) !== []) { + $this->syncRequestPhotos($verificationRequest, $validated); + } + } + }, + 'Gagal memperbarui restock', + ); + + if (! $isOwner) { + $this->notifyForPendingRequest( + $user, + 'Ubah Restock', + 'Pengajuan ubah restock menunggu verifikasi owner.', + route('admin.manage.restocks.index', ['search_id' => $restock->id]), + (string) $restock->id, + ); + } + + $this->cacheForgetByPattern('manage:restocks:*'); + } + + public function delete(Restock $restock, User $user): void + { + $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); + + if ($isOwner) { + $this->executeDelete($restock); + $this->cacheForgetByPattern('manage:restocks:*'); + + return; + } + + $restock->load(['items.productVariant.product:id,name']); + + $this->runInTransaction( + function () use ($restock, $user): void { + OwnerVerificationRequest::create([ + 'action' => OwnerVerificationAction::DELETE, + 'status' => OwnerVerificationStatus::PENDING, + 'subject_type' => Restock::class, + 'subject_id' => $restock->id, + 'submitted_by_id' => $user->id, + 'payload' => [ + 'old' => $this->snapshotRestock($restock), + 'new' => null, + ], + ]); + }, + 'Gagal mengajukan penghapusan restock', + ); + + $this->notifyForPendingRequest( + $user, + 'Hapus Restock', + 'Pengajuan hapus restock menunggu verifikasi owner.', + route('admin.manage.restocks.index', ['search_id' => $restock->id]), + (string) $restock->id, + ); + } + + public function applyVerificationRequest(OwnerVerificationRequest $verificationRequest): void + { + match ($verificationRequest->action) { + OwnerVerificationAction::CREATE => $this->applyCreate($verificationRequest), + OwnerVerificationAction::UPDATE => $this->applyUpdate($verificationRequest), + OwnerVerificationAction::DELETE => $this->applyDelete($verificationRequest), + default => throw ValidationException::withMessages([ + 'action' => 'Aksi verifikasi restock tidak didukung.', + ]), + }; + + $this->cacheForgetByPattern('manage:restocks:*'); + } + + public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void + { + match ($verificationRequest->action) { + OwnerVerificationAction::CREATE => $this->rejectCreate($verificationRequest), + OwnerVerificationAction::UPDATE, OwnerVerificationAction::DELETE => null, + default => throw ValidationException::withMessages([ + 'action' => 'Aksi verifikasi restock tidak didukung.', + ]), + }; + + $this->cacheForgetByPattern('manage:restocks:*'); + } + + public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void + { + $verificationRequest->clearMediaCollection('photos'); + } + + public function applyCreate(OwnerVerificationRequest $verificationRequest): void + { + $restock = $verificationRequest->subject; + + if (! $restock instanceof Restock) { + throw ValidationException::withMessages([ + 'restock' => 'Restock tidak ditemukan.', + ]); + } + + $restock->load('items'); + + $stockType = $restock->stock_type; + + foreach ($restock->items as $item) { + $this->incrementStock($item, $stockType); + } + } + + public function applyUpdate(OwnerVerificationRequest $verificationRequest): void + { + $restock = $verificationRequest->subject; + + if (! $restock instanceof Restock) { + throw ValidationException::withMessages([ + 'restock' => 'Restock tidak ditemukan.', + ]); + } + + $this->applyPayloadToRestock($restock, $this->payloadNew($verificationRequest), $verificationRequest); + } + + public function applyDelete(OwnerVerificationRequest $verificationRequest): void + { + $restock = $verificationRequest->subject; + + if (! $restock instanceof Restock) { + throw ValidationException::withMessages([ + 'restock' => 'Restock tidak ditemukan.', + ]); + } + + $this->executeDelete($restock); + } + + private function processRequestItems(array $items): array + { + $resolvedItems = []; + + foreach ($items as $index => $itemData) { + $variant = ProductVariant::query()->find($itemData['product_variant_id']); + + if ($variant === null) { + throw ValidationException::withMessages([ + "items.{$index}.product_variant_id" => 'Varian produk tidak ditemukan.', + ]); + } + + $quantity = (float) $itemData['quantity']; + $unitPrice = (int) $itemData['unit_price']; + $lineSubtotal = (int) round($quantity * $unitPrice); + + $resolvedItems[] = [ + 'product_variant_id' => $variant->id, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'subtotal' => $lineSubtotal, + ]; + } + + return $resolvedItems; + } + + private function syncPhotos(Restock $restock, array $validated): void + { + $this->mediaService->syncCollection( + $restock, + 'photos', + $validated['photos'] ?? null, + $validated['remove_media_ids'] ?? null, + self::MAX_PHOTOS, + required: false, + errorKey: 'photos', + s3Keys: $validated['s3_keys'] ?? null, + ); + } + + private function draftItemsQuery(User $user): Builder + { + return RestockItem::query() + ->whereNull('restock_id') + ->where('user_id', $user->id); + } + + private function presentDraftItem(RestockItem $item): array + { + $variant = $item->productVariant; + $product = $variant?->product; + + return [ + 'product_variant_id' => $item->product_variant_id, + 'product_name' => $product?->name ?? '', + 'variant_name' => $variant?->name ?? '', + 'stock' => $variant?->stock ?? 0, + 'quantity' => $item->quantity_input, + 'unit_price' => $item->unit_price, + 'images' => $variant ? MediaPresenter::collection($variant, 'images') : [], + ]; + } + + private function stockColumn(ProductStockQuality $stockType): string + { + return match ($stockType) { + ProductStockQuality::GOOD => 'stock', + ProductStockQuality::REJECT => 'reject_stock', + ProductStockQuality::RETAIL => 'retail_stock', + }; + } + + private function incrementStock(RestockItem $item, ProductStockQuality $stockType): void + { + ProductVariant::query() + ->whereKey($item->product_variant_id) + ->increment($this->stockColumn($stockType), $item->quantity); + } + + private function decrementStock(RestockItem $item, ProductStockQuality $stockType): void + { + ProductVariant::query() + ->whereKey($item->product_variant_id) + ->decrement($this->stockColumn($stockType), $item->quantity); + } + + private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl, ?string $search = null): void + { + $ownerUrl = route('admin.manage.restocks.index'); + if ($search !== null) { + $ownerUrl = route('admin.manage.restocks.index', ['search_id' => $search]); + } + + $this->pushNotificationService->sendToRoles( + "📦 {$typeLabel} Menunggu Persetujuan Owner", + $body, + ['owner', 'developer', 'direktur'], + $ownerUrl, + ); + + $this->pushNotificationService->sendToUser( + '📤 Pengajuan Terkirim', + $body, + $user->id, + $submitterUrl, + ); + } + + private function rejectCreate(OwnerVerificationRequest $verificationRequest): void + { + $restock = $verificationRequest->subject; + + if (! $restock instanceof Restock) { + return; + } + + $this->runInTransaction( + function () use ($restock): void { + $restock->clearMediaCollection('photos'); + $restock->items()->delete(); + $restock->delete(); + }, + 'Gagal menolak restock', + ); + } + + private function executeDelete(Restock $restock): void + { + $this->runInTransaction( + function () use ($restock): void { + $restock->load('items'); + + $stockType = $restock->stock_type; + + foreach ($restock->items as $item) { + $this->decrementStock($item, $stockType); + } + + $restock->clearMediaCollection('photos'); + $restock->items()->delete(); + $restock->delete(); + }, + 'Gagal menghapus restock', + ); + } + + private function applyPayloadToRestock( + Restock $restock, + array $payload, + ?OwnerVerificationRequest $verificationRequest = null, + ): void { + $this->runInTransaction( + function () use ($restock, $payload, $verificationRequest): void { + $restock->load('items'); + + $stockType = $restock->stock_type; + + foreach ($restock->items as $item) { + $this->decrementStock($item, $stockType); + } + + $restock->items()->delete(); + + foreach ($payload['items'] ?? [] as $itemData) { + $restockItem = $restock->items()->create([ + 'product_variant_id' => $itemData['product_variant_id'], + 'quantity' => $itemData['quantity'], + 'unit_price' => $itemData['unit_price'], + 'subtotal' => $itemData['subtotal'], + ]); + $this->incrementStock($restockItem, $stockType); + } + + $restock->update([ + 'subtotal' => $payload['subtotal'], + 'total' => $payload['total'], + 'notes' => $payload['notes'] ?? null, + 'stock_type' => $payload['stock_type'] ?? $restock->stock_type, + ]); + + if ($verificationRequest !== null) { + $this->applyRequestPhotos($verificationRequest, $restock, $payload); + } + }, + 'Gagal memperbarui restock', + ); + } + + private function snapshotRestock(Restock $restock): array + { + $restock->load([ + 'items.productVariant.product:id,name', + ]); + + return [ + 'subtotal' => $restock->subtotal, + 'total' => $restock->total, + 'notes' => $restock->notes, + 'stock_type' => $restock->stock_type->value, + 'items' => $restock->items + ->map(fn (RestockItem $item) => [ + 'product_variant_id' => $item->product_variant_id, + 'product_name' => $item->productVariant?->product?->name, + 'variant_name' => $item->productVariant?->name, + 'quantity' => (float) $item->quantity, + 'unit_price' => $item->unit_price, + 'subtotal' => $item->subtotal, + ]) + ->all(), + 'has_photos' => $restock->hasMedia('photos'), + ]; + } + + private function buildPayloadFromValidated(array $validated): array + { + $lineItems = $this->enrichLineItems($this->processRequestItems($validated['items'])); + $subtotal = array_sum(array_column($lineItems, 'subtotal')); + + return [ + 'subtotal' => $subtotal, + 'total' => $subtotal, + 'notes' => $validated['notes'] ?? null, + 'stock_type' => $validated['stock_type'] ?? 'good', + 'items' => $lineItems, + 'remove_media_ids' => $validated['remove_media_ids'] ?? [], + ]; + } + + private function enrichLineItems(array $lineItems): array + { + $variants = ProductVariant::query() + ->with('product:id,name') + ->whereIn('id', array_column($lineItems, 'product_variant_id')) + ->get() + ->keyBy('id'); + + return collect($lineItems) + ->map(function (array $item) use ($variants) { + $variant = $variants->get($item['product_variant_id']); + + return array_merge($item, [ + 'product_name' => $variant?->product?->name, + 'variant_name' => $variant?->name, + ]); + }) + ->all(); + } + + private function syncRequestPhotos(OwnerVerificationRequest $verificationRequest, array $validated): void + { + $this->mediaService->syncCollection( + $verificationRequest, + 'photos', + $validated['photos'] ?? null, + $validated['remove_media_ids'] ?? null, + self::MAX_PHOTOS, + required: false, + errorKey: 'photos', + s3Keys: $validated['s3_keys'] ?? null, + ); + } + + private function applyRequestPhotos( + OwnerVerificationRequest $verificationRequest, + Restock $restock, + array $payload, + ): void { + if ($verificationRequest->hasMedia('photos')) { + $restock->clearMediaCollection('photos'); + + foreach ($verificationRequest->getMedia('photos') as $media) { + $media->copy($restock, 'photos'); + } + + return; + } + + foreach ($payload['remove_media_ids'] ?? [] as $mediaId) { + $restock->deleteMedia((int) $mediaId); + } + } + + private function payloadNew(OwnerVerificationRequest $verificationRequest): array + { + $payload = $verificationRequest->payload ?? []; + + if (array_key_exists('new', $payload)) { + return is_array($payload['new']) ? $payload['new'] : []; + } + + return is_array($payload) ? $payload : []; + } + + private function breakItemCircularReference(RestockItem $item): void + { + $variant = $item->productVariant; + + if ($variant) { + $product = $variant->product; + + $item->setAttribute('variant_name', $variant->name); + $item->setAttribute('stock', $variant->stock ?? 0); + + if ($product) { + $item->setAttribute('product_id', $product->id); + $item->setAttribute('product_name', $product->name); + } + + $variant->unsetRelation('product'); + } + + $item->unsetRelation('productVariant'); + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['created_at', 'total', 'subtotal'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->latest(); + } +} diff --git a/app/Support/ActivityLog/ModelLabel.php b/app/Support/ActivityLog/ModelLabel.php index 51256bf..2160c48 100644 --- a/app/Support/ActivityLog/ModelLabel.php +++ b/app/Support/ActivityLog/ModelLabel.php @@ -27,6 +27,8 @@ use App\Models\RawMaterial; use App\Models\RawMaterialPrice; use App\Models\Rejection; +use App\Models\Restock; +use App\Models\RestockItem; use App\Models\Supplier; use App\Models\SystemConfiguration; use App\Models\User; @@ -64,6 +66,8 @@ class ModelLabel RawMaterial::class => 'Bahan Baku', RawMaterialPrice::class => 'Harga Bahan Baku', Rejection::class => 'Penolakan', + Restock::class => 'Restock', + RestockItem::class => 'Item Restock', Supplier::class => 'Supplier', SystemConfiguration::class => 'Konfigurasi Sistem', User::class => 'Pengguna', diff --git a/database/migrations/2026_07_13_000001_create_restocks_table.php b/database/migrations/2026_07_13_000001_create_restocks_table.php new file mode 100644 index 0000000..978e75f --- /dev/null +++ b/database/migrations/2026_07_13_000001_create_restocks_table.php @@ -0,0 +1,32 @@ +id(); + + $table->foreignId('created_by_id')->constrained('users')->cascadeOnDelete(); + + $table->unsignedBigInteger('subtotal'); + $table->unsignedBigInteger('total'); + $table->string('notes', 100)->nullable(); + $table->enum('stock_type', ProductStockQuality::values())->default(ProductStockQuality::GOOD->value); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('restocks'); + } +}; diff --git a/database/migrations/2026_07_13_000002_create_restock_items_table.php b/database/migrations/2026_07_13_000002_create_restock_items_table.php new file mode 100644 index 0000000..d739cfc --- /dev/null +++ b/database/migrations/2026_07_13_000002_create_restock_items_table.php @@ -0,0 +1,32 @@ +id(); + + $table->foreignId('restock_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('product_variant_id')->constrained()->cascadeOnDelete(); + + $table->integer('quantity'); + $table->unsignedBigInteger('unit_price'); + $table->unsignedBigInteger('subtotal'); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('restock_items'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index a95518e..52cf7dd 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + + diff --git a/resources/js/pages/admin/manage/restocks/Edit.vue b/resources/js/pages/admin/manage/restocks/Edit.vue new file mode 100644 index 0000000..b3e3156 --- /dev/null +++ b/resources/js/pages/admin/manage/restocks/Edit.vue @@ -0,0 +1,59 @@ + + + diff --git a/resources/js/pages/admin/manage/restocks/Index.vue b/resources/js/pages/admin/manage/restocks/Index.vue new file mode 100644 index 0000000..7bb0f54 --- /dev/null +++ b/resources/js/pages/admin/manage/restocks/Index.vue @@ -0,0 +1,90 @@ + + + diff --git a/resources/js/pages/admin/manage/restocks/form/RestockPosCartDetailDialog.vue b/resources/js/pages/admin/manage/restocks/form/RestockPosCartDetailDialog.vue new file mode 100644 index 0000000..246d7f5 --- /dev/null +++ b/resources/js/pages/admin/manage/restocks/form/RestockPosCartDetailDialog.vue @@ -0,0 +1,123 @@ + + + diff --git a/resources/js/pages/admin/manage/restocks/form/RestockPosCartSummaryItems.vue b/resources/js/pages/admin/manage/restocks/form/RestockPosCartSummaryItems.vue new file mode 100644 index 0000000..6d18931 --- /dev/null +++ b/resources/js/pages/admin/manage/restocks/form/RestockPosCartSummaryItems.vue @@ -0,0 +1,110 @@ + + + diff --git a/resources/js/pages/admin/manage/restocks/form/RestockPosCatalogPanel.vue b/resources/js/pages/admin/manage/restocks/form/RestockPosCatalogPanel.vue new file mode 100644 index 0000000..36c0f0d --- /dev/null +++ b/resources/js/pages/admin/manage/restocks/form/RestockPosCatalogPanel.vue @@ -0,0 +1,121 @@ + + + diff --git a/resources/js/pages/admin/manage/restocks/form/RestockPosCheckoutSection.vue b/resources/js/pages/admin/manage/restocks/form/RestockPosCheckoutSection.vue new file mode 100644 index 0000000..269e0a2 --- /dev/null +++ b/resources/js/pages/admin/manage/restocks/form/RestockPosCheckoutSection.vue @@ -0,0 +1,92 @@ + + +