647 lines
24 KiB
PHP
647 lines
24 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Manage;
|
|
|
|
use App\Enums\CuttingStatus;
|
|
use App\Models\Cutting;
|
|
use App\Models\CuttingMaterial;
|
|
use App\Models\CuttingResult;
|
|
use App\Models\Product;
|
|
use App\Models\ProductVariant;
|
|
use App\Models\RawMaterial;
|
|
use App\Models\RawMaterialPrice;
|
|
use App\Models\User;
|
|
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\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class CuttingService
|
|
{
|
|
public function __construct(
|
|
private readonly PushNotificationService $pushNotificationService,
|
|
) {}
|
|
|
|
/**
|
|
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
|
|
*/
|
|
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
|
|
{
|
|
$query = Cutting::query()
|
|
->with([
|
|
'createdBy.profile',
|
|
'rejection.rejectedBy.profile',
|
|
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
|
'results.productVariant.product:id,name',
|
|
'results.productVariant:id,product_id,name',
|
|
])
|
|
->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(10)
|
|
->withQueryString()
|
|
->through(function (Cutting $cutting) use ($user) {
|
|
$actions = collect($cutting->status->availableActions())
|
|
->filter(fn (array $action) => $user->can($action['permission']))
|
|
->values()
|
|
->all();
|
|
|
|
$cutting->setAttribute('available_actions', $actions);
|
|
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
|
|
|
return $cutting;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, Cutting>
|
|
*/
|
|
public function getInProgressCuttings(User $user): Collection
|
|
{
|
|
return Cutting::query()
|
|
->with([
|
|
'createdBy.profile',
|
|
'rejection.rejectedBy.profile',
|
|
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
|
'results.productVariant.product:id,name',
|
|
'results.productVariant:id,product_id,name',
|
|
])
|
|
->where('status', CuttingStatus::IN_PROGRESS)
|
|
->latest()
|
|
->get()
|
|
->each(function (Cutting $cutting) use ($user): void {
|
|
$actions = collect($cutting->status->availableActions())
|
|
->filter(fn (array $action) => $user->can($action['permission']))
|
|
->values()
|
|
->all();
|
|
|
|
$cutting->setAttribute('available_actions', $actions);
|
|
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, Cutting>
|
|
*/
|
|
public function getCompletedCuttings(User $user): Collection
|
|
{
|
|
return Cutting::query()
|
|
->with([
|
|
'createdBy.profile',
|
|
'rejection.rejectedBy.profile',
|
|
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
|
'results.productVariant.product:id,name',
|
|
'results.productVariant:id,product_id,name',
|
|
])
|
|
->where('status', CuttingStatus::COMPLETED)
|
|
->latest()
|
|
->get()
|
|
->each(function (Cutting $cutting) use ($user): void {
|
|
$actions = collect($cutting->status->availableActions())
|
|
->filter(fn (array $action) => $user->can($action['permission']))
|
|
->values()
|
|
->all();
|
|
|
|
$cutting->setAttribute('available_actions', $actions);
|
|
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, RawMaterial>
|
|
*/
|
|
public function rawMaterialCatalog(?Cutting $cutting = null): Collection
|
|
{
|
|
$selectedPriceIds = $cutting
|
|
? $cutting->materials()->pluck('raw_material_price_id')->all()
|
|
: [];
|
|
|
|
return RawMaterial::query()
|
|
->with([
|
|
'prices' => fn ($query) => $query
|
|
->orderBy('created_at')
|
|
->with(['rawMaterial:id,unit', '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): void {
|
|
$price->setAttribute(
|
|
'images',
|
|
MediaPresenter::collection($price, 'images'),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, Product>
|
|
*/
|
|
public function productCatalog(?Cutting $cutting = null): Collection
|
|
{
|
|
$selectedVariantIds = $cutting
|
|
? $cutting->results()->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->where('is_active', true);
|
|
|
|
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([
|
|
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
|
'materials.rawMaterialPrice.media',
|
|
'results.productVariant.product:id,name',
|
|
'results.productVariant.media',
|
|
'rejection.rejectedBy.profile',
|
|
]);
|
|
|
|
$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'),
|
|
);
|
|
}
|
|
});
|
|
|
|
return $cutting;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $validated
|
|
*/
|
|
public function create(array $validated, User $user): Cutting
|
|
{
|
|
$cutting = DB::transaction(function () use ($validated, $user): Cutting {
|
|
$materials = $this->buildMaterials($validated['materials']);
|
|
$results = $this->buildResults($validated['results']);
|
|
|
|
$cutting = Cutting::create([
|
|
'status' => CuttingStatus::IN_PROGRESS,
|
|
'description' => $validated['description'] ?? null,
|
|
'created_by_id' => $user->id,
|
|
]);
|
|
|
|
foreach ($materials as $materialData) {
|
|
$cutting->materials()->create($materialData);
|
|
}
|
|
|
|
foreach ($results as $resultData) {
|
|
$cutting->results()->create($resultData);
|
|
}
|
|
|
|
$cutting->load('materials.rawMaterialPrice.rawMaterial');
|
|
$this->deductMaterialStock($cutting);
|
|
|
|
return $cutting;
|
|
});
|
|
|
|
$this->pushNotificationService->sendToRoles(
|
|
'✂️ Proses Potong Baru',
|
|
"Proses potong dengan deskripsi ({$cutting->description}) telah dimulai oleh {$user->profile?->full_name}.",
|
|
['owner', 'developer'],
|
|
'/admin/manage/cuttings',
|
|
);
|
|
|
|
return $cutting;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $validated
|
|
*/
|
|
public function update(Cutting $cutting, array $validated): void
|
|
{
|
|
if (! $cutting->status->isEditable()) {
|
|
throw ValidationException::withMessages([
|
|
'status' => 'Proses potong tidak dapat diubah.',
|
|
]);
|
|
}
|
|
|
|
DB::transaction(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;
|
|
if ($cutting->status === CuttingStatus::REJECTED) {
|
|
$cutting->status = CuttingStatus::IN_PROGRESS;
|
|
$cutting->rejection()?->delete();
|
|
}
|
|
$cutting->save();
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
public function delete(Cutting $cutting): void
|
|
{
|
|
if (! in_array($cutting->status, [CuttingStatus::IN_PROGRESS, CuttingStatus::REJECTED], true)) {
|
|
throw ValidationException::withMessages([
|
|
'status' => 'Proses potong hanya dapat dihapus saat masih proses atau ditolak.',
|
|
]);
|
|
}
|
|
|
|
$description = $cutting->description ?? '-';
|
|
|
|
DB::transaction(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->delete();
|
|
});
|
|
|
|
$this->pushNotificationService->sendToRoles(
|
|
'🗑️ Proses Potong Dihapus',
|
|
"Proses potong dengan deskripsi {$description} telah dihapus.",
|
|
['owner', 'developer'],
|
|
'/admin/manage/cuttings',
|
|
);
|
|
}
|
|
|
|
public function transitionStatus(
|
|
Cutting $cutting,
|
|
CuttingStatus $status,
|
|
User $user,
|
|
?string $reason = null,
|
|
?string $verificationNote = null,
|
|
?array $results = null
|
|
): void {
|
|
if (! $cutting->status->canTransitionTo($status)) {
|
|
throw ValidationException::withMessages([
|
|
'status' => 'Status proses potong tidak dapat diubah.',
|
|
]);
|
|
}
|
|
|
|
DB::transaction(function () use ($cutting, $status, $verificationNote, $results, $user, $reason): void {
|
|
$cutting->load(['materials', 'results']);
|
|
|
|
if ($status === CuttingStatus::COMPLETED) {
|
|
$this->applyRemainingMaterialStock($cutting);
|
|
}
|
|
|
|
if ($status === CuttingStatus::IN_PROGRESS) {
|
|
$this->deductRemainingMaterialStock($cutting);
|
|
$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([
|
|
'warehouse_stock' => $item['warehouse_stock'],
|
|
'cutting_reject' => $item['cutting_reject'],
|
|
]);
|
|
}
|
|
$cutting->load('results');
|
|
}
|
|
$this->applyProductStockOnVerify($cutting);
|
|
|
|
if ($verificationNote !== null && trim($verificationNote) !== '') {
|
|
$cutting->rejection()->create([
|
|
'reason' => trim($verificationNote),
|
|
'rejected_by_id' => $user->id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
$cutting->status = $status;
|
|
$cutting->save();
|
|
});
|
|
|
|
$description = $cutting->description ?? '-';
|
|
$message = match ($status) {
|
|
CuttingStatus::COMPLETED => "Proses potong dengan deskripsi '{$description}' telah selesai dan menunggu verifikasi.",
|
|
CuttingStatus::VERIFIED => "Proses potong dengan deskripsi '{$description}' telah diverifikasi.",
|
|
CuttingStatus::REJECTED => "Proses potong dengan deskripsi '{$description}' ditolak".($reason ? " dengan alasan: '{$reason}'" : '').'.',
|
|
CuttingStatus::IN_PROGRESS => "Proses potong dengan deskripsi '{$description}' dikembalikan ke proses.",
|
|
default => "Status proses potong dengan deskripsi '{$description}' telah diperbarui ke: {$status->label()}.",
|
|
};
|
|
|
|
$title = match ($status) {
|
|
CuttingStatus::COMPLETED => '✂️ Proses Potong Selesai',
|
|
CuttingStatus::VERIFIED => '✂️ Proses Potong Terverifikasi',
|
|
CuttingStatus::REJECTED => '✂️ Proses Potong Ditolak',
|
|
CuttingStatus::IN_PROGRESS => '✂️ Proses Potong Dikembalikan',
|
|
default => '✂️ Proses Potong Diperbarui',
|
|
};
|
|
|
|
$this->pushNotificationService->sendToRoles(
|
|
$title,
|
|
$message,
|
|
['owner', 'developer'],
|
|
'/admin/manage/cuttings',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param list<array{raw_material_price_id: int, material_usage: float|int|string, remaining_material: float|int|string}> $materials
|
|
* @return list<array{raw_material_price_id: int, material_usage: float, remaining_material: float}>
|
|
*/
|
|
private function buildMaterials(array $materials): array
|
|
{
|
|
return collect($materials)
|
|
->map(function (array $itemData, int $index) {
|
|
$price = RawMaterialPrice::query()->find($itemData['raw_material_price_id']);
|
|
|
|
if ($price === null) {
|
|
throw ValidationException::withMessages([
|
|
"materials.{$index}.raw_material_price_id" => 'Bahan baku tidak ditemukan.',
|
|
]);
|
|
}
|
|
|
|
$materialUsage = (float) $itemData['material_usage'];
|
|
$remainingMaterial = (float) $itemData['remaining_material'];
|
|
|
|
if ($materialUsage <= 0) {
|
|
throw ValidationException::withMessages([
|
|
"materials.{$index}.material_usage" => 'Pemakaian bahan harus lebih dari 0.',
|
|
]);
|
|
}
|
|
|
|
if ($remainingMaterial < 0) {
|
|
throw ValidationException::withMessages([
|
|
"materials.{$index}.remaining_material" => 'Sisa bahan tidak boleh negatif.',
|
|
]);
|
|
}
|
|
|
|
return [
|
|
'raw_material_price_id' => $price->id,
|
|
'material_usage' => $materialUsage,
|
|
'remaining_material' => $remainingMaterial,
|
|
];
|
|
})
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param list<array{product_variant_id: int, cutting_result: int|string, warehouse_stock: int|string, cutting_reject?: int|string}> $results
|
|
* @return list<array{product_variant_id: int, cutting_result: int, warehouse_stock: int, cutting_reject: int}>
|
|
*/
|
|
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'];
|
|
$warehouseStock = (int) $itemData['warehouse_stock'];
|
|
$cuttingReject = (int) ($itemData['cutting_reject'] ?? 0);
|
|
|
|
if ($cuttingResult < 1) {
|
|
throw ValidationException::withMessages([
|
|
"results.{$index}.cutting_result" => 'Hasil potong minimal 1 pcs.',
|
|
]);
|
|
}
|
|
|
|
if ($warehouseStock < 0) {
|
|
throw ValidationException::withMessages([
|
|
"results.{$index}.warehouse_stock" => 'Stok gudang tidak boleh negatif.',
|
|
]);
|
|
}
|
|
|
|
if ($cuttingReject < 0) {
|
|
throw ValidationException::withMessages([
|
|
"results.{$index}.cutting_reject" => 'Reject tidak boleh negatif.',
|
|
]);
|
|
}
|
|
|
|
if (($warehouseStock + $cuttingReject) !== $cuttingResult) {
|
|
throw ValidationException::withMessages([
|
|
"results.{$index}.cutting_result" => 'Hasil potong harus sama dengan stok gudang ditambah reject.',
|
|
]);
|
|
}
|
|
|
|
return [
|
|
'product_variant_id' => $variant->id,
|
|
'cutting_result' => $cuttingResult,
|
|
'warehouse_stock' => $warehouseStock,
|
|
'cutting_reject' => $cuttingReject,
|
|
];
|
|
})
|
|
->all();
|
|
}
|
|
|
|
private function deductMaterialStock(Cutting $cutting): void
|
|
{
|
|
foreach ($cutting->materials as $material) {
|
|
$totalTaken = (float) $material->material_usage + (float) $material->remaining_material;
|
|
$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) {
|
|
$totalTaken = (float) $material->material_usage + (float) $material->remaining_material;
|
|
RawMaterialPrice::query()
|
|
->whereKey($material->raw_material_price_id)
|
|
->increment('stock', $totalTaken);
|
|
}
|
|
}
|
|
|
|
private function applyRemainingMaterialStock(Cutting $cutting): void
|
|
{
|
|
foreach ($cutting->materials as $material) {
|
|
if ((float) $material->remaining_material > 0) {
|
|
RawMaterialPrice::query()
|
|
->whereKey($material->raw_material_price_id)
|
|
->increment('stock', $material->remaining_material);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function deductRemainingMaterialStock(Cutting $cutting): void
|
|
{
|
|
foreach ($cutting->materials as $material) {
|
|
if ((float) $material->remaining_material > 0) {
|
|
RawMaterialPrice::query()
|
|
->whereKey($material->raw_material_price_id)
|
|
->decrement('stock', $material->remaining_material);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function reverseMaterialStock(Cutting $cutting): void
|
|
{
|
|
foreach ($cutting->materials as $material) {
|
|
RawMaterialPrice::query()
|
|
->whereKey($material->raw_material_price_id)
|
|
->increment('stock', $material->material_usage);
|
|
}
|
|
}
|
|
|
|
private function applyProductStockOnVerify(Cutting $cutting): void
|
|
{
|
|
foreach ($cutting->results as $result) {
|
|
if ($result->warehouse_stock < 1) {
|
|
continue;
|
|
}
|
|
|
|
ProductVariant::query()
|
|
->whereKey($result->product_variant_id)
|
|
->increment('stock', $result->warehouse_stock);
|
|
}
|
|
}
|
|
|
|
private function reverseProductStock(Cutting $cutting): void
|
|
{
|
|
foreach ($cutting->results as $result) {
|
|
if ($result->warehouse_stock < 1) {
|
|
continue;
|
|
}
|
|
|
|
ProductVariant::query()
|
|
->whereKey($result->product_variant_id)
|
|
->decrement('stock', $result->warehouse_stock);
|
|
}
|
|
}
|
|
|
|
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 applySorting(Builder $query, string $sort, string $direction): void
|
|
{
|
|
if (in_array($sort, ['created_at', 'status'], true)) {
|
|
$query->orderBy($sort, $direction);
|
|
|
|
return;
|
|
}
|
|
|
|
$query->latest();
|
|
}
|
|
}
|