feat: implement stock mutation functionality with service and controller; add pagination and UI components

This commit is contained in:
Yoga Pangestu 2026-08-01 17:30:05 +07:00
parent 10db7bc5a2
commit 44dab8271f
10 changed files with 814 additions and 25 deletions

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Admin\Master\Product;
use App\Http\Controllers\Controller;
use App\Http\Requests\StockMutationRequest;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Services\StockMutationService;
use Inertia\Inertia;
use Inertia\Response;
class StockMutationController extends Controller
{
public function __construct(
private StockMutationService $service = new StockMutationService,
) {}
public function index(StockMutationRequest $request, Product $product, ProductVariant $variant): Response
{
$perPage = $request->validatedWithDefaults()['perPage'];
return Inertia::render('admin/master/product/variant/stock-mutations', [
'product' => [
'id' => $product->id,
'name' => $product->name,
],
'variant' => [
'id' => $variant->id,
'name' => $variant->name,
],
'mutations' => $this->service->paginated($variant, $perPage),
]);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StockMutationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
public function validatedWithDefaults(): array
{
$validated = $this->validated();
return [
'perPage' => $validated['per_page'] ?? 20,
];
}
}

View File

@ -7,6 +7,7 @@
use App\Models\ProductVariant;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use App\Services\StockMutationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
@ -16,6 +17,7 @@ class ProductService
public function __construct(
private ProductVariantService $variantService = new ProductVariantService,
private S3PresignedService $s3Service = new S3PresignedService,
private StockMutationService $stockMutationService = new StockMutationService,
) {}
public function getAll(array $filters = []): Collection
@ -111,6 +113,8 @@ public function create(array $data): Product
if (! empty($variantData['photo_keys']) && is_array($variantData['photo_keys'])) {
$this->variantService->registerPhotos($variant, $variantData['photo_keys']);
}
$this->stockMutationService->recordInitial($variant, $variantData, 'Stok awal saat pembuatan varian');
}
return $product;
@ -195,12 +199,14 @@ public function update(Product $product, array $data): Product
if ($variantId) {
$variant = $product->productVariants()->findOrFail($variantId);
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
$variant->update([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'reject_stock' => $variantData['reject_stock'],
'retail_stock' => $variantData['retail_stock'],
]);
$this->stockMutationService->recordAdjustment($variant, $oldData, $variantData, 'Penyesuaian stok saat edit varian');
} else {
$variant = $product->productVariants()->create([
'name' => $variantData['name'],
@ -208,6 +214,7 @@ public function update(Product $product, array $data): Product
'reject_stock' => $variantData['reject_stock'],
'retail_stock' => $variantData['retail_stock'],
]);
$this->stockMutationService->recordInitial($variant, $variantData, 'Stok awal saat pembuatan varian');
}
$variant->productPrices()->delete();

View File

@ -5,10 +5,10 @@
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\StockMutation;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use App\Services\StockMutationService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@ -18,6 +18,7 @@ class ProductVariantService
public function __construct(
private S3PresignedService $s3Service = new S3PresignedService,
private StockMutationService $stockMutationService = new StockMutationService,
) {}
public function getForEdit(ProductVariant $variant): array
@ -47,6 +48,8 @@ public function getForEdit(ProductVariant $variant): array
public function update(ProductVariant $variant, array $data): ProductVariant
{
DB::transaction(function () use ($variant, $data) {
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
$variant->update([
'name' => $data['name'],
'stock' => $data['stock'],
@ -54,6 +57,8 @@ public function update(ProductVariant $variant, array $data): ProductVariant
'retail_stock' => $data['retail_stock'],
]);
$this->stockMutationService->recordAdjustment($variant, $oldData, $data, 'Penyesuaian stok saat edit varian');
$variant->productPrices()->delete();
foreach ($data['prices'] as $priceData) {
@ -137,29 +142,15 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
'retail_stock' => $retailBefore + $quantity,
]);
StockMutation::create([
'user_id' => auth()->id(),
'stockable_type' => ProductVariant::class,
'stockable_id' => $variant->id,
'type' => 'out',
'quantity' => -$quantity,
'stock_before' => $stockBefore,
'stock_after' => $stockBefore - $quantity,
'stock_quality' => 'good',
'description' => $data['description'] ?? 'Transfer stok bagus ke stok ecer',
]);
StockMutation::create([
'user_id' => auth()->id(),
'stockable_type' => ProductVariant::class,
'stockable_id' => $variant->id,
'type' => 'in',
'quantity' => $quantity,
'stock_before' => $retailBefore,
'stock_after' => $retailBefore + $quantity,
'stock_quality' => 'retail',
'description' => $data['description'] ?? 'Transfer stok bagus ke stok ecer',
]);
$this->stockMutationService->recordTransfer(
model: $variant,
quantity: $quantity,
fromQuality: 'good',
toQuality: 'retail',
fromBefore: $stockBefore,
toBefore: $retailBefore,
description: $data['description'] ?? 'Transfer stok bagus ke stok ecer',
);
});
NotificationService::notify(

View File

@ -0,0 +1,109 @@
<?php
namespace App\Services;
use App\Models\StockMutation;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model;
class StockMutationService
{
private const QUALITY_MAP = [
'stock' => 'good',
'reject_stock' => 'reject',
'retail_stock' => 'retail',
];
public function recordInitial(Model $model, array $stockData, string $description = 'Stok awal'): void
{
$userId = auth()->id();
foreach (self::QUALITY_MAP as $field => $quality) {
$quantity = (int) ($stockData[$field] ?? 0);
if ($quantity > 0) {
StockMutation::create([
'user_id' => $userId,
'stockable_type' => get_class($model),
'stockable_id' => $model->id,
'type' => 'in',
'quantity' => $quantity,
'stock_before' => 0,
'stock_after' => $quantity,
'stock_quality' => $quality,
'description' => $description,
]);
}
}
}
public function recordAdjustment(Model $model, array $oldData, array $newData, string $description = 'Penyesuaian stok'): void
{
$userId = auth()->id();
foreach (self::QUALITY_MAP as $field => $quality) {
$old = (int) ($oldData[$field] ?? 0);
$new = (int) ($newData[$field] ?? 0);
$diff = $new - $old;
if ($diff !== 0) {
StockMutation::create([
'user_id' => $userId,
'stockable_type' => get_class($model),
'stockable_id' => $model->id,
'type' => $diff > 0 ? 'in' : 'out',
'quantity' => $diff,
'stock_before' => $old,
'stock_after' => $new,
'stock_quality' => $quality,
'description' => $description,
]);
}
}
}
public function recordTransfer(
Model $model,
int $quantity,
string $fromQuality,
string $toQuality,
int $fromBefore,
int $toBefore,
string $description = 'Transfer stok',
): void {
$userId = auth()->id();
StockMutation::create([
'user_id' => $userId,
'stockable_type' => get_class($model),
'stockable_id' => $model->id,
'type' => 'out',
'quantity' => -$quantity,
'stock_before' => $fromBefore,
'stock_after' => $fromBefore - $quantity,
'stock_quality' => $fromQuality,
'description' => $description,
]);
StockMutation::create([
'user_id' => $userId,
'stockable_type' => get_class($model),
'stockable_id' => $model->id,
'type' => 'in',
'quantity' => $quantity,
'stock_before' => $toBefore,
'stock_after' => $toBefore + $quantity,
'stock_quality' => $toQuality,
'description' => $description,
]);
}
public function paginated(Model $model, int $perPage = 20): LengthAwarePaginator
{
return StockMutation::query()
->where('stockable_type', get_class($model))
->where('stockable_id', $model->id)
->with('user:id,username,email')
->latest()
->paginate($perPage);
}
}

View File

@ -0,0 +1,88 @@
import { router } from '@inertiajs/react';
import { useCallback, useEffect, useRef, useState } from 'react';
type PaginatedData<T> = {
data: T[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
type UseInfiniteScrollOptions<T> = {
initialData: PaginatedData<T>;
fetchUrl: string;
perPage?: number;
};
export function useInfiniteScroll<T>({
initialData,
fetchUrl,
perPage = 20,
}: UseInfiniteScrollOptions<T>) {
const [items, setItems] = useState<T[]>(initialData.data);
const [currentPage, setCurrentPage] = useState(initialData.current_page);
const [lastPage, setLastPage] = useState(initialData.last_page);
const [loading, setLoading] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const loadMore = useCallback(() => {
if (loading || currentPage >= lastPage) return;
setLoading(true);
const url = new URL(fetchUrl, window.location.origin);
url.searchParams.set('page', String(currentPage + 1));
url.searchParams.set('per_page', String(perPage));
router.get(
url.pathname + url.search,
{},
{
preserveState: true,
replace: true,
only: ['mutations'],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSuccess: (page: any) => {
const newMutations = (page.props as Record<string, unknown>)
.mutations as PaginatedData<T>;
setItems((prev) => [...prev, ...newMutations.data]);
setCurrentPage(newMutations.current_page);
setLastPage(newMutations.last_page);
setLoading(false);
},
onError: () => {
setLoading(false);
},
},
);
}, [fetchUrl, currentPage, lastPage, loading, perPage]);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [loadMore]);
const hasNextPage = currentPage < lastPage;
return {
items,
loading,
hasNextPage,
sentinelRef,
};
}

View File

@ -0,0 +1,230 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useInfiniteScroll } from '@/hooks/use-infinite-scroll';
import { stockMutations } from '@/routes/admin/master/products/variants';
import { Head } from '@inertiajs/react';
import {
ArrowDown,
ArrowLeft,
ArrowRightLeft,
ArrowUp,
Loader2,
Package,
Pencil,
ScrollText,
} from 'lucide-react';
import { index as productIndex } from '@/routes/admin/master/products';
type Mutation = {
id: number;
type: 'in' | 'out';
quantity: number;
stock_before: number;
stock_after: number;
stock_quality: string;
description: string | null;
created_at: string;
user: {
id: number;
username: string;
email: string;
full_name?: string;
};
};
type PaginatedMutations = {
data: Mutation[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
type Props = {
product: { id: number; name: string };
variant: { id: number; name: string };
mutations: PaginatedMutations;
};
function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID').format(num);
}
function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(dateStr));
}
function getQualityLabel(quality: string): string {
const labels: Record<string, string> = {
good: 'Bagus',
reject: 'Reject',
retail: 'Ecer',
};
return labels[quality] ?? quality;
}
function getQualityColor(quality: string): string {
const colors: Record<string, string> = {
good: 'bg-green-100 text-green-800',
reject: 'bg-red-100 text-red-800',
retail: 'bg-blue-100 text-blue-800',
};
return colors[quality] ?? 'bg-gray-100 text-gray-800';
}
function getTypeLabel(type: string, quantity: number): string {
if (type === 'in') {
return quantity >= 0 ? 'Penambahan' : 'Pengurangan';
}
return quantity < 0 ? 'Pengurangan' : 'Penambahan';
}
function getMutationTitle(description: string | null): string {
if (!description) return 'Perubahan Stok';
if (description.includes('Transfer stok')) return 'Transfer Stok';
if (description.includes('Stok awal')) return 'Stok Awal';
if (description.includes('Penyesuaian stok')) return 'Edit Varian';
return 'Perubahan Stok';
}
function getMutationIcon(description: string | null): React.ReactNode {
if (description?.includes('Transfer stok')) {
return <ArrowRightLeft className="h-4 w-4" />;
}
if (description?.includes('Stok awal')) {
return <Package className="h-4 w-4" />;
}
if (description?.includes('Penyesuaian stok')) {
return <Pencil className="h-4 w-4" />;
}
return <ScrollText className="h-4 w-4" />;
}
export default function StockMutationsPage({
product,
variant,
mutations,
}: Props) {
const { items, loading, hasNextPage, sentinelRef } = useInfiniteScroll({
initialData: mutations,
fetchUrl: stockMutations.url({ product: product.id, variant: variant.id }),
});
return (
<>
<Head title="Mutasi Stok" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Mutasi Stok
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{product.name} {variant.name}
</p>
</div>
<Button asChild variant="outline">
<a href={productIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<div className="grid gap-4">
{items.length === 0 && !loading ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<ScrollText className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-sm text-muted-foreground">
Belum ada mutasi stok.
</p>
</CardContent>
</Card>
) : (
items.map((mutation) => {
const isPositive = mutation.quantity > 0;
const qualityColor = getQualityColor(mutation.stock_quality);
return (
<Card key={mutation.id}>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${isPositive ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}>
{isPositive ? (
<ArrowUp className="h-5 w-5" />
) : (
<ArrowDown className="h-5 w-5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{getMutationTitle(mutation.description)}
</span>
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${qualityColor}`}>
{getQualityLabel(mutation.stock_quality)}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span>
{formatDate(mutation.created_at)}
</span>
<span>
{mutation.user?.full_name ?? mutation.user?.username}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span className={isPositive ? 'font-medium text-green-600' : 'font-medium text-red-600'}>
{isPositive ? '+' : ''}{formatNumber(mutation.quantity)}
</span>
<span className="text-muted-foreground">
{formatNumber(mutation.stock_before)} {formatNumber(mutation.stock_after)}
</span>
</div>
{mutation.description && (
<p className="mt-1 text-xs text-muted-foreground">
{mutation.description}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})
)}
{hasNextPage && (
<div ref={sentinelRef} className="flex items-center justify-center py-4">
{loading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Memuat data...
</div>
)}
</div>
)}
{!hasNextPage && items.length > 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
Semua data sudah dimuat.
</p>
)}
</div>
</div>
</>
);
}

View File

@ -1,4 +1,4 @@
import { ArrowRightLeft, Pencil, Trash2 } from 'lucide-react';
import { ArrowRightLeft, Pencil, ScrollText, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button';
@ -17,6 +17,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { Product, ProductVariant } from '../columns';
import { stockMutations } from '@/routes/admin/master/products/variants';
import { TransferStockDialog } from './transfer-stock-dialog';
function formatCurrency(amount: number): string {
@ -181,6 +182,25 @@ export function VariantSubRow({
Transfer Stok
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
window.location.href = stockMutations.url({
product: product.id,
variant: variant.id,
});
}}
>
<ScrollText className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Mutasi Stok
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@ -14,6 +14,7 @@
use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\Product\ProductController;
use App\Http\Controllers\Admin\Master\Product\ProductVariantController;
use App\Http\Controllers\Admin\Master\Product\StockMutationController;
use App\Http\Controllers\Admin\Master\SupplierController;
use App\Http\Controllers\Admin\RoleController;
use Illuminate\Support\Facades\Route;
@ -38,6 +39,7 @@
Route::get('products/{product}/variants/{variant}/edit', [ProductVariantController::class, 'edit'])->name('products.variants.edit');
Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update');
Route::post('products/{product}/variants/{variant}/transfer-stock', [ProductVariantController::class, 'transferStock'])->name('products.variants.transfer-stock');
Route::get('products/{product}/variants/{variant}/stock-mutations', [StockMutationController::class, 'index'])->name('products.variants.stock-mutations');
Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']);
Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']);
});

View File

@ -2526,3 +2526,281 @@ function allPriceTypes(): array
$response->assertRedirect(route('admin.master.products.index'));
});
/*
|--------------------------------------------------------------------------
| STOCK MUTATION AUDIT TRAIL
|--------------------------------------------------------------------------
*/
test('creating product with stock creates initial stock mutations', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => 'Varian Audit',
'stock' => 50,
'reject_stock' => 5,
'retail_stock' => 10,
'photo_keys' => ['product-variant/audit.jpg'],
'prices' => allPriceTypes(),
]],
]));
$variant = ProductVariant::where('name', 'Varian Audit')->first();
$this->assertDatabaseCount('stock_mutations', 3);
$goodMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'good')->first();
expect($goodMutation)->not->toBeNull();
expect($goodMutation->type)->toBe('in');
expect((float) $goodMutation->quantity)->toBe(50.0);
expect((float) $goodMutation->stock_before)->toBe(0.0);
expect((float) $goodMutation->stock_after)->toBe(50.0);
expect($goodMutation->description)->toBe('Stok awal saat pembuatan varian');
$rejectMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'reject')->first();
expect((float) $rejectMutation->quantity)->toBe(5.0);
$retailMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'retail')->first();
expect((float) $retailMutation->quantity)->toBe(10.0);
});
test('creating product with zero stock creates no mutations', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => 'Varian Kosong',
'stock' => 0,
'reject_stock' => 0,
'retail_stock' => 0,
'photo_keys' => ['product-variant/empty.jpg'],
'prices' => allPriceTypes(),
]],
]));
$this->assertDatabaseCount('stock_mutations', 0);
});
test('updating variant stock creates adjustment mutations', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$category = Category::factory()->create();
DB::table('product_categories')->insert([
'product_id' => $product->id,
'category_id' => $category->id,
]);
$variant = ProductVariant::factory()->for($product)->create([
'name' => 'Original',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
]);
foreach (allPriceTypes() as $price) {
ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => $price['type'], 'price' => $price['price']]);
}
$this->put(route('admin.master.products.update', $product), makeValidProductPayload([
'name' => $product->name,
'category_ids' => [$category->id],
'variants' => [[
'id' => $variant->id,
'name' => 'Original',
'stock' => 120,
'reject_stock' => 10,
'retail_stock' => 25,
'photo_keys' => ['product-variant/updated.jpg'],
'prices' => allPriceTypes(),
]],
]));
$goodMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'good')->first();
expect($goodMutation)->not->toBeNull();
expect($goodMutation->type)->toBe('in');
expect((float) $goodMutation->quantity)->toBe(20.0);
expect((float) $goodMutation->stock_before)->toBe(100.0);
expect((float) $goodMutation->stock_after)->toBe(120.0);
$retailMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'retail')->first();
expect((float) $retailMutation->quantity)->toBe(5.0);
});
test('updating variant via single edit creates adjustment mutations', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create([
'name' => 'Single Edit',
'stock' => 80,
'reject_stock' => 5,
'retail_stock' => 15,
]);
$this->put(route('admin.master.products.variants.update', [$product, $variant]), [
'name' => 'Single Edit',
'stock' => 70,
'reject_stock' => 8,
'retail_stock' => 15,
'photo_keys' => ['product-variant/single-edit.jpg'],
'prices' => allPriceTypes(),
]);
$goodMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'good')->first();
expect($goodMutation)->not->toBeNull();
expect($goodMutation->type)->toBe('out');
expect((float) $goodMutation->quantity)->toBe(-10.0);
$rejectMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'reject')->first();
expect((float) $rejectMutation->quantity)->toBe(3.0);
$retailMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'retail')->first();
expect($retailMutation)->toBeNull();
});
test('no mutation created when stock values unchanged', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$category = Category::factory()->create();
DB::table('product_categories')->insert([
'product_id' => $product->id,
'category_id' => $category->id,
]);
$variant = ProductVariant::factory()->for($product)->create([
'name' => 'Unchanged',
'stock' => 50,
'reject_stock' => 10,
'retail_stock' => 20,
]);
foreach (allPriceTypes() as $price) {
ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => $price['type'], 'price' => $price['price']]);
}
$this->put(route('admin.master.products.update', $product), makeValidProductPayload([
'name' => 'Updated Name Only',
'category_ids' => [$category->id],
'variants' => [[
'id' => $variant->id,
'name' => 'Unchanged',
'stock' => 50,
'reject_stock' => 10,
'retail_stock' => 20,
'photo_keys' => ['product-variant/unchanged.jpg'],
'prices' => allPriceTypes(),
]],
]));
$this->assertDatabaseCount('stock_mutations', 0);
});
test('stock mutations record user who performed the action', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$mutation = StockMutation::where('stockable_id', $variant->id)->first();
expect($mutation->user_id)->toBe($user->id);
});
/*
|--------------------------------------------------------------------------
| STOCK MUTATIONS PAGE
|--------------------------------------------------------------------------
*/
test('guest cannot access stock mutations page', function () {
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant]));
$response->assertRedirect(route('login'));
});
test('authenticated user can access stock mutations page', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant]));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/variant/stock-mutations')
->has('mutations.data', 0)
->where('product.id', $product->id)
->where('variant.id', $variant->id)
);
});
test('stock mutations page displays mutations for specific variant', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant]));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/variant/stock-mutations')
->has('mutations.data', 2)
);
});
test('stock mutations page does not show mutations from other variants', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant1 = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$variant2 = ProductVariant::factory()->for($product)->create(['stock' => 50]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant1]), [
'quantity' => 10,
]);
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant1]));
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/variant/stock-mutations')
->has('mutations.data', 2)
);
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant2]));
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/variant/stock-mutations')
->has('mutations.data', 0)
);
});
test('stock mutations page shows correct product and variant info', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create(['name' => 'Produk Test']);
$variant = ProductVariant::factory()->for($product)->create(['name' => 'Varian Test']);
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant]));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('product.name', 'Produk Test')
->where('variant.name', 'Varian Test')
);
});