distinct() ->pluck('name'); } public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = [], ?int $highlight = null): LengthAwarePaginator { $stockSumQuery = '(SELECT IFNULL(SUM(stock), 0) FROM raw_material_prices WHERE raw_material_prices.raw_material_id = raw_materials.id AND raw_material_prices.deleted_at IS NULL)'; $valueSumQuery = '(SELECT IFNULL(SUM(price * stock), 0) FROM raw_material_prices WHERE raw_material_prices.raw_material_id = raw_materials.id AND raw_material_prices.deleted_at IS NULL)'; return RawMaterial::query() ->select(['id', 'name', 'unit', 'is_active']) ->withCount('rawMaterialPrices as variants_count') ->selectRaw("{$stockSumQuery} as total_stock") ->selectRaw("{$valueSumQuery} as total_value") ->when($highlight, fn ($q) => $q->where('id', $highlight)) ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%") ->orWhereHas('rawMaterialPrices', fn ($vq) => $vq->where('variant', 'like', "%{$search}%")) ) ->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%")) ->when(($filters['is_active'] ?? null) !== null && ($filters['is_active'] ?? null) !== '', function ($q) use ($filters) { $q->where('is_active', $filters['is_active'] === 'true'); }) ->when(($filters['stock'] ?? null) === 'empty', function ($q) use ($stockSumQuery) { $q->whereRaw("{$stockSumQuery} = 0"); }) ->when(($filters['stock'] ?? null) === 'low', function ($q) use ($stockSumQuery) { $q->whereRaw("{$stockSumQuery} BETWEEN 0.0001 AND 9.9999"); }) ->orderBy($sort, $direction) ->paginate($perPage); } public function getActiveMaterials(): Collection { return RawMaterial::select(['id', 'name', 'unit']) ->active() ->orderBy('name') ->get(); } public function getVariants(RawMaterial $rawMaterial): Collection { return $rawMaterial->rawMaterialPrices() ->select(['id', 'raw_material_id', 'variant', 'price', 'stock']) ->get() ->each(function (RawMaterialPrice $price) { $media = $price->getFirstMedia('images'); if ($media) { $price->photo_url = $this->s3Service->getTemporaryUrl($media->getPath()); $price->photo_conversion_url = $media->getGeneratedConversions()->contains('thumb') ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) : $this->s3Service->getTemporaryUrl($media->getPath()); } else { $price->photo_url = null; $price->photo_conversion_url = null; } }); } public function store(array $data): RawMaterial { $rawMaterial = DB::transaction(function () use ($data) { $rawMaterial = RawMaterial::create([ 'name' => $data['name'], 'unit' => $data['unit'], 'is_active' => $data['is_active'] ?? true, ]); $now = now(); $priceRows = collect($data['variants'])->map(fn ($v) => [ 'raw_material_id' => $rawMaterial->id, 'variant' => $v['variant'], 'price' => $v['price'], 'stock' => $v['stock'], 'created_at' => $now, 'updated_at' => $now, ])->toArray(); DB::table('raw_material_prices')->insert($priceRows); $insertedPrices = RawMaterialPrice::where('raw_material_id', $rawMaterial->id)->get(); $variantMap = $insertedPrices->mapWithKeys(fn ($p) => [$p->variant => $p->id]); foreach ($data['variants'] as $variantData) { if (! empty($variantData['photo_key'])) { $priceId = $variantMap[$variantData['variant']]; $priceModel = RawMaterialPrice::find($priceId); $this->registerPhoto($priceModel, $variantData['photo_key']); } } return $rawMaterial; }); NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU], title: 'Bahan Baku Baru', body: "Bahan baku \"{$rawMaterial->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]), ); $this->logCreated($rawMaterial, 'Bahan Baku', $this->getRawMaterialLogValues($rawMaterial)); return $rawMaterial; } public function getForEdit(RawMaterial $rawMaterial): array { $rawMaterial->load([ 'rawMaterialPrices.media', ]); $variants = $rawMaterial->rawMaterialPrices->map(function (RawMaterialPrice $price) { $media = $price->getMedia('images'); $photoKeys = $media->map(fn ($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray(); $photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray(); return [ 'id' => $price->id, 'variant' => $price->variant, 'price' => $price->price, 'stock' => $price->stock, 'photo_key' => $photoKeys[0] ?? null, 'photo_url' => $photoUrls[0] ?? null, ]; }); return [ 'id' => $rawMaterial->id, 'name' => $rawMaterial->name, 'unit' => $rawMaterial->unit->value, 'is_active' => $rawMaterial->is_active, 'raw_material_prices' => $variants, ]; } public function update(RawMaterial $rawMaterial, array $data): RawMaterial { $oldValues = $this->getRawMaterialLogValues($rawMaterial); $rawMaterial = DB::transaction(function () use ($rawMaterial, $data) { $rawMaterial->update([ 'name' => $data['name'], 'unit' => $data['unit'], 'is_active' => $data['is_active'] ?? $rawMaterial->is_active, ]); $existingVariantIds = collect($data['variants']) ->pluck('id') ->filter() ->toArray(); $variantsToDelete = $rawMaterial->rawMaterialPrices() ->whereNotIn('id', $existingVariantIds) ->get(); foreach ($variantsToDelete as $price) { if ($price->stock > 0) { throw ValidationException::withMessages([ 'variants' => "Varian \"{$price->variant}\" tidak dapat dihapus karena masih memiliki stok.", ]); } $activeCuttingUsage = $price->cuttingMaterials() ->whereHas('cutting', fn ($q) => $q->inProgress()) ->exists(); if ($activeCuttingUsage) { throw ValidationException::withMessages([ 'variants' => "Varian \"{$price->variant}\" tidak dapat dihapus karena masih digunakan di cutting yang sedang diproses.", ]); } $price->delete(); } $existingPricesMap = RawMaterialPrice::whereIn('id', $existingVariantIds) ->with('media') ->get() ->mapWithKeys(fn ($p) => [$p->id => $p]); $now = now(); $newVariantsData = collect($data['variants'])->filter(fn ($v) => ! isset($v['id'])); $newVariantIdMap = []; if ($newVariantsData->isNotEmpty()) { $newRows = $newVariantsData->map(fn ($v) => [ 'raw_material_id' => $rawMaterial->id, 'variant' => $v['variant'], 'price' => $v['price'], 'stock' => $v['stock'], 'created_at' => $now, 'updated_at' => $now, ])->toArray(); DB::table('raw_material_prices')->insert($newRows); $newlyCreated = RawMaterialPrice::where('raw_material_id', $rawMaterial->id) ->whereIn('variant', $newVariantsData->pluck('variant')->toArray()) ->get(); $newVariantIdMap = $newlyCreated->mapWithKeys(fn ($p) => [$p->variant => $p->id])->toArray(); } foreach ($data['variants'] as $variantData) { $priceId = $variantData['id'] ?? $newVariantIdMap[$variantData['variant']] ?? null; if (! $priceId) { continue; } $priceModel = $existingPricesMap[$priceId] ?? RawMaterialPrice::find($priceId); if (! $priceModel) { continue; } $priceModel->update([ 'variant' => $variantData['variant'], 'price' => $variantData['price'], 'stock' => $variantData['stock'], ]); if (isset($variantData['photo_key'])) { $existingKey = $priceModel->getMedia('images')->first()?->getCustomProperty('s3_key'); if ($existingKey !== $variantData['photo_key']) { $priceModel->clearMediaCollection('images'); if ($variantData['photo_key']) { $this->registerPhoto($priceModel, $variantData['photo_key']); } } } } return $rawMaterial; }); NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU], title: 'Bahan Baku Diperbarui', body: "Bahan baku \"{$rawMaterial->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]), ); $this->logUpdated($rawMaterial, 'Bahan Baku', $oldValues, $this->getRawMaterialLogValues($rawMaterial), ['Nama Bahan Baku']); return $rawMaterial; } public function destroy(RawMaterial $rawMaterial): bool { $oldValues = $this->getRawMaterialLogValues($rawMaterial); foreach ($rawMaterial->rawMaterialPrices as $price) { if ($price->stock > 0) { throw ValidationException::withMessages([ 'raw_material' => "Bahan baku \"{$rawMaterial->name}\" tidak dapat dihapus karena varian \"{$price->variant}\" masih memiliki stok.", ]); } $activeCuttingUsage = $price->cuttingMaterials() ->whereHas('cutting', fn ($q) => $q->inProgress()) ->exists(); if ($activeCuttingUsage) { throw ValidationException::withMessages([ 'raw_material' => "Bahan baku \"{$rawMaterial->name}\" tidak dapat dihapus karena varian \"{$price->variant}\" masih digunakan di cutting yang sedang diproses.", ]); } } $result = DB::transaction(function () use ($rawMaterial) { $rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) { $price->delete(); }); return $rawMaterial->delete(); }); NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU], title: 'Bahan Baku Dihapus', body: "Bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.raw-materials.index'), ); $this->logDeleted($rawMaterial, 'Bahan Baku', $oldValues); return $result; } public function toggleStatus(RawMaterial $rawMaterial): void { $oldValues = $this->getRawMaterialLogValues($rawMaterial); $rawMaterial->update([ 'is_active' => ! $rawMaterial->is_active, ]); $status = $rawMaterial->is_active ? 'diaktifkan' : 'dinonaktifkan'; NotificationService::notify( roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU], title: 'Status Bahan Baku Diubah', body: "Bahan baku \"{$rawMaterial->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]), ); $this->logUpdated($rawMaterial, 'Bahan Baku', $oldValues, $this->getRawMaterialLogValues($rawMaterial), ['Nama Bahan Baku']); } private function getRawMaterialLogValues(RawMaterial $rawMaterial): array { $rawMaterial->load('rawMaterialPrices'); return [ 'Nama Bahan Baku' => $rawMaterial->name, 'Satuan' => $rawMaterial->unit?->label(), 'Status' => $this->formatBoolean($rawMaterial->is_active), 'Varian' => $rawMaterial->rawMaterialPrices->map(fn ($price) => [ 'Nama Varian' => $price->variant, 'Harga' => $this->formatCurrency($price->price), 'Stok' => number_format((float) $price->stock, 2, ',', '.'), ])->toArray(), ]; } }