From ec8b49be058e057fc60f738ff3e2a0efb85ca32e Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 12 Jun 2026 10:56:50 +0700 Subject: [PATCH] Add cutting management features including Cutting, CuttingMaterial, and CuttingResult models. Implement CuttingController for CRUD operations and request validation. Introduce CuttingStatus enum for status management and enhance sidebar navigation for cuttings. Integrate UI components for creating, editing, and listing cuttings, along with permissions in the Permission and Role enums. --- app/Enums/CuttingStatus.php | 96 ++++ app/Enums/Permission.php | 19 + app/Enums/Role.php | 15 + .../Admin/Manage/CuttingController.php | 107 ++++ .../Requests/Admin/Manage/CuttingRequest.php | 48 ++ .../Manage/CuttingStatusTransitionRequest.php | 55 ++ app/Models/Cutting.php | 60 +++ app/Models/CuttingMaterial.php | 85 +++ app/Models/CuttingResult.php | 30 ++ app/Models/ProductVariant.php | 5 + app/Models/RawMaterialPrice.php | 6 + app/Services/Manage/CuttingService.php | 458 ++++++++++++++++ ...026_06_12_110001_create_cuttings_table.php | 27 + ..._110002_create_cutting_materials_table.php | 29 + ...12_110003_create_cutting_results_table.php | 30 ++ resources/js/components/AppSidebar.vue | 13 +- .../manage/cuttings/CuttingGroupedTable.vue | 227 ++++++++ .../admin/manage/cuttings/CuttingPosForm.vue | 506 ++++++++++++++++++ .../manage/cuttings/data-table-actions.vue | 264 +++++++++ .../js/pages/admin/manage/cuttings/Create.vue | 42 ++ .../js/pages/admin/manage/cuttings/Edit.vue | 73 +++ .../js/pages/admin/manage/cuttings/Index.vue | 89 +++ resources/js/types/cutting.ts | 139 +++++ routes/web.php | 30 ++ 24 files changed, 2451 insertions(+), 2 deletions(-) create mode 100644 app/Enums/CuttingStatus.php create mode 100644 app/Http/Controllers/Admin/Manage/CuttingController.php create mode 100644 app/Http/Requests/Admin/Manage/CuttingRequest.php create mode 100644 app/Http/Requests/Admin/Manage/CuttingStatusTransitionRequest.php create mode 100644 app/Models/Cutting.php create mode 100644 app/Models/CuttingMaterial.php create mode 100644 app/Models/CuttingResult.php create mode 100644 app/Services/Manage/CuttingService.php create mode 100644 database/migrations/2026_06_12_110001_create_cuttings_table.php create mode 100644 database/migrations/2026_06_12_110002_create_cutting_materials_table.php create mode 100644 database/migrations/2026_06_12_110003_create_cutting_results_table.php create mode 100644 resources/js/components/admin/manage/cuttings/CuttingGroupedTable.vue create mode 100644 resources/js/components/admin/manage/cuttings/CuttingPosForm.vue create mode 100644 resources/js/components/admin/manage/cuttings/data-table-actions.vue create mode 100644 resources/js/pages/admin/manage/cuttings/Create.vue create mode 100644 resources/js/pages/admin/manage/cuttings/Edit.vue create mode 100644 resources/js/pages/admin/manage/cuttings/Index.vue create mode 100644 resources/js/types/cutting.ts diff --git a/app/Enums/CuttingStatus.php b/app/Enums/CuttingStatus.php new file mode 100644 index 0000000..36859e9 --- /dev/null +++ b/app/Enums/CuttingStatus.php @@ -0,0 +1,96 @@ + 'Proses', + self::COMPLETED => 'Selesai', + self::VERIFIED => 'Terverifikasi', + self::REJECTED => 'Ditolak', + }; + } + + public function isEditable(): bool + { + return in_array($this, [self::IN_PROGRESS, self::REJECTED], true); + } + + public function canTransitionTo(self $status): bool + { + return match ($this) { + self::IN_PROGRESS => $status === self::COMPLETED, + self::COMPLETED => in_array($status, [self::VERIFIED, self::REJECTED], true), + self::REJECTED => $status === self::IN_PROGRESS, + self::VERIFIED => false, + }; + } + + public function transitionPermission(): Permission + { + return match ($this) { + self::COMPLETED => Permission::CUTTINGS_COMPLETE, + self::VERIFIED => Permission::CUTTINGS_VERIFY, + self::REJECTED => Permission::CUTTINGS_REJECT, + self::IN_PROGRESS => Permission::CUTTINGS_UPDATE, + default => throw new InvalidArgumentException('Status tidak mendukung transisi.'), + }; + } + + /** + * @return list + */ + public function availableActions(): array + { + return match ($this) { + self::IN_PROGRESS => [ + [ + 'status' => self::COMPLETED->value, + 'label' => 'Selesai', + 'destructive' => false, + 'permission' => Permission::CUTTINGS_COMPLETE->value, + 'icon_only' => false, + ], + ], + self::COMPLETED => [ + [ + 'status' => self::VERIFIED->value, + 'label' => 'Verifikasi', + 'destructive' => false, + 'permission' => Permission::CUTTINGS_VERIFY->value, + 'icon_only' => false, + ], + [ + 'status' => self::REJECTED->value, + 'label' => 'Tolak', + 'destructive' => true, + 'permission' => Permission::CUTTINGS_REJECT->value, + 'icon_only' => false, + ], + ], + self::REJECTED => [ + [ + 'status' => self::IN_PROGRESS->value, + 'label' => 'Perbaiki', + 'destructive' => false, + 'permission' => Permission::CUTTINGS_UPDATE->value, + 'icon_only' => false, + ], + ], + default => [], + }; + } +} diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index 975a7c9..13a1ddf 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -68,6 +68,14 @@ enum Permission: string case ORDERS_COMPLETE = 'orders.complete'; case ORDERS_CANCEL = 'orders.cancel'; + case CUTTINGS_VIEW = 'cuttings.view'; + case CUTTINGS_CREATE = 'cuttings.create'; + case CUTTINGS_UPDATE = 'cuttings.update'; + case CUTTINGS_DELETE = 'cuttings.delete'; + case CUTTINGS_COMPLETE = 'cuttings.complete'; + case CUTTINGS_VERIFY = 'cuttings.verify'; + case CUTTINGS_REJECT = 'cuttings.reject'; + case CASH_VIEW = 'cash.view'; case CASH_DEPOSIT = 'cash.deposit'; case CASH_UPDATE = 'cash.update'; @@ -156,6 +164,14 @@ public function label(): string self::ORDERS_COMPLETE => 'Selesaikan Pesanan', self::ORDERS_CANCEL => 'Batalkan Pesanan', + self::CUTTINGS_VIEW => 'Lihat Cutting', + self::CUTTINGS_CREATE => 'Tambah Cutting', + self::CUTTINGS_UPDATE => 'Ubah Cutting', + self::CUTTINGS_DELETE => 'Hapus Cutting', + self::CUTTINGS_COMPLETE => 'Selesaikan Cutting', + self::CUTTINGS_VERIFY => 'Verifikasi Cutting', + self::CUTTINGS_REJECT => 'Tolak Cutting', + self::CASH_VIEW => 'Lihat Kas', self::CASH_DEPOSIT => 'Setor Kas', self::CASH_UPDATE => 'Ubah Setor Kas', @@ -208,6 +224,9 @@ public function group(): string self::ORDERS_VIEW, self::ORDERS_CREATE, self::ORDERS_UPDATE, self::ORDERS_DELETE, self::ORDERS_SEND, self::ORDERS_COMPLETE, self::ORDERS_CANCEL => 'Pesanan', + self::CUTTINGS_VIEW, self::CUTTINGS_CREATE, self::CUTTINGS_UPDATE, + self::CUTTINGS_DELETE, self::CUTTINGS_COMPLETE, self::CUTTINGS_VERIFY, + self::CUTTINGS_REJECT => 'Cutting', 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 a1ca3c4..caf911a 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -85,6 +85,13 @@ public function permissions(): array Permission::ORDERS_SEND, Permission::ORDERS_COMPLETE, Permission::ORDERS_CANCEL, + Permission::CUTTINGS_VIEW, + Permission::CUTTINGS_CREATE, + Permission::CUTTINGS_UPDATE, + Permission::CUTTINGS_DELETE, + Permission::CUTTINGS_COMPLETE, + Permission::CUTTINGS_VERIFY, + Permission::CUTTINGS_REJECT, Permission::CASH_VIEW, Permission::CASH_DEPOSIT, Permission::CASH_UPDATE, @@ -142,6 +149,9 @@ public function permissions(): array Permission::ORDERS_SEND, Permission::ORDERS_COMPLETE, Permission::ORDERS_CANCEL, + Permission::CUTTINGS_VIEW, + Permission::CUTTINGS_VERIFY, + Permission::CUTTINGS_REJECT, Permission::CASH_VIEW, Permission::CASH_DEPOSIT, Permission::CASH_UPDATE, @@ -188,6 +198,11 @@ public function permissions(): array Permission::PURCHASES_CREATE, Permission::PURCHASES_UPDATE, Permission::PURCHASES_DELETE, + Permission::CUTTINGS_VIEW, + Permission::CUTTINGS_CREATE, + Permission::CUTTINGS_UPDATE, + Permission::CUTTINGS_DELETE, + Permission::CUTTINGS_COMPLETE, ], self::MARKETING => [ Permission::DASHBOARD_VIEW, diff --git a/app/Http/Controllers/Admin/Manage/CuttingController.php b/app/Http/Controllers/Admin/Manage/CuttingController.php new file mode 100644 index 0000000..26162f1 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/CuttingController.php @@ -0,0 +1,107 @@ +parseDataTableQuery($request); + + return Inertia::render('admin/manage/cuttings/Index', [ + 'cuttings' => $this->cuttingService->paginateForIndex($tableQuery, $request->user()), + 'filters' => $this->dataTableFilters($tableQuery), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/manage/cuttings/Create', [ + 'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog(), + 'productCatalog' => $this->cuttingService->productCatalog(), + ]); + } + + public function store(CuttingRequest $request): RedirectResponse + { + $this->cuttingService->create($request->validated(), $request->user()); + + Inertia::flash('success', 'Cutting berhasil ditambahkan.'); + + return redirect()->route('admin.manage.cuttings.index'); + } + + public function edit(Cutting $cutting): Response|RedirectResponse + { + if (! $cutting->status->isEditable()) { + Inertia::flash('error', 'Cutting tidak dapat diubah.'); + + return redirect()->route('admin.manage.cuttings.index'); + } + + return Inertia::render('admin/manage/cuttings/Edit', [ + 'cutting' => $this->cuttingService->findForEdit($cutting), + 'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog($cutting), + 'productCatalog' => $this->cuttingService->productCatalog($cutting), + ]); + } + + public function update(CuttingRequest $request, Cutting $cutting): RedirectResponse + { + $this->cuttingService->update($cutting, $request->validated()); + + Inertia::flash('success', 'Cutting berhasil diperbarui.'); + + return redirect()->route('admin.manage.cuttings.index'); + } + + public function destroy(Cutting $cutting): RedirectResponse + { + $this->cuttingService->delete($cutting); + + Inertia::flash('success', 'Cutting berhasil dihapus.'); + + return redirect()->route('admin.manage.cuttings.index'); + } + + public function transitionStatus(CuttingStatusTransitionRequest $request, Cutting $cutting): RedirectResponse + { + $status = CuttingStatus::from($request->validated('status')); + + $this->cuttingService->transitionStatus( + $cutting, + $status, + $request->validated('reason'), + ); + + $message = match ($status) { + CuttingStatus::COMPLETED => 'Cutting berhasil diselesaikan. Menunggu verifikasi admin toko.', + CuttingStatus::VERIFIED => 'Cutting berhasil diverifikasi. Stok produk telah diperbarui.', + CuttingStatus::REJECTED => 'Cutting ditolak.', + CuttingStatus::IN_PROGRESS => 'Cutting dikembalikan ke proses.', + default => 'Status cutting berhasil diperbarui.', + }; + + Inertia::flash('success', $message); + + return redirect()->route('admin.manage.cuttings.index'); + } +} diff --git a/app/Http/Requests/Admin/Manage/CuttingRequest.php b/app/Http/Requests/Admin/Manage/CuttingRequest.php new file mode 100644 index 0000000..6e3e481 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/CuttingRequest.php @@ -0,0 +1,48 @@ +isMethod('POST') + ? Permission::CUTTINGS_CREATE + : Permission::CUTTINGS_UPDATE; + + return $this->user()?->can($permission->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'description' => ['nullable', 'string', 'max:500'], + 'materials' => ['required', 'array', 'min:1'], + 'materials.*.raw_material_price_id' => [ + 'required', + 'integer', + 'distinct', + Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), + ], + 'materials.*.material_usage' => ['required', 'numeric', 'gt:0'], + 'materials.*.remaining_material' => ['required', 'numeric', 'gte:0'], + 'results' => ['required', 'array', 'min:1'], + 'results.*.product_variant_id' => [ + 'required', + 'integer', + 'distinct', + Rule::exists('product_variants', 'id')->whereNull('deleted_at'), + ], + 'results.*.cutting_result' => ['required', 'integer', 'min:1'], + 'results.*.warehouse_stock' => ['required', 'integer', 'min:0'], + 'results.*.cutting_reject' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/CuttingStatusTransitionRequest.php b/app/Http/Requests/Admin/Manage/CuttingStatusTransitionRequest.php new file mode 100644 index 0000000..03ef008 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/CuttingStatusTransitionRequest.php @@ -0,0 +1,55 @@ +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(CuttingStatus::cases(), 'value'))], + 'reason' => ['nullable', 'string', 'max:500'], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + /** @var Cutting $cutting */ + $cutting = $this->route('cutting'); + $status = CuttingStatus::tryFrom((string) $this->input('status')); + + if ($status === null) { + return; + } + + if (! $cutting->status->canTransitionTo($status)) { + $validator->errors()->add('status', 'Status cutting tidak dapat diubah.'); + } + + if ($status === CuttingStatus::REJECTED && trim((string) $this->input('reason')) === '') { + $validator->errors()->add('reason', 'Alasan penolakan wajib diisi.'); + } + }); + } +} diff --git a/app/Models/Cutting.php b/app/Models/Cutting.php new file mode 100644 index 0000000..55e96dd --- /dev/null +++ b/app/Models/Cutting.php @@ -0,0 +1,60 @@ + CuttingStatus::class, + ]; + } + + public function statusLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->status->label(), + ); + } + + public function createdAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_id'); + } + + public function materials(): HasMany + { + return $this->hasMany(CuttingMaterial::class); + } + + public function results(): HasMany + { + return $this->hasMany(CuttingResult::class); + } +} diff --git a/app/Models/CuttingMaterial.php b/app/Models/CuttingMaterial.php new file mode 100644 index 0000000..63582ed --- /dev/null +++ b/app/Models/CuttingMaterial.php @@ -0,0 +1,85 @@ + 'decimal:4', + 'remaining_material' => 'decimal:4', + ]; + } + + public function materialUsageFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->formatQuantity($this->material_usage), + ); + } + + public function materialUsageInput(): Attribute + { + return Attribute::make( + get: fn () => $this->formatQuantityInput($this->material_usage), + ); + } + + public function remainingMaterialFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->formatQuantity($this->remaining_material), + ); + } + + public function remainingMaterialInput(): Attribute + { + return Attribute::make( + get: fn () => $this->formatQuantityInput($this->remaining_material), + ); + } + + public function unitAbbreviation(): Attribute + { + return Attribute::make( + get: fn () => $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(), + ); + } + + public function cutting(): BelongsTo + { + return $this->belongsTo(Cutting::class); + } + + public function rawMaterialPrice(): BelongsTo + { + return $this->belongsTo(RawMaterialPrice::class); + } + + private function formatQuantity(float|string|null $value): string + { + $formatted = rtrim(rtrim(number_format((float) $value, 4, ',', '.'), '0'), ','); + + return "{$formatted} {$this->unitAbbreviation}"; + } + + private function formatQuantityInput(float|string|null $value): string + { + return rtrim(rtrim(number_format((float) $value, 4, '.', ''), '0'), '.'); + } +} diff --git a/app/Models/CuttingResult.php b/app/Models/CuttingResult.php new file mode 100644 index 0000000..1f4edab --- /dev/null +++ b/app/Models/CuttingResult.php @@ -0,0 +1,30 @@ + 'integer', + 'warehouse_stock' => 'integer', + 'cutting_reject' => 'integer', + ]; + } + + public function cutting(): BelongsTo + { + return $this->belongsTo(Cutting::class); + } + + public function productVariant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php index 7cb6972..3096536 100644 --- a/app/Models/ProductVariant.php +++ b/app/Models/ProductVariant.php @@ -47,4 +47,9 @@ public function orderItems(): HasMany { return $this->hasMany(OrderItem::class); } + + public function cuttingResults(): HasMany + { + return $this->hasMany(CuttingResult::class); + } } diff --git a/app/Models/RawMaterialPrice.php b/app/Models/RawMaterialPrice.php index c5b587e..1c5ed04 100644 --- a/app/Models/RawMaterialPrice.php +++ b/app/Models/RawMaterialPrice.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\MediaLibrary\HasMedia; @@ -72,4 +73,9 @@ public function rawMaterial(): BelongsTo { return $this->belongsTo(RawMaterial::class); } + + public function cuttingMaterials(): HasMany + { + return $this->hasMany(CuttingMaterial::class); + } } diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php new file mode 100644 index 0000000..78f598d --- /dev/null +++ b/app/Services/Manage/CuttingService.php @@ -0,0 +1,458 @@ +with([ + 'createdBy.profile', + 'rejection.rejectedBy.profile', + 'materials.rawMaterialPrice.rawMaterial:id,name,unit', + 'results.productVariant.product:id,name', + 'results.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('description', 'like', "%{$search}%") + ->orWhereHas('materials.rawMaterialPrice', function (Builder $query) use ($search): void { + $query->where('variant', 'like', "%{$search}%") + ->orWhereHas('rawMaterial', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")); + }) + ->orWhereHas('results.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 (Cutting $cutting) use ($user) { + $actions = collect($cutting->status->availableActions()) + ->filter(fn (array $action) => $user->can($action['permission'])) + ->values() + ->all(); + + $cutting->setAttribute('available_actions', $actions); + $cutting->setAttribute('is_editable', $cutting->status->isEditable()); + + return $cutting; + }); + } + + /** + * @return Collection + */ + public function rawMaterialCatalog(?Cutting $cutting = null): Collection + { + $selectedPriceIds = $cutting + ? $cutting->materials()->pluck('raw_material_price_id')->all() + : []; + + return RawMaterial::query() + ->with([ + 'prices' => fn ($query) => $query + ->orderBy('created_at') + ->with(['rawMaterial:id,unit', 'media']), + ]) + ->where(function (Builder $query) use ($selectedPriceIds): void { + $query->active(); + + if ($selectedPriceIds !== []) { + $query->orWhereHas( + 'prices', + fn (Builder $query) => $query->whereIn('id', $selectedPriceIds), + ); + } + }) + ->orderBy('name') + ->get() + ->each(function (RawMaterial $rawMaterial): void { + $rawMaterial->prices->each(function (RawMaterialPrice $price): void { + $price->setAttribute( + 'images', + MediaPresenter::collection($price, 'images'), + ); + }); + }); + } + + /** + * @return Collection + */ + public function productCatalog(?Cutting $cutting = null): Collection + { + $selectedVariantIds = $cutting + ? $cutting->results()->pluck('product_variant_id')->all() + : []; + + return Product::query() + ->with([ + 'variants' => fn ($query) => $query + ->with('media') + ->orderBy('created_at'), + ]) + ->where(function (Builder $query) use ($selectedVariantIds): void { + $query->where('is_active', true); + + 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'), + ); + }); + }); + } + + public function findForEdit(Cutting $cutting): Cutting + { + $cutting->load([ + 'materials.rawMaterialPrice.rawMaterial:id,name,unit', + 'materials.rawMaterialPrice.media', + 'results.productVariant.product:id,name', + 'results.productVariant.media', + 'rejection.rejectedBy.profile', + ]); + + $cutting->materials->each(function (CuttingMaterial $material): void { + $price = $material->rawMaterialPrice; + + if ($price) { + $price->setAttribute( + 'images', + MediaPresenter::collection($price, 'images'), + ); + } + }); + + $cutting->results->each(function (CuttingResult $result): void { + $variant = $result->productVariant; + + if ($variant) { + $variant->setAttribute( + 'images', + MediaPresenter::collection($variant, 'images'), + ); + } + }); + + return $cutting; + } + + /** + * @param array $validated + */ + public function create(array $validated, User $user): Cutting + { + return DB::transaction(function () use ($validated, $user): Cutting { + $materials = $this->buildMaterials($validated['materials']); + $results = $this->buildResults($validated['results']); + + $cutting = Cutting::create([ + 'status' => CuttingStatus::IN_PROGRESS, + 'description' => $validated['description'] ?? null, + 'created_by_id' => $user->id, + ]); + + foreach ($materials as $materialData) { + $cutting->materials()->create($materialData); + } + + foreach ($results as $resultData) { + $cutting->results()->create($resultData); + } + + return $cutting; + }); + } + + /** + * @param array $validated + */ + public function update(Cutting $cutting, array $validated): void + { + if (! $cutting->status->isEditable()) { + throw ValidationException::withMessages([ + 'status' => 'Cutting tidak dapat diubah.', + ]); + } + + DB::transaction(function () use ($cutting, $validated): void { + $cutting->materials()->delete(); + $cutting->results()->delete(); + + $materials = $this->buildMaterials($validated['materials']); + $results = $this->buildResults($validated['results']); + + $cutting->description = $validated['description'] ?? null; + $cutting->save(); + + foreach ($materials as $materialData) { + $cutting->materials()->create($materialData); + } + + foreach ($results as $resultData) { + $cutting->results()->create($resultData); + } + + if ($cutting->status === CuttingStatus::REJECTED) { + $cutting->rejection()?->delete(); + } + }); + } + + public function delete(Cutting $cutting): void + { + if ($cutting->status !== CuttingStatus::IN_PROGRESS) { + throw ValidationException::withMessages([ + 'status' => 'Cutting hanya dapat dihapus saat masih proses.', + ]); + } + + DB::transaction(function () use ($cutting): void { + $cutting->materials()->delete(); + $cutting->results()->delete(); + $cutting->delete(); + }); + } + + public function transitionStatus(Cutting $cutting, CuttingStatus $status, ?string $reason = null): void + { + if (! $cutting->status->canTransitionTo($status)) { + throw ValidationException::withMessages([ + 'status' => 'Status cutting tidak dapat diubah.', + ]); + } + + DB::transaction(function () use ($cutting, $status, $reason): void { + $cutting->load(['materials', 'results']); + + if ($status === CuttingStatus::COMPLETED) { + $this->applyMaterialStockOnComplete($cutting); + } + + if ($status === CuttingStatus::VERIFIED) { + $this->applyProductStockOnVerify($cutting); + } + + if ($status === CuttingStatus::REJECTED) { + $this->reverseMaterialStock($cutting); + $this->storeRejection($cutting, $reason); + } + + if ($status === CuttingStatus::IN_PROGRESS && $cutting->status === CuttingStatus::REJECTED) { + $cutting->rejection()?->delete(); + } + + $cutting->status = $status; + $cutting->save(); + }); + } + + /** + * @param list $materials + * @return list + */ + private function buildMaterials(array $materials): array + { + return collect($materials) + ->map(function (array $itemData, int $index) { + $price = RawMaterialPrice::query()->find($itemData['raw_material_price_id']); + + if ($price === null) { + throw ValidationException::withMessages([ + "materials.{$index}.raw_material_price_id" => 'Bahan baku tidak ditemukan.', + ]); + } + + $materialUsage = (float) $itemData['material_usage']; + $remainingMaterial = (float) $itemData['remaining_material']; + + if ($materialUsage <= 0) { + throw ValidationException::withMessages([ + "materials.{$index}.material_usage" => 'Pemakaian bahan harus lebih dari 0.', + ]); + } + + if ($remainingMaterial < 0) { + throw ValidationException::withMessages([ + "materials.{$index}.remaining_material" => 'Sisa bahan tidak boleh negatif.', + ]); + } + + return [ + 'raw_material_price_id' => $price->id, + 'material_usage' => $materialUsage, + 'remaining_material' => $remainingMaterial, + ]; + }) + ->all(); + } + + /** + * @param list $results + * @return list + */ + private function buildResults(array $results): array + { + return collect($results) + ->map(function (array $itemData, int $index) { + $variant = ProductVariant::query()->find($itemData['product_variant_id']); + + if ($variant === null) { + throw ValidationException::withMessages([ + "results.{$index}.product_variant_id" => 'Varian produk tidak ditemukan.', + ]); + } + + $cuttingResult = (int) $itemData['cutting_result']; + $warehouseStock = (int) $itemData['warehouse_stock']; + $cuttingReject = (int) ($itemData['cutting_reject'] ?? 0); + + if ($cuttingResult < 1) { + throw ValidationException::withMessages([ + "results.{$index}.cutting_result" => 'Hasil cutting minimal 1 pcs.', + ]); + } + + if ($warehouseStock < 0) { + throw ValidationException::withMessages([ + "results.{$index}.warehouse_stock" => 'Stok gudang tidak boleh negatif.', + ]); + } + + if ($cuttingReject < 0) { + throw ValidationException::withMessages([ + "results.{$index}.cutting_reject" => 'Reject tidak boleh negatif.', + ]); + } + + if (($warehouseStock + $cuttingReject) !== $cuttingResult) { + throw ValidationException::withMessages([ + "results.{$index}.cutting_result" => 'Hasil cutting harus sama dengan stok gudang ditambah reject.', + ]); + } + + return [ + 'product_variant_id' => $variant->id, + 'cutting_result' => $cuttingResult, + 'warehouse_stock' => $warehouseStock, + 'cutting_reject' => $cuttingReject, + ]; + }) + ->all(); + } + + private function applyMaterialStockOnComplete(Cutting $cutting): void + { + foreach ($cutting->materials as $material) { + $totalTaken = (float) $material->material_usage + (float) $material->remaining_material; + $price = RawMaterialPrice::query()->lockForUpdate()->find($material->raw_material_price_id); + + if ($price === null) { + throw ValidationException::withMessages([ + 'materials' => 'Bahan baku tidak ditemukan.', + ]); + } + + if ((float) $price->stock < $totalTaken) { + throw ValidationException::withMessages([ + 'materials' => "Stok {$price->rawMaterial?->name} ({$price->variant}) tidak mencukupi.", + ]); + } + + $price->decrement('stock', $totalTaken); + + if ((float) $material->remaining_material > 0) { + $price->increment('stock', $material->remaining_material); + } + } + } + + private function reverseMaterialStock(Cutting $cutting): void + { + foreach ($cutting->materials as $material) { + RawMaterialPrice::query() + ->whereKey($material->raw_material_price_id) + ->increment('stock', $material->material_usage); + } + } + + private function applyProductStockOnVerify(Cutting $cutting): void + { + foreach ($cutting->results as $result) { + if ($result->warehouse_stock < 1) { + continue; + } + + ProductVariant::query() + ->whereKey($result->product_variant_id) + ->increment('stock', $result->warehouse_stock); + } + } + + private function storeRejection(Cutting $cutting, ?string $reason): void + { + if ($reason === null || trim($reason) === '') { + throw ValidationException::withMessages([ + 'reason' => 'Alasan penolakan wajib diisi.', + ]); + } + + $user = auth()->user(); + + $cutting->rejection()?->delete(); + + $cutting->rejection()->create([ + 'reason' => trim($reason), + 'rejected_by_id' => $user->id, + ]); + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['created_at', 'status'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->latest(); + } +} diff --git a/database/migrations/2026_06_12_110001_create_cuttings_table.php b/database/migrations/2026_06_12_110001_create_cuttings_table.php new file mode 100644 index 0000000..6a0b52a --- /dev/null +++ b/database/migrations/2026_06_12_110001_create_cuttings_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('status')->default('in_progress'); + $table->text('description')->nullable(); + $table->foreignId('created_by_id')->constrained('users')->restrictOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('cuttings'); + } +}; diff --git a/database/migrations/2026_06_12_110002_create_cutting_materials_table.php b/database/migrations/2026_06_12_110002_create_cutting_materials_table.php new file mode 100644 index 0000000..aa5419e --- /dev/null +++ b/database/migrations/2026_06_12_110002_create_cutting_materials_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('cutting_id')->constrained()->cascadeOnDelete(); + $table->foreignId('raw_material_price_id')->constrained()->restrictOnDelete(); + $table->decimal('material_usage', 18, 4); + $table->decimal('remaining_material', 18, 4); + $table->timestamps(); + + $table->unique(['cutting_id', 'raw_material_price_id']); + $table->index('cutting_id'); + $table->index('raw_material_price_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('cutting_materials'); + } +}; diff --git a/database/migrations/2026_06_12_110003_create_cutting_results_table.php b/database/migrations/2026_06_12_110003_create_cutting_results_table.php new file mode 100644 index 0000000..495cd54 --- /dev/null +++ b/database/migrations/2026_06_12_110003_create_cutting_results_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('cutting_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_variant_id')->constrained()->restrictOnDelete(); + $table->unsignedInteger('cutting_result'); + $table->unsignedInteger('warehouse_stock'); + $table->unsignedInteger('cutting_reject')->default(0); + $table->timestamps(); + + $table->unique(['cutting_id', 'product_variant_id']); + $table->index('cutting_id'); + $table->index('product_variant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('cutting_results'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index fde7893..b23a808 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + + diff --git a/resources/js/components/admin/manage/cuttings/CuttingPosForm.vue b/resources/js/components/admin/manage/cuttings/CuttingPosForm.vue new file mode 100644 index 0000000..14638e1 --- /dev/null +++ b/resources/js/components/admin/manage/cuttings/CuttingPosForm.vue @@ -0,0 +1,506 @@ + + +