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 @@ + + +