with([ 'media', 'createdBy.profile', 'rejection.rejectedBy.profile', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.media', 'materials.combination', 'results.productVariant.product:id,name', 'results.productVariant.media', ]) ->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(25) ->withQueryString() ->through(function (Cutting $cutting) use ($user) { $cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user)); $cutting->setAttribute('is_editable', $cutting->status->isEditable()); $this->appendCostPreview($cutting); $this->appendImages($cutting); $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); return $cutting; }); } public function getInProgressCuttings(User $user): Collection { return Cutting::query() ->with([ 'media', 'createdBy.profile', 'rejection.rejectedBy.profile', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.media', 'materials.combination', 'results.productVariant.product:id,name', 'results.productVariant.media', ]) ->inProgress() ->latest() ->get() ->each(function (Cutting $cutting) use ($user): void { $cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user)); $cutting->setAttribute('is_editable', $cutting->status->isEditable()); $this->appendCostPreview($cutting); $this->appendImages($cutting); $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); }); } public function getCompletedCuttings(User $user): Collection { return Cutting::query() ->with([ 'media', 'createdBy.profile', 'rejection.rejectedBy.profile', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.media', 'materials.combination', 'results.productVariant.product:id,name', 'results.productVariant.media', ]) ->completed() ->latest() ->get() ->each(function (Cutting $cutting) use ($user): void { $cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user)); $cutting->setAttribute('is_editable', $cutting->status->isEditable()); $this->appendCostPreview($cutting); $this->appendImages($cutting); $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); }); } public function rawMaterialCatalog(?Cutting $cutting = null, ?User $user = null): Collection { $selectedPriceIds = $cutting ? $cutting->materials()->pluck('raw_material_price_id')->all() : ($user ? $this->draftMaterialsQuery($user)->pluck('raw_material_price_id')->all() : []); return RawMaterial::query() ->with([ 'prices' => fn ($query) => $query ->orderBy('created_at') ->with('media') ->where(fn ($q) => $q->where('stock', '>', 0)->orWhereIn('id', $selectedPriceIds)), ]) ->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) use ($rawMaterial): void { $price->setAttribute('images', MediaPresenter::collection($price, 'images')); $price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation()); $price->unsetRelation('rawMaterial'); }); }); } public function productCatalog(?Cutting $cutting = null, ?User $user = null): Collection { $selectedVariantIds = $cutting ? $cutting->results()->pluck('product_variant_id')->all() : ($user ? $this->draftResultsQuery($user)->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->active(); if ($selectedVariantIds !== []) { $query->orWhereHas( 'variants', fn (Builder $query) => $query->whereIn('id', $selectedVariantIds), ); } }) ->orderBy('name') ->get() ->each(function (Product $product): void { $product->variants->each(function (ProductVariant $variant): void { $variant->setAttribute( 'images', MediaPresenter::collection($variant, 'images'), ); }); }); } public function findForEdit(Cutting $cutting): Cutting { $cutting->load([ 'media', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.media', 'materials.combination', 'results.productVariant.product:id,name', 'results.productVariant.media', 'rejection.rejectedBy.profile', ]); $this->appendImages($cutting); $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); return $cutting; } public function findForShare(Cutting $cutting): Cutting { $cutting->load([ 'media', 'createdBy.profile', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.media', 'materials.combination', 'results.productVariant.product:id,name', 'results.productVariant.media', ]); $this->appendCostPreview($cutting); $this->appendImages($cutting); $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); return $cutting; } private function appendImages(Cutting $cutting): void { $cutting->setAttribute('images', MediaPresenter::collection($cutting, 'images')); $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'), ); } }); } public function draftMaterialsForUser(User $user): array { return $this->draftMaterialsQuery($user) ->with([ 'rawMaterialPrice.rawMaterial:id,name,unit', 'rawMaterialPrice.media', 'combination', ]) ->get() ->map(function (CuttingMaterial $item) { $result = $this->presentDraftMaterial($item); $this->breakMaterialCircularReference($item); return $result; }) ->values() ->all(); } public function draftResultsForUser(User $user): array { return $this->draftResultsQuery($user) ->with([ 'productVariant.product:id,name', 'productVariant.media', ]) ->get() ->map(fn (CuttingResult $item) => $this->presentDraftResult($item)) ->values() ->all(); } public function syncDraftMaterial(array $validated, User $user): array { $price = RawMaterialPrice::query() ->with('rawMaterial') ->findOrFail($validated['raw_material_price_id']); $materialUsage = round((float) $validated['material_usage'], 2); if ($materialUsage <= 0) { throw ValidationException::withMessages([ 'material_usage' => 'Pemakaian bahan harus lebih dari 0.', ]); } $materialResult = array_key_exists('material_result', $validated) && $validated['material_result'] !== null ? (int) $validated['material_result'] : null; $item = CuttingMaterial::query()->updateOrCreate( [ 'user_id' => $user->id, 'raw_material_price_id' => $price->id, 'cutting_id' => null, ], [ 'material_usage' => $materialUsage, 'material_result' => $materialResult, ], ); $item->load([ 'rawMaterialPrice.rawMaterial:id,name,unit', 'rawMaterialPrice.media', ]); $result = $this->presentDraftMaterial($item); $this->breakMaterialCircularReference($item); return $result; } public function syncDraftResult(array $validated, User $user): array { $variant = ProductVariant::query() ->with('product:id,name') ->findOrFail($validated['product_variant_id']); $cuttingResult = array_key_exists('cutting_result', $validated) && $validated['cutting_result'] !== null ? (int) $validated['cutting_result'] : null; $sample = array_key_exists('sample', $validated) && $validated['sample'] !== null ? (int) $validated['sample'] : null; $originalOutsideSample = array_key_exists('original_outside_sample', $validated) && $validated['original_outside_sample'] !== null ? (int) $validated['original_outside_sample'] : null; if ($cuttingResult !== null) { if ($cuttingResult < 1) { throw ValidationException::withMessages([ 'cutting_result' => 'Hasil cutting minimal 1 pcs.', ]); } if ($sample !== null && $sample < 0) { throw ValidationException::withMessages([ 'sample' => 'sample tidak boleh negatif.', ]); } if ($originalOutsideSample !== null && $originalOutsideSample < 0) { throw ValidationException::withMessages([ 'original_outside_sample' => 'Hasil cutting diluar sample tidak boleh negatif.', ]); } if ($sample !== null && $originalOutsideSample !== null && ($sample + $originalOutsideSample) !== $cuttingResult) { throw ValidationException::withMessages([ 'cutting_result' => 'Hasil cutting harus sama dengan sample ditambah hasil cutting diluar sample.', ]); } } $item = CuttingResult::query()->updateOrCreate( [ 'user_id' => $user->id, 'product_variant_id' => $variant->id, 'cutting_id' => null, ], [ 'cutting_result' => $cuttingResult, 'sample' => $sample, 'original_outside_sample' => $originalOutsideSample, ], ); $item->load([ 'productVariant.product:id,name', 'productVariant.media', ]); return $this->presentDraftResult($item); } public function removeDraftMaterial(User $user, RawMaterialPrice $rawMaterialPrice): void { CuttingMaterial::query() ->whereNull('cutting_id') ->where('user_id', $user->id) ->where('raw_material_price_id', $rawMaterialPrice->id) ->delete(); } public function removeDraftResult(User $user, ProductVariant $productVariant): void { CuttingResult::query() ->whereNull('cutting_id') ->where('user_id', $user->id) ->where('product_variant_id', $productVariant->id) ->delete(); } /** * @return list */ public function syncDraftCombination(array $validated, User $user): array { $materialResult = array_key_exists('material_result', $validated) && $validated['material_result'] !== null ? (int) $validated['material_result'] : null; $combination = CuttingMaterialCombination::create([ 'user_id' => $user->id, 'cutting_id' => null, 'material_result' => $materialResult, ]); $items = []; foreach ($validated['materials'] as $index => $materialData) { $price = RawMaterialPrice::query() ->with('rawMaterial') ->findOrFail($materialData['raw_material_price_id']); $materialUsage = round((float) $materialData['material_usage'], 2); if ($materialUsage <= 0) { throw ValidationException::withMessages([ "materials.{$index}.material_usage" => 'Pemakaian bahan harus lebih dari 0.', ]); } $item = CuttingMaterial::create([ 'user_id' => $user->id, 'cutting_id' => null, 'raw_material_price_id' => $price->id, 'combination_id' => $combination->id, 'material_usage' => $materialUsage, ]); $item->load([ 'rawMaterialPrice.rawMaterial:id,name,unit', 'rawMaterialPrice.media', ]); $items[] = $this->presentDraftMaterial($item); $this->breakMaterialCircularReference($item); } return $items; } public function removeDraftCombination(User $user, int $combinationId): void { $combination = CuttingMaterialCombination::query() ->whereNull('cutting_id') ->where('user_id', $user->id) ->where('id', $combinationId) ->first(); if ($combination === null) { return; } // Hard delete associated materials first (cascadeOnDelete only works with hard delete) CuttingMaterial::query() ->where('combination_id', $combination->id) ->forceDelete(); $combination->forceDelete(); } public function create(array $validated, User $user): Cutting { $cutting = $this->runInTransaction( function () use ($validated, $user): Cutting { $draftMaterials = $this->draftMaterialsQuery($user) ->with('rawMaterialPrice.rawMaterial') ->lockForUpdate() ->get(); $draftResults = $this->draftResultsQuery($user) ->lockForUpdate() ->get(); if ($draftMaterials->isEmpty()) { throw ValidationException::withMessages([ 'materials' => 'Tambahkan minimal satu bahan baku.', ]); } if ($draftResults->isEmpty()) { throw ValidationException::withMessages([ 'results' => 'Tambahkan minimal satu hasil produk.', ]); } $cutting = Cutting::create([ 'status' => CuttingStatus::IN_PROGRESS, 'description' => $validated['description'] ?? null, 'sewing_cost' => (int) ($validated['sewing_cost'] ?? 0), 'other_cost' => (int) ($validated['other_cost'] ?? 0), 'created_by_id' => $user->id, ]); $this->syncImages($cutting, $validated); // Transfer draft combinations to cutting $draftCombinations = CuttingMaterialCombination::query() ->whereNull('cutting_id') ->where('user_id', $user->id) ->lockForUpdate() ->get(); foreach ($draftCombinations as $combination) { $combination->cutting_id = $cutting->id; $combination->user_id = null; $combination->save(); } foreach ($draftMaterials as $material) { $material->cutting_id = $cutting->id; $material->user_id = null; $material->save(); } foreach ($draftResults as $result) { $result->cutting_id = $cutting->id; $result->user_id = null; $result->save(); } $cutting->load('materials.rawMaterialPrice.rawMaterial'); $this->deductMaterialStock($cutting); return $cutting; }, 'Gagal membuat proses cutting', ); $this->pushNotificationService->sendToRoles( '✂️ Proses Cutting Baru', "Proses cutting dengan deskripsi ({$cutting->description}) telah dimulai oleh {$user->profile?->full_name}.", ['owner', 'developer', 'direktur'], route('admin.manage.cuttings.index'), ); $this->cacheForgetByPattern('manage:cuttings:*'); return $cutting; } public function update(Cutting $cutting, array $validated, User $user): void { if (! $cutting->status->isEditable()) { throw ValidationException::withMessages([ 'status' => 'Proses cutting tidak dapat diubah.', ]); } $this->runInTransaction( function () use ($cutting, $validated): void { $cutting->load(['materials.rawMaterialPrice.rawMaterial', 'results', 'materials.combination']); if ($cutting->status === CuttingStatus::IN_PROGRESS) { $this->reverseTotalMaterialStock($cutting); } elseif ($cutting->status === CuttingStatus::REJECTED) { $this->reverseMaterialStock($cutting); } $cutting->materials()->delete(); $cutting->results()->delete(); $cutting->combinations()->delete(); $materials = $this->buildMaterials($validated['materials']); $results = $this->buildResults($validated['results']); $cutting->description = $validated['description'] ?? null; $cutting->sewing_cost = (int) ($validated['sewing_cost'] ?? 0); $cutting->other_cost = (int) ($validated['other_cost'] ?? 0); if ($cutting->status === CuttingStatus::REJECTED) { $cutting->status = CuttingStatus::IN_PROGRESS; $cutting->rejection()?->delete(); } $cutting->save(); $this->syncImages($cutting, $validated); // Group materials by combination_id to create combinations $combinationGroups = []; foreach ($materials as $materialData) { $combinationId = $materialData['combination_id']; if ($combinationId !== null) { if (! isset($combinationGroups[$combinationId])) { $combinationGroups[$combinationId] = [ 'materials' => [], 'material_result' => $materialData['combination_material_result'] ?? null, ]; } $combinationGroups[$combinationId]['materials'][] = $materialData; } } // Create combinations and update combination_id for materials $combinationIdMap = []; foreach ($combinationGroups as $oldCombinationId => $group) { $combination = $cutting->combinations()->create([ 'material_result' => $group['material_result'], ]); $combinationIdMap[$oldCombinationId] = $combination->id; } foreach ($materials as $materialData) { $newCombinationId = $materialData['combination_id'] !== null ? $combinationIdMap[$materialData['combination_id']] ?? null : null; $cutting->materials()->create([ 'raw_material_price_id' => $materialData['raw_material_price_id'], 'material_usage' => $materialData['material_usage'], 'material_result' => $materialData['material_result'], 'combination_id' => $newCombinationId, ]); } foreach ($results as $resultData) { $cutting->results()->create($resultData); } if ($cutting->status === CuttingStatus::IN_PROGRESS) { $cutting->load('materials.rawMaterialPrice.rawMaterial'); $this->deductMaterialStock($cutting); } }, 'Gagal memperbarui proses cutting', ); $description = $cutting->description ?? '-'; $this->pushNotificationService->sendToRoles( '✏️ Proses Cutting Diperbarui', "Proses cutting dengan deskripsi '{$description}' telah diperbarui oleh {$user->profile?->full_name}.", ['owner', 'developer', 'direktur'], route('admin.manage.cuttings.index'), ); $this->cacheForgetByPattern('manage:cuttings:*'); } public function delete(Cutting $cutting, User $user): void { if (! in_array($cutting->status, [CuttingStatus::IN_PROGRESS, CuttingStatus::REJECTED], true)) { throw ValidationException::withMessages([ 'status' => 'Proses cutting hanya dapat dihapus saat masih proses atau ditolak.', ]); } $description = $cutting->description ?? '-'; $this->runInTransaction( function () use ($cutting): void { $cutting->load(['materials.rawMaterialPrice.rawMaterial']); if ($cutting->status === CuttingStatus::IN_PROGRESS) { $this->reverseTotalMaterialStock($cutting); } elseif ($cutting->status === CuttingStatus::REJECTED) { $this->reverseMaterialStock($cutting); } $cutting->materials()->delete(); $cutting->results()->delete(); $cutting->delete(); }, 'Gagal menghapus proses cutting', ); $this->pushNotificationService->sendToRoles( '🗑️ Proses Cutting Dihapus', "Proses cutting dengan deskripsi {$description} telah dihapus oleh {$user->profile?->full_name}.", ['owner', 'developer', 'direktur'], route('admin.manage.cuttings.index'), ); $this->cacheForgetByPattern('manage:cuttings:*'); } public function transitionStatus( Cutting $cutting, CuttingStatus $status, User $user, ?string $reason = null, ?string $verificationNote = null, ?array $results = null, ?array $resultPrices = null, ): void { if (! $cutting->status->canTransitionTo($status)) { throw ValidationException::withMessages([ 'status' => 'Status proses cutting tidak dapat diubah.', ]); } $this->runInTransaction( function () use ($cutting, $status, $verificationNote, $results, $resultPrices, $user, $reason): void { $cutting->load(['materials.rawMaterialPrice', 'results']); if ($status === CuttingStatus::COMPLETED) { $cutting->total_material_cost = $this->calculateTotalMaterialCost($cutting); $cutting->cost_per_unit = $this->calculateCostPerUnit($cutting); } if ($status === CuttingStatus::IN_PROGRESS) { $cutting->rejection()?->delete(); } if ($status === CuttingStatus::REJECTED) { $this->storeRejection($cutting, $reason, $user); } if ($status === CuttingStatus::VERIFIED) { if ($results !== null) { foreach ($results as $item) { $cutting->results() ->where('product_variant_id', $item['product_variant_id']) ->update([ 'sample' => $item['sample'], 'original_outside_sample' => $item['original_outside_sample'], ]); } $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 = $status; $cutting->save(); }, 'Gagal mengubah status proses cutting', ); $description = $cutting->description ?? '-'; $message = match ($status) { CuttingStatus::COMPLETED => "Proses cutting dengan deskripsi '{$description}' telah selesai oleh {$user->profile?->full_name} dan menunggu verifikasi.", CuttingStatus::VERIFIED => "Proses cutting dengan deskripsi '{$description}' telah diverifikasi oleh {$user->profile?->full_name}.", CuttingStatus::REJECTED => "Proses cutting dengan deskripsi '{$description}' ditolak oleh {$user->profile?->full_name}".($reason ? " dengan alasan: '{$reason}'" : '').'.', CuttingStatus::IN_PROGRESS => "Proses cutting dengan deskripsi '{$description}' dikembalikan ke proses oleh {$user->profile?->full_name}.", default => "Status proses cutting dengan deskripsi '{$description}' telah diperbarui ke: {$status->label()} oleh {$user->profile?->full_name}.", }; $title = match ($status) { CuttingStatus::COMPLETED => '✂️ Proses Cutting Selesai', CuttingStatus::VERIFIED => '✂️ Proses Cutting Terverifikasi', CuttingStatus::REJECTED => '✂️ Proses Cutting Ditolak', CuttingStatus::IN_PROGRESS => '✂️ Proses Cutting Dikembalikan', default => '✂️ Proses Cutting Diperbarui', }; $roles = $status === CuttingStatus::COMPLETED ? ['owner', 'developer', 'direktur'] : ['owner', 'developer', 'direktur']; $this->pushNotificationService->sendToRoles( $title, $message, $roles, route('admin.manage.cuttings.index'), ); if ($status === CuttingStatus::COMPLETED) { $this->pushNotificationService->sendToRoles( $title, $message, ['admin-toko'], route('admin.manage.stocks.index'), ); } $this->cacheForgetByPattern('manage:cuttings:*'); $this->cacheForgetByPattern('prices:*'); } private function buildMaterials(array $materials): array { return collect($materials) ->map(function (array $itemData, int $index) { $price = RawMaterialPrice::query() ->with('rawMaterial') ->find($itemData['raw_material_price_id']); if ($price === null) { throw ValidationException::withMessages([ "materials.{$index}.raw_material_price_id" => 'Bahan baku tidak ditemukan.', ]); } $materialUsage = round((float) $itemData['material_usage'], 2); if ($materialUsage <= 0) { throw ValidationException::withMessages([ "materials.{$index}.material_usage" => 'Pemakaian bahan harus lebih dari 0.', ]); } $materialResult = array_key_exists('material_result', $itemData) && $itemData['material_result'] !== null ? (int) $itemData['material_result'] : null; return [ 'raw_material_price_id' => $price->id, 'material_usage' => $materialUsage, 'material_result' => $materialResult, 'combination_id' => $itemData['combination_id'] ?? null, 'combination_material_result' => $itemData['combination_material_result'] ?? null, ]; }) ->all(); } 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']; $sample = (int) $itemData['sample']; $originalOutsideSample = (int) ($itemData['original_outside_sample'] ?? 0); if ($cuttingResult < 1) { throw ValidationException::withMessages([ "results.{$index}.cutting_result" => 'Hasil cutting minimal 1 pcs.', ]); } if ($sample < 0) { throw ValidationException::withMessages([ "results.{$index}.sample" => 'sample tidak boleh negatif.', ]); } if ($originalOutsideSample < 0) { throw ValidationException::withMessages([ "results.{$index}.original_outside_sample" => 'Hasil cutting diluar sample tidak boleh negatif.', ]); } if (($sample + $originalOutsideSample) !== $cuttingResult) { throw ValidationException::withMessages([ "results.{$index}.cutting_result" => 'Hasil cutting harus sama dengan sample ditambah hasil cutting diluar sample.', ]); } return [ 'product_variant_id' => $variant->id, 'cutting_result' => $cuttingResult, 'sample' => $sample, 'original_outside_sample' => $originalOutsideSample, ]; }) ->all(); } private function deductMaterialStock(Cutting $cutting): void { foreach ($cutting->materials as $material) { $totalTaken = (float) $material->material_usage; $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); } } private function reverseTotalMaterialStock(Cutting $cutting): void { foreach ($cutting->materials as $material) { RawMaterialPrice::query() ->whereKey($material->raw_material_price_id) ->increment('stock', (float) $material->material_usage); } } private function reverseMaterialStock(Cutting $cutting): void { foreach ($cutting->materials as $material) { RawMaterialPrice::query() ->whereKey($material->raw_material_price_id) ->increment('stock', (float) $material->material_usage); } } private function applyProductStockOnVerify(Cutting $cutting): void { foreach ($cutting->results as $result) { if ($result->sample > 0) { ProductVariant::query() ->whereKey($result->product_variant_id) ->increment('stock', $result->sample); } if ($result->original_outside_sample > 0) { ProductVariant::query() ->whereKey($result->product_variant_id) ->increment('reject_stock', $result->original_outside_sample); } } } private function reverseProductStock(Cutting $cutting): void { foreach ($cutting->results as $result) { if ($result->sample > 0) { ProductVariant::query() ->whereKey($result->product_variant_id) ->decrement('stock', $result->sample); } if ($result->original_outside_sample > 0) { ProductVariant::query() ->whereKey($result->product_variant_id) ->decrement('reject_stock', $result->original_outside_sample); } } } private function storeRejection(Cutting $cutting, ?string $reason, User $user): void { if ($reason === null || trim($reason) === '') { throw ValidationException::withMessages([ 'reason' => 'Alasan penolakan wajib diisi.', ]); } $cutting->rejection()?->delete(); $cutting->rejection()->create([ 'reason' => trim($reason), 'rejected_by_id' => $user->id, ]); } private function breakMaterialCircularReference(CuttingMaterial $material): void { $price = $material->rawMaterialPrice; // Always set these attributes regardless of price $material->setAttribute('material_result', $material->material_result); $material->setAttribute('material_result_input', $material->material_result); $material->setAttribute('combination_id', $material->combination_id); $material->setAttribute('combination_material_result', $material->combination?->material_result); if ($price) { $rawMaterial = $price->rawMaterial; $material->setAttribute('variant', $price->variant); $material->setAttribute('stock_input', $price->stock_input); $material->setAttribute('images', $price->getAttribute('images') ?? []); if ($rawMaterial) { $unitAbbreviation = $rawMaterial->unit->abbreviation(); $price->setAttribute('unit_abbreviation', $unitAbbreviation); $material->setAttribute('unit_abbreviation', $unitAbbreviation); $material->setAttribute('unit', $rawMaterial->unit->value); $material->setAttribute('raw_material_id', $rawMaterial->id); $material->setAttribute('raw_material_name', $rawMaterial->name); $material->setAttribute('raw_material_unit_label', $rawMaterial->unit->label()); } $price->unsetRelation('rawMaterial'); } $material->unsetRelation('rawMaterialPrice'); $material->unsetRelation('combination'); } 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(); } private function draftMaterialsQuery(User $user): Builder { return CuttingMaterial::query() ->whereNull('cutting_id') ->where('user_id', $user->id); } private function draftResultsQuery(User $user): Builder { return CuttingResult::query() ->whereNull('cutting_id') ->where('user_id', $user->id); } private function presentDraftMaterial(CuttingMaterial $item): array { $price = $item->rawMaterialPrice; $rawMaterial = $price?->rawMaterial; $combination = $item->combination; return [ 'raw_material_price_id' => $item->raw_material_price_id, 'raw_material_name' => $rawMaterial?->name ?? '', 'variant' => $price?->variant ?? '', 'unit' => $rawMaterial?->unit?->value ?? '', 'unit_abbreviation' => $rawMaterial?->unit?->abbreviation() ?? '', 'stock_input' => $price?->stock_input ?? '', 'material_usage' => $this->formatQuantityInput((float) $item->material_usage), 'material_result' => $item->material_result !== null ? (int) $item->material_result : null, 'images' => $price ? MediaPresenter::collection($price, 'images') : [], 'combination_id' => $item->combination_id, 'combination_material_result' => $combination?->material_result, ]; } private function presentDraftResult(CuttingResult $item): array { $variant = $item->productVariant; return [ 'product_variant_id' => $item->product_variant_id, 'product_name' => $variant?->product?->name ?? '', 'variant_name' => $variant?->name ?? '', 'stock' => $variant?->stock ?? 0, 'cutting_result' => $item->cutting_result !== null ? (string) $item->cutting_result : null, 'sample' => $item->sample !== null ? (string) $item->sample : null, 'original_outside_sample' => $item->original_outside_sample !== null ? (string) $item->original_outside_sample : null, 'images' => $variant ? MediaPresenter::collection($variant, 'images') : [], ]; } private function formatQuantityInput(float $value): string { return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); } private function formatMaterialUsageSummary(Cutting $cutting): string { $groups = []; foreach ($cutting->materials as $material) { $unit = $material->rawMaterialPrice?->rawMaterial?->unit?->abbreviation() ?? ''; $groups[$unit] = ($groups[$unit] ?? 0) + (float) $material->material_usage; } return collect($groups) ->filter(fn (float $total) => $total > 0) ->map(function (float $total, string $unit) { $formatted = rtrim(rtrim(number_format($total, 2, ',', '.'), '0'), ','); return "{$formatted} {$unit}"; }) ->join(', '); } /** * @return list */ private function filterActionsForUser(Cutting $cutting, User $user): array { return collect($cutting->status->availableActions()) ->filter(fn (array $action) => $user->can($action['permission'])) ->filter(function (array $action) use ($cutting, $user): bool { // Only owner/developer can verify directly from COMPLETED status if ($cutting->status === CuttingStatus::COMPLETED && $action['status'] === CuttingStatus::VERIFIED->value) { return $user->hasRole([Role::OWNER->value, Role::DEVELOPER->value]); } return true; }) ->values() ->all(); } private function appendCostPreview(Cutting $cutting): void { $totalResultPieces = (int) $cutting->results->sum('cutting_result'); $cutting->setAttribute('total_result_pieces', $totalResultPieces); $cutting->setAttribute('total_material_usage', (float) $cutting->materials->sum(fn (CuttingMaterial $material) => (float) $material->material_usage)); $cutting->setAttribute('total_material_usage_summary_formatted', $this->formatMaterialUsageSummary($cutting)); $totalMaterialCost = $cutting->total_material_cost ?? $this->calculateTotalMaterialCost($cutting); $sewingCost = (int) ($cutting->sewing_cost ?? 0); $otherCost = (int) ($cutting->other_cost ?? 0); $totalProductionCost = $totalMaterialCost + $sewingCost + $otherCost; $costPerUnit = $cutting->cost_per_unit ?? $this->calculateCostPerUnit($cutting); $materialCostPerProduct = $totalResultPieces > 0 ? (int) round($totalMaterialCost / $totalResultPieces) : 0; $cutting->setAttribute('total_material_cost', $totalMaterialCost); $cutting->setAttribute('total_material_cost_formatted', 'Rp '.number_format($totalMaterialCost, 0, ',', '.')); $cutting->setAttribute('material_cost_per_product', $materialCostPerProduct); $cutting->setAttribute('material_cost_per_product_formatted', 'Rp '.number_format($materialCostPerProduct, 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, ',', '.')); } public function calculateTotalMaterialCost(Cutting $cutting): int { return (int) $cutting->materials->sum(fn (CuttingMaterial $material) => $material->materialCost()); } public function calculateTotalProductionCost(Cutting $cutting): int { return $this->calculateTotalMaterialCost($cutting) + (int) ($cutting->sewing_cost ?? 0) + (int) ($cutting->other_cost ?? 0); } public function calculateCostPerUnit(Cutting $cutting): int { $totalPieces = (int) $cutting->results->sum('cutting_result'); if ($totalPieces <= 0) { return 0; } return (int) round($this->calculateTotalProductionCost($cutting) / $totalPieces); } private function storeResultPrices(Cutting $cutting, array $resultPrices): void { $costPerUnit = (int) ($cutting->cost_per_unit ?? $this->calculateCostPerUnit($cutting)); foreach ($resultPrices as $resultData) { foreach ($resultData['prices'] as $priceData) { 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, ]); } } } /** * Quick-create a product with variants and prices, bypassing owner verification. */ public function quickCreateProduct(array $validated): array { $product = Product::create([ 'name' => $validated['name'], 'description' => $validated['description'] ?? null, 'is_active' => true, ]); if (! empty($validated['category_ids'])) { $product->categories()->sync($validated['category_ids']); } $createdVariants = []; $maxVariantImages = 5; foreach ($validated['variants'] as $index => $variantData) { $variant = $product->variants()->create([ 'name' => $variantData['name'], 'stock' => $variantData['stock'] ?? 0, 'retail_stock' => $variantData['retail_stock'] ?? 0, ]); if (! empty($variantData['prices'])) { foreach ($variantData['prices'] as $type => $priceValue) { $variant->prices()->create([ 'type' => $type, 'price' => (int) $priceValue, ]); } } $this->mediaService->syncCollection( $variant, 'images', $variantData['images'] ?? null, null, $maxVariantImages, required: false, errorKey: "variants.{$index}.images", s3Keys: $variantData['s3_keys'] ?? null, ); $variant->refresh(); $createdVariants[] = [ 'id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock, 'images' => MediaPresenter::collection($variant, 'images'), ]; } $this->cacheForgetByPattern('manage:cuttings:*'); return [ 'id' => $product->id, 'name' => $product->name, 'is_active' => true, 'variants' => $createdVariants, ]; } private function formatStockForUnit(float $stock, RawMaterialUnit $unit): string { $formatted = rtrim(rtrim(number_format($stock, 2, ',', '.'), '0'), ','); return "{$formatted} {$unit->abbreviation()}"; } private function syncImages(Cutting $cutting, array $validated): void { $this->mediaService->syncCollection( $cutting, 'images', $validated['images'] ?? null, $validated['remove_media_ids'] ?? null, 10, required: false, errorKey: 'images', s3Keys: $validated['s3_keys'] ?? null, ); } }