store/app/Services/Master/RawMaterialService.php
Yoga Pangestu 3052398ad8 feat: add stock mutation tracking and history feature
- Introduced StockMutation model and service to handle stock changes.
- Created migration for stock_mutations table.
- Implemented stock mutation recording in various services (CuttingService, OrderService, PurchaseService, RestockService, RetailStockService).
- Added StockHistoryController to manage stock history views.
- Developed frontend components for displaying stock history and actions.
- Updated routes to include stock history access with appropriate permissions.
- Enhanced ProductVariant and RawMaterialPrice models to support stock mutations.
- Added RowHistoryAction button for accessing stock history in product and raw material tables.
2026-07-27 22:45:49 +07:00

653 lines
24 KiB
PHP

<?php
namespace App\Services\Master;
use App\Enums\CuttingStatus;
use App\Enums\OwnerVerificationAction;
use App\Enums\RawMaterialUnit;
use App\Models\CuttingMaterial;
use App\Models\OwnerVerificationRequest;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Models\User;
use App\Services\Concerns\CachesQuery;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Manage\StockMutationService;
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\Validation\ValidationException;
class RawMaterialService
{
use CachesQuery, RunsInTransaction;
private const MAX_VARIANT_IMAGES = 5;
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {}
public function paginateForIndex(array $tableQuery, string $isActive, string $stockStatus = '', string $rawMaterialId = ''): LengthAwarePaginator
{
$query = RawMaterial::query()
->with([
'prices' => fn ($query) => $query
->orderBy('created_at')
->with('media')
->when($tableQuery['search'] !== '', function ($query) use ($tableQuery): void {
$query->where('variant', 'like', "%{$tableQuery['search']}%");
})
->when($stockStatus === 'out_of_stock', fn ($q) => $q->where('stock', '<=', 0))
->when($stockStatus === 'low_stock', function ($q): void {
$q->where('stock', '>', 0)
->where(function ($q): void {
$q->orWhere(function ($q): void {
$q->whereHas('rawMaterial', fn ($q) => $q->where('unit', RawMaterialUnit::YARD->value))
->where('stock', '<', 20);
})
->orWhere(function ($q): void {
$q->whereHas('rawMaterial', fn ($q) => $q->where('unit', RawMaterialUnit::METER->value))
->where('stock', '<', 10);
})
->orWhere(function ($q): void {
$q->whereHas('rawMaterial', fn ($q) => $q->where('unit', RawMaterialUnit::KILOGRAM->value))
->where('stock', '<', 5);
});
});
}),
])
->when(($tableQuery['search_id'] ?? '') !== '', function (Builder $query) use ($tableQuery): void {
$query->where('raw_materials.id', $tableQuery['search_id']);
})
->when(($tableQuery['search_id'] ?? '') === '' && $tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->whereHas('prices', fn (Builder $query) => $query->where('variant', 'like', "%{$search}%"));
})
->when($rawMaterialId !== '', fn (Builder $query) => $query->where('id', $rawMaterialId))
->when($isActive !== '', fn (Builder $query) => $query->where('is_active', $isActive === '1'))
->when($stockStatus === 'out_of_stock', function (Builder $query): void {
$query->whereHas('prices', fn (Builder $priceQuery) => $priceQuery->where('stock', '<=', 0));
})
->when($stockStatus === 'low_stock', function (Builder $query): void {
$query->where(function (Builder $query): void {
$query->where('unit', RawMaterialUnit::YARD->value)
->whereHas('prices', fn (Builder $q) => $q->where('stock', '>', 0)->where('stock', '<', 20));
})->orWhere(function (Builder $query): void {
$query->where('unit', RawMaterialUnit::METER->value)
->whereHas('prices', fn (Builder $q) => $q->where('stock', '>', 0)->where('stock', '<', 10));
})->orWhere(function (Builder $query): void {
$query->where('unit', RawMaterialUnit::KILOGRAM->value)
->whereHas('prices', fn (Builder $q) => $q->where('stock', '>', 0)->where('stock', '<', 5));
});
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(25)
->withQueryString()
->through(function (RawMaterial $rawMaterial) {
$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');
});
return $rawMaterial;
});
}
public function findForEdit(RawMaterial $rawMaterial): RawMaterial
{
$rawMaterial->load([
'prices' => fn ($query) => $query->orderBy('created_at')->with('media'),
]);
$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');
});
return $rawMaterial;
}
public function create(array $validated, User $user): void
{
$rawMaterial = $this->runInTransaction(
function () use ($validated): RawMaterial {
$rawMaterial = RawMaterial::create([
'name' => $validated['name'],
'unit' => $validated['unit'],
'is_active' => true,
]);
foreach ($validated['prices'] as $index => $priceData) {
$this->createPrice($rawMaterial, $priceData, $index);
}
return $rawMaterial;
},
'Gagal membuat bahan baku',
);
$this->cacheForgetByPattern('master:raw_materials:*');
$this->notifyOwner(
'Tambah Bahan Baku',
"Bahan baku '{$validated['name']}' telah ditambahkan oleh {$user->profile?->full_name}.",
route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]),
);
}
public function update(RawMaterial $rawMaterial, array $validated, User $user): void
{
$this->runInTransaction(
function () use ($validated, $rawMaterial): void {
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
$this->applyPayloadToRawMaterial($rawMaterial, $payload, originalPrices: $validated['prices'] ?? []);
},
'Gagal memperbarui bahan baku',
);
$this->cacheForgetByPattern('master:raw_materials:*');
$this->notifyOwner(
'Ubah Bahan Baku',
"Bahan baku '{$rawMaterial->name}' telah diperbarui oleh {$user->profile?->full_name}.",
route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]),
);
}
public function delete(RawMaterial $rawMaterial, User $user): void
{
$name = $rawMaterial->name;
$this->applyDeleteSubject($rawMaterial);
$this->cacheForgetByPattern('master:raw_materials:*');
$this->notifyOwner(
'Hapus Bahan Baku',
"Bahan baku '{$name}' telah dihapus oleh {$user->profile?->full_name}.",
route('admin.master.raw_materials.index'),
);
}
public function toggleStatus(RawMaterial $rawMaterial, array $validated, User $user): void
{
$isActive = $validated['status'] === 'active';
$rawMaterial->update([
'is_active' => $isActive,
]);
$this->cacheForgetByPattern('master:raw_materials:*');
$statusLabel = $isActive ? 'aktif' : 'nonaktif';
$this->notifyOwner(
'Ubah Status Bahan Baku',
"Status bahan baku '{$rawMaterial->name}' telah diubah menjadi {$statusLabel} oleh {$user->profile?->full_name}.",
route('admin.master.raw_materials.index', ['search_id' => $rawMaterial->id]),
);
}
public function applyVerificationRequest(OwnerVerificationRequest $verificationRequest): void
{
match ($verificationRequest->action) {
OwnerVerificationAction::CREATE => $this->applyCreate($verificationRequest),
OwnerVerificationAction::UPDATE => $this->applyUpdate($verificationRequest),
OwnerVerificationAction::DELETE => $this->applyDelete($verificationRequest),
OwnerVerificationAction::TOGGLE_STATUS => $this->applyToggleStatus($verificationRequest),
default => throw ValidationException::withMessages([
'action' => 'Aksi verifikasi bahan baku tidak didukung.',
]),
};
$this->cacheForgetByPattern('master:raw_materials:*');
}
public function rejectVerificationRequest(OwnerVerificationRequest $verificationRequest): void
{
match ($verificationRequest->action) {
OwnerVerificationAction::CREATE => $this->rejectCreate($verificationRequest),
OwnerVerificationAction::UPDATE => $this->rollbackUpdate($verificationRequest),
OwnerVerificationAction::DELETE, OwnerVerificationAction::TOGGLE_STATUS => null,
default => throw ValidationException::withMessages([
'action' => 'Aksi verifikasi bahan baku tidak didukung.',
]),
};
$this->cacheForgetByPattern('master:raw_materials:*');
}
public function clearVerificationRequestMedia(OwnerVerificationRequest $verificationRequest): void
{
$verificationRequest->load('media');
$newPayload = $this->payloadNew($verificationRequest);
foreach ($newPayload['prices'] ?? [] as $index => $_price) {
$verificationRequest->clearMediaCollection($verificationRequest->priceImageCollection((int) $index));
}
}
private function notifyOwner(string $typeLabel, string $body, string $url): void
{
$this->pushNotificationService->sendToRoles(
"📦 {$typeLabel}",
$body,
['owner', 'developer'],
$url,
);
}
public function applyCreate(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;
if (! $rawMaterial instanceof RawMaterial) {
throw ValidationException::withMessages([
'raw_material' => 'Bahan baku tidak ditemukan.',
]);
}
$rawMaterial->update([
'is_active' => true,
]);
}
public function applyUpdate(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;
if (! $rawMaterial instanceof RawMaterial) {
throw ValidationException::withMessages([
'raw_material' => 'Bahan baku tidak ditemukan.',
]);
}
$this->applyPayloadToRawMaterial($rawMaterial, $this->payloadNew($verificationRequest), $verificationRequest);
}
public function applyDelete(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;
if (! $rawMaterial instanceof RawMaterial) {
throw ValidationException::withMessages([
'raw_material' => 'Bahan baku tidak ditemukan.',
]);
}
$this->applyDeleteSubject($rawMaterial);
}
private function applyDeleteSubject(RawMaterial $rawMaterial): void
{
$this->ensureNotUsedInActiveCutting($rawMaterial);
$this->runInTransaction(
function () use ($rawMaterial): void {
$rawMaterial->prices()->delete();
$rawMaterial->delete();
},
'Gagal menghapus bahan baku',
);
}
private function ensureNotUsedInActiveCutting(RawMaterial $rawMaterial): void
{
$usedInActiveCutting = CuttingMaterial::query()
->whereHas('rawMaterialPrice', fn ($q) => $q->where('raw_material_id', $rawMaterial->id))
->whereHas('cutting', fn ($q) => $q->where('status', '!=', CuttingStatus::COMPLETED))
->exists();
if ($usedInActiveCutting) {
throw ValidationException::withMessages([
'raw_material' => 'Bahan baku tidak dapat dihapus karena masih digunakan dalam proses cutting yang belum selesai.',
]);
}
}
private function ensurePricesNotUsedInActiveCutting(array $priceIds): void
{
$usedPriceIds = CuttingMaterial::query()
->whereIn('raw_material_price_id', $priceIds)
->whereHas('cutting', fn ($q) => $q->where('status', '!=', CuttingStatus::COMPLETED))
->pluck('raw_material_price_id')
->unique()
->toArray();
if ($usedPriceIds !== []) {
$variantNames = RawMaterialPrice::whereIn('id', $usedPriceIds)
->pluck('variant')
->unique()
->implode(', ');
throw ValidationException::withMessages([
'prices' => "Variant '{$variantNames}' tidak dapat dihapus karena masih digunakan dalam proses cutting yang belum selesai.",
]);
}
}
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;
if (! $rawMaterial instanceof RawMaterial) {
throw ValidationException::withMessages([
'raw_material' => 'Bahan baku tidak ditemukan.',
]);
}
$newPayload = $this->payloadNew($verificationRequest);
$rawMaterial->update([
'is_active' => ($newPayload['status'] ?? 'inactive') === 'active',
]);
}
private function applyPayloadToRawMaterial(
RawMaterial $rawMaterial,
array $payload,
?OwnerVerificationRequest $verificationRequest = null,
array $originalPrices = [],
): void {
$rawMaterial->update([
'name' => $payload['name'],
'unit' => $payload['unit'] ?? $rawMaterial->unit,
]);
if (array_key_exists('is_active', $payload)) {
$rawMaterial->update([
'is_active' => (bool) $payload['is_active'],
]);
}
$submittedPriceIds = collect($payload['prices'] ?? [])
->pluck('id')
->filter()
->map(fn ($id) => (int) $id)
->all();
$deletingPriceIds = $rawMaterial->prices()
->whereNotIn('id', $submittedPriceIds)
->pluck('id')
->toArray();
if ($deletingPriceIds !== []) {
$this->ensurePricesNotUsedInActiveCutting($deletingPriceIds);
}
$rawMaterial->prices()
->whereNotIn('id', $submittedPriceIds)
->get()
->each(function (RawMaterialPrice $price): void {
$price->delete();
});
foreach ($payload['prices'] ?? [] as $index => $priceData) {
if (! empty($priceData['id'])) {
$price = $rawMaterial->prices()->findOrFail($priceData['id']);
$oldStock = (float) $price->stock;
if ((float) $priceData['stock'] !== $oldStock) {
$this->stockMutationService->record(
stockable: $price,
type: 'adjustment',
quantity: (float) $priceData['stock'] - $oldStock,
stockBefore: $oldStock,
stockAfter: (float) $priceData['stock'],
description: 'Edit langsung dari master data',
);
}
$price->update([
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
]);
if ($verificationRequest !== null) {
$this->applyPriceImageChanges($verificationRequest, $price, $priceData, (int) $index);
} elseif (isset($originalPrices[$index])) {
$this->syncPriceImages($price, $originalPrices[$index], $index);
}
continue;
}
$price = $rawMaterial->prices()->create([
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
]);
$this->stockMutationService->record(
stockable: $price,
type: 'adjustment',
quantity: (float) $priceData['stock'],
stockBefore: 0,
stockAfter: (float) $priceData['stock'],
description: 'Stok awal dari master data',
);
if ($verificationRequest !== null) {
$this->copyRequestPriceImages($verificationRequest, (int) $index, $price);
} elseif (isset($originalPrices[$index])) {
$this->syncPriceImages($price, $originalPrices[$index], $index);
}
}
}
private function rejectCreate(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;
if (! $rawMaterial instanceof RawMaterial) {
return;
}
$this->runInTransaction(
function () use ($rawMaterial): void {
$rawMaterial->prices()->delete();
$rawMaterial->delete();
},
'Gagal menolak bahan baku',
);
}
private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void
{
$rawMaterial = $verificationRequest->subject;
if (! $rawMaterial instanceof RawMaterial) {
throw ValidationException::withMessages([
'raw_material' => 'Bahan baku tidak ditemukan.',
]);
}
$oldPayload = $this->payloadOld($verificationRequest);
if ($oldPayload === []) {
return;
}
$this->applyPayloadToRawMaterial($rawMaterial, $oldPayload);
}
private function payloadOld(OwnerVerificationRequest $verificationRequest): array
{
$payload = $verificationRequest->payload ?? [];
if (array_key_exists('old', $payload)) {
return is_array($payload['old']) ? $payload['old'] : [];
}
return [];
}
private function payloadNew(OwnerVerificationRequest $verificationRequest): array
{
$payload = $verificationRequest->payload ?? [];
if (array_key_exists('new', $payload)) {
return is_array($payload['new']) ? $payload['new'] : [];
}
return $payload;
}
private function snapshotRawMaterial(RawMaterial $rawMaterial): array
{
$rawMaterial->load(['prices']);
return $this->enrichPayload([
'name' => $rawMaterial->name,
'unit' => $rawMaterial->unit->value,
'is_active' => $rawMaterial->is_active,
'prices' => $rawMaterial->prices
->map(fn (RawMaterialPrice $price) => [
'id' => $price->id,
'variant' => $price->variant,
'price' => $price->price,
'stock' => $price->stock,
])
->all(),
]);
}
private function enrichPayload(array $data): array
{
if (isset($data['unit'])) {
$unit = $data['unit'] instanceof RawMaterialUnit
? $data['unit']
: RawMaterialUnit::from($data['unit']);
$data['unit'] = $unit->value;
$data['unit_label'] = $unit->label();
}
return $data;
}
private function buildPayloadFromValidated(array $validated): array
{
return [
'name' => $validated['name'],
'unit' => $validated['unit'],
'prices' => collect($validated['prices'] ?? [])
->map(fn (array $priceData) => [
'id' => $priceData['id'] ?? null,
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
'remove_media_ids' => $priceData['remove_media_ids'] ?? [],
])
->all(),
];
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['name', 'unit', 'is_active'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
private function createPrice(RawMaterial $rawMaterial, array $priceData, int $index): RawMaterialPrice
{
$price = $rawMaterial->prices()->create([
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
]);
$this->stockMutationService->record(
stockable: $price,
type: 'adjustment',
quantity: (float) $priceData['stock'],
stockBefore: 0,
stockAfter: (float) $priceData['stock'],
description: 'Stok awal dari master data',
);
$this->syncPriceImages($price, $priceData, $index);
return $price;
}
private function syncPriceImages(RawMaterialPrice $price, array $priceData, int $index): void
{
$this->mediaService->syncCollection(
$price,
'images',
$priceData['images'] ?? null,
$priceData['remove_media_ids'] ?? null,
self::MAX_VARIANT_IMAGES,
required: true,
errorKey: "prices.{$index}.s3_keys",
s3Keys: $priceData['s3_keys'] ?? null,
);
}
private function syncRequestPriceImages(
OwnerVerificationRequest $verificationRequest,
array $priceData,
int $index,
bool $required = true,
): void {
$collection = $verificationRequest->priceImageCollection($index);
$this->mediaService->syncCollection(
$verificationRequest,
$collection,
$priceData['images'] ?? null,
$priceData['remove_media_ids'] ?? null,
self::MAX_VARIANT_IMAGES,
required: $required,
errorKey: "prices.{$index}.s3_keys",
s3Keys: $priceData['s3_keys'] ?? null,
);
}
private function applyPriceImageChanges(
OwnerVerificationRequest $verificationRequest,
RawMaterialPrice $price,
array $priceData,
int $index,
): void {
$removeIds = $priceData['remove_media_ids'] ?? [];
if ($removeIds !== []) {
$price->getMedia('images')
->whereIn('id', $removeIds)
->each->delete();
}
$this->copyRequestPriceImages($verificationRequest, $index, $price);
if ($price->getMedia('images')->isEmpty()) {
throw ValidationException::withMessages([
"prices.{$index}.images" => 'Foto varian wajib diisi.',
]);
}
}
private function copyRequestPriceImages(OwnerVerificationRequest $verificationRequest, int $index, RawMaterialPrice $price): void
{
$verificationRequest->getMedia($verificationRequest->priceImageCollection($index))
->each(fn ($media) => $media->copy($price, 'images'));
}
}