store/app/Services/Manage/CuttingService.php

1248 lines
47 KiB
PHP

<?php
namespace App\Services\Manage;
use App\Enums\CuttingStatus;
use App\Enums\RawMaterialUnit;
use App\Enums\Role;
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult;
use App\Models\CuttingResultPrice;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Models\User;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
class CuttingService
{
use RunsInTransaction;
public function __construct(
private readonly PushNotificationService $pushNotificationService,
private readonly MediaService $mediaService,
) {}
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
{
$query = 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',
])
->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(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.',
]);
}
$item = CuttingMaterial::query()->updateOrCreate(
[
'user_id' => $user->id,
'raw_material_price_id' => $price->id,
'cutting_id' => null,
],
[
'material_usage' => $materialUsage,
],
);
$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<array>
*/
public function syncDraftCombination(array $validated, User $user): array
{
$combination = CuttingMaterialCombination::create([
'user_id' => $user->id,
'cutting_id' => null,
]);
$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'),
);
return $cutting;
}
public function update(Cutting $cutting, array $validated): 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']);
if ($cutting->status === CuttingStatus::IN_PROGRESS) {
$this->reverseTotalMaterialStock($cutting);
} elseif ($cutting->status === CuttingStatus::REJECTED) {
$this->reverseMaterialStock($cutting);
}
$cutting->materials()->delete();
$cutting->results()->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);
foreach ($materials as $materialData) {
$cutting->materials()->create($materialData);
}
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.",
['owner', 'developer', 'direktur'],
route('admin.manage.cuttings.index'),
);
}
public function delete(Cutting $cutting): 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->clearMediaCollection('images');
$cutting->delete();
},
'Gagal menghapus proses cutting',
);
$this->pushNotificationService->sendToRoles(
'🗑️ Proses Cutting Dihapus',
"Proses cutting dengan deskripsi {$description} telah dihapus.",
['owner', 'developer', 'direktur'],
route('admin.manage.cuttings.index'),
);
}
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 dan menunggu verifikasi.",
CuttingStatus::VERIFIED => "Proses cutting dengan deskripsi '{$description}' telah diverifikasi.",
CuttingStatus::REJECTED => "Proses cutting dengan deskripsi '{$description}' ditolak".($reason ? " dengan alasan: '{$reason}'" : '').'.',
CuttingStatus::IN_PROGRESS => "Proses cutting dengan deskripsi '{$description}' dikembalikan ke proses.",
default => "Status proses cutting dengan deskripsi '{$description}' telah diperbarui ke: {$status->label()}.",
};
$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', 'admin-toko']
: ['owner', 'developer', 'direktur'];
$this->pushNotificationService->sendToRoles(
$title,
$message,
$roles,
route('admin.manage.cuttings.index'),
);
}
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.',
]);
}
return [
'raw_material_price_id' => $price->id,
'material_usage' => $materialUsage,
'combination_id' => $itemData['combination_id'] ?? 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;
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->setAttribute('combination_id', $material->combination_id);
$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;
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),
'images' => $price ? MediaPresenter::collection($price, 'images') : [],
'combination_id' => $item->combination_id,
];
}
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<array{status: string, label: string, destructive: bool, permission: string, icon_only: bool}>
*/
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 raw material with variants, bypassing owner verification.
*/
public function quickCreateRawMaterial(array $validated): array
{
$rawMaterial = RawMaterial::create([
'name' => $validated['name'],
'unit' => $validated['unit'],
'is_active' => true,
]);
$createdPrices = [];
$maxVariantImages = 5;
foreach ($validated['prices'] as $index => $priceData) {
$price = $rawMaterial->prices()->create([
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
]);
$this->mediaService->syncCollection(
$price,
'images',
$priceData['images'] ?? null,
null,
$maxVariantImages,
required: true,
errorKey: "prices.{$index}.images",
s3Keys: $priceData['s3_keys'] ?? null,
);
$price->refresh();
$createdPrices[] = [
'id' => $price->id,
'variant' => $price->variant,
'price' => $price->price,
'price_formatted' => 'Rp '.number_format($price->price, 0, ',', '.'),
'stock' => $price->stock,
'stock_formatted' => $this->formatStockForUnit((float) $price->stock, $rawMaterial->unit),
'stock_input' => $this->formatQuantityInput((float) $price->stock),
'images' => MediaPresenter::collection($price, 'images'),
];
}
return [
'id' => $rawMaterial->id,
'name' => $rawMaterial->name,
'unit' => $rawMaterial->unit->value,
'unit_label' => $rawMaterial->unit->label(),
'unit_abbreviation' => $rawMaterial->unit->abbreviation(),
'is_active' => true,
'prices' => $createdPrices,
];
}
/**
* 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: true,
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'),
];
}
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,
);
}
}