select(['id', 'created_by_id', 'status', 'description', 'total_material_cost', 'cost_per_unit', 'created_at']) ->with([ 'createdBy:id', 'createdBy.userProfile:id,user_id,full_name', ]) ->selectRaw("{$materialsCountQuery} as materials_count") ->selectRaw("{$totalUsageQuery} as total_usage") ->selectRaw("{$productNameQuery} as product_name") ->selectRaw("{$cuttingResultQuery} as cutting_result") ->when($highlight, fn ($q) => $q->where('id', $highlight)) ->when($search, function ($q) use ($search) { $q->whereHas('cuttingResults', fn ($rq) => $rq->where('product_name', 'like', "%{$search}%")) ->orWhere('description', 'like', "%{$search}%"); }) ->when($filters['product_name'] ?? null, fn ($q, $productName) => $q->whereHas('cuttingResults', fn ($rq) => $rq->where('product_name', $productName))) ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status)) ->orderBy($sort, $direction) ->paginate($perPage); $paginator->getCollection()->each(function (Cutting $cutting) { $cuttingMedia = $cutting->getMedia('images'); $cutting->photo_urls = $cuttingMedia->map( fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath()) )->toArray(); $cutting->photo_conversion_urls = $cuttingMedia->map( fn ($media) => $media->getGeneratedConversions()->contains('thumb') ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) : $this->s3Service->getTemporaryUrl($media->getPath()) )->toArray(); }); return $paginator; } public function getMaterials(Cutting $cutting): Collection { return $cutting->cuttingMaterials() ->select(['id', 'cutting_id', 'raw_material_price_id', 'material_usage', 'material_result', 'combination_id']) ->with([ 'rawMaterialPrice:id,raw_material_id,variant', 'rawMaterialPrice.rawMaterial:id,name,unit', ]) ->get() ->each(function (CuttingMaterial $material) { if (! $material->rawMaterialPrice) { return; } $media = $material->rawMaterialPrice->getFirstMedia('images'); $material->rawMaterialPrice->photo_url = $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null; $material->rawMaterialPrice->photo_conversion_url = $media ? ($media->getGeneratedConversions()->contains('thumb') ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) : $this->s3Service->getTemporaryUrl($media->getPath())) : null; }); } public function getCombinations(Cutting $cutting): Collection { return $cutting->cuttingMaterialCombinations() ->select(['id', 'cutting_id', 'material_result']) ->get(); } public function getProductNames(): Collection { return CuttingResult::query() ->select('product_name') ->whereNotNull('product_name') ->where('product_name', '!=', '') ->distinct() ->orderBy('product_name') ->get(); } public function getForShow(Cutting $cutting): array { $cutting->load([ 'createdBy:id', 'createdBy.userProfile:id,user_id,full_name', 'cuttingResults:id,cutting_id,product_name,cutting_result,sample,original_outside_sample', 'cuttingMaterials:id,cutting_id,raw_material_price_id,material_usage,material_result,combination_id', 'cuttingMaterials.rawMaterialPrice:id,raw_material_id,variant', 'cuttingMaterials.rawMaterialPrice.rawMaterial:id,name,unit', 'cuttingMaterialCombinations:id,cutting_id,material_result', ]); $cuttingMedia = $cutting->getMedia('images'); $cutting->photo_urls = $cuttingMedia->map( fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath()) )->toArray(); $cutting->photo_conversion_urls = $cuttingMedia->map( fn ($media) => $media->getGeneratedConversions()->contains('thumb') ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) : $this->s3Service->getTemporaryUrl($media->getPath()) )->toArray(); $cutting->cuttingMaterials->each(function (CuttingMaterial $material) { $media = $material->rawMaterialPrice?->getFirstMedia('images'); if ($material->rawMaterialPrice) { $material->rawMaterialPrice->photo_url = $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null; $material->rawMaterialPrice->photo_conversion_url = $media ? ($media->getGeneratedConversions()->contains('thumb') ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) : $this->s3Service->getTemporaryUrl($media->getPath())) : null; } }); return [ 'id' => $cutting->id, 'status' => $cutting->status->value, 'status_label' => $cutting->status_label, 'description' => $cutting->description, 'total_material_cost' => $cutting->total_material_cost, 'formatted_total_material_cost' => $cutting->formatted_total_material_cost, 'cost_per_unit' => $cutting->cost_per_unit, 'formatted_cost_per_unit' => $cutting->formatted_cost_per_unit, 'created_at' => $cutting->created_at, 'formatted_created_at' => $cutting->formatted_created_at, 'photo_urls' => $cutting->photo_urls, 'photo_conversion_urls' => $cutting->photo_conversion_urls, 'created_by' => [ 'id' => $cutting->createdBy->id, 'user_profile' => [ 'full_name' => $cutting->createdBy->userProfile->full_name ?? '-', ], ], 'cutting_results' => $cutting->cuttingResults->map(fn ($r) => [ 'id' => $r->id, 'product_name' => $r->product_name, 'cutting_result' => $r->cutting_result, 'sample' => $r->sample, 'original_outside_sample' => $r->original_outside_sample, ]), 'cutting_materials' => $cutting->cuttingMaterials->map(fn ($m) => [ 'id' => $m->id, 'material_usage' => $m->material_usage, 'material_result' => $m->material_result, 'combination_id' => $m->combination_id, 'raw_material_price' => [ 'id' => $m->rawMaterialPrice->id, 'variant' => $m->rawMaterialPrice->variant, 'price' => $m->rawMaterialPrice->price, 'photo_url' => $m->rawMaterialPrice->photo_url, 'photo_conversion_url' => $m->rawMaterialPrice->photo_conversion_url, 'raw_material' => [ 'id' => $m->rawMaterialPrice->rawMaterial->id, 'name' => $m->rawMaterialPrice->rawMaterial->name, 'unit' => $m->rawMaterialPrice->rawMaterial->unit, ], ], ]), 'cutting_material_combinations' => $cutting->cuttingMaterialCombinations->map(fn ($c) => [ 'id' => $c->id, 'material_result' => $c->material_result, ]), ]; } public function getForEdit(Cutting $cutting): array { $cutting->load([ 'cuttingResults', 'cuttingMaterials.rawMaterialPrice.rawMaterial', 'cuttingMaterialCombinations', ]); $result = $cutting->cuttingResults->first(); $materials = $cutting->cuttingMaterials->map(function (CuttingMaterial $material) { $media = $material->rawMaterialPrice?->getFirstMedia('images'); return [ 'id' => $material->id, 'raw_material_price_id' => $material->raw_material_price_id, 'material_usage' => $material->material_usage, 'material_result' => $material->material_result, 'combination_id' => $material->combination_id, 'variant' => $material->rawMaterialPrice?->variant, 'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null, ]; }); $combinations = $cutting->cuttingMaterialCombinations->map(function (CuttingMaterialCombination $combination) use ($materials) { $materialIndices = $materials ->filter(fn ($m) => $m['combination_id'] === $combination->id) ->keys() ->values(); return [ 'id' => $combination->id, 'material_result' => $combination->material_result, 'material_indices' => $materialIndices, ]; }); $cuttingMedia = $cutting->getMedia('images'); $photoKeys = $cuttingMedia->map( fn ($media) => $media->getCustomProperty('s3_key') ?? $media->file_name )->toArray(); $photoUrls = $cuttingMedia->map( fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath()) )->toArray(); return [ 'id' => $cutting->id, 'status' => $cutting->status->value, 'description' => $cutting->description, 'product_name' => $result?->product_name ?? '', 'sample' => $result?->sample ?? 0, 'original_outside_sample' => $result?->original_outside_sample ?? 0, 'cutting_result' => $result?->cutting_result ?? 0, 'materials' => $materials, 'combinations' => $combinations, 'photo_keys' => $photoKeys, 'photo_urls' => $photoUrls, 'existing_material_ids' => $cutting->cuttingMaterials ->map(fn (CuttingMaterial $m) => $m->rawMaterialPrice?->raw_material_id) ->filter() ->unique() ->values() ->all(), ]; } public function store(array $data): Cutting { $cutting = DB::transaction(function () use ($data) { $priceIds = collect($data['materials'])->pluck('raw_material_price_id')->unique()->values(); $pricesMap = RawMaterialPrice::whereIn('id', $priceIds)->get()->keyBy('id'); foreach ($data['materials'] as $materialData) { $usage = (float) ($materialData['material_usage'] ?? 0); if ($usage <= 0) { continue; } $price = $pricesMap[$materialData['raw_material_price_id']] ?? null; if (! $price) { continue; } if ($usage > $price->stock) { throw ValidationException::withMessages([ 'materials' => "Stok {$price->variant} tidak mencukupi. Tersedia: {$price->stock}, dibutuhkan: {$usage}.", ]); } } $cutting = Cutting::create([ 'created_by_id' => auth()->id(), 'status' => 'in_progress', 'description' => $data['description'] ?? null, ]); $totalMaterialCost = 0; $now = now(); $combinations = $data['combinations'] ?? []; $combinationMap = []; foreach ($combinations as $index => $combo) { $combination = CuttingMaterialCombination::create([ 'cutting_id' => $cutting->id, 'user_id' => auth()->id(), 'material_result' => $combo['material_result'] ?? null, 'created_at' => $now, 'updated_at' => $now, ]); $combinationMap[$index] = $combination->id; } $stockDecrementMap = []; foreach ($data['materials'] as $materialData) { $price = $pricesMap[$materialData['raw_material_price_id']] ?? null; $materialCost = $price ? $price->price * ($materialData['material_usage'] ?? 0) : 0; $totalMaterialCost += $materialCost; $combinationId = null; if (isset($materialData['combination_index']) && isset($combinationMap[$materialData['combination_index']])) { $combinationId = $combinationMap[$materialData['combination_index']]; } CuttingMaterial::create([ 'cutting_id' => $cutting->id, 'user_id' => auth()->id(), 'raw_material_price_id' => $materialData['raw_material_price_id'], 'material_usage' => $materialData['material_usage'] ?? 0, 'material_result' => $materialData['material_result'] ?? null, 'combination_id' => $combinationId, 'created_at' => $now, 'updated_at' => $now, ]); $usage = (float) ($materialData['material_usage'] ?? 0); if ($price && $usage > 0) { $stockDecrementMap[$materialData['raw_material_price_id']] = ($stockDecrementMap[$materialData['raw_material_price_id']] ?? 0) + $usage; } } foreach ($stockDecrementMap as $priceId => $totalUsage) { RawMaterialPrice::where('id', $priceId)->decrement('stock', $totalUsage); } $costPerUnit = 0; if (($data['cutting_result'] ?? 0) > 0) { $costPerUnit = (int) ($totalMaterialCost / $data['cutting_result']); } $cutting->update([ 'total_material_cost' => $totalMaterialCost, 'cost_per_unit' => $costPerUnit, ]); CuttingResult::create([ 'cutting_id' => $cutting->id, 'user_id' => auth()->id(), 'product_name' => $data['product_name'] ?? null, 'sample' => $data['sample'] ?? null, 'original_outside_sample' => $data['original_outside_sample'] ?? null, 'cutting_result' => $data['cutting_result'] ?? null, 'created_at' => $now, 'updated_at' => $now, ]); if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) { $this->registerPhotos( model: $cutting, photoKeys: $data['photo_keys'], collectionName: 'images', ); } return $cutting; }); NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU], title: 'Cutting Baru', body: 'Cutting berhasil ditambahkan oleh '.auth()->user()->full_name.'.', url: route('admin.manage.cuttings.index', ['highlight' => $cutting->id]), ); return $cutting; } public function update(Cutting $cutting, array $data): Cutting { $cutting = DB::transaction(function () use ($cutting, $data) { $oldMaterialUsages = $cutting->cuttingMaterials() ->select('raw_material_price_id', 'material_usage') ->where('material_usage', '>', 0) ->get() ->filter(fn ($m) => $m->raw_material_price_id) ->groupBy('raw_material_price_id') ->map(fn ($group) => $group->sum('material_usage')); if ($oldMaterialUsages->isNotEmpty()) { foreach ($oldMaterialUsages as $priceId => $totalUsage) { RawMaterialPrice::where('id', $priceId)->increment('stock', $totalUsage); } } $priceIds = collect($data['materials'])->pluck('raw_material_price_id')->unique()->values(); $pricesMap = RawMaterialPrice::whereIn('id', $priceIds)->get()->keyBy('id'); foreach ($data['materials'] as $materialData) { $usage = (float) ($materialData['material_usage'] ?? 0); if ($usage <= 0) { continue; } $price = $pricesMap[$materialData['raw_material_price_id']] ?? null; if (! $price) { continue; } if ($usage > $price->stock) { throw ValidationException::withMessages([ 'materials' => "Stok {$price->variant} tidak mencukupi. Tersedia: {$price->stock}, dibutuhkan: {$usage}.", ]); } } $cutting->cuttingResults()->delete(); $cutting->cuttingMaterials()->delete(); $cutting->cuttingMaterialCombinations()->delete(); $totalMaterialCost = 0; $now = now(); $combinations = $data['combinations'] ?? []; $combinationMap = []; foreach ($combinations as $index => $combo) { $combination = CuttingMaterialCombination::create([ 'cutting_id' => $cutting->id, 'user_id' => auth()->id(), 'material_result' => $combo['material_result'] ?? null, 'created_at' => $now, 'updated_at' => $now, ]); $combinationMap[$index] = $combination->id; } $stockDecrementMap = []; foreach ($data['materials'] as $materialData) { $price = $pricesMap[$materialData['raw_material_price_id']] ?? null; $materialCost = $price ? $price->price * ($materialData['material_usage'] ?? 0) : 0; $totalMaterialCost += $materialCost; $combinationId = null; if (isset($materialData['combination_index']) && isset($combinationMap[$materialData['combination_index']])) { $combinationId = $combinationMap[$materialData['combination_index']]; } CuttingMaterial::create([ 'cutting_id' => $cutting->id, 'user_id' => auth()->id(), 'raw_material_price_id' => $materialData['raw_material_price_id'], 'material_usage' => $materialData['material_usage'] ?? 0, 'material_result' => $materialData['material_result'] ?? null, 'combination_id' => $combinationId, 'created_at' => $now, 'updated_at' => $now, ]); $usage = (float) ($materialData['material_usage'] ?? 0); if ($price && $usage > 0) { $stockDecrementMap[$materialData['raw_material_price_id']] = ($stockDecrementMap[$materialData['raw_material_price_id']] ?? 0) + $usage; } } foreach ($stockDecrementMap as $priceId => $totalUsage) { RawMaterialPrice::where('id', $priceId)->decrement('stock', $totalUsage); } $costPerUnit = 0; if (($data['cutting_result'] ?? 0) > 0) { $costPerUnit = (int) ($totalMaterialCost / $data['cutting_result']); } $cutting->update([ 'description' => $data['description'] ?? null, 'total_material_cost' => $totalMaterialCost, 'cost_per_unit' => $costPerUnit, ]); CuttingResult::create([ 'cutting_id' => $cutting->id, 'user_id' => auth()->id(), 'product_name' => $data['product_name'] ?? null, 'sample' => $data['sample'] ?? null, 'original_outside_sample' => $data['original_outside_sample'] ?? null, 'cutting_result' => $data['cutting_result'] ?? null, 'created_at' => $now, 'updated_at' => $now, ]); $this->syncPhotos($cutting, $data['photo_keys'] ?? [], 'images'); return $cutting; }); NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU], title: 'Cutting Diperbarui', body: 'Cutting berhasil diperbarui oleh '.auth()->user()->full_name.'.', url: route('admin.manage.cuttings.index', ['highlight' => $cutting->id]), ); return $cutting; } public function destroy(Cutting $cutting): bool { $result = DB::transaction(function () use ($cutting) { $stockRestoreMap = $cutting->cuttingMaterials() ->select('raw_material_price_id', 'material_usage') ->where('material_usage', '>', 0) ->get() ->groupBy('raw_material_price_id') ->map(fn ($group) => $group->sum('material_usage')); foreach ($stockRestoreMap as $priceId => $totalUsage) { RawMaterialPrice::where('id', $priceId)->increment('stock', $totalUsage); } $cutting->cuttingResults()->delete(); $cutting->cuttingMaterials()->delete(); $cutting->cuttingMaterialCombinations()->delete(); $cutting->delete(); return true; }); NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU], title: 'Cutting Dihapus', body: 'Cutting berhasil dihapus oleh '.auth()->user()->full_name.'.', url: route('admin.manage.cuttings.index'), ); return $result; } public function complete(Cutting $cutting): Cutting { $cutting->update(['status' => CuttingStatus::COMPLETED]); NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU], title: 'Cutting Selesai', body: 'Cutting berhasil diselesaikan oleh '.auth()->user()->full_name.'.', url: route('admin.manage.cuttings.index', ['highlight' => $cutting->id]), ); return $cutting; } }