select(['id', 'supplier_id', 'created_by_id', 'subtotal', 'discount', 'shipping_cost', 'total', 'notes', 'created_at']) ->with([ 'supplier:id,name', 'createdBy:id', 'createdBy.userProfile:id,user_id,full_name', 'purchaseItems' => fn ($q) => $q ->select(['id', 'purchase_id', 'raw_material_price_id', 'quantity', 'unit_price', 'subtotal']) ->orderByRaw('(SELECT variant FROM raw_material_prices WHERE raw_material_prices.id = purchase_items.raw_material_price_id)'), 'purchaseItems.rawMaterialPrice:id,raw_material_id,variant,price,stock', 'purchaseItems.rawMaterialPrice.rawMaterial:id,name,unit', ]) ->when($search, function ($q) use ($search) { $q->whereHas('supplier', fn ($sq) => $sq->where('name', 'like', "%{$search}%")) ->orWhere('notes', 'like', "%{$search}%"); }) ->when($filters['supplier_id'] ?? null, fn ($q, $supplierId) => $q->where('supplier_id', $supplierId)) ->orderBy($sort, $direction) ->paginate($perPage); $paginator->getCollection()->each(function (Purchase $purchase) { $purchaseMedia = $purchase->getFirstMedia('photos'); $purchase->photo_url = $purchaseMedia ? $this->s3Service->getTemporaryUrl($purchaseMedia->file_name) : null; $purchase->purchaseItems->each(function (PurchaseItem $item) { if (! $item->rawMaterialPrice) { return; } $media = $item->rawMaterialPrice->getMedia('photos'); $item->rawMaterialPrice->photo_url = $media->first() ? $this->s3Service->getTemporaryUrl($media->first()->file_name) : null; }); }); return $paginator; } public function getForCreate(): array { return [ 'suppliers' => Supplier::select(['id', 'name'])->latest()->get(), 'rawMaterials' => RawMaterial::query() ->select(['id', 'name', 'unit', 'is_active']) ->with([ 'rawMaterialPrices:id,raw_material_id,variant,price,stock', ]) ->orderBy('name') ->get() ->each(function (RawMaterial $rawMaterial) { $rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) { $media = $price->getFirstMedia('photos'); $price->photo_url = $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null; }); }), ]; } public function getForEdit(Purchase $purchase): array { $purchase->load([ 'purchaseItems' => fn ($q) => $q ->orderByRaw('(SELECT variant FROM raw_material_prices WHERE raw_material_prices.id = purchase_items.raw_material_price_id)'), 'purchaseItems.rawMaterialPrice.rawMaterial', 'supplier', ]); $rawMaterial = $purchase->purchaseItems->first()?->rawMaterialPrice?->rawMaterial; $variants = $purchase->purchaseItems->map(function (PurchaseItem $item) { if (! $item->rawMaterialPrice) { return null; } $media = $item->rawMaterialPrice->getFirstMedia('photos'); return [ 'id' => $item->rawMaterialPrice->id, 'variant' => $item->rawMaterialPrice->variant, 'price' => $item->unit_price, 'stock' => $item->quantity, 'photo_key' => $media?->file_name, 'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null, ]; })->filter()->values(); $items = $purchase->purchaseItems; $items = $items->filter(fn (PurchaseItem $item) => $item->rawMaterialPrice !== null); $materials = $items ->map(fn (PurchaseItem $item) => $item->rawMaterialPrice->rawMaterial) ->filter() ->unique(fn (RawMaterial $material) => $material->id); $singleMaterial = $materials->count() === 1; $sharedWithOther = PurchaseItem::where('purchase_id', '!=', $purchase->id) ->whereIn('raw_material_price_id', $items->pluck('raw_material_price_id')) ->exists(); $purchaseMedia = $purchase->getFirstMedia('photos'); $purchasePhotoKey = $purchaseMedia?->file_name; $purchasePhotoUrl = $purchaseMedia ? $this->s3Service->getTemporaryUrl($purchaseMedia->file_name) : null; return [ 'id' => $purchase->id, 'name' => $rawMaterial?->name ?? '', 'unit' => $rawMaterial?->unit->value ?? 'kg', 'supplier_id' => $purchase->supplier_id, 'discount' => $purchase->discount, 'shipping_cost' => $purchase->shipping_cost, 'notes' => $purchase->notes, 'photo_key' => $purchasePhotoKey, 'photo_url' => $purchasePhotoUrl, 'variants' => $variants, 'default_mode' => $singleMaterial && ! $sharedWithOther ? 'new' : 'existing', 'existing_material_name' => $singleMaterial ? $materials->first()->name : null, 'existing_quantities' => $singleMaterial ? $items->mapWithKeys(fn (PurchaseItem $item) => [ (int) $item->raw_material_price_id => (int) $item->quantity, ])->all() : [], ]; } public function create(array $data): Purchase { if (($data['mode'] ?? 'new') === 'existing') { return $this->createFromExisting($data); } return $this->createNew($data); } private function createFromExisting(array $data): Purchase { return DB::transaction(function () use ($data) { $now = now(); $subtotal = 0; $itemRows = collect($data['existing_items'])->map(function ($item) use ($now, &$subtotal) { $itemSubtotal = (int) ($item['unit_price'] * $item['quantity']); $subtotal += $itemSubtotal; return [ 'purchase_id' => null, 'raw_material_price_id' => $item['raw_material_price_id'], 'user_id' => auth()->id(), 'quantity' => $item['quantity'], 'unit_price' => $item['unit_price'], 'subtotal' => $itemSubtotal, 'created_at' => $now, 'updated_at' => $now, ]; })->toArray(); $discount = $data['discount'] ?? 0; $shippingCost = $data['shipping_cost'] ?? 0; $total = $subtotal - $discount + $shippingCost; $purchase = Purchase::create([ 'supplier_id' => $data['supplier_id'], 'created_by_id' => auth()->id(), 'subtotal' => $subtotal, 'discount' => $discount, 'shipping_cost' => $shippingCost, 'total' => $total, 'notes' => $data['notes'] ?? null, ]); foreach ($itemRows as &$row) { $row['purchase_id'] = $purchase->id; } DB::table('purchase_items')->insert($itemRows); foreach ($data['existing_items'] as $item) { RawMaterialPrice::whereKey($item['raw_material_price_id']) ->increment('stock', (int) $item['quantity']); } if (! empty($data['photo_key'])) { $this->registerMedia( model: $purchase, s3Key: $data['photo_key'], collectionName: 'photos', orderColumn: 1, ); } NotificationService::notify( roles: ['Owner', 'Developer', 'Admin Bahan Baku'], title: 'Belanja Baru', body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.', url: route('admin.manage.purchases.index'), ); return $purchase; }); } private function createNew(array $data): Purchase { return DB::transaction(function () use ($data) { $rawMaterial = RawMaterial::create([ 'name' => $data['name'], 'unit' => $data['unit'], 'is_active' => true, ]); $subtotal = 0; $now = now(); $priceRows = collect($data['variants'])->map(function ($v) use ($rawMaterial, $now, &$subtotal) { $itemSubtotal = (int) ($v['price'] * $v['stock']); $subtotal += $itemSubtotal; return [ '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->registerMedia( model: $priceModel, s3Key: $variantData['photo_key'], collectionName: 'photos', orderColumn: 1, ); } } $discount = $data['discount'] ?? 0; $shippingCost = $data['shipping_cost'] ?? 0; $total = $subtotal - $discount + $shippingCost; $purchase = Purchase::create([ 'supplier_id' => $data['supplier_id'], 'created_by_id' => auth()->id(), 'subtotal' => $subtotal, 'discount' => $discount, 'shipping_cost' => $shippingCost, 'total' => $total, 'notes' => $data['notes'] ?? null, ]); $purchaseItems = $insertedPrices->map(fn ($price) => [ 'purchase_id' => $purchase->id, 'raw_material_price_id' => $price->id, 'user_id' => auth()->id(), 'quantity' => $price->stock, 'unit_price' => $price->price, 'subtotal' => (int) ($price->price * $price->stock), 'created_at' => $now, 'updated_at' => $now, ])->toArray(); DB::table('purchase_items')->insert($purchaseItems); if (! empty($data['photo_key'])) { $this->registerMedia( model: $purchase, s3Key: $data['photo_key'], collectionName: 'photos', orderColumn: 1, ); } NotificationService::notify( roles: ['Owner', 'Developer', 'Admin Bahan Baku'], title: 'Belanja Baru', body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.', url: route('admin.manage.purchases.index'), ); return $purchase; }); } public function update(Purchase $purchase, array $data): Purchase { return DB::transaction(function () use ($purchase, $data) { $purchase->load('purchaseItems.rawMaterialPrice.rawMaterial'); $oldItems = $purchase->purchaseItems; // 1. Reverse the stock increments of the old items, so prices // get adjusted by the difference instead of being reset. $oldItems->each(function (PurchaseItem $item) { if ($item->rawMaterialPrice) { $item->rawMaterialPrice->decrement('stock', (int) $item->quantity); } }); $oldMaterial = $oldItems ->map(fn (PurchaseItem $item) => $item->rawMaterialPrice?->rawMaterial) ->filter() ->unique(fn (RawMaterial $material) => $material->id) ->first(); $oldMaterial = $oldItems ->map(fn (PurchaseItem $item) => $item->rawMaterialPrice?->rawMaterial) ->filter() ->unique(fn (RawMaterial $material) => $material->id) ->first(); $oldItems->each->delete(); $now = now(); $subtotal = 0; if (($data['mode'] ?? 'new') === 'existing') { // 2a. Reference existing prices and add their new stock. $itemRows = collect($data['existing_items'])->map(function ($item) use ($now, &$subtotal) { $itemSubtotal = (int) ($item['unit_price'] * $item['quantity']); $subtotal += $itemSubtotal; return [ 'purchase_id' => null, 'raw_material_price_id' => $item['raw_material_price_id'], 'user_id' => auth()->id(), 'quantity' => $item['quantity'], 'unit_price' => $item['unit_price'], 'subtotal' => $itemSubtotal, 'created_at' => $now, 'updated_at' => $now, ]; })->toArray(); foreach ($data['existing_items'] as $item) { RawMaterialPrice::whereKey($item['raw_material_price_id']) ->increment('stock', (int) $item['quantity']); } } else { // 2a. Always reuse the purchase's existing material in place; // a fresh material is only created when the purchase has // no items yet. if ($oldMaterial) { $rawMaterial = $oldMaterial; $rawMaterial->update([ 'name' => $data['name'], 'is_active' => true, ]); } else { $rawMaterial = RawMaterial::create([ 'name' => $data['name'], 'unit' => $data['unit'] ?? 'kg', 'is_active' => true, ]); } // 2b. Adjust the stock of existing variant prices, create // prices for new variants, but never delete variants. $itemRows = collect($data['variants'])->map(function ($v) use ($purchase, $rawMaterial, $now, &$subtotal) { $price = null; if (! empty($v['id'])) { $price = RawMaterialPrice::withTrashed()->find($v['id']); } if (! $price) { $price = $rawMaterial->rawMaterialPrices() ->where('variant', $v['variant']) ->first(); } if ($price) { $price->increment('stock', (int) $v['stock']); $price->update(['price' => $v['price']]); } else { $price = $rawMaterial->rawMaterialPrices()->create([ 'variant' => $v['variant'], 'price' => $v['price'], 'stock' => $v['stock'], ]); } if (! empty($v['photo_key']) && $price->getFirstMedia('photos')?->file_name !== $v['photo_key']) { $this->registerMedia( model: $price, s3Key: $v['photo_key'], collectionName: 'photos', orderColumn: 1, ); } $itemSubtotal = (int) ($v['price'] * $v['stock']); $subtotal += $itemSubtotal; return [ 'purchase_id' => $purchase->id, 'raw_material_price_id' => $price->id, 'user_id' => auth()->id(), 'quantity' => $v['stock'], 'unit_price' => $v['price'], 'subtotal' => $itemSubtotal, 'created_at' => $now, 'updated_at' => $now, ]; })->toArray(); } $discount = $data['discount'] ?? 0; $shippingCost = $data['shipping_cost'] ?? 0; $total = $subtotal - $discount + $shippingCost; $purchase->update([ 'supplier_id' => $data['supplier_id'], 'subtotal' => $subtotal, 'discount' => $discount, 'shipping_cost' => $shippingCost, 'total' => $total, 'notes' => $data['notes'] ?? null, ]); foreach ($itemRows as &$row) { $row['purchase_id'] = $purchase->id; } DB::table('purchase_items')->insert($itemRows); $this->syncPhoto($purchase, $data); return $purchase; }); } public function delete(Purchase $purchase): bool { return DB::transaction(function () use ($purchase) { $purchase->load('purchaseItems.rawMaterialPrice'); // Remove the stock the purchase added, keep the variants. $purchase->purchaseItems->each(function (PurchaseItem $item) { if ($item->rawMaterialPrice) { $item->rawMaterialPrice->decrement('stock', (int) $item->quantity); } }); $purchase->clearMediaCollection('photos'); $purchase->purchaseItems()->delete(); $purchase->delete(); return true; }); } }