428 lines
17 KiB
PHP
428 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Manage;
|
|
|
|
use App\Enums\CuttingStatus;
|
|
use App\Enums\Permission;
|
|
use App\Models\Cutting;
|
|
use App\Models\CuttingMaterial;
|
|
use App\Models\CuttingResult;
|
|
use App\Models\CuttingResultPrice;
|
|
use App\Models\ProductPrice;
|
|
use App\Models\ProductVariant;
|
|
use App\Models\User;
|
|
use App\Services\Concerns\CachesQuery;
|
|
use App\Services\System\PushNotificationService;
|
|
use App\Support\Media\MediaPresenter;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class StockService
|
|
{
|
|
use CachesQuery;
|
|
|
|
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',
|
|
'materials.rawMaterialPrice.media',
|
|
'materials.combination',
|
|
'results.productVariant.product:id,name',
|
|
'results.productVariant.media',
|
|
])
|
|
->completed()
|
|
->latest()
|
|
->get()
|
|
->each(function (Cutting $cutting): void {
|
|
$this->appendCostPreview($cutting);
|
|
$this->appendPhotos($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',
|
|
'materials.rawMaterialPrice.media',
|
|
'materials.combination',
|
|
'results.productVariant.product:id,name',
|
|
'results.productVariant.media',
|
|
'resultPrices.productVariant:id,product_id,name',
|
|
])
|
|
->pendingVerification()
|
|
->latest()
|
|
->get()
|
|
->each(function (Cutting $cutting): void {
|
|
$this->appendCostPreview($cutting);
|
|
$this->appendPhotos($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.',
|
|
]);
|
|
}
|
|
|
|
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
|
|
|
try {
|
|
DB::transaction(function () use ($cutting, $user, $verificationNote, $results, $resultPrices, $isOwner): 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;
|
|
|
|
if ($isOwner) {
|
|
$this->applyProductStockOnVerify($cutting);
|
|
$this->applyResultPricesToProducts($cutting);
|
|
$cutting->status = CuttingStatus::VERIFIED;
|
|
} else {
|
|
$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 ?? '-';
|
|
|
|
if ($isOwner) {
|
|
$this->pushNotificationService->sendToRoles(
|
|
'📦 Stok Cutting Diverifikasi',
|
|
"Cutting dengan deskripsi '{$description}' telah disetujui oleh {$user->profile?->full_name} dan stok produk telah ditambahkan ke toko.",
|
|
['owner', 'developer', 'direktur'],
|
|
route('admin.manage.stocks.index'),
|
|
);
|
|
} else {
|
|
$this->pushNotificationService->sendToRoles(
|
|
'📦 Verifikasi Stok Menunggu Persetujuan',
|
|
"Cutting dengan deskripsi '{$description}' telah diajukan verifikasi oleh {$user->profile?->full_name} dan menunggu persetujuan owner.",
|
|
['owner', 'developer', 'direktur'],
|
|
route('admin.manage.stocks.index'),
|
|
);
|
|
}
|
|
|
|
$this->cacheForgetByPattern('manage:stocks:*');
|
|
if ($isOwner) {
|
|
$this->cacheForgetByPattern('prices:*');
|
|
}
|
|
}
|
|
|
|
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 oleh {$user->profile?->full_name} dan stok produk telah ditambahkan ke toko.",
|
|
['owner', 'developer', 'direktur'],
|
|
route('admin.manage.stocks.index'),
|
|
);
|
|
|
|
$this->cacheForgetByPattern('manage:stocks:*');
|
|
$this->cacheForgetByPattern('prices:*');
|
|
}
|
|
|
|
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 {$user->profile?->full_name} dengan alasan: '{$reason}'.",
|
|
['owner', 'developer', 'direktur'],
|
|
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'),
|
|
);
|
|
}
|
|
|
|
$this->cacheForgetByPattern('manage:stocks:*');
|
|
}
|
|
|
|
private function breakMaterialCircularReference(CuttingMaterial $material): void
|
|
{
|
|
$price = $material->rawMaterialPrice;
|
|
|
|
// Always set these attributes regardless of price
|
|
$material->setAttribute('material_result', $material->material_result);
|
|
$material->setAttribute('material_result_input', $material->material_result);
|
|
$material->setAttribute('combination_id', $material->combination_id);
|
|
$material->setAttribute('combination_material_result', $material->combination?->material_result);
|
|
|
|
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->unsetRelation('rawMaterialPrice');
|
|
$material->unsetRelation('combination');
|
|
}
|
|
|
|
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
|
|
{
|
|
$totalResultPieces = (int) $cutting->results->sum('cutting_result');
|
|
$cutting->setAttribute('total_result_pieces', $totalResultPieces);
|
|
$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;
|
|
$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, ',', '.'));
|
|
}
|
|
|
|
private function appendPhotos(Cutting $cutting): void
|
|
{
|
|
$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'),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|