From 8146da86c62bf8637ff9e3476b568d183d85a05f Mon Sep 17 00:00:00 2001
From: Yoga Pangestu
Date: Sun, 28 Jun 2026 10:42:57 +0700
Subject: [PATCH] feat: add stock ecer transfer functionality with new
controller, service, and frontend modal integration
---
app/Enums/OwnerVerificationAction.php | 2 +
app/Enums/ProductStockQuality.php | 2 +
.../Admin/Manage/StockEcerController.php | 32 ++++
.../Requests/Admin/Manage/OrderRequest.php | 5 +-
.../Admin/Manage/StockEcerTransferRequest.php | 43 +++++
.../Requests/Admin/Master/ProductRequest.php | 2 +
app/Models/ProductVariant.php | 1 +
app/Models/StockEcerHistory.php | 37 ++++
app/Services/Manage/OrderService.php | 25 ++-
.../Manage/OwnerVerificationService.php | 15 +-
app/Services/Manage/StockEcerService.php | 151 +++++++++++++++
app/Services/Master/ProductService.php | 16 ++
app/Services/System/AnalysisService.php | 2 +
.../VerificationChangeFormatter.php | 11 ++
...d_stock_ecer_to_product_variants_table.php | 22 +++
...4400_create_stock_ecer_histories_table.php | 29 +++
.../modal/StockEcerTransferModal.vue | 177 ++++++++++++++++++
resources/js/constants/stock-quality.ts | 1 +
.../admin/manage/orders/form/OrderPosForm.vue | 77 +++++---
.../js/pages/admin/master/products/Edit.vue | 1 +
.../master/products/form/ProductForm.vue | 13 ++
.../products/table/ProductGroupedTable.vue | 63 ++++++-
resources/js/types/order.ts | 4 +
resources/js/types/product.ts | 3 +
routes/web.php | 7 +
25 files changed, 703 insertions(+), 38 deletions(-)
create mode 100644 app/Http/Controllers/Admin/Manage/StockEcerController.php
create mode 100644 app/Http/Requests/Admin/Manage/StockEcerTransferRequest.php
create mode 100644 app/Models/StockEcerHistory.php
create mode 100644 app/Services/Manage/StockEcerService.php
create mode 100644 database/migrations/2026_06_28_093300_add_stock_ecer_to_product_variants_table.php
create mode 100644 database/migrations/2026_06_28_094400_create_stock_ecer_histories_table.php
create mode 100644 resources/js/components/modal/StockEcerTransferModal.vue
diff --git a/app/Enums/OwnerVerificationAction.php b/app/Enums/OwnerVerificationAction.php
index 528b8a9..fea8c8e 100644
--- a/app/Enums/OwnerVerificationAction.php
+++ b/app/Enums/OwnerVerificationAction.php
@@ -13,6 +13,7 @@ enum OwnerVerificationAction: string
case DELETE = 'delete';
case TOGGLE_STATUS = 'toggle_status';
case STOCK_VERIFY = 'stock_verify';
+ case STOCK_ECER_TRANSFER = 'stock_ecer_transfer';
public function label(): string
{
@@ -22,6 +23,7 @@ public function label(): string
self::DELETE => 'Hapus',
self::TOGGLE_STATUS => 'Ubah Status',
self::STOCK_VERIFY => 'Verifikasi Stok',
+ self::STOCK_ECER_TRANSFER => 'Transfer Stok Ecer',
};
}
}
diff --git a/app/Enums/ProductStockQuality.php b/app/Enums/ProductStockQuality.php
index 1b99e98..cfe5282 100644
--- a/app/Enums/ProductStockQuality.php
+++ b/app/Enums/ProductStockQuality.php
@@ -10,12 +10,14 @@ enum ProductStockQuality: string
case GOOD = 'good';
case REJECT = 'reject';
+ case ECER = 'ecer';
public function label(): string
{
return match ($this) {
self::GOOD => 'Bagus',
self::REJECT => 'Reject',
+ self::ECER => 'Eceran',
};
}
}
diff --git a/app/Http/Controllers/Admin/Manage/StockEcerController.php b/app/Http/Controllers/Admin/Manage/StockEcerController.php
new file mode 100644
index 0000000..ceb38c6
--- /dev/null
+++ b/app/Http/Controllers/Admin/Manage/StockEcerController.php
@@ -0,0 +1,32 @@
+validated();
+
+ $this->stockEcerService->transfer(
+ $validated['product_variant_id'],
+ $validated['quantity'],
+ $request->user(),
+ $validated['notes'] ?? null,
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Pengajuan transfer stok ecer berhasil dikirim dan menunggu verifikasi owner.',
+ ]);
+ }
+}
diff --git a/app/Http/Requests/Admin/Manage/OrderRequest.php b/app/Http/Requests/Admin/Manage/OrderRequest.php
index 882df26..d87473b 100644
--- a/app/Http/Requests/Admin/Manage/OrderRequest.php
+++ b/app/Http/Requests/Admin/Manage/OrderRequest.php
@@ -84,7 +84,10 @@ public function attributes(): array
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
- if ($this->filled('marketing_id') && $this->marketing_id !== 'none' && ! $this->filled('customer_id')) {
+ // Skip customer validation for cashier role
+ $isCashier = $this->user()?->hasRole('cashier') ?? false;
+
+ if (! $isCashier && $this->filled('marketing_id') && $this->marketing_id !== 'none' && ! $this->filled('customer_id')) {
$validator->errors()->add('customer_id', 'Pelanggan wajib diisi jika marketing dipilih.');
}
diff --git a/app/Http/Requests/Admin/Manage/StockEcerTransferRequest.php b/app/Http/Requests/Admin/Manage/StockEcerTransferRequest.php
new file mode 100644
index 0000000..4ebfae9
--- /dev/null
+++ b/app/Http/Requests/Admin/Manage/StockEcerTransferRequest.php
@@ -0,0 +1,43 @@
+user()?->can(Permission::STOCKS_VIEW->value) ?? false;
+ }
+
+ /**
+ * @return array
+ */
+ public function rules(): array
+ {
+ return [
+ 'product_variant_id' => [
+ 'required',
+ 'integer',
+ Rule::exists('product_variants', 'id')->whereNull('deleted_at'),
+ ],
+ 'quantity' => ['required', 'integer', 'min:1'],
+ 'notes' => ['nullable', 'string', 'max:500'],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function attributes(): array
+ {
+ return [
+ 'product_variant_id' => 'varian produk',
+ 'quantity' => 'jumlah',
+ 'notes' => 'catatan',
+ ];
+ }
+}
diff --git a/app/Http/Requests/Admin/Master/ProductRequest.php b/app/Http/Requests/Admin/Master/ProductRequest.php
index 319a37d..c7ae080 100644
--- a/app/Http/Requests/Admin/Master/ProductRequest.php
+++ b/app/Http/Requests/Admin/Master/ProductRequest.php
@@ -42,6 +42,7 @@ public function rules(): array
],
'variants.*.name' => ['required', 'string', 'max:200'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
+ 'variants.*.stock_ecer' => ['required', 'integer', 'min:0'],
...$this->variantImageRules(),
];
}
@@ -59,6 +60,7 @@ public function attributes(): array
'variants' => 'varian',
'variants.*.name' => 'nama varian',
'variants.*.stock' => 'stok',
+ 'variants.*.stock_ecer' => 'stok ecer',
...$this->variantImageAttributes('variants', 'foto varian'),
];
}
diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php
index a5fda91..c81d673 100644
--- a/app/Models/ProductVariant.php
+++ b/app/Models/ProductVariant.php
@@ -24,6 +24,7 @@ protected function casts(): array
return [
'stock' => 'integer',
'reject_stock' => 'integer',
+ 'stock_ecer' => 'integer',
];
}
diff --git a/app/Models/StockEcerHistory.php b/app/Models/StockEcerHistory.php
new file mode 100644
index 0000000..b41133e
--- /dev/null
+++ b/app/Models/StockEcerHistory.php
@@ -0,0 +1,37 @@
+ 'integer',
+ 'stock_before' => 'integer',
+ 'stock_ecer_before' => 'integer',
+ 'stock_after' => 'integer',
+ 'stock_ecer_after' => 'integer',
+ 'created_at' => 'datetime',
+ ];
+ }
+
+ public function productVariant(): BelongsTo
+ {
+ return $this->belongsTo(ProductVariant::class);
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php
index c516d8d..b1381bb 100644
--- a/app/Services/Manage/OrderService.php
+++ b/app/Services/Manage/OrderService.php
@@ -72,6 +72,7 @@ public function stockColumn(ProductStockQuality $quality): string
return match ($quality) {
ProductStockQuality::GOOD => 'stock',
ProductStockQuality::REJECT => 'reject_stock',
+ ProductStockQuality::ECER => 'stock_ecer',
};
}
@@ -314,8 +315,15 @@ public function draftItemsForUser(User $user): array
*/
public function syncDraftItem(array $validated, User $user): array
{
- $priceType = PriceType::from($validated['price_type']);
- $stockQuality = ProductStockQuality::from($validated['stock_quality']);
+ // Force ecer price type and stock quality for cashier role
+ $priceType = $user->hasRole('cashier')
+ ? PriceType::ECER
+ : PriceType::from($validated['price_type']);
+
+ $stockQuality = $user->hasRole('cashier')
+ ? ProductStockQuality::ECER
+ : ProductStockQuality::from($validated['stock_quality']);
+
$variant = ProductVariant::query()->findOrFail($validated['product_variant_id']);
$unitPrice = $this->resolveUnitPrice($variant->id, $priceType);
@@ -367,7 +375,10 @@ public function removeDraftItem(User $user, ProductVariant $productVariant, Prod
*/
public function resyncDraftPrices(User $user, string $priceTypeValue): array
{
- $priceType = PriceType::from($priceTypeValue);
+ // Force ecer price type for cashier role
+ $priceType = $user->hasRole('cashier')
+ ? PriceType::ECER
+ : PriceType::from($priceTypeValue);
$items = $this->draftItemsQuery($user)
->with([
@@ -400,6 +411,14 @@ public function resyncDraftPrices(User $user, string $priceTypeValue): array
public function create(array $validated, User $user): Order
{
try {
+ // Force cashier settings
+ if ($user->hasRole('cashier')) {
+ $validated['channel'] = 'store';
+ $validated['price_type'] = 'ecer';
+ $validated['payment_type'] = 'cash';
+ unset($validated['customer_id']);
+ }
+
$order = DB::transaction(function () use ($validated, $user): Order {
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
diff --git a/app/Services/Manage/OwnerVerificationService.php b/app/Services/Manage/OwnerVerificationService.php
index 8f1c570..d1766d3 100644
--- a/app/Services/Manage/OwnerVerificationService.php
+++ b/app/Services/Manage/OwnerVerificationService.php
@@ -8,6 +8,7 @@
use App\Models\Cutting;
use App\Models\OwnerVerificationRequest;
use App\Models\Product;
+use App\Models\ProductVariant;
use App\Models\Purchase;
use App\Models\RawMaterial;
use App\Models\User;
@@ -35,6 +36,7 @@ public function __construct(
private readonly PurchaseService $purchaseService,
private readonly PushNotificationService $pushNotificationService,
private readonly MarketplaceService $marketplaceService,
+ private readonly StockEcerService $stockEcerService,
) {}
/**
@@ -282,6 +284,7 @@ private function rejectVerificationRequest(OwnerVerificationRequest $request): v
RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request),
Purchase::class => $this->purchaseService->rejectVerificationRequest($request),
MarketplaceSettings::class => null,
+ ProductVariant::class => $this->stockEcerService->rejectStockEcerTransfer($request),
default => throw ValidationException::withMessages([
'subject_type' => 'Tipe data verifikasi tidak didukung.',
]),
@@ -295,6 +298,7 @@ private function applyVerificationRequest(OwnerVerificationRequest $request): vo
RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request),
Purchase::class => $this->purchaseService->applyVerificationRequest($request),
MarketplaceSettings::class => $this->marketplaceService->applyVerificationRequest($request),
+ ProductVariant::class => $this->stockEcerService->applyStockEcerTransfer($request),
default => throw ValidationException::withMessages([
'subject_type' => 'Tipe data verifikasi tidak didukung.',
]),
@@ -408,7 +412,7 @@ private function buildVerificationRequestQuery(
$query->where('username', 'like', "%{$search}%")
->orWhereHas('profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%"));
})
- ->orWhereHasMorph('subject', [Product::class, RawMaterial::class, Purchase::class], function (Builder $query, string $type) use ($search): void {
+ ->orWhereHasMorph('subject', [Product::class, RawMaterial::class, Purchase::class, ProductVariant::class], function (Builder $query, string $type) use ($search): void {
if ($type === Purchase::class) {
$query->where('notes', 'like', "%{$search}%")
->orWhereHas('supplier', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
@@ -488,6 +492,13 @@ private function requestTitle(OwnerVerificationRequest $request): string
return $request->subject->supplier?->name ?? 'Belanja';
}
+ if ($request->subject instanceof ProductVariant) {
+ $payload = is_array($request->payload) ? $request->payload : [];
+ $newData = $payload['new'] ?? [];
+
+ return ($newData['product_name'] ?? 'Produk') . ' - ' . ($newData['variant_name'] ?? $request->subject->name);
+ }
+
$payload = is_array($request->payload) ? $request->payload : [];
foreach (['new', 'old'] as $key) {
@@ -523,7 +534,7 @@ private function notifyRequestSubmitter(
}
$url = match ($request->subject_type) {
- Product::class => route('admin.master.products.index'),
+ Product::class, ProductVariant::class => route('admin.master.products.index'),
RawMaterial::class => route('admin.master.raw_materials.index'),
Purchase::class => route('admin.manage.purchases.index'),
MarketplaceSettings::class => route('admin.system.settings.index'),
diff --git a/app/Services/Manage/StockEcerService.php b/app/Services/Manage/StockEcerService.php
new file mode 100644
index 0000000..81fd5fb
--- /dev/null
+++ b/app/Services/Manage/StockEcerService.php
@@ -0,0 +1,151 @@
+ 'Jumlah harus lebih dari 0.',
+ ]);
+ }
+
+ $variant = ProductVariant::query()->findOrFail($variantId);
+
+ if ($variant->stock < $quantity) {
+ throw ValidationException::withMessages([
+ 'quantity' => "Stok bagus tidak mencukupi. Stok saat ini: {$variant->stock} pcs.",
+ ]);
+ }
+
+ $existingPending = OwnerVerificationRequest::query()
+ ->where('subject_type', ProductVariant::class)
+ ->where('subject_id', $variant->id)
+ ->where('action', OwnerVerificationAction::STOCK_ECER_TRANSFER)
+ ->where('status', OwnerVerificationStatus::PENDING)
+ ->exists();
+
+ if ($existingPending) {
+ throw ValidationException::withMessages([
+ 'quantity' => 'Masih ada pengajuan transfer stok ecer yang menunggu verifikasi untuk varian ini.',
+ ]);
+ }
+
+ try {
+ $request = OwnerVerificationRequest::create([
+ 'action' => OwnerVerificationAction::STOCK_ECER_TRANSFER,
+ 'status' => OwnerVerificationStatus::PENDING,
+ 'subject_type' => ProductVariant::class,
+ 'subject_id' => $variant->id,
+ 'submitted_by_id' => $user->id,
+ 'payload' => [
+ 'old' => [
+ 'stock' => $variant->stock,
+ 'stock_ecer' => $variant->stock_ecer,
+ ],
+ 'new' => [
+ 'quantity' => $quantity,
+ 'stock' => $variant->stock - $quantity,
+ 'stock_ecer' => $variant->stock_ecer + $quantity,
+ 'notes' => $notes,
+ 'variant_name' => $variant->name,
+ 'product_name' => $variant->product?->name ?? '-',
+ ],
+ ],
+ ]);
+
+ $this->pushNotificationService->sendToRoles(
+ '馃摝 Transfer Stok Ecer Menunggu Persetujuan Owner',
+ "Pengajuan transfer {$quantity} pcs stok ecer untuk varian '{$variant->name}' menunggu verifikasi owner.",
+ ['owner', 'developer'],
+ route('admin.master.products.index'),
+ );
+
+ $this->pushNotificationService->sendToUser(
+ '馃摛 Pengajuan Transfer Terkirim',
+ "Pengajuan transfer {$quantity} pcs stok ecer untuk varian '{$variant->name}' telah dikirim dan menunggu verifikasi owner.",
+ $user->id,
+ route('admin.master.products.index'),
+ );
+
+ return $request;
+ } catch (ValidationException $e) {
+ throw $e;
+ } catch (\Throwable $e) {
+ Log::error('Gagal mengajukan transfer stok ecer: '.$e->getMessage(), [
+ 'trace' => $e->getTraceAsString(),
+ ]);
+
+ throw ValidationException::withMessages([
+ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
+ ]);
+ }
+ }
+
+ /**
+ * Apply the stock ecer transfer when owner approves.
+ */
+ public function applyStockEcerTransfer(OwnerVerificationRequest $request): void
+ {
+ $payload = $request->payload ?? [];
+ $newData = $payload['new'] ?? [];
+ $quantity = (int) ($newData['quantity'] ?? 0);
+
+ if ($quantity <= 0) {
+ return;
+ }
+
+ DB::transaction(function () use ($request, $quantity, $newData): void {
+ $variant = ProductVariant::query()
+ ->whereKey($request->subject_id)
+ ->lockForUpdate()
+ ->firstOrFail();
+
+ $stockBefore = $variant->stock;
+ $stockEcerBefore = $variant->stock_ecer;
+
+ $variant->decrement('stock', $quantity);
+ $variant->increment('stock_ecer', $quantity);
+
+ StockEcerHistory::create([
+ 'product_variant_id' => $variant->id,
+ 'user_id' => $request->submitted_by_id,
+ 'quantity' => $quantity,
+ 'stock_before' => $stockBefore,
+ 'stock_ecer_before' => $stockEcerBefore,
+ 'stock_after' => $stockBefore - $quantity,
+ 'stock_ecer_after' => $stockEcerBefore + $quantity,
+ 'notes' => $newData['notes'] ?? null,
+ 'created_at' => now(),
+ ]);
+ });
+ }
+
+ /**
+ * Reject the stock ecer transfer (no-op since nothing changed).
+ */
+ public function rejectStockEcerTransfer(OwnerVerificationRequest $request): void
+ {
+ // No-op: nothing was changed yet, so nothing to rollback.
+ }
+}
diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php
index b8b9b13..1842a01 100644
--- a/app/Services/Master/ProductService.php
+++ b/app/Services/Master/ProductService.php
@@ -76,6 +76,17 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
$pendingRequest = $product->pendingOwnerVerificationRequest;
+ // Also check for pending stock ecer transfer on variants
+ if ($pendingRequest === null) {
+ $pendingRequest = OwnerVerificationRequest::query()
+ ->where('subject_type', ProductVariant::class)
+ ->whereIn('subject_id', $product->variants->pluck('id'))
+ ->where('action', OwnerVerificationAction::STOCK_ECER_TRANSFER)
+ ->pending()
+ ->latest()
+ ->first();
+ }
+
$product->setAttribute('has_pending_request', $pendingRequest !== null);
$product->setAttribute('pending_request_id', $pendingRequest?->id);
$product->setAttribute('pending_request_action', $pendingRequest?->action->value);
@@ -108,6 +119,7 @@ public function create(array $validated, User $user): void
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
+ 'stock_ecer' => $variantData['stock_ecer'],
]);
$this->syncVariantImages($variant, $variantData, $index);
@@ -369,6 +381,7 @@ private function applyPayloadToProduct(
$variant->update([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
+ 'stock_ecer' => $variantData['stock_ecer'],
]);
if ($verificationRequest !== null) {
@@ -381,6 +394,7 @@ private function applyPayloadToProduct(
$variant = $product->variants()->create([
'name' => $variantData['name'],
'stock' => $variantData['stock'],
+ 'stock_ecer' => $variantData['stock_ecer'],
]);
if ($verificationRequest !== null) {
@@ -525,6 +539,7 @@ private function snapshotProduct(Product $product): array
'id' => $variant->id,
'name' => $variant->name,
'stock' => $variant->stock,
+ 'stock_ecer' => $variant->stock_ecer,
])
->all(),
]);
@@ -560,6 +575,7 @@ private function buildPayloadFromValidated(array $validated): array
'id' => $variantData['id'] ?? null,
'name' => $variantData['name'],
'stock' => $variantData['stock'],
+ 'stock_ecer' => $variantData['stock_ecer'],
'remove_media_ids' => $variantData['remove_media_ids'] ?? [],
])
->all(),
diff --git a/app/Services/System/AnalysisService.php b/app/Services/System/AnalysisService.php
index 1e6e37b..fb85a78 100644
--- a/app/Services/System/AnalysisService.php
+++ b/app/Services/System/AnalysisService.php
@@ -498,6 +498,7 @@ public function getProductStock(): array
->selectRaw('
SUM(product_variants.stock) as total_stock,
SUM(product_variants.reject_stock) as total_reject,
+ SUM(product_variants.stock_ecer) as total_ecer,
COUNT(product_variants.id) as total_variants,
COUNT(DISTINCT products.id) as total_products
')
@@ -515,6 +516,7 @@ public function getProductStock(): array
return [
'total_stock' => (int) ($variants->total_stock ?? 0),
'total_reject' => (int) ($variants->total_reject ?? 0),
+ 'total_ecer' => (int) ($variants->total_ecer ?? 0),
'total_value' => (int) ($totalValue ?? 0),
'total_products' => (int) ($variants->total_products ?? 0),
'total_variants' => (int) ($variants->total_variants ?? 0),
diff --git a/app/Support/OwnerVerification/VerificationChangeFormatter.php b/app/Support/OwnerVerification/VerificationChangeFormatter.php
index 260b875..2cf41fc 100644
--- a/app/Support/OwnerVerification/VerificationChangeFormatter.php
+++ b/app/Support/OwnerVerification/VerificationChangeFormatter.php
@@ -10,6 +10,8 @@ class VerificationChangeFormatter
private const HIDDEN_FIELDS = [
'category_ids',
'unit',
+ 'variant_name',
+ 'product_name',
];
/**
@@ -73,6 +75,11 @@ private static function label(string $field): string
'is_active' => 'Status Aktif',
'variants' => 'Varian',
'prices' => 'Varian Harga',
+ 'quantity' => 'Jumlah Transfer',
+ 'stock' => 'Stok Bagus',
+ 'stock_ecer' => 'Stok Ecer',
+ 'variant_name' => 'Varian',
+ 'product_name' => 'Produk',
'tiktok_shop_platform_commission' => 'TikTok Shop - Komisi Platform',
'tiktok_shop_logistics_service_fee' => 'TikTok Shop - Biaya Logistik',
'tiktok_shop_dynamic_commission' => 'TikTok Shop - Komisi Dinamis',
@@ -179,6 +186,10 @@ private static function presentValue(string $field, mixed $value): mixed
return implode(', ', $value);
}
+ if (in_array($field, ['quantity', 'stock', 'stock_ecer'], true)) {
+ return (int) $value;
+ }
+
return $value;
}
}
diff --git a/database/migrations/2026_06_28_093300_add_stock_ecer_to_product_variants_table.php b/database/migrations/2026_06_28_093300_add_stock_ecer_to_product_variants_table.php
new file mode 100644
index 0000000..05593ee
--- /dev/null
+++ b/database/migrations/2026_06_28_093300_add_stock_ecer_to_product_variants_table.php
@@ -0,0 +1,22 @@
+unsignedInteger('stock_ecer')->default(0)->after('reject_stock');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('product_variants', function (Blueprint $table) {
+ $table->dropColumn('stock_ecer');
+ });
+ }
+};
diff --git a/database/migrations/2026_06_28_094400_create_stock_ecer_histories_table.php b/database/migrations/2026_06_28_094400_create_stock_ecer_histories_table.php
new file mode 100644
index 0000000..f4c58b7
--- /dev/null
+++ b/database/migrations/2026_06_28_094400_create_stock_ecer_histories_table.php
@@ -0,0 +1,29 @@
+id();
+ $table->foreignId('product_variant_id')->constrained('product_variants')->cascadeOnDelete();
+ $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+ $table->unsignedInteger('quantity');
+ $table->unsignedInteger('stock_before');
+ $table->unsignedInteger('stock_ecer_before');
+ $table->unsignedInteger('stock_after');
+ $table->unsignedInteger('stock_ecer_after');
+ $table->string('notes')->nullable();
+ $table->timestamp('created_at')->useCurrent();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('stock_ecer_histories');
+ }
+};
diff --git a/resources/js/components/modal/StockEcerTransferModal.vue b/resources/js/components/modal/StockEcerTransferModal.vue
new file mode 100644
index 0000000..937176c
--- /dev/null
+++ b/resources/js/components/modal/StockEcerTransferModal.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
diff --git a/resources/js/constants/stock-quality.ts b/resources/js/constants/stock-quality.ts
index d94fb5d..a0706dc 100644
--- a/resources/js/constants/stock-quality.ts
+++ b/resources/js/constants/stock-quality.ts
@@ -1,4 +1,5 @@
export const StockQuality = {
GOOD: 'good',
REJECT: 'reject',
+ ECER: 'ecer',
} as const;
diff --git a/resources/js/pages/admin/manage/orders/form/OrderPosForm.vue b/resources/js/pages/admin/manage/orders/form/OrderPosForm.vue
index 8975e49..f2bd209 100644
--- a/resources/js/pages/admin/manage/orders/form/OrderPosForm.vue
+++ b/resources/js/pages/admin/manage/orders/form/OrderPosForm.vue
@@ -51,7 +51,7 @@ import { formErrors } from '@/lib/form';
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
import { store as syncDraftRoute, resync_prices as resyncPricesRoute, destroy as destroyDraftRoute } from '@/routes/admin/manage/orders/draft_items';
import type { Auth } from '@/types/auth';
-import { STOCK_QUALITY_OPTIONS } from '@/types/order';
+import { STOCK_QUALITY_OPTIONS, STOCK_QUALITY_OPTIONS_CASHIER } from '@/types/order';
import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order';
import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
import PosCatalogCard from '../../shared/PosCatalogCard.vue';
@@ -99,8 +99,12 @@ const isMarketingUser = computed(() =>
(authUser.value?.roles?.includes('marketing-offline') || authUser.value?.roles?.includes('marketing-online')) ?? false,
);
+const isCashierUser = computed(() =>
+ authUser.value?.roles?.includes('cashier') ?? false,
+);
+
const search = ref('');
-const selectedStockQuality = ref<'good' | 'reject'>(StockQuality.GOOD);
+const selectedStockQuality = ref<'good' | 'reject' | 'ecer'>(isCashierUser.value ? StockQuality.ECER : StockQuality.GOOD);
const cart = ref([]);
const customerFormOpen = ref(false);
const cartDetailOpen = ref(false);
@@ -233,7 +237,9 @@ function upsertCartItem(item: OrderCartItem) {
}
function availableStockForVariant(variant: ProductVariantItem, stockQuality: string): number {
- return stockQuality === StockQuality.REJECT ? variant.reject_stock : variant.stock;
+ if (stockQuality === StockQuality.REJECT) return variant.reject_stock;
+ if (stockQuality === StockQuality.ECER) return variant.stock_ecer ?? 0;
+ return variant.stock;
}
const filteredCatalog = computed(() => {
@@ -313,7 +319,7 @@ async function addToCart(product: OrderCatalogItem, variant: ProductVariantItem)
const availableStock = availableStockForVariant(variant, stockQuality);
if (availableStock < 1) {
- toast.error(`Stok ${stockQuality === StockQuality.REJECT ? 'reject' : 'bagus'} tidak tersedia.`);
+ toast.error(`Stok ${stockQuality === StockQuality.REJECT ? 'reject' : stockQuality === StockQuality.ECER ? 'ecer' : 'bagus'} tidak tersedia.`);
return;
}
@@ -326,7 +332,7 @@ async function addToCart(product: OrderCatalogItem, variant: ProductVariantItem)
const nextQty = existing ? (Number(existing.quantity) || 0) + 1 : 1;
if (nextQty > availableStock) {
- toast.error(`Stok ${stockQuality === StockQuality.REJECT ? 'reject' : 'bagus'} tidak mencukupi.`);
+ toast.error(`Stok ${stockQuality === StockQuality.REJECT ? 'reject' : stockQuality === StockQuality.ECER ? 'ecer' : 'bagus'} tidak mencukupi.`);
return;
}
@@ -433,7 +439,7 @@ function buildFormData(): FormData {
formData.append('_method', 'PUT');
}
- if (form.customer_id) {
+ if (!isCashierUser.value && form.customer_id) {
formData.append('customer_id', form.customer_id);
}
@@ -443,20 +449,29 @@ function buildFormData(): FormData {
formData.append('marketing_id', String(authUser.value.id));
}
- formData.append('channel', form.channel);
- formData.append('price_type', form.price_type);
- formData.append('payment_type', form.payment_type);
+ // For cashier, force store channel with ecer price type and cash payment
+ if (isCashierUser.value) {
+ formData.append('channel', 'store');
+ formData.append('price_type', 'ecer');
+ formData.append('payment_type', OrderPaymentType.CASH);
+ } else {
+ formData.append('channel', form.channel);
+ formData.append('price_type', form.price_type);
+ formData.append('payment_type', form.payment_type);
+ }
- if (!isStoreChannel.value) {
+ if (!isStoreChannel.value && !isCashierUser.value) {
formData.append('is_affiliate', form.is_affiliate ? '1' : '0');
}
- if (form.channel === OrderChannel.TIKTOK && form.tiktok_order_id) {
- formData.append('tiktok_order_id', form.tiktok_order_id);
- }
+ if (!isCashierUser.value) {
+ if (form.channel === OrderChannel.TIKTOK && form.tiktok_order_id) {
+ formData.append('tiktok_order_id', form.tiktok_order_id);
+ }
- if (form.channel === OrderChannel.SHOPEE && form.shopee_order_id) {
- formData.append('shopee_order_id', form.shopee_order_id);
+ if (form.channel === OrderChannel.SHOPEE && form.shopee_order_id) {
+ formData.append('shopee_order_id', form.shopee_order_id);
+ }
}
formData.append('discount', parseRupiah(form.discount));
@@ -512,7 +527,7 @@ function submit() {
-
+
- Bagus: {{ variant.stock }}
- 路
- Reject: {{ variant.reject_stock ?? 0 }}
+ Ecer: {{ variant.stock_ecer ?? 0 }}
+
+ Bagus: {{ variant.stock }}
+ 路
+ Reject: {{ variant.reject_stock ?? 0 }}
+
路
{{ getVariantPrice(variant)!.price_formatted }}
@@ -609,7 +627,7 @@ function submit() {