diff --git a/app/Enums/OwnerVerificationAction.php b/app/Enums/OwnerVerificationAction.php index 528b8a9..fea8c8e 100644 --- a/app/Enums/OwnerVerificationAction.php +++ b/app/Enums/OwnerVerificationAction.php @@ -13,6 +13,7 @@ enum OwnerVerificationAction: string case DELETE = 'delete'; case TOGGLE_STATUS = 'toggle_status'; case STOCK_VERIFY = 'stock_verify'; + case STOCK_ECER_TRANSFER = 'stock_ecer_transfer'; public function label(): string { @@ -22,6 +23,7 @@ public function label(): string self::DELETE => 'Hapus', self::TOGGLE_STATUS => 'Ubah Status', self::STOCK_VERIFY => 'Verifikasi Stok', + self::STOCK_ECER_TRANSFER => 'Transfer Stok Ecer', }; } } diff --git a/app/Enums/ProductStockQuality.php b/app/Enums/ProductStockQuality.php index 1b99e98..cfe5282 100644 --- a/app/Enums/ProductStockQuality.php +++ b/app/Enums/ProductStockQuality.php @@ -10,12 +10,14 @@ enum ProductStockQuality: string case GOOD = 'good'; case REJECT = 'reject'; + case ECER = 'ecer'; public function label(): string { return match ($this) { self::GOOD => 'Bagus', self::REJECT => 'Reject', + self::ECER => 'Eceran', }; } } diff --git a/app/Http/Controllers/Admin/Manage/StockEcerController.php b/app/Http/Controllers/Admin/Manage/StockEcerController.php new file mode 100644 index 0000000..ceb38c6 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/StockEcerController.php @@ -0,0 +1,32 @@ +validated(); + + $this->stockEcerService->transfer( + $validated['product_variant_id'], + $validated['quantity'], + $request->user(), + $validated['notes'] ?? null, + ); + + return response()->json([ + 'success' => true, + 'message' => 'Pengajuan transfer stok ecer berhasil dikirim dan menunggu verifikasi owner.', + ]); + } +} diff --git a/app/Http/Requests/Admin/Manage/OrderRequest.php b/app/Http/Requests/Admin/Manage/OrderRequest.php index 882df26..d87473b 100644 --- a/app/Http/Requests/Admin/Manage/OrderRequest.php +++ b/app/Http/Requests/Admin/Manage/OrderRequest.php @@ -84,7 +84,10 @@ public function attributes(): array public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator): void { - if ($this->filled('marketing_id') && $this->marketing_id !== 'none' && ! $this->filled('customer_id')) { + // Skip customer validation for cashier role + $isCashier = $this->user()?->hasRole('cashier') ?? false; + + if (! $isCashier && $this->filled('marketing_id') && $this->marketing_id !== 'none' && ! $this->filled('customer_id')) { $validator->errors()->add('customer_id', 'Pelanggan wajib diisi jika marketing dipilih.'); } diff --git a/app/Http/Requests/Admin/Manage/StockEcerTransferRequest.php b/app/Http/Requests/Admin/Manage/StockEcerTransferRequest.php new file mode 100644 index 0000000..4ebfae9 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/StockEcerTransferRequest.php @@ -0,0 +1,43 @@ +user()?->can(Permission::STOCKS_VIEW->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'product_variant_id' => [ + 'required', + 'integer', + Rule::exists('product_variants', 'id')->whereNull('deleted_at'), + ], + 'quantity' => ['required', 'integer', 'min:1'], + 'notes' => ['nullable', 'string', 'max:500'], + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'product_variant_id' => 'varian produk', + 'quantity' => 'jumlah', + 'notes' => 'catatan', + ]; + } +} diff --git a/app/Http/Requests/Admin/Master/ProductRequest.php b/app/Http/Requests/Admin/Master/ProductRequest.php index 319a37d..c7ae080 100644 --- a/app/Http/Requests/Admin/Master/ProductRequest.php +++ b/app/Http/Requests/Admin/Master/ProductRequest.php @@ -42,6 +42,7 @@ public function rules(): array ], 'variants.*.name' => ['required', 'string', 'max:200'], 'variants.*.stock' => ['required', 'integer', 'min:0'], + 'variants.*.stock_ecer' => ['required', 'integer', 'min:0'], ...$this->variantImageRules(), ]; } @@ -59,6 +60,7 @@ public function attributes(): array 'variants' => 'varian', 'variants.*.name' => 'nama varian', 'variants.*.stock' => 'stok', + 'variants.*.stock_ecer' => 'stok ecer', ...$this->variantImageAttributes('variants', 'foto varian'), ]; } diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php index a5fda91..c81d673 100644 --- a/app/Models/ProductVariant.php +++ b/app/Models/ProductVariant.php @@ -24,6 +24,7 @@ protected function casts(): array return [ 'stock' => 'integer', 'reject_stock' => 'integer', + 'stock_ecer' => 'integer', ]; } diff --git a/app/Models/StockEcerHistory.php b/app/Models/StockEcerHistory.php new file mode 100644 index 0000000..b41133e --- /dev/null +++ b/app/Models/StockEcerHistory.php @@ -0,0 +1,37 @@ + 'integer', + 'stock_before' => 'integer', + 'stock_ecer_before' => 'integer', + 'stock_after' => 'integer', + 'stock_ecer_after' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function productVariant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php index c516d8d..b1381bb 100644 --- a/app/Services/Manage/OrderService.php +++ b/app/Services/Manage/OrderService.php @@ -72,6 +72,7 @@ public function stockColumn(ProductStockQuality $quality): string return match ($quality) { ProductStockQuality::GOOD => 'stock', ProductStockQuality::REJECT => 'reject_stock', + ProductStockQuality::ECER => 'stock_ecer', }; } @@ -314,8 +315,15 @@ public function draftItemsForUser(User $user): array */ public function syncDraftItem(array $validated, User $user): array { - $priceType = PriceType::from($validated['price_type']); - $stockQuality = ProductStockQuality::from($validated['stock_quality']); + // Force ecer price type and stock quality for cashier role + $priceType = $user->hasRole('cashier') + ? PriceType::ECER + : PriceType::from($validated['price_type']); + + $stockQuality = $user->hasRole('cashier') + ? ProductStockQuality::ECER + : ProductStockQuality::from($validated['stock_quality']); + $variant = ProductVariant::query()->findOrFail($validated['product_variant_id']); $unitPrice = $this->resolveUnitPrice($variant->id, $priceType); @@ -367,7 +375,10 @@ public function removeDraftItem(User $user, ProductVariant $productVariant, Prod */ public function resyncDraftPrices(User $user, string $priceTypeValue): array { - $priceType = PriceType::from($priceTypeValue); + // Force ecer price type for cashier role + $priceType = $user->hasRole('cashier') + ? PriceType::ECER + : PriceType::from($priceTypeValue); $items = $this->draftItemsQuery($user) ->with([ @@ -400,6 +411,14 @@ public function resyncDraftPrices(User $user, string $priceTypeValue): array public function create(array $validated, User $user): Order { try { + // Force cashier settings + if ($user->hasRole('cashier')) { + $validated['channel'] = 'store'; + $validated['price_type'] = 'ecer'; + $validated['payment_type'] = 'cash'; + unset($validated['customer_id']); + } + $order = DB::transaction(function () use ($validated, $user): Order { $priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']); diff --git a/app/Services/Manage/OwnerVerificationService.php b/app/Services/Manage/OwnerVerificationService.php index 8f1c570..d1766d3 100644 --- a/app/Services/Manage/OwnerVerificationService.php +++ b/app/Services/Manage/OwnerVerificationService.php @@ -8,6 +8,7 @@ use App\Models\Cutting; use App\Models\OwnerVerificationRequest; use App\Models\Product; +use App\Models\ProductVariant; use App\Models\Purchase; use App\Models\RawMaterial; use App\Models\User; @@ -35,6 +36,7 @@ public function __construct( private readonly PurchaseService $purchaseService, private readonly PushNotificationService $pushNotificationService, private readonly MarketplaceService $marketplaceService, + private readonly StockEcerService $stockEcerService, ) {} /** @@ -282,6 +284,7 @@ private function rejectVerificationRequest(OwnerVerificationRequest $request): v RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request), Purchase::class => $this->purchaseService->rejectVerificationRequest($request), MarketplaceSettings::class => null, + ProductVariant::class => $this->stockEcerService->rejectStockEcerTransfer($request), default => throw ValidationException::withMessages([ 'subject_type' => 'Tipe data verifikasi tidak didukung.', ]), @@ -295,6 +298,7 @@ private function applyVerificationRequest(OwnerVerificationRequest $request): vo RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request), Purchase::class => $this->purchaseService->applyVerificationRequest($request), MarketplaceSettings::class => $this->marketplaceService->applyVerificationRequest($request), + ProductVariant::class => $this->stockEcerService->applyStockEcerTransfer($request), default => throw ValidationException::withMessages([ 'subject_type' => 'Tipe data verifikasi tidak didukung.', ]), @@ -408,7 +412,7 @@ private function buildVerificationRequestQuery( $query->where('username', 'like', "%{$search}%") ->orWhereHas('profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%")); }) - ->orWhereHasMorph('subject', [Product::class, RawMaterial::class, Purchase::class], function (Builder $query, string $type) use ($search): void { + ->orWhereHasMorph('subject', [Product::class, RawMaterial::class, Purchase::class, ProductVariant::class], function (Builder $query, string $type) use ($search): void { if ($type === Purchase::class) { $query->where('notes', 'like', "%{$search}%") ->orWhereHas('supplier', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")); @@ -488,6 +492,13 @@ private function requestTitle(OwnerVerificationRequest $request): string return $request->subject->supplier?->name ?? 'Belanja'; } + if ($request->subject instanceof ProductVariant) { + $payload = is_array($request->payload) ? $request->payload : []; + $newData = $payload['new'] ?? []; + + return ($newData['product_name'] ?? 'Produk') . ' - ' . ($newData['variant_name'] ?? $request->subject->name); + } + $payload = is_array($request->payload) ? $request->payload : []; foreach (['new', 'old'] as $key) { @@ -523,7 +534,7 @@ private function notifyRequestSubmitter( } $url = match ($request->subject_type) { - Product::class => route('admin.master.products.index'), + Product::class, ProductVariant::class => route('admin.master.products.index'), RawMaterial::class => route('admin.master.raw_materials.index'), Purchase::class => route('admin.manage.purchases.index'), MarketplaceSettings::class => route('admin.system.settings.index'), diff --git a/app/Services/Manage/StockEcerService.php b/app/Services/Manage/StockEcerService.php new file mode 100644 index 0000000..81fd5fb --- /dev/null +++ b/app/Services/Manage/StockEcerService.php @@ -0,0 +1,151 @@ + 'Jumlah harus lebih dari 0.', + ]); + } + + $variant = ProductVariant::query()->findOrFail($variantId); + + if ($variant->stock < $quantity) { + throw ValidationException::withMessages([ + 'quantity' => "Stok bagus tidak mencukupi. Stok saat ini: {$variant->stock} pcs.", + ]); + } + + $existingPending = OwnerVerificationRequest::query() + ->where('subject_type', ProductVariant::class) + ->where('subject_id', $variant->id) + ->where('action', OwnerVerificationAction::STOCK_ECER_TRANSFER) + ->where('status', OwnerVerificationStatus::PENDING) + ->exists(); + + if ($existingPending) { + throw ValidationException::withMessages([ + 'quantity' => 'Masih ada pengajuan transfer stok ecer yang menunggu verifikasi untuk varian ini.', + ]); + } + + try { + $request = OwnerVerificationRequest::create([ + 'action' => OwnerVerificationAction::STOCK_ECER_TRANSFER, + 'status' => OwnerVerificationStatus::PENDING, + 'subject_type' => ProductVariant::class, + 'subject_id' => $variant->id, + 'submitted_by_id' => $user->id, + 'payload' => [ + 'old' => [ + 'stock' => $variant->stock, + 'stock_ecer' => $variant->stock_ecer, + ], + 'new' => [ + 'quantity' => $quantity, + 'stock' => $variant->stock - $quantity, + 'stock_ecer' => $variant->stock_ecer + $quantity, + 'notes' => $notes, + 'variant_name' => $variant->name, + 'product_name' => $variant->product?->name ?? '-', + ], + ], + ]); + + $this->pushNotificationService->sendToRoles( + '馃摝 Transfer Stok Ecer Menunggu Persetujuan Owner', + "Pengajuan transfer {$quantity} pcs stok ecer untuk varian '{$variant->name}' menunggu verifikasi owner.", + ['owner', 'developer'], + route('admin.master.products.index'), + ); + + $this->pushNotificationService->sendToUser( + '馃摛 Pengajuan Transfer Terkirim', + "Pengajuan transfer {$quantity} pcs stok ecer untuk varian '{$variant->name}' telah dikirim dan menunggu verifikasi owner.", + $user->id, + route('admin.master.products.index'), + ); + + return $request; + } catch (ValidationException $e) { + throw $e; + } catch (\Throwable $e) { + Log::error('Gagal mengajukan transfer stok ecer: '.$e->getMessage(), [ + 'trace' => $e->getTraceAsString(), + ]); + + throw ValidationException::withMessages([ + 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', + ]); + } + } + + /** + * Apply the stock ecer transfer when owner approves. + */ + public function applyStockEcerTransfer(OwnerVerificationRequest $request): void + { + $payload = $request->payload ?? []; + $newData = $payload['new'] ?? []; + $quantity = (int) ($newData['quantity'] ?? 0); + + if ($quantity <= 0) { + return; + } + + DB::transaction(function () use ($request, $quantity, $newData): void { + $variant = ProductVariant::query() + ->whereKey($request->subject_id) + ->lockForUpdate() + ->firstOrFail(); + + $stockBefore = $variant->stock; + $stockEcerBefore = $variant->stock_ecer; + + $variant->decrement('stock', $quantity); + $variant->increment('stock_ecer', $quantity); + + StockEcerHistory::create([ + 'product_variant_id' => $variant->id, + 'user_id' => $request->submitted_by_id, + 'quantity' => $quantity, + 'stock_before' => $stockBefore, + 'stock_ecer_before' => $stockEcerBefore, + 'stock_after' => $stockBefore - $quantity, + 'stock_ecer_after' => $stockEcerBefore + $quantity, + 'notes' => $newData['notes'] ?? null, + 'created_at' => now(), + ]); + }); + } + + /** + * Reject the stock ecer transfer (no-op since nothing changed). + */ + public function rejectStockEcerTransfer(OwnerVerificationRequest $request): void + { + // No-op: nothing was changed yet, so nothing to rollback. + } +} diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index b8b9b13..1842a01 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -76,6 +76,17 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca $pendingRequest = $product->pendingOwnerVerificationRequest; + // Also check for pending stock ecer transfer on variants + if ($pendingRequest === null) { + $pendingRequest = OwnerVerificationRequest::query() + ->where('subject_type', ProductVariant::class) + ->whereIn('subject_id', $product->variants->pluck('id')) + ->where('action', OwnerVerificationAction::STOCK_ECER_TRANSFER) + ->pending() + ->latest() + ->first(); + } + $product->setAttribute('has_pending_request', $pendingRequest !== null); $product->setAttribute('pending_request_id', $pendingRequest?->id); $product->setAttribute('pending_request_action', $pendingRequest?->action->value); @@ -108,6 +119,7 @@ public function create(array $validated, User $user): void $variant = $product->variants()->create([ 'name' => $variantData['name'], 'stock' => $variantData['stock'], + 'stock_ecer' => $variantData['stock_ecer'], ]); $this->syncVariantImages($variant, $variantData, $index); @@ -369,6 +381,7 @@ private function applyPayloadToProduct( $variant->update([ 'name' => $variantData['name'], 'stock' => $variantData['stock'], + 'stock_ecer' => $variantData['stock_ecer'], ]); if ($verificationRequest !== null) { @@ -381,6 +394,7 @@ private function applyPayloadToProduct( $variant = $product->variants()->create([ 'name' => $variantData['name'], 'stock' => $variantData['stock'], + 'stock_ecer' => $variantData['stock_ecer'], ]); if ($verificationRequest !== null) { @@ -525,6 +539,7 @@ private function snapshotProduct(Product $product): array 'id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock, + 'stock_ecer' => $variant->stock_ecer, ]) ->all(), ]); @@ -560,6 +575,7 @@ private function buildPayloadFromValidated(array $validated): array 'id' => $variantData['id'] ?? null, 'name' => $variantData['name'], 'stock' => $variantData['stock'], + 'stock_ecer' => $variantData['stock_ecer'], 'remove_media_ids' => $variantData['remove_media_ids'] ?? [], ]) ->all(), diff --git a/app/Services/System/AnalysisService.php b/app/Services/System/AnalysisService.php index 1e6e37b..fb85a78 100644 --- a/app/Services/System/AnalysisService.php +++ b/app/Services/System/AnalysisService.php @@ -498,6 +498,7 @@ public function getProductStock(): array ->selectRaw(' SUM(product_variants.stock) as total_stock, SUM(product_variants.reject_stock) as total_reject, + SUM(product_variants.stock_ecer) as total_ecer, COUNT(product_variants.id) as total_variants, COUNT(DISTINCT products.id) as total_products ') @@ -515,6 +516,7 @@ public function getProductStock(): array return [ 'total_stock' => (int) ($variants->total_stock ?? 0), 'total_reject' => (int) ($variants->total_reject ?? 0), + 'total_ecer' => (int) ($variants->total_ecer ?? 0), 'total_value' => (int) ($totalValue ?? 0), 'total_products' => (int) ($variants->total_products ?? 0), 'total_variants' => (int) ($variants->total_variants ?? 0), diff --git a/app/Support/OwnerVerification/VerificationChangeFormatter.php b/app/Support/OwnerVerification/VerificationChangeFormatter.php index 260b875..2cf41fc 100644 --- a/app/Support/OwnerVerification/VerificationChangeFormatter.php +++ b/app/Support/OwnerVerification/VerificationChangeFormatter.php @@ -10,6 +10,8 @@ class VerificationChangeFormatter private const HIDDEN_FIELDS = [ 'category_ids', 'unit', + 'variant_name', + 'product_name', ]; /** @@ -73,6 +75,11 @@ private static function label(string $field): string 'is_active' => 'Status Aktif', 'variants' => 'Varian', 'prices' => 'Varian Harga', + 'quantity' => 'Jumlah Transfer', + 'stock' => 'Stok Bagus', + 'stock_ecer' => 'Stok Ecer', + 'variant_name' => 'Varian', + 'product_name' => 'Produk', 'tiktok_shop_platform_commission' => 'TikTok Shop - Komisi Platform', 'tiktok_shop_logistics_service_fee' => 'TikTok Shop - Biaya Logistik', 'tiktok_shop_dynamic_commission' => 'TikTok Shop - Komisi Dinamis', @@ -179,6 +186,10 @@ private static function presentValue(string $field, mixed $value): mixed return implode(', ', $value); } + if (in_array($field, ['quantity', 'stock', 'stock_ecer'], true)) { + return (int) $value; + } + return $value; } } diff --git a/database/migrations/2026_06_28_093300_add_stock_ecer_to_product_variants_table.php b/database/migrations/2026_06_28_093300_add_stock_ecer_to_product_variants_table.php new file mode 100644 index 0000000..05593ee --- /dev/null +++ b/database/migrations/2026_06_28_093300_add_stock_ecer_to_product_variants_table.php @@ -0,0 +1,22 @@ +unsignedInteger('stock_ecer')->default(0)->after('reject_stock'); + }); + } + + public function down(): void + { + Schema::table('product_variants', function (Blueprint $table) { + $table->dropColumn('stock_ecer'); + }); + } +}; diff --git a/database/migrations/2026_06_28_094400_create_stock_ecer_histories_table.php b/database/migrations/2026_06_28_094400_create_stock_ecer_histories_table.php new file mode 100644 index 0000000..f4c58b7 --- /dev/null +++ b/database/migrations/2026_06_28_094400_create_stock_ecer_histories_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('product_variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->unsignedInteger('quantity'); + $table->unsignedInteger('stock_before'); + $table->unsignedInteger('stock_ecer_before'); + $table->unsignedInteger('stock_after'); + $table->unsignedInteger('stock_ecer_after'); + $table->string('notes')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_ecer_histories'); + } +}; diff --git a/resources/js/components/modal/StockEcerTransferModal.vue b/resources/js/components/modal/StockEcerTransferModal.vue new file mode 100644 index 0000000..937176c --- /dev/null +++ b/resources/js/components/modal/StockEcerTransferModal.vue @@ -0,0 +1,177 @@ + + +