From 3052398ad822ef17602903860e1741a09b05b889 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Mon, 27 Jul 2026 22:45:49 +0700 Subject: [PATCH] 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. --- app/Enums/Role.php | 4 + .../Manage/Stock/StockHistoryController.php | 48 +++++++ app/Models/ProductVariant.php | 5 + app/Models/RawMaterialPrice.php | 5 + app/Models/StockMutation.php | 59 ++++++++ app/Services/Manage/CuttingService.php | 38 +++++- app/Services/Manage/OrderService.php | 39 +++++- app/Services/Manage/PurchaseService.php | 35 ++++- app/Services/Manage/RestockService.php | 41 +++++- app/Services/Manage/RetailStockService.php | 37 +++-- app/Services/Manage/StockMutationService.php | 36 +++++ app/Services/Manage/StokOpnameService.php | 19 ++- app/Services/Master/ProductService.php | 64 +++++++++ app/Services/Master/RawMaterialService.php | 33 +++++ ...27_000001_create_stock_mutations_table.php | 32 +++++ .../js/components/button/RowHistoryAction.vue | 35 +++++ resources/js/components/button/index.ts | 1 + .../js/pages/admin/manage/stock/History.vue | 129 ++++++++++++++++++ .../products/table/ProductGroupedTable.vue | 13 +- .../table/RawMaterialGroupedTable.vue | 13 +- resources/js/types/stock-mutation.ts | 27 ++++ routes/web.php | 7 + 22 files changed, 678 insertions(+), 42 deletions(-) create mode 100644 app/Http/Controllers/Admin/Manage/Stock/StockHistoryController.php create mode 100644 app/Models/StockMutation.php create mode 100644 app/Services/Manage/StockMutationService.php create mode 100644 database/migrations/2026_07_27_000001_create_stock_mutations_table.php create mode 100644 resources/js/components/button/RowHistoryAction.vue create mode 100644 resources/js/pages/admin/manage/stock/History.vue create mode 100644 resources/js/types/stock-mutation.ts diff --git a/app/Enums/Role.php b/app/Enums/Role.php index a1819f6..39872f7 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -95,6 +95,7 @@ public function permissions(): array Permission::CUSTOMERS_VIEW, Permission::PRODUCTS_VIEW, + Permission::STOCKS_VIEW, Permission::ORDERS_VIEW, @@ -166,6 +167,7 @@ public function permissions(): array Permission::PRODUCTS_UPDATE, Permission::PRODUCTS_DELETE, Permission::PRODUCTS_TOGGLE_STATUS, + Permission::STOCKS_VIEW, Permission::OWNER_VERIFICATIONS_VIEW, @@ -293,6 +295,7 @@ public function permissions(): array Permission::RAW_MATERIALS_CREATE, Permission::RAW_MATERIALS_UPDATE, Permission::RAW_MATERIALS_TOGGLE_STATUS, + Permission::STOCKS_VIEW, Permission::OWNER_VERIFICATIONS_VIEW, @@ -394,6 +397,7 @@ public function permissions(): array Permission::LEAVE_REQUESTS_DELETE, Permission::PRODUCTS_VIEW, + Permission::STOCKS_VIEW, Permission::STOK_OPNAMES_VIEW, Permission::STOK_OPNAMES_CREATE, diff --git a/app/Http/Controllers/Admin/Manage/Stock/StockHistoryController.php b/app/Http/Controllers/Admin/Manage/Stock/StockHistoryController.php new file mode 100644 index 0000000..319a868 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/Stock/StockHistoryController.php @@ -0,0 +1,48 @@ +string('stockable_type')->toString(); + $stockableId = (int) $request->string('stockable_id')->toString(); + + $modelClass = match ($stockableType) { + 'product-variant' => ProductVariant::class, + 'raw-material-price' => RawMaterialPrice::class, + default => abort(404, 'Tipe stok tidak valid.'), + }; + + $stockable = $modelClass::with( + $stockableType === 'product-variant' + ? ['product', 'stockMutations.user.profile', 'stockMutations.source'] + : ['rawMaterial', 'stockMutations.user.profile', 'stockMutations.source'] + )->findOrFail($stockableId); + + $mutations = $stockable->stockMutations() + ->with(['user.profile', 'source']) + ->latest() + ->paginate(50); + + $title = $stockableType === 'product-variant' + ? "{$stockable->product?->name} - {$stockable->name}" + : "{$stockable->rawMaterial?->name} - {$stockable->variant}"; + + return Inertia::render('admin/manage/stock/History', [ + 'title' => $title, + 'stockableType' => $stockableType, + 'stockableId' => $stockable->id, + 'mutations' => $mutations, + ]); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php index 1dfe96f..b526633 100644 --- a/app/Models/ProductVariant.php +++ b/app/Models/ProductVariant.php @@ -91,4 +91,9 @@ public function product(): BelongsTo { return $this->belongsTo(Product::class)->withTrashed(); } + + public function stockMutations(): \Illuminate\Database\Eloquent\Relations\MorphMany + { + return $this->morphMany(StockMutation::class, 'stockable'); + } } diff --git a/app/Models/RawMaterialPrice.php b/app/Models/RawMaterialPrice.php index a63cb89..ac3a9a2 100644 --- a/app/Models/RawMaterialPrice.php +++ b/app/Models/RawMaterialPrice.php @@ -91,4 +91,9 @@ public function rawMaterial(): BelongsTo { return $this->belongsTo(RawMaterial::class)->withTrashed(); } + + public function stockMutations(): \Illuminate\Database\Eloquent\Relations\MorphMany + { + return $this->morphMany(StockMutation::class, 'stockable'); + } } diff --git a/app/Models/StockMutation.php b/app/Models/StockMutation.php new file mode 100644 index 0000000..68c6558 --- /dev/null +++ b/app/Models/StockMutation.php @@ -0,0 +1,59 @@ + 'decimal:4', + 'stock_before' => 'decimal:4', + 'stock_after' => 'decimal:4', + ]; + } + + public function createdAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function typeLabel(): Attribute + { + return Attribute::make( + get: fn () => match ($this->type) { + 'in' => 'Masuk', + 'out' => 'Keluar', + 'transfer' => 'Transfer', + 'adjustment' => 'Penyesuaian', + default => $this->type, + }, + ); + } + + public function stockable(): MorphTo + { + return $this->morphTo(); + } + + public function source(): MorphTo + { + return $this->morphTo(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index 3494a7b..84682d0 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -30,6 +30,7 @@ class CuttingService public function __construct( private readonly PushNotificationService $pushNotificationService, private readonly MediaService $mediaService, + private readonly StockMutationService $stockMutationService, ) {} public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator @@ -772,22 +773,51 @@ private function deductMaterialStock(Cutting $cutting): void ]); } - if ((float) $price->stock < $totalTaken) { + $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) { - RawMaterialPrice::query() - ->whereKey($material->raw_material_price_id) - ->increment('stock', (float) $material->material_usage); + $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)", + ); } } diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php index 19c68b5..4a0d544 100644 --- a/app/Services/Manage/OrderService.php +++ b/app/Services/Manage/OrderService.php @@ -38,6 +38,7 @@ public function __construct( private readonly CashService $cashService, private readonly CuttingResultPriceResolver $cuttingResultPriceResolver, private readonly MediaService $mediaService, + private readonly StockMutationService $stockMutationService, ) {} public function defaultPriceType(OrderChannel $channel): ?PriceType @@ -770,19 +771,45 @@ private function applyDraftPrices(EloquentCollection $items, PriceType $priceTyp private function decrementStock(OrderItem $item): void { $stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD; + $column = $this->stockColumn($stockQuality); - ProductVariant::query() - ->whereKey($item->product_variant_id) - ->decrement($this->stockColumn($stockQuality), $item->quantity); + $variant = ProductVariant::query()->findOrFail($item->product_variant_id); + $stockBefore = (int) $variant->{$column}; + + $variant->decrement($column, $item->quantity); + + $this->stockMutationService->record( + stockable: $variant, + type: 'out', + quantity: -$item->quantity, + stockBefore: $stockBefore, + stockAfter: $stockBefore - $item->quantity, + stockQuality: $stockQuality->value, + source: $item->order, + description: $item->order ? "Pesanan #{$item->order->order_number}" : null, + ); } private function incrementStock(OrderItem $item): void { $stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD; + $column = $this->stockColumn($stockQuality); - ProductVariant::query() - ->whereKey($item->product_variant_id) - ->increment($this->stockColumn($stockQuality), $item->quantity); + $variant = ProductVariant::query()->findOrFail($item->product_variant_id); + $stockBefore = (int) $variant->{$column}; + + $variant->increment($column, $item->quantity); + + $this->stockMutationService->record( + stockable: $variant, + type: 'in', + quantity: $item->quantity, + stockBefore: $stockBefore, + stockAfter: $stockBefore + $item->quantity, + stockQuality: $stockQuality->value, + source: $item->order, + description: $item->order ? "Pesanan #{$item->order->order_number} (batal)" : null, + ); } private function availableStock(ProductVariant $variant, ProductStockQuality $stockQuality): int diff --git a/app/Services/Manage/PurchaseService.php b/app/Services/Manage/PurchaseService.php index b145585..494a4f9 100644 --- a/app/Services/Manage/PurchaseService.php +++ b/app/Services/Manage/PurchaseService.php @@ -29,6 +29,7 @@ class PurchaseService public function __construct( private readonly MediaService $mediaService, private readonly PushNotificationService $pushNotificationService, + private readonly StockMutationService $stockMutationService, ) {} public function paginateForIndex(array $tableQuery): LengthAwarePaginator @@ -597,16 +598,38 @@ private function presentDraftItem(PurchaseItem $item): array private function incrementStock(PurchaseItem $item): void { - RawMaterialPrice::query() - ->whereKey($item->raw_material_price_id) - ->increment('stock', $item->quantity); + $price = RawMaterialPrice::query()->findOrFail($item->raw_material_price_id); + $stockBefore = (float) $price->stock; + + $price->increment('stock', $item->quantity); + + $this->stockMutationService->record( + stockable: $price, + type: 'in', + quantity: (float) $item->quantity, + stockBefore: $stockBefore, + stockAfter: $stockBefore + (float) $item->quantity, + source: $item->purchase, + description: $item->purchase ? "Belanja #{$item->purchase->id}" : null, + ); } private function decrementStock(PurchaseItem $item): void { - RawMaterialPrice::query() - ->whereKey($item->raw_material_price_id) - ->decrement('stock', $item->quantity); + $price = RawMaterialPrice::query()->findOrFail($item->raw_material_price_id); + $stockBefore = (float) $price->stock; + + $price->decrement('stock', $item->quantity); + + $this->stockMutationService->record( + stockable: $price, + type: 'out', + quantity: -((float) $item->quantity), + stockBefore: $stockBefore, + stockAfter: $stockBefore - (float) $item->quantity, + source: $item->purchase, + description: $item->purchase ? "Belanja #{$item->purchase->id}" : null, + ); } private function notifyPurchase(string $typeLabel, string $body, string $url, ?string $userId = null): void diff --git a/app/Services/Manage/RestockService.php b/app/Services/Manage/RestockService.php index c494d09..adb1725 100644 --- a/app/Services/Manage/RestockService.php +++ b/app/Services/Manage/RestockService.php @@ -32,6 +32,7 @@ class RestockService public function __construct( private readonly MediaService $mediaService, private readonly PushNotificationService $pushNotificationService, + private readonly StockMutationService $stockMutationService, ) {} public function paginateForIndex(array $tableQuery): LengthAwarePaginator @@ -529,16 +530,44 @@ private function stockColumn(ProductStockQuality $stockType): string private function incrementStock(RestockItem $item, ProductStockQuality $stockType): void { - ProductVariant::query() - ->whereKey($item->product_variant_id) - ->increment($this->stockColumn($stockType), $item->quantity); + $column = $this->stockColumn($stockType); + + $variant = ProductVariant::query()->findOrFail($item->product_variant_id); + $stockBefore = (int) $variant->{$column}; + + $variant->increment($column, $item->quantity); + + $this->stockMutationService->record( + stockable: $variant, + type: 'in', + quantity: $item->quantity, + stockBefore: $stockBefore, + stockAfter: $stockBefore + $item->quantity, + stockQuality: $stockType->value, + source: $item->restock, + description: $item->restock ? "Restock #{$item->restock->id}" : null, + ); } private function decrementStock(RestockItem $item, ProductStockQuality $stockType): void { - ProductVariant::query() - ->whereKey($item->product_variant_id) - ->decrement($this->stockColumn($stockType), $item->quantity); + $column = $this->stockColumn($stockType); + + $variant = ProductVariant::query()->findOrFail($item->product_variant_id); + $stockBefore = (int) $variant->{$column}; + + $variant->decrement($column, $item->quantity); + + $this->stockMutationService->record( + stockable: $variant, + type: 'out', + quantity: -$item->quantity, + stockBefore: $stockBefore, + stockAfter: $stockBefore - $item->quantity, + stockQuality: $stockType->value, + source: $item->restock, + description: $item->restock ? "Restock #{$item->restock->id} (batal)" : null, + ); } private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl, ?string $search = null): void diff --git a/app/Services/Manage/RetailStockService.php b/app/Services/Manage/RetailStockService.php index 4867760..f6715ce 100644 --- a/app/Services/Manage/RetailStockService.php +++ b/app/Services/Manage/RetailStockService.php @@ -2,8 +2,8 @@ namespace App\Services\Manage; +use App\Models\OwnerVerificationRequest; use App\Models\ProductVariant; -use App\Models\RetailStockHistory; use App\Models\User; use App\Services\System\PushNotificationService; use Illuminate\Support\Facades\DB; @@ -13,6 +13,7 @@ class RetailStockService { public function __construct( private readonly PushNotificationService $pushNotificationService, + private readonly StockMutationService $stockMutationService, ) {} public function transfer(int $variantId, int $quantity, User $user, ?string $notes = null): void @@ -68,23 +69,33 @@ private function executeTransfer(ProductVariant $variant, int $quantity, User $u ->lockForUpdate() ->firstOrFail(); - $stockBefore = $variant->stock; + $goodStockBefore = $variant->stock; $retailStockBefore = $variant->retail_stock; $variant->decrement('stock', $quantity); $variant->increment('retail_stock', $quantity); - RetailStockHistory::create([ - 'product_variant_id' => $variant->id, - 'user_id' => $user->id, - 'quantity' => $quantity, - 'stock_before' => $stockBefore, - 'retail_stock_before' => $retailStockBefore, - 'stock_after' => $stockBefore - $quantity, - 'retail_stock_after' => $retailStockBefore + $quantity, - 'notes' => $notes, - 'created_at' => now(), - ]); + $this->stockMutationService->record( + stockable: $variant, + type: 'out', + quantity: -$quantity, + stockBefore: $goodStockBefore, + stockAfter: $goodStockBefore - $quantity, + stockQuality: 'good', + description: $notes ? "Transfer ke stok ecer: {$notes}" : 'Transfer ke stok ecer', + user: $user, + ); + + $this->stockMutationService->record( + stockable: $variant, + type: 'in', + quantity: $quantity, + stockBefore: $retailStockBefore, + stockAfter: $retailStockBefore + $quantity, + stockQuality: 'retail', + description: $notes ? "Transfer dari stok bagus: {$notes}" : 'Transfer dari stok bagus', + user: $user, + ); }); } } diff --git a/app/Services/Manage/StockMutationService.php b/app/Services/Manage/StockMutationService.php new file mode 100644 index 0000000..65cbfdb --- /dev/null +++ b/app/Services/Manage/StockMutationService.php @@ -0,0 +1,36 @@ + $stockable->getMorphClass(), + 'stockable_id' => $stockable->id, + 'type' => $type, + 'source_type' => $source?->getMorphClass(), + 'source_id' => $source?->id, + 'quantity' => $quantity, + 'stock_before' => $stockBefore, + 'stock_after' => $stockAfter, + 'stock_quality' => $stockQuality, + 'description' => $description, + 'user_id' => $user?->id ?? auth()->id(), + ]); + } +} diff --git a/app/Services/Manage/StokOpnameService.php b/app/Services/Manage/StokOpnameService.php index ab1a23d..fd54dae 100644 --- a/app/Services/Manage/StokOpnameService.php +++ b/app/Services/Manage/StokOpnameService.php @@ -23,6 +23,7 @@ class StokOpnameService public function __construct( private readonly PushNotificationService $pushNotificationService, + private readonly StockMutationService $stockMutationService, ) {} public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator @@ -259,9 +260,25 @@ public function verify(StokOpname $stokOpname, User $user, ?string $verification ProductStockQuality::RETAIL => 'retail_stock', ProductStockQuality::REJECT => 'reject_stock', }; - $item->productVariant()->update([ + + $variant = $item->productVariant; + $stockBefore = (int) $variant->{$column}; + + $variant->update([ $column => $item->physical_stock, ]); + + $this->stockMutationService->record( + stockable: $variant, + type: 'adjustment', + quantity: $item->difference, + stockBefore: $stockBefore, + stockAfter: $item->physical_stock, + stockQuality: $item->stock_quality->value, + source: $stokOpname, + description: "Stok Opname #{$stokOpname->id}", + user: $user, + ); } } diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index 7d9e3eb..45d0c85 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -13,6 +13,7 @@ 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; @@ -29,6 +30,7 @@ class ProductService public function __construct( private readonly MediaService $mediaService, private readonly PushNotificationService $pushNotificationService, + private readonly StockMutationService $stockMutationService, ) {} public function paginateForIndex(array $tableQuery, string $status, string $categoryId = '', string $stockStatus = '', string $productId = ''): LengthAwarePaginator @@ -145,6 +147,8 @@ function () use ($validated, $user, $isOwner, $isDraft): Product { 'retail_stock' => $variantData['retail_stock'], ]); + $this->recordVariantStockMutations($variant, $variantData); + $this->syncVariantImages($variant, $variantData, $index); if (! empty($variantData['prices'])) { @@ -540,6 +544,9 @@ private function applyPayloadToProduct( foreach ($payload['variants'] ?? [] as $index => $variantData) { if (! empty($variantData['id'])) { $variant = $product->variants()->findOrFail($variantData['id']); + + $this->recordVariantStockChanges($variant, $variantData); + $variant->update([ 'name' => $variantData['name'], 'stock' => $variantData['stock'], @@ -572,6 +579,8 @@ private function applyPayloadToProduct( 'retail_stock' => $variantData['retail_stock'], ]); + $this->recordVariantStockMutations($variant, $variantData); + if ($verificationRequest !== null) { $this->copyRequestVariantImages($verificationRequest, (int) $index, $variant); } else { @@ -853,4 +862,59 @@ private function applySorting(Builder $query, string $sort, string $direction): $query->latest(); } + + private function recordVariantStockMutations(ProductVariant $variant, array $variantData): void + { + $stockQualities = [ + ['column' => 'stock', 'quality' => 'good'], + ['column' => 'reject_stock', 'quality' => 'reject'], + ['column' => 'retail_stock', 'quality' => 'retail'], + ]; + + foreach ($stockQualities as $sq) { + $value = (int) ($variantData[$sq['column']] ?? 0); + + if ($value <= 0) { + continue; + } + + $this->stockMutationService->record( + stockable: $variant, + type: 'adjustment', + quantity: $value, + stockBefore: 0, + stockAfter: $value, + stockQuality: $sq['quality'], + description: 'Stok awal dari master data', + ); + } + } + + private function recordVariantStockChanges(ProductVariant $variant, array $variantData): void + { + $stockQualities = [ + ['column' => 'stock', 'quality' => 'good'], + ['column' => 'reject_stock', 'quality' => 'reject'], + ['column' => 'retail_stock', 'quality' => 'retail'], + ]; + + foreach ($stockQualities as $sq) { + $oldValue = (int) $variant->{$sq['column']}; + $newValue = (int) ($variantData[$sq['column']] ?? 0); + + if ($newValue === $oldValue) { + continue; + } + + $this->stockMutationService->record( + stockable: $variant, + type: 'adjustment', + quantity: $newValue - $oldValue, + stockBefore: $oldValue, + stockAfter: $newValue, + stockQuality: $sq['quality'], + description: 'Edit langsung dari master data', + ); + } + } } diff --git a/app/Services/Master/RawMaterialService.php b/app/Services/Master/RawMaterialService.php index d2dd82e..049c316 100644 --- a/app/Services/Master/RawMaterialService.php +++ b/app/Services/Master/RawMaterialService.php @@ -12,6 +12,7 @@ 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; @@ -28,6 +29,7 @@ class RawMaterialService 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 @@ -393,6 +395,19 @@ private function applyPayloadToRawMaterial( 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'], @@ -414,6 +429,15 @@ private function applyPayloadToRawMaterial( '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])) { @@ -549,6 +573,15 @@ private function createPrice(RawMaterial $rawMaterial, array $priceData, int $in '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; diff --git a/database/migrations/2026_07_27_000001_create_stock_mutations_table.php b/database/migrations/2026_07_27_000001_create_stock_mutations_table.php new file mode 100644 index 0000000..62a89b3 --- /dev/null +++ b/database/migrations/2026_07_27_000001_create_stock_mutations_table.php @@ -0,0 +1,32 @@ +id(); + $table->morphs('stockable'); + $table->string('type'); + $table->nullableMorphs('source'); + $table->decimal('quantity', 18, 4); + $table->decimal('stock_before', 18, 4); + $table->decimal('stock_after', 18, 4); + $table->string('stock_quality')->nullable(); + $table->string('description')->nullable(); + $table->foreignId('user_id')->constrained(); + $table->timestamps(); + + $table->index(['stockable_type', 'stockable_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_mutations'); + } +}; diff --git a/resources/js/components/button/RowHistoryAction.vue b/resources/js/components/button/RowHistoryAction.vue new file mode 100644 index 0000000..53ae1a7 --- /dev/null +++ b/resources/js/components/button/RowHistoryAction.vue @@ -0,0 +1,35 @@ + + + diff --git a/resources/js/components/button/index.ts b/resources/js/components/button/index.ts index 7548802..02032e5 100644 --- a/resources/js/components/button/index.ts +++ b/resources/js/components/button/index.ts @@ -10,5 +10,6 @@ export { default as RowPrintAction } from './RowPrintAction.vue'; export { default as RowDetailAction } from './RowDetailAction.vue'; export { default as RowTransferAction } from './RowTransferAction.vue'; export { default as RowShareAction } from './RowShareAction.vue'; +export { default as RowHistoryAction } from './RowHistoryAction.vue'; export { default as CreateButton } from './CreateButton.vue'; export { default as BackButton } from './BackButton.vue'; diff --git a/resources/js/pages/admin/manage/stock/History.vue b/resources/js/pages/admin/manage/stock/History.vue new file mode 100644 index 0000000..b0bd149 --- /dev/null +++ b/resources/js/pages/admin/manage/stock/History.vue @@ -0,0 +1,129 @@ + + + diff --git a/resources/js/pages/admin/master/products/table/ProductGroupedTable.vue b/resources/js/pages/admin/master/products/table/ProductGroupedTable.vue index 6afb4cf..bbbac9e 100644 --- a/resources/js/pages/admin/master/products/table/ProductGroupedTable.vue +++ b/resources/js/pages/admin/master/products/table/ProductGroupedTable.vue @@ -1,6 +1,6 @@