store/app/Services/Manage/StockService.php

347 lines
13 KiB
PHP

<?php
namespace App\Services\Manage;
use App\Enums\CuttingStatus;
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\CuttingResultPrice;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\RawMaterialPrice;
use App\Models\User;
use App\Services\System\PushNotificationService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class StockService
{
public function __construct(
private readonly PushNotificationService $pushNotificationService,
) {}
public function getPendingVerificationCuttings(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',
])
->completed()
->latest()
->get()
->each(function (Cutting $cutting): void {
$this->appendCostPreview($cutting);
$cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m));
});
}
public function getPendingApprovalCuttings(User $user): Collection
{
return Cutting::query()
->with([
'createdBy.profile',
'submittedBy.profile',
'rejection.rejectedBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'results.productVariant.product:id,name',
'results.productVariant:id,product_id,name',
'resultPrices.productVariant:id,product_id,name',
])
->pendingVerification()
->latest()
->get()
->each(function (Cutting $cutting): void {
$this->appendCostPreview($cutting);
$cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m));
});
}
public function submitVerification(
Cutting $cutting,
User $user,
?string $verificationNote = null,
?array $results = null,
?array $resultPrices = null,
): void {
if ($cutting->status !== CuttingStatus::COMPLETED) {
throw ValidationException::withMessages([
'status' => 'Hanya cutting yang sudah selesai yang dapat diverifikasi.',
]);
}
try {
DB::transaction(function () use ($cutting, $user, $verificationNote, $results, $resultPrices): void {
$cutting->load(['materials.rawMaterialPrice', 'results']);
if ($results !== null) {
foreach ($results as $item) {
$cutting->results()
->where('product_variant_id', $item['product_variant_id'])
->update([
'sample' => $item['good'],
'original_outside_sample' => $item['reject'],
]);
}
$cutting->load('results');
}
$this->storeResultPrices($cutting, $resultPrices ?? []);
if ($verificationNote !== null && trim($verificationNote) !== '') {
$cutting->rejection()->create([
'reason' => trim($verificationNote),
'rejected_by_id' => $user->id,
]);
}
$cutting->submitted_by_id = $user->id;
$cutting->status = CuttingStatus::PENDING_VERIFICATION;
$cutting->save();
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mengajukan verifikasi stok: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$description = $cutting->description ?? '-';
$this->pushNotificationService->sendToRoles(
'📦 Verifikasi Stok Menunggu Persetujuan',
"Cutting dengan deskripsi '{$description}' telah diajukan verifikasi dan menunggu persetujuan owner.",
['owner', 'developer'],
route('admin.manage.stocks.index'),
);
}
public function approveVerification(
Cutting $cutting,
User $user,
?string $approvalNote = null,
): void {
if ($cutting->status !== CuttingStatus::PENDING_VERIFICATION) {
throw ValidationException::withMessages([
'status' => 'Hanya cutting yang menunggu verifikasi yang dapat disetujui.',
]);
}
try {
DB::transaction(function () use ($cutting, $user, $approvalNote): void {
$cutting->load(['materials.rawMaterialPrice', 'results', 'resultPrices']);
$this->applyProductStockOnVerify($cutting);
$this->applyResultPricesToProducts($cutting);
if ($approvalNote !== null && trim($approvalNote) !== '') {
$cutting->rejection()->create([
'reason' => trim($approvalNote),
'rejected_by_id' => $user->id,
]);
}
$cutting->status = CuttingStatus::VERIFIED;
$cutting->save();
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menyetujui verifikasi stok: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$description = $cutting->description ?? '-';
$this->pushNotificationService->sendToRoles(
'📦 Stok Cutting Diverifikasi',
"Cutting dengan deskripsi '{$description}' telah disetujui owner dan stok produk telah ditambahkan ke toko.",
['owner', 'developer'],
route('admin.manage.stocks.index'),
);
}
public function rejectVerification(
Cutting $cutting,
User $user,
string $reason,
): void {
if ($cutting->status !== CuttingStatus::PENDING_VERIFICATION) {
throw ValidationException::withMessages([
'status' => 'Hanya cutting yang menunggu verifikasi yang dapat ditolak.',
]);
}
try {
DB::transaction(function () use ($cutting, $user, $reason): void {
$cutting->rejection()->create([
'reason' => trim($reason),
'rejected_by_id' => $user->id,
]);
$cutting->status = CuttingStatus::COMPLETED;
$cutting->save();
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menolak verifikasi stok: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$description = $cutting->description ?? '-';
$this->pushNotificationService->sendToRoles(
'📦 Verifikasi Cutting Ditolak Owner',
"Cutting dengan deskripsi '{$description}' ditolak oleh owner dengan alasan: '{$reason}'.",
['owner', 'developer'],
route('admin.manage.stocks.index'),
);
if ($cutting->submitted_by_id) {
$this->pushNotificationService->sendToUser(
'📦 Verifikasi Cutting Ditolak Owner',
"Cutting dengan deskripsi '{$description}' yang Anda verifikasi ditolak oleh owner dengan alasan: '{$reason}'.",
$cutting->submitted_by_id,
route('admin.manage.stocks.index'),
);
}
}
private function breakMaterialCircularReference(CuttingMaterial $material): void
{
$price = $material->rawMaterialPrice;
if ($price) {
$rawMaterial = $price->rawMaterial;
if ($rawMaterial) {
$unitAbbreviation = $rawMaterial->unit->abbreviation();
$price->setAttribute('unit_abbreviation', $unitAbbreviation);
$material->setAttribute('unit_abbreviation', $unitAbbreviation);
}
$price->unsetRelation('rawMaterial');
}
$material->unsetRelation('rawMaterialPrice');
}
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 storeResultPrices(Cutting $cutting, array $resultPrices): void
{
// Extract harga_modal from the first variant's prices and set as cost_per_unit
$costPerUnit = 0;
foreach ($resultPrices as $resultData) {
foreach ($resultData['prices'] as $priceData) {
if ($priceData['type'] === 'harga_modal' && (int) $priceData['price'] > 0) {
$costPerUnit = (int) $priceData['price'];
break 2;
}
}
}
if ($costPerUnit > 0) {
$cutting->cost_per_unit = $costPerUnit;
} else {
$costPerUnit = (int) ($cutting->cost_per_unit ?? 0);
}
foreach ($resultPrices as $resultData) {
foreach ($resultData['prices'] as $priceData) {
if ((int) $priceData['price'] > 0) {
// Store/update to cutting_result_prices
CuttingResultPrice::query()->updateOrCreate(
[
'cutting_id' => $cutting->id,
'product_variant_id' => $resultData['product_variant_id'],
'price_type' => $priceData['type'],
],
[
'price' => (int) $priceData['price'],
'cost_per_unit' => $costPerUnit,
],
);
}
}
}
}
private function applyResultPricesToProducts(Cutting $cutting): void
{
$resultPrices = $cutting->resultPrices()->with('productVariant')->get();
foreach ($resultPrices as $resultPrice) {
if ($resultPrice->price > 0) {
ProductPrice::query()->updateOrCreate(
[
'variant_id' => $resultPrice->product_variant_id,
'type' => $resultPrice->price_type->value,
],
[
'price' => $resultPrice->price,
],
);
}
}
}
private function appendCostPreview(Cutting $cutting): void
{
$cutting->setAttribute('total_result_pieces', (int) $cutting->results->sum('cutting_result'));
$cutting->setAttribute('total_material_usage', (float) $cutting->materials->sum('material_usage'));
$totalMaterialCost = $cutting->total_material_cost ?? 0;
$sewingCost = (int) ($cutting->sewing_cost ?? 0);
$otherCost = (int) ($cutting->other_cost ?? 0);
$totalProductionCost = $totalMaterialCost + $sewingCost + $otherCost;
$costPerUnit = $cutting->cost_per_unit ?? 0;
$cutting->setAttribute('total_material_cost', $totalMaterialCost);
$cutting->setAttribute('total_material_cost_formatted', 'Rp '.number_format($totalMaterialCost, 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, ',', '.'));
}
}