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:
parent
7dacf785cb
commit
3052398ad8
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
app/Models/StockMutation.php
Normal file
59
app/Models/StockMutation.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
app/Services/Manage/StockMutationService.php
Normal file
36
app/Services/Manage/StockMutationService.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
35
resources/js/components/button/RowHistoryAction.vue
Normal file
35
resources/js/components/button/RowHistoryAction.vue
Normal 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>
|
||||||
@ -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';
|
||||||
|
|||||||
129
resources/js/pages/admin/manage/stock/History.vue
Normal file
129
resources/js/pages/admin/manage/stock/History.vue
Normal 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>
|
||||||
@ -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">
|
||||||
<RowEditAction :disabled="product.has_pending_request" tooltip="Ubah Varian"
|
<div class="flex items-center justify-center gap-1">
|
||||||
@click="openEditModal(variant, product)" />
|
<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"
|
||||||
|
@click="openEditModal(variant, product)" />
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@ -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">
|
||||||
<RowEditAction tooltip="Ubah Varian"
|
<div class="flex items-center justify-center gap-1">
|
||||||
@click="openEditModal(price, material)" />
|
<RowHistoryAction
|
||||||
|
:href="stockHistory.url({ query: { stockable_type: 'raw-material-price', stockable_id: price.id } })"
|
||||||
|
tooltip="Riwayat Stok" />
|
||||||
|
<RowEditAction tooltip="Ubah Varian"
|
||||||
|
@click="openEditModal(price, material)" />
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
27
resources/js/types/stock-mutation.ts
Normal file
27
resources/js/types/stock-mutation.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -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 () {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user