feat: implement stock transfer functionality for product variants with validation and notifications

This commit is contained in:
Yoga Pangestu 2026-08-01 15:15:38 +07:00
parent fbd7aa5862
commit 15161b6ea4
8 changed files with 523 additions and 3 deletions

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\Product\ProductVariantRequest;
use App\Http\Requests\Admin\Master\Product\TransferStockRequest;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Services\Admin\Master\Product\ProductVariantService;
@ -43,4 +44,13 @@ public function destroy(Product $product, ProductVariant $variant): RedirectResp
'admin.master.products.index'
);
}
public function transferStock(TransferStockRequest $request, Product $product, ProductVariant $variant): RedirectResponse
{
return $this->handleAction(
fn () => $this->variantService->transferStock($variant, $request->validated()),
'Transfer stok berhasil dilakukan.',
'admin.master.products.index'
);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Admin\Master\Product;
use Illuminate\Foundation\Http\FormRequest;
class TransferStockRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'quantity' => ['required', 'integer', 'min:1'],
'description' => ['nullable', 'string', 'max:500'],
];
}
public function attributes(): array
{
return [
'quantity' => 'Jumlah Transfer',
'description' => 'Keterangan',
];
}
}

View File

@ -45,4 +45,10 @@ public function stokOpnameItems(): HasMany
{
return $this->hasMany(StokOpnameItem::class);
}
public function stockMutations(): HasMany
{
return $this->hasMany(StockMutation::class, 'stockable_id')
->where('stockable_type', self::class);
}
}

View File

@ -5,10 +5,12 @@
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 Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ProductVariantService
{
@ -113,4 +115,58 @@ public function getTemporaryUrl(ProductVariant $variant): ?string
return $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null;
}
public function transferStock(ProductVariant $variant, array $data): ProductVariant
{
$quantity = (int) $data['quantity'];
if ($variant->stock < $quantity) {
throw ValidationException::withMessages([
'quantity' => "Stok bagus tidak mencukupi. Stok tersedia: {$variant->stock}.",
]);
}
DB::transaction(function () use ($variant, $quantity, $data) {
$stockBefore = $variant->stock;
$retailBefore = $variant->retail_stock;
$variant->update([
'stock' => $stockBefore - $quantity,
'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',
]);
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Transfer Stok',
body: "{$quantity} unit dari varian \"{$variant->name}\" berhasil ditransfer dari stok bagus ke stok ecer".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
);
return $variant->fresh();
}
}

View File

@ -1,4 +1,4 @@
import { Pencil, Trash2 } from 'lucide-react';
import { ArrowRightLeft, Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button';
@ -16,7 +16,8 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { Product, ProductVariant } from './columns';
import type { Product, ProductVariant } from '../columns';
import { TransferStockDialog } from './transfer-stock-dialog';
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
@ -65,6 +66,10 @@ export function VariantSubRow({
onDeleteVariantClick: (product: Product, variant: ProductVariant) => void;
}) {
const variants = product.product_variants ?? [];
const [transferVariant, setTransferVariant] = useState<{
product: Product;
variant: ProductVariant;
} | null>(null);
return (
<div className="overflow-x-auto">
@ -84,7 +89,7 @@ export function VariantSubRow({
</TableHead>
<TableHead className="text-center">Stok Ecer</TableHead>
<TableHead>Harga</TableHead>
<TableHead className="w-[80px] text-center">
<TableHead className="w-[120px] text-center">
Aksi
</TableHead>
</TableRow>
@ -151,6 +156,25 @@ export function VariantSubRow({
<TableCell>
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
setTransferVariant({
product,
variant,
})
}
>
<ArrowRightLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Transfer Stok
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -197,6 +221,19 @@ export function VariantSubRow({
)}
</TableBody>
</Table>
{transferVariant && (
<TransferStockDialog
open={transferVariant !== null}
onOpenChange={(open) => {
if (!open) {
setTransferVariant(null);
}
}}
product={transferVariant.product}
variant={transferVariant.variant}
/>
)}
</div>
);
}

View File

@ -0,0 +1,121 @@
import { Form } from '@inertiajs/react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { transferStock } from '@/routes/admin/master/products/variants';
import type { Product, ProductVariant } from '../columns';
type TransferStockDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
product: Product;
variant: ProductVariant;
};
export function TransferStockDialog({
open,
onOpenChange,
product,
variant,
}: TransferStockDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<Form
action={transferStock.post({
product: product.id,
variant: variant.id,
})}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors, processing }) => (
<>
<DialogHeader>
<DialogTitle>
Transfer Stok Bagus Stok Ecer
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label className="text-sm text-muted-foreground">
Produk
</Label>
<p className="text-sm font-medium">
{product.name}
</p>
</div>
<div className="grid gap-2">
<Label className="text-sm text-muted-foreground">
Varian
</Label>
<p className="text-sm font-medium">
{variant.name}
</p>
</div>
<div className="grid gap-2">
<Label className="text-sm text-muted-foreground">
Stok Bagus Tersedia
</Label>
<p className="text-sm font-medium">
{variant.stock}
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="quantity">
Jumlah Transfer{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="quantity"
name="quantity"
type="number"
min={1}
max={variant.stock}
placeholder="Masukkan jumlah transfer"
/>
<InputError message={errors.quantity} />
</div>
<div className="grid gap-2">
<Label htmlFor="description">
Keterangan
</Label>
<Textarea
id="description"
name="description"
placeholder="Masukkan keterangan"
rows={3}
/>
<InputError message={errors.description} />
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Batal
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Transfer'}
</Button>
</DialogFooter>
</>
)}
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -37,6 +37,7 @@
Route::delete('products/{product}/variants/{variant}', [ProductVariantController::class, 'destroy'])->name('products.variants.destroy');
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::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']);
Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']);
});

View File

@ -4,6 +4,7 @@
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\StockMutation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
@ -2266,3 +2267,262 @@ function allPriceTypes(): array
$response = $this->delete(route('admin.master.products.variants.destroy', [$product, 99999]));
$response->assertStatus(404);
});
/*
|--------------------------------------------------------------------------
| TRANSFER STOCK (BAGUS -> ECER)
|--------------------------------------------------------------------------
*/
test('guest cannot transfer stock', function () {
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$response->assertRedirect(route('login'));
});
test('authenticated user can transfer stock', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create([
'stock' => 100,
'retail_stock' => 20,
]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$response->assertRedirect();
$variant->refresh();
expect($variant->stock)->toBe(90);
expect($variant->retail_stock)->toBe(30);
});
test('transfer stock creates two stock mutation records', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100, 'retail_stock' => 20]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$this->assertDatabaseCount('stock_mutations', 2);
$outMutation = StockMutation::where('stockable_id', $variant->id)->where('type', 'out')->first();
expect($outMutation)->not->toBeNull();
expect((float) $outMutation->quantity)->toBe(-10.0);
expect((float) $outMutation->stock_before)->toBe(100.0);
expect((float) $outMutation->stock_after)->toBe(90.0);
expect($outMutation->stock_quality)->toBe('good');
expect($outMutation->user_id)->toBe($user->id);
$inMutation = StockMutation::where('stockable_id', $variant->id)->where('type', 'in')->first();
expect($inMutation)->not->toBeNull();
expect((float) $inMutation->quantity)->toBe(10.0);
expect((float) $inMutation->stock_before)->toBe(20.0);
expect((float) $inMutation->stock_after)->toBe(30.0);
expect($inMutation->stock_quality)->toBe('retail');
expect($inMutation->user_id)->toBe($user->id);
});
test('transfer stock with description saves to mutations', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 50, 'retail_stock' => 10]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 5,
'description' => 'Transfer untuk display toko',
]);
$mutation = StockMutation::where('stockable_id', $variant->id)->where('type', 'out')->first();
expect($mutation->description)->toBe('Transfer untuk display toko');
});
test('transfer stock without description uses default', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 50, 'retail_stock' => 10]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 5,
]);
$mutation = StockMutation::where('stockable_id', $variant->id)->where('type', 'out')->first();
expect($mutation->description)->toBe('Transfer stok bagus ke stok ecer');
});
test('transfer stock quantity must be provided', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), []);
$variant->refresh();
expect($variant->stock)->toBe(100);
$this->assertDatabaseCount('stock_mutations', 0);
});
test('transfer stock quantity must be integer', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 'abc',
]);
$variant->refresh();
expect($variant->stock)->toBe(100);
$this->assertDatabaseCount('stock_mutations', 0);
});
test('transfer stock quantity must be at least 1', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 0,
]);
$variant->refresh();
expect($variant->stock)->toBe(100);
$this->assertDatabaseCount('stock_mutations', 0);
});
test('transfer stock quantity cannot exceed available stock', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 10, 'retail_stock' => 5]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 15,
]);
$variant->refresh();
expect($variant->stock)->toBe(10);
expect($variant->retail_stock)->toBe(5);
$this->assertDatabaseCount('stock_mutations', 0);
});
test('transfer stock exactly equal to available stock is allowed', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 10, 'retail_stock' => 5]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$response->assertRedirect();
$variant->refresh();
expect($variant->stock)->toBe(0);
expect($variant->retail_stock)->toBe(15);
});
test('transfer stock from zero stock is rejected', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 0, 'retail_stock' => 0]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 1,
]);
$variant->refresh();
expect($variant->stock)->toBe(0);
expect($variant->retail_stock)->toBe(0);
$this->assertDatabaseCount('stock_mutations', 0);
});
test('transfer stock multiple times sequentially', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100, 'retail_stock' => 0]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), ['quantity' => 20]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), ['quantity' => 30]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), ['quantity' => 10]);
$variant->refresh();
expect($variant->stock)->toBe(40);
expect($variant->retail_stock)->toBe(60);
$this->assertDatabaseCount('stock_mutations', 6);
});
test('transfer stock does not affect reject_stock', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create([
'stock' => 100,
'reject_stock' => 50,
'retail_stock' => 10,
]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 25,
]);
$variant->refresh();
expect($variant->stock)->toBe(75);
expect($variant->reject_stock)->toBe(50);
expect($variant->retail_stock)->toBe(35);
});
test('transfer stock on non-existent variant returns 404', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, 99999]), [
'quantity' => 10,
]);
$response->assertStatus(404);
});
test('transfer stock redirects to product index', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$response->assertRedirect(route('admin.master.products.index'));
});