diff --git a/app/Http/Controllers/Admin/Master/Product/ProductVariantController.php b/app/Http/Controllers/Admin/Master/Product/ProductVariantController.php
index ec2e314..c334947 100644
--- a/app/Http/Controllers/Admin/Master/Product/ProductVariantController.php
+++ b/app/Http/Controllers/Admin/Master/Product/ProductVariantController.php
@@ -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'
+ );
+ }
}
diff --git a/app/Http/Requests/Admin/Master/Product/TransferStockRequest.php b/app/Http/Requests/Admin/Master/Product/TransferStockRequest.php
new file mode 100644
index 0000000..caa235a
--- /dev/null
+++ b/app/Http/Requests/Admin/Master/Product/TransferStockRequest.php
@@ -0,0 +1,29 @@
+ ['required', 'integer', 'min:1'],
+ 'description' => ['nullable', 'string', 'max:500'],
+ ];
+ }
+
+ public function attributes(): array
+ {
+ return [
+ 'quantity' => 'Jumlah Transfer',
+ 'description' => 'Keterangan',
+ ];
+ }
+}
diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php
index aa6427c..6fa54db 100644
--- a/app/Models/ProductVariant.php
+++ b/app/Models/ProductVariant.php
@@ -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);
+ }
}
diff --git a/app/Services/Admin/Master/Product/ProductVariantService.php b/app/Services/Admin/Master/Product/ProductVariantService.php
index 5214258..4a74d8d 100644
--- a/app/Services/Admin/Master/Product/ProductVariantService.php
+++ b/app/Services/Admin/Master/Product/ProductVariantService.php
@@ -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();
+ }
}
diff --git a/resources/js/pages/admin/master/product/variant/sub-row.tsx b/resources/js/pages/admin/master/product/variant/sub-row.tsx
index 91b4d75..0a327fe 100644
--- a/resources/js/pages/admin/master/product/variant/sub-row.tsx
+++ b/resources/js/pages/admin/master/product/variant/sub-row.tsx
@@ -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 (
@@ -84,7 +89,7 @@ export function VariantSubRow({
Stok Ecer
Harga
-
+
Aksi
@@ -151,6 +156,25 @@ export function VariantSubRow({
+
+
+
+
+
+ Transfer Stok
+
+
);
}
diff --git a/resources/js/pages/admin/master/product/variant/transfer-stock-dialog.tsx b/resources/js/pages/admin/master/product/variant/transfer-stock-dialog.tsx
new file mode 100644
index 0000000..d174ba2
--- /dev/null
+++ b/resources/js/pages/admin/master/product/variant/transfer-stock-dialog.tsx
@@ -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 (
+
+ );
+}
diff --git a/routes/web.php b/routes/web.php
index 471d3a7..03e3112 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -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']);
});
diff --git a/tests/Feature/Admin/Master/ProductTest.php b/tests/Feature/Admin/Master/ProductTest.php
index 843d371..1b6cac7 100644
--- a/tests/Feature/Admin/Master/ProductTest.php
+++ b/tests/Feature/Admin/Master/ProductTest.php
@@ -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'));
+});