From e4b475cd829c63c28d352733c23f1c34b80f7c8e Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sun, 21 Jun 2026 11:53:41 +0700 Subject: [PATCH] feat: add stock management functionality by introducing StockController and updating AppSidebar to include stock menu item for improved inventory handling --- .../Admin/Manage/StockController.php | 64 +++ .../Admin/Manage/StockVerifyRequest.php | 108 +++++ app/Services/Manage/StockService.php | 232 ++++++++++ resources/js/components/AppSidebar.vue | 3 +- .../js/pages/admin/manage/stocks/Index.vue | 39 ++ .../stocks/table/StockPendingSection.vue | 162 +++++++ .../stocks/table/StockVerifiedSection.vue | 154 +++++++ .../stocks/table/data-table-actions.vue | 409 ++++++++++++++++++ routes/web.php | 15 + 9 files changed, 1185 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Admin/Manage/StockController.php create mode 100644 app/Http/Requests/Admin/Manage/StockVerifyRequest.php create mode 100644 app/Services/Manage/StockService.php create mode 100644 resources/js/pages/admin/manage/stocks/Index.vue create mode 100644 resources/js/pages/admin/manage/stocks/table/StockPendingSection.vue create mode 100644 resources/js/pages/admin/manage/stocks/table/StockVerifiedSection.vue create mode 100644 resources/js/pages/admin/manage/stocks/table/data-table-actions.vue diff --git a/app/Http/Controllers/Admin/Manage/StockController.php b/app/Http/Controllers/Admin/Manage/StockController.php new file mode 100644 index 0000000..51d2ddc --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/StockController.php @@ -0,0 +1,64 @@ +user(); + + return Inertia::render('admin/manage/stocks/Index', [ + 'pendingCuttings' => $this->stockService->getPendingVerificationCuttings($user), + 'verifiedCuttings' => $this->stockService->getVerifiedCuttings(), + ]); + } + + public function verify(StockVerifyRequest $request, Cutting $cutting): RedirectResponse + { + $this->stockService->verify( + $cutting, + $request->user(), + $request->validated('verification_note'), + $request->validated('results'), + $request->validated('result_prices'), + ); + + $this->flashSuccess('Cutting berhasil diverifikasi. Stok produk telah ditambahkan ke toko.'); + + return redirect()->route('admin.manage.stocks.index'); + } + + public function reject(Request $request, Cutting $cutting): RedirectResponse + { + $request->validate([ + 'reason' => ['required', 'string', 'max:500'], + ]); + + $this->stockService->reject( + $cutting, + $request->user(), + $request->validated('reason'), + ); + + $this->flashSuccess('Cutting berhasil ditolak dan dikembalikan ke proses.'); + + return redirect()->route('admin.manage.stocks.index'); + } +} diff --git a/app/Http/Requests/Admin/Manage/StockVerifyRequest.php b/app/Http/Requests/Admin/Manage/StockVerifyRequest.php new file mode 100644 index 0000000..498fea0 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/StockVerifyRequest.php @@ -0,0 +1,108 @@ +user()?->can(Permission::CUTTINGS_VERIFY->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'verification_note' => ['nullable', 'string', 'max:500'], + 'results' => ['nullable', 'array'], + 'results.*.product_variant_id' => ['required_with:results', 'integer', 'exists:product_variants,id'], + 'results.*.warehouse_stock' => ['required_with:results', 'integer', 'min:0'], + 'results.*.cutting_reject' => ['required_with:results', 'integer', 'min:0'], + 'result_prices' => ['nullable', 'array'], + 'result_prices.*.product_variant_id' => ['required_with:result_prices', 'integer', 'exists:product_variants,id'], + 'result_prices.*.prices' => ['required_with:result_prices', 'array', 'min:1'], + 'result_prices.*.prices.*.type' => ['required', Rule::enum(PriceType::class)], + 'result_prices.*.prices.*.price' => ['required', 'integer', 'gt:0'], + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'verification_note' => 'catatan verifikasi', + 'result_prices' => 'harga jual', + 'result_prices.*.product_variant_id' => 'varian produk', + 'result_prices.*.prices' => 'harga jual', + 'result_prices.*.prices.*.type' => 'tipe harga', + 'result_prices.*.prices.*.price' => 'harga jual', + 'results' => 'hasil cutting', + 'results.*.product_variant_id' => 'varian produk', + 'results.*.warehouse_stock' => 'stok bagus', + 'results.*.cutting_reject' => 'stok reject', + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + /** @var Cutting $cutting */ + $cutting = $this->route('cutting'); + + if ($cutting->status !== CuttingStatus::COMPLETED) { + $validator->errors()->add('status', 'Hanya cutting yang sudah selesai yang dapat diverifikasi.'); + + return; + } + + if ($this->has('results')) { + $cuttingResults = $cutting->results->keyBy('product_variant_id'); + foreach ($this->input('results', []) as $index => $item) { + $variantId = $item['product_variant_id'] ?? 0; + $warehouseStock = (int) ($item['warehouse_stock'] ?? 0); + $cuttingReject = (int) ($item['cutting_reject'] ?? 0); + + $originalResult = $cuttingResults->get($variantId); + if ($originalResult === null) { + $validator->errors()->add("results.{$index}.product_variant_id", 'Varian produk tidak ditemukan pada cutting ini.'); + + continue; + } + + if (($warehouseStock + $cuttingReject) !== (int) $originalResult->cutting_result) { + $validator->errors()->add("results.{$index}.warehouse_stock", "Total jumlah (diterima + reject) harus sama dengan hasil cutting asli ({$originalResult->cutting_result} pcs)."); + } + } + } + + if (! $this->has('result_prices') || $this->input('result_prices') === []) { + $validator->errors()->add('result_prices', 'Harga jual wajib diisi saat verifikasi.'); + } else { + $variantIds = $cutting->results->pluck('product_variant_id')->all(); + $submittedVariantIds = collect($this->input('result_prices', [])) + ->pluck('product_variant_id') + ->map(fn ($id) => (int) $id) + ->all(); + + foreach ($variantIds as $variantId) { + if (! in_array($variantId, $submittedVariantIds, true)) { + $validator->errors()->add('result_prices', 'Harga jual wajib diisi untuk semua varian hasil cutting.'); + break; + } + } + } + }); + } +} diff --git a/app/Services/Manage/StockService.php b/app/Services/Manage/StockService.php new file mode 100644 index 0000000..7da4667 --- /dev/null +++ b/app/Services/Manage/StockService.php @@ -0,0 +1,232 @@ + + */ + public function getPendingVerificationCuttings(User $user): Collection + { + return Cutting::query() + ->with([ + 'createdBy.profile', + 'rejection.rejectedBy.profile', + 'materials.rawMaterialPrice.rawMaterial:id,name,unit', + 'results.productVariant.product:id,name', + 'results.productVariant:id,product_id,name', + ]) + ->where('status', CuttingStatus::COMPLETED) + ->latest() + ->get() + ->each(function (Cutting $cutting): void { + $this->appendCostPreview($cutting); + }); + } + + /** + * Get all verified cuttings (stock history). + * + * @return Collection + */ + public function getVerifiedCuttings(): Collection + { + return Cutting::query() + ->with([ + 'createdBy.profile', + 'rejection.rejectedBy.profile', + 'materials.rawMaterialPrice.rawMaterial:id,name,unit', + 'results.productVariant.product:id,name', + 'results.productVariant:id,product_id,name', + ]) + ->where('status', CuttingStatus::VERIFIED) + ->latest() + ->limit(50) + ->get() + ->each(function (Cutting $cutting): void { + $this->appendCostPreview($cutting); + }); + } + + /** + * Verify a completed cutting - adds stock to store. + * + * @param list|null $results + * @param list}>|null $resultPrices + */ + public function verify( + Cutting $cutting, + User $user, + ?string $verificationNote = null, + ?array $results = null, + ?array $resultPrices = null, + ): void { + if ($cutting->status !== CuttingStatus::COMPLETED) { + throw ValidationException::withMessages([ + 'status' => 'Hanya cutting yang sudah selesai yang dapat diverifikasi.', + ]); + } + + DB::transaction(function () use ($cutting, $user, $verificationNote, $results, $resultPrices): void { + $cutting->load(['materials.rawMaterialPrice', 'results']); + + if ($results !== null) { + foreach ($results as $item) { + $cutting->results() + ->where('product_variant_id', $item['product_variant_id']) + ->update([ + 'warehouse_stock' => $item['warehouse_stock'], + 'cutting_reject' => $item['cutting_reject'], + ]); + } + $cutting->load('results'); + } + + $this->applyProductStockOnVerify($cutting); + $this->storeResultPrices($cutting, $resultPrices ?? []); + + if ($verificationNote !== null && trim($verificationNote) !== '') { + $cutting->rejection()->create([ + 'reason' => trim($verificationNote), + 'rejected_by_id' => $user->id, + ]); + } + + $cutting->status = CuttingStatus::VERIFIED; + $cutting->save(); + }); + + $description = $cutting->description ?? '-'; + + $this->pushNotificationService->sendToRoles( + '📦 Stok Cutting Diverifikasi', + "Cutting dengan deskripsi '{$description}' telah diverifikasi dan stok produk telah ditambahkan ke toko.", + ['owner', 'developer'], + '/admin/manage/stocks', + ); + } + + /** + * Reject a completed cutting - sends back to in_progress. + */ + public function reject(Cutting $cutting, User $user, string $reason): void + { + if ($cutting->status !== CuttingStatus::COMPLETED) { + throw ValidationException::withMessages([ + 'status' => 'Hanya cutting yang sudah selesai yang dapat ditolak.', + ]); + } + + DB::transaction(function () use ($cutting, $user, $reason): void { + $cutting->load(['materials.rawMaterialPrice', 'results']); + $this->deductRemainingMaterialStock($cutting); + + $cutting->rejection()->create([ + 'reason' => trim($reason), + 'rejected_by_id' => $user->id, + ]); + + $cutting->status = CuttingStatus::IN_PROGRESS; + $cutting->save(); + }); + + $description = $cutting->description ?? '-'; + $this->pushNotificationService->sendToRoles( + '📦 Verifikasi Cutting Ditolak', + "Cutting dengan deskripsi '{$description}' ditolak dari verifikasi stok dengan alasan: '{$reason}'.", + ['owner', 'developer'], + '/admin/manage/stocks', + ); + } + + private function applyProductStockOnVerify(Cutting $cutting): void + { + foreach ($cutting->results as $result) { + if ($result->warehouse_stock > 0) { + ProductVariant::query() + ->whereKey($result->product_variant_id) + ->increment('stock', $result->warehouse_stock); + } + + if ($result->cutting_reject > 0) { + ProductVariant::query() + ->whereKey($result->product_variant_id) + ->increment('reject_stock', $result->cutting_reject); + } + } + } + + private function deductRemainingMaterialStock(Cutting $cutting): void + { + foreach ($cutting->materials as $material) { + if ((float) $material->remaining_material > 0) { + RawMaterialPrice::query() + ->whereKey($material->raw_material_price_id) + ->decrement('stock', $material->remaining_material); + } + } + } + + /** + * @param list}> $resultPrices + */ + private function storeResultPrices(Cutting $cutting, array $resultPrices): void + { + $costPerUnit = (int) ($cutting->cost_per_unit ?? 0); + + foreach ($resultPrices as $resultData) { + foreach ($resultData['prices'] as $priceData) { + if ((int) $priceData['price'] > 0) { + CuttingResultPrice::query()->create([ + 'cutting_id' => $cutting->id, + 'product_variant_id' => $resultData['product_variant_id'], + 'price_type' => $priceData['type'], + 'price' => (int) $priceData['price'], + 'cost_per_unit' => $costPerUnit, + ]); + } + } + } + } + + private function appendCostPreview(Cutting $cutting): void + { + $cutting->setAttribute('total_result_pieces', (int) $cutting->results->sum('cutting_result')); + $cutting->setAttribute('total_material_usage', (float) $cutting->materials->sum('material_usage')); + + $totalMaterialCost = $cutting->total_material_cost ?? 0; + $sewingCost = (int) ($cutting->sewing_cost ?? 0); + $otherCost = (int) ($cutting->other_cost ?? 0); + $totalProductionCost = $totalMaterialCost + $sewingCost + $otherCost; + $costPerUnit = $cutting->cost_per_unit ?? 0; + + $cutting->setAttribute('total_material_cost', $totalMaterialCost); + $cutting->setAttribute('total_material_cost_formatted', 'Rp '.number_format($totalMaterialCost, 0, ',', '.')); + $cutting->setAttribute('sewing_cost', $sewingCost); + $cutting->setAttribute('sewing_cost_formatted', 'Rp '.number_format($sewingCost, 0, ',', '.')); + $cutting->setAttribute('other_cost', $otherCost); + $cutting->setAttribute('other_cost_formatted', 'Rp '.number_format($otherCost, 0, ',', '.')); + $cutting->setAttribute('total_production_cost', $totalProductionCost); + $cutting->setAttribute('total_production_cost_formatted', 'Rp '.number_format($totalProductionCost, 0, ',', '.')); + $cutting->setAttribute('estimated_cost_per_unit', $costPerUnit); + $cutting->setAttribute('estimated_cost_per_unit_formatted', 'Rp '.number_format($costPerUnit, 0, ',', '.')); + } +} diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 3c9d058..68d6952 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/stocks/table/StockPendingSection.vue b/resources/js/pages/admin/manage/stocks/table/StockPendingSection.vue new file mode 100644 index 0000000..d124bb3 --- /dev/null +++ b/resources/js/pages/admin/manage/stocks/table/StockPendingSection.vue @@ -0,0 +1,162 @@ + + + diff --git a/resources/js/pages/admin/manage/stocks/table/StockVerifiedSection.vue b/resources/js/pages/admin/manage/stocks/table/StockVerifiedSection.vue new file mode 100644 index 0000000..b9fd290 --- /dev/null +++ b/resources/js/pages/admin/manage/stocks/table/StockVerifiedSection.vue @@ -0,0 +1,154 @@ + + + diff --git a/resources/js/pages/admin/manage/stocks/table/data-table-actions.vue b/resources/js/pages/admin/manage/stocks/table/data-table-actions.vue new file mode 100644 index 0000000..297e8ef --- /dev/null +++ b/resources/js/pages/admin/manage/stocks/table/data-table-actions.vue @@ -0,0 +1,409 @@ + + +