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.
This commit is contained in:
Yoga Pangestu 2026-07-27 22:45:49 +07:00
parent 7dacf785cb
commit 3052398ad8
22 changed files with 678 additions and 42 deletions

View File

@ -95,6 +95,7 @@ public function permissions(): array
Permission::CUSTOMERS_VIEW, Permission::CUSTOMERS_VIEW,
Permission::PRODUCTS_VIEW, Permission::PRODUCTS_VIEW,
Permission::STOCKS_VIEW,
Permission::ORDERS_VIEW, Permission::ORDERS_VIEW,
@ -166,6 +167,7 @@ public function permissions(): array
Permission::PRODUCTS_UPDATE, Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE, Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS, Permission::PRODUCTS_TOGGLE_STATUS,
Permission::STOCKS_VIEW,
Permission::OWNER_VERIFICATIONS_VIEW, Permission::OWNER_VERIFICATIONS_VIEW,
@ -293,6 +295,7 @@ public function permissions(): array
Permission::RAW_MATERIALS_CREATE, Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE, Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_TOGGLE_STATUS, Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::STOCKS_VIEW,
Permission::OWNER_VERIFICATIONS_VIEW, Permission::OWNER_VERIFICATIONS_VIEW,
@ -394,6 +397,7 @@ public function permissions(): array
Permission::LEAVE_REQUESTS_DELETE, Permission::LEAVE_REQUESTS_DELETE,
Permission::PRODUCTS_VIEW, Permission::PRODUCTS_VIEW,
Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW, Permission::STOK_OPNAMES_VIEW,
Permission::STOK_OPNAMES_CREATE, Permission::STOK_OPNAMES_CREATE,

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Admin\Manage\Stock;
use App\Http\Controllers\Controller;
use App\Models\ProductVariant;
use App\Models\RawMaterialPrice;
use App\Models\StockMutation;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class StockHistoryController extends Controller
{
public function __invoke(Request $request): Response
{
$stockableType = $request->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,
]);
}
}

View File

@ -91,4 +91,9 @@ public function product(): BelongsTo
{ {
return $this->belongsTo(Product::class)->withTrashed(); return $this->belongsTo(Product::class)->withTrashed();
} }
public function stockMutations(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
return $this->morphMany(StockMutation::class, 'stockable');
}
} }

View File

@ -91,4 +91,9 @@ public function rawMaterial(): BelongsTo
{ {
return $this->belongsTo(RawMaterial::class)->withTrashed(); return $this->belongsTo(RawMaterial::class)->withTrashed();
} }
public function stockMutations(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
return $this->morphMany(StockMutation::class, 'stockable');
}
} }

View File

@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Appends(['created_at_formatted', 'type_label'])]
class StockMutation extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return [
'quantity' => '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);
}
}

View File

@ -30,6 +30,7 @@ class CuttingService
public function __construct( public function __construct(
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator 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([ throw ValidationException::withMessages([
'materials' => "Stok {$price->rawMaterial?->name} ({$price->variant}) tidak mencukupi.", 'materials' => "Stok {$price->rawMaterial?->name} ({$price->variant}) tidak mencukupi.",
]); ]);
} }
$price->decrement('stock', $totalTaken); $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 private function reverseTotalMaterialStock(Cutting $cutting): void
{ {
foreach ($cutting->materials as $material) { foreach ($cutting->materials as $material) {
RawMaterialPrice::query() $price = RawMaterialPrice::query()->lockForUpdate()->find($material->raw_material_price_id);
->whereKey($material->raw_material_price_id)
->increment('stock', (float) $material->material_usage); 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)",
);
} }
} }

View File

@ -38,6 +38,7 @@ public function __construct(
private readonly CashService $cashService, private readonly CashService $cashService,
private readonly CuttingResultPriceResolver $cuttingResultPriceResolver, private readonly CuttingResultPriceResolver $cuttingResultPriceResolver,
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function defaultPriceType(OrderChannel $channel): ?PriceType public function defaultPriceType(OrderChannel $channel): ?PriceType
@ -770,19 +771,45 @@ private function applyDraftPrices(EloquentCollection $items, PriceType $priceTyp
private function decrementStock(OrderItem $item): void private function decrementStock(OrderItem $item): void
{ {
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD; $stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
$column = $this->stockColumn($stockQuality);
ProductVariant::query() $variant = ProductVariant::query()->findOrFail($item->product_variant_id);
->whereKey($item->product_variant_id) $stockBefore = (int) $variant->{$column};
->decrement($this->stockColumn($stockQuality), $item->quantity);
$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 private function incrementStock(OrderItem $item): void
{ {
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD; $stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
$column = $this->stockColumn($stockQuality);
ProductVariant::query() $variant = ProductVariant::query()->findOrFail($item->product_variant_id);
->whereKey($item->product_variant_id) $stockBefore = (int) $variant->{$column};
->increment($this->stockColumn($stockQuality), $item->quantity);
$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 private function availableStock(ProductVariant $variant, ProductStockQuality $stockQuality): int

View File

@ -29,6 +29,7 @@ class PurchaseService
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function paginateForIndex(array $tableQuery): LengthAwarePaginator public function paginateForIndex(array $tableQuery): LengthAwarePaginator
@ -597,16 +598,38 @@ private function presentDraftItem(PurchaseItem $item): array
private function incrementStock(PurchaseItem $item): void private function incrementStock(PurchaseItem $item): void
{ {
RawMaterialPrice::query() $price = RawMaterialPrice::query()->findOrFail($item->raw_material_price_id);
->whereKey($item->raw_material_price_id) $stockBefore = (float) $price->stock;
->increment('stock', $item->quantity);
$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 private function decrementStock(PurchaseItem $item): void
{ {
RawMaterialPrice::query() $price = RawMaterialPrice::query()->findOrFail($item->raw_material_price_id);
->whereKey($item->raw_material_price_id) $stockBefore = (float) $price->stock;
->decrement('stock', $item->quantity);
$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 private function notifyPurchase(string $typeLabel, string $body, string $url, ?string $userId = null): void

View File

@ -32,6 +32,7 @@ class RestockService
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function paginateForIndex(array $tableQuery): LengthAwarePaginator public function paginateForIndex(array $tableQuery): LengthAwarePaginator
@ -529,16 +530,44 @@ private function stockColumn(ProductStockQuality $stockType): string
private function incrementStock(RestockItem $item, ProductStockQuality $stockType): void private function incrementStock(RestockItem $item, ProductStockQuality $stockType): void
{ {
ProductVariant::query() $column = $this->stockColumn($stockType);
->whereKey($item->product_variant_id)
->increment($this->stockColumn($stockType), $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: $stockType->value,
source: $item->restock,
description: $item->restock ? "Restock #{$item->restock->id}" : null,
);
} }
private function decrementStock(RestockItem $item, ProductStockQuality $stockType): void private function decrementStock(RestockItem $item, ProductStockQuality $stockType): void
{ {
ProductVariant::query() $column = $this->stockColumn($stockType);
->whereKey($item->product_variant_id)
->decrement($this->stockColumn($stockType), $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: $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 private function notifyForPendingRequest(User $user, string $typeLabel, string $body, string $submitterUrl, ?string $search = null): void

View File

@ -2,8 +2,8 @@
namespace App\Services\Manage; namespace App\Services\Manage;
use App\Models\OwnerVerificationRequest;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Models\RetailStockHistory;
use App\Models\User; use App\Models\User;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -13,6 +13,7 @@ class RetailStockService
{ {
public function __construct( public function __construct(
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function transfer(int $variantId, int $quantity, User $user, ?string $notes = null): void 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() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
$stockBefore = $variant->stock; $goodStockBefore = $variant->stock;
$retailStockBefore = $variant->retail_stock; $retailStockBefore = $variant->retail_stock;
$variant->decrement('stock', $quantity); $variant->decrement('stock', $quantity);
$variant->increment('retail_stock', $quantity); $variant->increment('retail_stock', $quantity);
RetailStockHistory::create([ $this->stockMutationService->record(
'product_variant_id' => $variant->id, stockable: $variant,
'user_id' => $user->id, type: 'out',
'quantity' => $quantity, quantity: -$quantity,
'stock_before' => $stockBefore, stockBefore: $goodStockBefore,
'retail_stock_before' => $retailStockBefore, stockAfter: $goodStockBefore - $quantity,
'stock_after' => $stockBefore - $quantity, stockQuality: 'good',
'retail_stock_after' => $retailStockBefore + $quantity, description: $notes ? "Transfer ke stok ecer: {$notes}" : 'Transfer ke stok ecer',
'notes' => $notes, user: $user,
'created_at' => now(), );
]);
$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,
);
}); });
} }
} }

View File

@ -0,0 +1,36 @@
<?php
namespace App\Services\Manage;
use App\Models\StockMutation;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class StockMutationService
{
public function record(
Model $stockable,
string $type,
float|int $quantity,
float|int $stockBefore,
float|int $stockAfter,
?Model $source = null,
?string $stockQuality = null,
?string $description = null,
?User $user = null,
): StockMutation {
return StockMutation::create([
'stockable_type' => $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(),
]);
}
}

View File

@ -23,6 +23,7 @@ class StokOpnameService
public function __construct( public function __construct(
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator 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::RETAIL => 'retail_stock',
ProductStockQuality::REJECT => 'reject_stock', ProductStockQuality::REJECT => 'reject_stock',
}; };
$item->productVariant()->update([
$variant = $item->productVariant;
$stockBefore = (int) $variant->{$column};
$variant->update([
$column => $item->physical_stock, $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,
);
} }
} }

View File

@ -13,6 +13,7 @@
use App\Models\User; use App\Models\User;
use App\Services\Concerns\CachesQuery; use App\Services\Concerns\CachesQuery;
use App\Services\Concerns\RunsInTransaction; use App\Services\Concerns\RunsInTransaction;
use App\Services\Manage\StockMutationService;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
@ -29,6 +30,7 @@ class ProductService
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function paginateForIndex(array $tableQuery, string $status, string $categoryId = '', string $stockStatus = '', string $productId = ''): LengthAwarePaginator 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'], 'retail_stock' => $variantData['retail_stock'],
]); ]);
$this->recordVariantStockMutations($variant, $variantData);
$this->syncVariantImages($variant, $variantData, $index); $this->syncVariantImages($variant, $variantData, $index);
if (! empty($variantData['prices'])) { if (! empty($variantData['prices'])) {
@ -540,6 +544,9 @@ private function applyPayloadToProduct(
foreach ($payload['variants'] ?? [] as $index => $variantData) { foreach ($payload['variants'] ?? [] as $index => $variantData) {
if (! empty($variantData['id'])) { if (! empty($variantData['id'])) {
$variant = $product->variants()->findOrFail($variantData['id']); $variant = $product->variants()->findOrFail($variantData['id']);
$this->recordVariantStockChanges($variant, $variantData);
$variant->update([ $variant->update([
'name' => $variantData['name'], 'name' => $variantData['name'],
'stock' => $variantData['stock'], 'stock' => $variantData['stock'],
@ -572,6 +579,8 @@ private function applyPayloadToProduct(
'retail_stock' => $variantData['retail_stock'], 'retail_stock' => $variantData['retail_stock'],
]); ]);
$this->recordVariantStockMutations($variant, $variantData);
if ($verificationRequest !== null) { if ($verificationRequest !== null) {
$this->copyRequestVariantImages($verificationRequest, (int) $index, $variant); $this->copyRequestVariantImages($verificationRequest, (int) $index, $variant);
} else { } else {
@ -853,4 +862,59 @@ private function applySorting(Builder $query, string $sort, string $direction):
$query->latest(); $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',
);
}
}
} }

View File

@ -12,6 +12,7 @@
use App\Models\User; use App\Models\User;
use App\Services\Concerns\CachesQuery; use App\Services\Concerns\CachesQuery;
use App\Services\Concerns\RunsInTransaction; use App\Services\Concerns\RunsInTransaction;
use App\Services\Manage\StockMutationService;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
@ -28,6 +29,7 @@ class RawMaterialService
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly StockMutationService $stockMutationService,
) {} ) {}
public function paginateForIndex(array $tableQuery, string $isActive, string $stockStatus = '', string $rawMaterialId = ''): LengthAwarePaginator 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) { foreach ($payload['prices'] ?? [] as $index => $priceData) {
if (! empty($priceData['id'])) { if (! empty($priceData['id'])) {
$price = $rawMaterial->prices()->findOrFail($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([ $price->update([
'variant' => $priceData['variant'], 'variant' => $priceData['variant'],
'price' => $priceData['price'], 'price' => $priceData['price'],
@ -414,6 +429,15 @@ private function applyPayloadToRawMaterial(
'stock' => $priceData['stock'], '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) { if ($verificationRequest !== null) {
$this->copyRequestPriceImages($verificationRequest, (int) $index, $price); $this->copyRequestPriceImages($verificationRequest, (int) $index, $price);
} elseif (isset($originalPrices[$index])) { } elseif (isset($originalPrices[$index])) {
@ -549,6 +573,15 @@ private function createPrice(RawMaterial $rawMaterial, array $priceData, int $in
'stock' => $priceData['stock'], '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); $this->syncPriceImages($price, $priceData, $index);
return $price; return $price;

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('stock_mutations', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { History } from '@lucide/vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
defineProps<{
href?: string;
tooltip?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
click: [];
}>();
</script>
<template>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" :disabled="disabled" :as-child="!!href && !disabled"
@click="!href && !disabled && emit('click')">
<Link v-if="href && !disabled" :href="href">
<History class="size-4" />
<span class="sr-only">{{ tooltip || 'Riwayat Stok' }}</span>
</Link>
<template v-else>
<History class="size-4" />
<span class="sr-only">{{ tooltip || 'Riwayat Stok' }}</span>
</template>
</Button>
</TooltipTrigger>
<TooltipContent>{{ tooltip || 'Riwayat Stok' }}</TooltipContent>
</Tooltip>
</template>

View File

@ -10,5 +10,6 @@ export { default as RowPrintAction } from './RowPrintAction.vue';
export { default as RowDetailAction } from './RowDetailAction.vue'; export { default as RowDetailAction } from './RowDetailAction.vue';
export { default as RowTransferAction } from './RowTransferAction.vue'; export { default as RowTransferAction } from './RowTransferAction.vue';
export { default as RowShareAction } from './RowShareAction.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 CreateButton } from './CreateButton.vue';
export { default as BackButton } from './BackButton.vue'; export { default as BackButton } from './BackButton.vue';

View File

@ -0,0 +1,129 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import BackButton from '@/components/button/BackButton.vue';
import DataTable from '@/components/data-table/DataTable.vue';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { index as productsIndex } from '@/routes/admin/master/products';
import { index as rawMaterialsIndex } from '@/routes/admin/master/raw_materials';
import type { PaginatedStockMutations, StockMutation } from '@/types/stock-mutation';
const props = defineProps<{
title: string;
stockableType: string;
stockableId: number;
mutations: PaginatedStockMutations;
}>();
const backUrl = props.stockableType === 'product-variant'
? productsIndex.url()
: rawMaterialsIndex.url();
const pagination = {
currentPage: props.mutations.current_page,
perPage: props.mutations.per_page,
lastPage: props.mutations.last_page,
total: props.mutations.total,
};
const typeBadgeVariant = (type: string): string => {
const map: Record<string, string> = {
in: 'success',
out: 'destructive',
transfer: 'info',
adjustment: 'warning',
};
return map[type] ?? 'secondary';
};
const columns: ColumnDef<StockMutation>[] = [
{
accessorKey: 'created_at_formatted',
header: 'Tanggal',
cell: ({ row }) => row.original.created_at_formatted ?? '-',
},
{
accessorKey: 'type_label',
header: 'Tipe',
cell: ({ row }) => h(Badge, {
variant: typeBadgeVariant(row.original.type),
}, () => row.original.type_label),
},
{
accessorKey: 'description',
header: 'Keterangan',
cell: ({ row }) => {
const mutation = row.original;
const parts = [mutation.description ?? '-'];
if (mutation.stock_quality) {
const qualityLabels: Record<string, string> = {
good: 'Stok Bagus',
reject: 'Stok Reject',
retail: 'Stok Ecer',
};
parts.push(qualityLabels[mutation.stock_quality] ?? mutation.stock_quality);
}
return parts.join(' — ');
},
},
{
accessorKey: 'quantity',
header: 'Qty',
cell: ({ row }) => {
const qty = Number(row.original.quantity);
const prefix = qty >= 0 ? '+' : '';
const cls = qty > 0 ? 'text-green-600 font-medium tabular-nums'
: qty < 0 ? 'text-red-600 font-medium tabular-nums'
: 'tabular-nums';
return h('span', { class: cls }, `${prefix}${qty.toLocaleString('id-ID')}`);
},
},
{
accessorKey: 'stock_before',
header: 'Stok Sebelum',
cell: ({ row }) => h('span', { class: 'tabular-nums' }, Number(row.original.stock_before).toLocaleString('id-ID')),
},
{
accessorKey: 'stock_after',
header: 'Stok Sesudah',
cell: ({ row }) => h('span', { class: 'tabular-nums' }, Number(row.original.stock_after).toLocaleString('id-ID')),
},
{
accessorKey: 'user.profile.full_name',
id: 'user',
header: 'User',
cell: ({ row }) => row.original.user?.profile?.full_name ?? 'Sistem',
},
];
</script>
<template>
<Head :title="`Riwayat Stok - ${title}`" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">Riwayat Stok</h2>
<p class="text-sm text-muted-foreground">{{ title }}</p>
</div>
<BackButton :href="backUrl" />
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<DataTable
:columns="columns"
:data="mutations.data"
:pagination="pagination"
:pagination-links="mutations.links"
:show-row-number="true"
pagination-item-label="mutasi"
/>
</CardContent>
</Card>
</AdminLayout>
</template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { RowEditAction } from '@/components/button'; import { RowEditAction, RowHistoryAction } from '@/components/button';
import { DataTableEmpty } from '@/components/data-table'; import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue'; import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue'; import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
@ -83,6 +83,8 @@ function openVerificationDetail(requestId: number | undefined) {
} }
} }
import { history as stockHistory } from '@/routes/admin/manage/stock';
// Modal Edit Varian State // Modal Edit Varian State
const isEditing = ref(false); const isEditing = ref(false);
const editingProduct = ref<ProductListItem | null>(null); const editingProduct = ref<ProductListItem | null>(null);
@ -208,8 +210,13 @@ function openEditModal(variant: Variant, product: ProductListItem) {
<span v-else class="text-xs text-muted-foreground">Belum ada harga</span> <span v-else class="text-xs text-muted-foreground">Belum ada harga</span>
</TableCell> </TableCell>
<TableCell class="text-center"> <TableCell class="text-center">
<div class="flex items-center justify-center gap-1">
<RowHistoryAction
:href="stockHistory.url({ query: { stockable_type: 'product-variant', stockable_id: variant.id } })"
tooltip="Riwayat Stok" />
<RowEditAction :disabled="product.has_pending_request" tooltip="Ubah Varian" <RowEditAction :disabled="product.has_pending_request" tooltip="Ubah Varian"
@click="openEditModal(variant, product)" /> @click="openEditModal(variant, product)" />
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { RowEditAction } from '@/components/button'; import { RowEditAction, RowHistoryAction } from '@/components/button';
import { DataTableEmpty } from '@/components/data-table'; import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue'; import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue'; import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
@ -66,6 +66,8 @@ function allVariantImageStocks(material: RawMaterialListItem): string[] {
); );
} }
import { history as stockHistory } from '@/routes/admin/manage/stock';
// Modal Edit Varian State // Modal Edit Varian State
const isEditing = ref(false); const isEditing = ref(false);
const editingMaterial = ref<RawMaterialListItem | null>(null); const editingMaterial = ref<RawMaterialListItem | null>(null);
@ -166,8 +168,13 @@ function openEditModal(price: RawMaterialPrice, material: RawMaterialListItem) {
{{ price.price_formatted }} {{ price.price_formatted }}
</TableCell> </TableCell>
<TableCell class="text-center"> <TableCell class="text-center">
<div class="flex items-center justify-center gap-1">
<RowHistoryAction
:href="stockHistory.url({ query: { stockable_type: 'raw-material-price', stockable_id: price.id } })"
tooltip="Riwayat Stok" />
<RowEditAction tooltip="Ubah Varian" <RowEditAction tooltip="Ubah Varian"
@click="openEditModal(price, material)" /> @click="openEditModal(price, material)" />
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>

View File

@ -0,0 +1,27 @@
import type { Paginated } from '@/types/common';
export interface StockMutation {
id: number;
stockable_type: string;
stockable_id: number;
type: 'in' | 'out' | 'transfer' | 'adjustment';
quantity: string;
stock_before: string;
stock_after: string;
stock_quality: string | null;
description: string | null;
created_at: string;
user: {
id: number;
profile: {
full_name: string;
} | null;
} | null;
}
export type PaginatedStockMutations = Paginated<StockMutation> & {
from: number | null;
to: number | null;
prev_page_url: string | null;
next_page_url: string | null;
};

View File

@ -24,6 +24,7 @@
use App\Http\Controllers\Admin\Manage\Restock\RestockController; use App\Http\Controllers\Admin\Manage\Restock\RestockController;
use App\Http\Controllers\Admin\Manage\Restock\RestockDraftItemController; use App\Http\Controllers\Admin\Manage\Restock\RestockDraftItemController;
use App\Http\Controllers\Admin\Manage\Stock\RetailStockController; use App\Http\Controllers\Admin\Manage\Stock\RetailStockController;
use App\Http\Controllers\Admin\Manage\Stock\StockHistoryController;
use App\Http\Controllers\Admin\Manage\StokOpnameController; use App\Http\Controllers\Admin\Manage\StokOpnameController;
use App\Http\Controllers\Admin\Master\CategoryController; use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController; use App\Http\Controllers\Admin\Master\CustomerController;
@ -422,6 +423,12 @@
Route::post('/transfer', [RetailStockController::class, 'transfer'])->name('transfer'); Route::post('/transfer', [RetailStockController::class, 'transfer'])->name('transfer');
}); });
Route::prefix('stock')->name('stock.')
->middleware('permission:'.Permission::STOCKS_VIEW->value)
->group(function () {
Route::get('history', StockHistoryController::class)->name('history');
});
Route::prefix('stok-opnames')->name('stok-opnames.') Route::prefix('stok-opnames')->name('stok-opnames.')
->middleware('permission:'.Permission::STOK_OPNAMES_VIEW->value) ->middleware('permission:'.Permission::STOK_OPNAMES_VIEW->value)
->group(function () { ->group(function () {