- 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.
1086 lines
41 KiB
PHP
1086 lines
41 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Manage;
|
|
|
|
use App\Enums\CuttingStatus;
|
|
use App\Enums\RawMaterialUnit;
|
|
use App\Models\Cutting;
|
|
use App\Models\CuttingMaterial;
|
|
use App\Models\CuttingMaterialCombination;
|
|
use App\Models\CuttingResult;
|
|
use App\Models\Product;
|
|
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\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\Support\Facades\Log;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class CuttingService
|
|
{
|
|
use CachesQuery, RunsInTransaction;
|
|
|
|
public function __construct(
|
|
private readonly PushNotificationService $pushNotificationService,
|
|
private readonly MediaService $mediaService,
|
|
private readonly StockMutationService $stockMutationService,
|
|
) {}
|
|
|
|
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',
|
|
])
|
|
->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', function (Builder $query) use ($search): void {
|
|
$query->where('product_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',
|
|
])
|
|
->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',
|
|
])
|
|
->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(fn ($q) => $q->where('stock', '>', 0)->orWhereIn('id', $selectedPriceIds)),
|
|
])
|
|
->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 findForEdit(Cutting $cutting): Cutting
|
|
{
|
|
$cutting->load([
|
|
'media',
|
|
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
|
'materials.rawMaterialPrice.media',
|
|
'materials.combination',
|
|
'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',
|
|
'results',
|
|
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
|
'materials.rawMaterialPrice.media',
|
|
'materials.combination',
|
|
]);
|
|
|
|
$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 {
|
|
$result->setAttribute('product_name', $result->product_name ?? '');
|
|
});
|
|
}
|
|
|
|
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)
|
|
->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.',
|
|
]);
|
|
}
|
|
|
|
$materialResult = array_key_exists('material_result', $validated) && $validated['material_result'] !== null
|
|
? (int) $validated['material_result']
|
|
: null;
|
|
|
|
$item = CuttingMaterial::query()->updateOrCreate(
|
|
[
|
|
'user_id' => $user->id,
|
|
'raw_material_price_id' => $price->id,
|
|
'cutting_id' => null,
|
|
],
|
|
[
|
|
'material_usage' => $materialUsage,
|
|
'material_result' => $materialResult,
|
|
],
|
|
);
|
|
|
|
$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
|
|
{
|
|
$productName = $validated['product_name'] ?? null;
|
|
|
|
$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()->create([
|
|
'user_id' => $user->id,
|
|
'cutting_id' => null,
|
|
'product_name' => $productName,
|
|
'cutting_result' => $cuttingResult,
|
|
'sample' => $sample,
|
|
'original_outside_sample' => $originalOutsideSample,
|
|
]);
|
|
|
|
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, CuttingResult $cuttingResult): void
|
|
{
|
|
if ($cuttingResult->cutting_id !== null || $cuttingResult->user_id !== $user->id) {
|
|
return;
|
|
}
|
|
|
|
$cuttingResult->delete();
|
|
}
|
|
|
|
/**
|
|
* @return list<array>
|
|
*/
|
|
public function syncDraftCombination(array $validated, User $user): array
|
|
{
|
|
$materialResult = array_key_exists('material_result', $validated) && $validated['material_result'] !== null
|
|
? (int) $validated['material_result']
|
|
: null;
|
|
|
|
$combination = CuttingMaterialCombination::create([
|
|
'user_id' => $user->id,
|
|
'cutting_id' => null,
|
|
'material_result' => $materialResult,
|
|
]);
|
|
|
|
$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 updateDraftCombinationResult(User $user, int $combinationId, mixed $result): void
|
|
{
|
|
$combination = CuttingMaterialCombination::query()
|
|
->whereNull('cutting_id')
|
|
->where('user_id', $user->id)
|
|
->where('id', $combinationId)
|
|
->first();
|
|
|
|
if ($combination === null) {
|
|
return;
|
|
}
|
|
|
|
$materialResult = $result !== null ? (int) $result : null;
|
|
|
|
$combination->update(['material_result' => $materialResult]);
|
|
}
|
|
|
|
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();
|
|
|
|
if ($draftMaterials->isEmpty()) {
|
|
throw ValidationException::withMessages([
|
|
'materials' => 'Tambahkan minimal satu bahan baku.',
|
|
]);
|
|
}
|
|
|
|
$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();
|
|
}
|
|
|
|
$results = $this->buildResults($validated['results']);
|
|
foreach ($results as $resultData) {
|
|
$cutting->results()->create($resultData);
|
|
}
|
|
|
|
$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'),
|
|
);
|
|
|
|
$this->cacheForgetByPattern('manage:cuttings:*');
|
|
|
|
return $cutting;
|
|
}
|
|
|
|
public function update(Cutting $cutting, array $validated, User $user): 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', 'materials.combination']);
|
|
|
|
if ($cutting->status === CuttingStatus::IN_PROGRESS) {
|
|
$this->reverseTotalMaterialStock($cutting);
|
|
}
|
|
|
|
$cutting->materials()->delete();
|
|
$cutting->results()->delete();
|
|
$cutting->combinations()->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);
|
|
$cutting->save();
|
|
|
|
$this->syncImages($cutting, $validated);
|
|
|
|
// Group materials by combination_id to create combinations
|
|
$combinationGroups = [];
|
|
foreach ($materials as $materialData) {
|
|
$combinationId = $materialData['combination_id'];
|
|
if ($combinationId !== null) {
|
|
if (! isset($combinationGroups[$combinationId])) {
|
|
$combinationGroups[$combinationId] = [
|
|
'materials' => [],
|
|
'material_result' => $materialData['combination_material_result'] ?? null,
|
|
];
|
|
}
|
|
$combinationGroups[$combinationId]['materials'][] = $materialData;
|
|
}
|
|
}
|
|
|
|
// Create combinations and update combination_id for materials
|
|
$combinationIdMap = [];
|
|
foreach ($combinationGroups as $oldCombinationId => $group) {
|
|
$combination = $cutting->combinations()->create([
|
|
'material_result' => $group['material_result'],
|
|
]);
|
|
$combinationIdMap[$oldCombinationId] = $combination->id;
|
|
}
|
|
|
|
foreach ($materials as $materialData) {
|
|
$newCombinationId = $materialData['combination_id'] !== null
|
|
? $combinationIdMap[$materialData['combination_id']] ?? null
|
|
: null;
|
|
|
|
$cutting->materials()->create([
|
|
'raw_material_price_id' => $materialData['raw_material_price_id'],
|
|
'material_usage' => $materialData['material_usage'],
|
|
'material_result' => $materialData['material_result'],
|
|
'combination_id' => $newCombinationId,
|
|
]);
|
|
}
|
|
|
|
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 oleh {$user->profile?->full_name}.",
|
|
['owner', 'developer', 'direktur'],
|
|
route('admin.manage.cuttings.index'),
|
|
);
|
|
|
|
$this->cacheForgetByPattern('manage:cuttings:*');
|
|
}
|
|
|
|
public function delete(Cutting $cutting, User $user): void
|
|
{
|
|
if ($cutting->status !== CuttingStatus::IN_PROGRESS) {
|
|
throw ValidationException::withMessages([
|
|
'status' => 'Proses cutting hanya dapat dihapus saat masih proses.',
|
|
]);
|
|
}
|
|
|
|
$description = $cutting->description ?? '-';
|
|
|
|
$this->runInTransaction(
|
|
function () use ($cutting): void {
|
|
$cutting->load(['materials.rawMaterialPrice.rawMaterial']);
|
|
|
|
$this->reverseTotalMaterialStock($cutting);
|
|
|
|
$cutting->materials()->delete();
|
|
$cutting->results()->delete();
|
|
$cutting->delete();
|
|
},
|
|
'Gagal menghapus proses cutting',
|
|
);
|
|
|
|
$this->pushNotificationService->sendToRoles(
|
|
'🗑️ Proses Cutting Dihapus',
|
|
"Proses cutting dengan deskripsi {$description} telah dihapus oleh {$user->profile?->full_name}.",
|
|
['owner', 'developer', 'direktur'],
|
|
route('admin.manage.cuttings.index'),
|
|
);
|
|
|
|
$this->cacheForgetByPattern('manage:cuttings:*');
|
|
}
|
|
|
|
public function transitionStatus(
|
|
Cutting $cutting,
|
|
CuttingStatus $status,
|
|
User $user,
|
|
?string $reason = null,
|
|
): void {
|
|
if (! $cutting->status->canTransitionTo($status)) {
|
|
throw ValidationException::withMessages([
|
|
'status' => 'Status proses cutting tidak dapat diubah.',
|
|
]);
|
|
}
|
|
|
|
$this->runInTransaction(
|
|
function () use ($cutting, $status): void {
|
|
$cutting->load(['materials.rawMaterialPrice', 'results']);
|
|
|
|
if ($status === CuttingStatus::COMPLETED) {
|
|
$cutting->total_material_cost = $this->calculateTotalMaterialCost($cutting);
|
|
$cutting->cost_per_unit = $this->calculateCostPerUnit($cutting);
|
|
}
|
|
|
|
$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 oleh {$user->profile?->full_name}.",
|
|
CuttingStatus::IN_PROGRESS => "Proses cutting dengan deskripsi '{$description}' dikembalikan ke proses oleh {$user->profile?->full_name}.",
|
|
default => "Status proses cutting dengan deskripsi '{$description}' telah diperbarui ke: {$status->label()} oleh {$user->profile?->full_name}.",
|
|
};
|
|
|
|
$title = match ($status) {
|
|
CuttingStatus::COMPLETED => '✂️ Proses Cutting Selesai',
|
|
CuttingStatus::IN_PROGRESS => '✂️ Proses Cutting Dikembalikan',
|
|
default => '✂️ Proses Cutting Diperbarui',
|
|
};
|
|
|
|
$this->pushNotificationService->sendToRoles(
|
|
$title,
|
|
$message,
|
|
['owner', 'developer', 'direktur'],
|
|
route('admin.manage.cuttings.index'),
|
|
);
|
|
|
|
$this->cacheForgetByPattern('manage:cuttings:*');
|
|
$this->cacheForgetByPattern('prices:*');
|
|
}
|
|
|
|
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) {
|
|
Log::warning('Bahan baku tidak ditemukan saat build materials cutting', [
|
|
'index' => $index,
|
|
'raw_material_price_id' => $itemData['raw_material_price_id'] ?? 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.',
|
|
]);
|
|
}
|
|
|
|
$materialResult = array_key_exists('material_result', $itemData) && $itemData['material_result'] !== null
|
|
? (int) $itemData['material_result']
|
|
: null;
|
|
|
|
return [
|
|
'raw_material_price_id' => $price->id,
|
|
'material_usage' => $materialUsage,
|
|
'material_result' => $materialResult,
|
|
'combination_id' => $itemData['combination_id'] ?? null,
|
|
'combination_material_result' => $itemData['combination_material_result'] ?? null,
|
|
];
|
|
})
|
|
->all();
|
|
}
|
|
|
|
private function buildResults(array $results): array
|
|
{
|
|
return collect($results)
|
|
->map(function (array $itemData, int $index) {
|
|
$productName = $itemData['product_name'] ?? null;
|
|
|
|
$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_name' => $productName,
|
|
'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.',
|
|
]);
|
|
}
|
|
|
|
$stockBefore = (float) $price->stock;
|
|
|
|
if ($stockBefore < $totalTaken) {
|
|
throw ValidationException::withMessages([
|
|
'materials' => "Stok {$price->rawMaterial?->name} ({$price->variant}) tidak mencukupi.",
|
|
]);
|
|
}
|
|
|
|
$price->decrement('stock', $totalTaken);
|
|
|
|
$this->stockMutationService->record(
|
|
stockable: $price,
|
|
type: 'out',
|
|
quantity: -$totalTaken,
|
|
stockBefore: $stockBefore,
|
|
stockAfter: $stockBefore - $totalTaken,
|
|
source: $cutting,
|
|
description: "Cutting #{$cutting->id}",
|
|
);
|
|
}
|
|
}
|
|
|
|
private function reverseTotalMaterialStock(Cutting $cutting): void
|
|
{
|
|
foreach ($cutting->materials as $material) {
|
|
$price = RawMaterialPrice::query()->lockForUpdate()->find($material->raw_material_price_id);
|
|
|
|
if ($price === null) {
|
|
continue;
|
|
}
|
|
|
|
$stockBefore = (float) $price->stock;
|
|
$totalReturned = (float) $material->material_usage;
|
|
|
|
$price->increment('stock', $totalReturned);
|
|
|
|
$this->stockMutationService->record(
|
|
stockable: $price,
|
|
type: 'in',
|
|
quantity: $totalReturned,
|
|
stockBefore: $stockBefore,
|
|
stockAfter: $stockBefore + $totalReturned,
|
|
source: $cutting,
|
|
description: "Cutting #{$cutting->id} (batal)",
|
|
);
|
|
}
|
|
}
|
|
|
|
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 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;
|
|
$combination = $item->combination;
|
|
|
|
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),
|
|
'material_result' => $item->material_result !== null ? (int) $item->material_result : null,
|
|
'images' => $price ? MediaPresenter::collection($price, 'images') : [],
|
|
'combination_id' => $item->combination_id,
|
|
'combination_material_result' => $combination?->material_result,
|
|
];
|
|
}
|
|
|
|
private function presentDraftResult(CuttingResult $item): array
|
|
{
|
|
return [
|
|
'product_name' => $item->product_name ?? '',
|
|
'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,
|
|
];
|
|
}
|
|
|
|
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']))
|
|
->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);
|
|
}
|
|
|
|
/**
|
|
* 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: false,
|
|
errorKey: "variants.{$index}.s3_keys",
|
|
s3Keys: $variantData['s3_keys'] ?? null,
|
|
);
|
|
|
|
$variant->refresh();
|
|
|
|
$createdVariants[] = [
|
|
'id' => $variant->id,
|
|
'name' => $variant->name,
|
|
'stock' => $variant->stock,
|
|
'images' => MediaPresenter::collection($variant, 'images'),
|
|
];
|
|
}
|
|
|
|
$this->cacheForgetByPattern('manage:cuttings:*');
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|