dstpabuaran.com/app/Services/Admin/Manage/CuttingService.php
Yoga Pangestu b85bbb1ba4 feat: enhance purchase and restock management with variant support
- Added existing_material_ids to PurchaseForEdit and RestockForEdit types.
- Introduced RawMaterialVariant and ProductVariantForRestock types for better variant handling.
- Updated PurchaseCreate and PurchaseEdit components to fetch and display raw material variants.
- Enhanced RestockCreate and RestockEdit components to manage product variants dynamically.
- Modified TransactionCreate and TransactionEdit components to support product variants.
- Added routes for fetching active products and raw materials.
2026-08-20 10:48:24 +07:00

562 lines
24 KiB
PHP

<?php
namespace App\Services\Admin\Manage;
use App\Enums\CuttingStatus;
use App\Enums\Role;
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CuttingService
{
use RegistersMedia;
public function __construct(
private S3PresignedService $s3Service,
) {}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = [], ?int $highlight = null): LengthAwarePaginator
{
$materialsCountQuery = '(SELECT COUNT(*) FROM cutting_materials WHERE cutting_materials.cutting_id = cuttings.id AND cutting_materials.deleted_at IS NULL)';
$totalUsageQuery = '(SELECT IFNULL(SUM(material_usage), 0) FROM cutting_materials WHERE cutting_materials.cutting_id = cuttings.id AND cutting_materials.deleted_at IS NULL)';
$productNameQuery = '(SELECT product_name FROM cutting_results WHERE cutting_results.cutting_id = cuttings.id AND cutting_results.deleted_at IS NULL LIMIT 1)';
$cuttingResultQuery = '(SELECT cutting_result FROM cutting_results WHERE cutting_results.cutting_id = cuttings.id AND cutting_results.deleted_at IS NULL LIMIT 1)';
$paginator = Cutting::query()
->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;
}
}