feat: add stock quality management to StokOpname; update validation rules, database schema, and frontend components to support stock quality categorization (good, retail, reject) for improved inventory tracking and reporting
This commit is contained in:
parent
18ccd7a3c0
commit
0d77d6a4fd
@ -25,6 +25,7 @@ public function rules(): array
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'items' => ['nullable', 'array'],
|
||||
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
|
||||
'items.*.stock_quality' => ['required', \Illuminate\Validation\Rule::enum(\App\Enums\ProductStockQuality::class)],
|
||||
'items.*.physical_stock' => ['nullable', 'integer', 'min:0'],
|
||||
'items.*.notes' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
|
||||
@ -23,6 +23,7 @@ public function rules(): array
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'items' => ['required', 'array', 'min:1'],
|
||||
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
|
||||
'items.*.stock_quality' => ['required', \Illuminate\Validation\Rule::enum(\App\Enums\ProductStockQuality::class)],
|
||||
'items.*.physical_stock' => ['required', 'integer', 'min:0'],
|
||||
'items.*.notes' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ProductStockQuality;
|
||||
use App\Models\Concerns\InteractsWithActivityLog;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@ -21,9 +22,23 @@ protected function casts(): array
|
||||
'difference' => 'integer',
|
||||
'physical_stock' => 'integer',
|
||||
'system_stock' => 'integer',
|
||||
'stock_quality' => ProductStockQuality::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected $appends = ['product_name', 'variant_name'];
|
||||
|
||||
public function getProductNameAttribute(): string
|
||||
{
|
||||
return $this->productVariant->product->name;
|
||||
}
|
||||
|
||||
public function getVariantNameAttribute(): string
|
||||
{
|
||||
$qualityLabel = $this->stock_quality ? ' (' . $this->stock_quality->label() . ')' : '';
|
||||
return $this->productVariant->name . $qualityLabel;
|
||||
}
|
||||
|
||||
// 3. Relation
|
||||
public function productVariant(): BelongsTo
|
||||
{
|
||||
|
||||
@ -54,7 +54,7 @@ public function catalogItems(): array
|
||||
->active()
|
||||
->with([
|
||||
'variants' => fn ($query) => $query
|
||||
->select('id', 'product_id', 'name', 'stock')
|
||||
->select('id', 'product_id', 'name', 'stock', 'retail_stock', 'reject_stock')
|
||||
->orderBy('created_at'),
|
||||
])
|
||||
->orderBy('name')
|
||||
@ -67,6 +67,10 @@ public function catalogItems(): array
|
||||
'name' => $variant->name,
|
||||
'stock' => $variant->stock,
|
||||
'stock_formatted' => $variant->stock_formatted,
|
||||
'retail_stock' => $variant->retail_stock,
|
||||
'retail_stock_formatted' => $variant->retail_stock_formatted,
|
||||
'reject_stock' => $variant->reject_stock,
|
||||
'reject_stock_formatted' => $variant->reject_stock_formatted,
|
||||
]),
|
||||
])
|
||||
->toArray();
|
||||
@ -90,6 +94,7 @@ public function findForEdit(StokOpname $stokOpname): array
|
||||
'product_variant_id' => $item->product_variant_id,
|
||||
'variant_name' => $item->productVariant->name,
|
||||
'product_name' => $item->productVariant->product->name,
|
||||
'stock_quality' => $item->stock_quality->value,
|
||||
'system_stock' => $item->system_stock,
|
||||
'physical_stock' => $item->physical_stock,
|
||||
'difference' => $item->difference,
|
||||
@ -235,8 +240,13 @@ public function verify(StokOpname $stokOpname, User $user, ?string $verification
|
||||
DB::transaction(function () use ($stokOpname, $user, $verificationNotes): void {
|
||||
foreach ($stokOpname->items as $item) {
|
||||
if ($item->difference !== 0) {
|
||||
$column = match ($item->stock_quality) {
|
||||
\App\Enums\ProductStockQuality::GOOD => 'stock',
|
||||
\App\Enums\ProductStockQuality::RETAIL => 'retail_stock',
|
||||
\App\Enums\ProductStockQuality::REJECT => 'reject_stock',
|
||||
};
|
||||
$item->productVariant()->update([
|
||||
'stock' => $item->physical_stock,
|
||||
$column => $item->physical_stock,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -346,10 +356,23 @@ private function syncItems(StokOpname $stokOpname, array $items): void
|
||||
$stokOpname->items()->delete();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$systemStock = ProductVariant::find($item['product_variant_id'])?->stock ?? 0;
|
||||
$variant = ProductVariant::find($item['product_variant_id']);
|
||||
if (!$variant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quality = \App\Enums\ProductStockQuality::from($item['stock_quality']);
|
||||
$column = match ($quality) {
|
||||
\App\Enums\ProductStockQuality::GOOD => 'stock',
|
||||
\App\Enums\ProductStockQuality::RETAIL => 'retail_stock',
|
||||
\App\Enums\ProductStockQuality::REJECT => 'reject_stock',
|
||||
};
|
||||
|
||||
$systemStock = $variant->$column ?? 0;
|
||||
|
||||
$stokOpname->items()->create([
|
||||
'product_variant_id' => $item['product_variant_id'],
|
||||
'stock_quality' => $quality->value,
|
||||
'system_stock' => $systemStock,
|
||||
'physical_stock' => $item['physical_stock'],
|
||||
'difference' => $item['physical_stock'] - $systemStock,
|
||||
|
||||
@ -325,39 +325,39 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
|
||||
$deduction = $discount + $fees;
|
||||
|
||||
$monthItems = $monthlyItemsData->get($key, collect());
|
||||
$gudangItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'good'
|
||||
$warehouseItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'good'
|
||||
);
|
||||
$ecerItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'retail'
|
||||
$retailItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'retail'
|
||||
);
|
||||
$rejectItem = $monthItems->first(fn ($item) => ($item->stock_quality instanceof ProductStockQuality ? $item->stock_quality->value : $item->stock_quality) === 'reject'
|
||||
);
|
||||
|
||||
$gudangSubtotal = (int) ($gudangItem?->subtotal ?? 0);
|
||||
$gudangHpp = (int) ($gudangItem?->hpp ?? 0);
|
||||
$ecerSubtotal = (int) ($ecerItem?->subtotal ?? 0);
|
||||
$ecerHpp = (int) ($ecerItem?->hpp ?? 0);
|
||||
$warehouseSubtotal = (int) ($warehouseItem?->subtotal ?? 0);
|
||||
$warehouseHpp = (int) ($warehouseItem?->hpp ?? 0);
|
||||
$retailSubtotal = (int) ($retailItem?->subtotal ?? 0);
|
||||
$retailHpp = (int) ($retailItem?->hpp ?? 0);
|
||||
$rejectSubtotal = (int) ($rejectItem?->subtotal ?? 0);
|
||||
$rejectHpp = (int) ($rejectItem?->hpp ?? 0);
|
||||
|
||||
$totalItemsSubtotal = $gudangSubtotal + $ecerSubtotal + $rejectSubtotal;
|
||||
$hpp = $gudangHpp + $ecerHpp + $rejectHpp;
|
||||
$totalItemsSubtotal = $warehouseSubtotal + $retailSubtotal + $rejectSubtotal;
|
||||
$hpp = $warehouseHpp + $retailHpp + $rejectHpp;
|
||||
|
||||
$gudangDeduction = 0;
|
||||
$ecerDeduction = 0;
|
||||
$warehouseDeduction = 0;
|
||||
$retailDeduction = 0;
|
||||
if ($totalItemsSubtotal > 0) {
|
||||
$gudangDeduction = ($gudangSubtotal / $totalItemsSubtotal) * $deduction;
|
||||
$ecerDeduction = ($ecerSubtotal / $totalItemsSubtotal) * $deduction;
|
||||
$warehouseDeduction = ($warehouseSubtotal / $totalItemsSubtotal) * $deduction;
|
||||
$retailDeduction = ($retailSubtotal / $totalItemsSubtotal) * $deduction;
|
||||
}
|
||||
|
||||
$netGudang = $gudangSubtotal - $gudangDeduction - $gudangHpp;
|
||||
$netEcer = $ecerSubtotal - $ecerDeduction - $ecerHpp;
|
||||
$netWarehouse = $warehouseSubtotal - $warehouseDeduction - $warehouseHpp;
|
||||
$netRetail = $retailSubtotal - $retailDeduction - $retailHpp;
|
||||
|
||||
$result[] = [
|
||||
'month' => $monthLabel,
|
||||
'total' => (int) ($revenue->total_revenue ?? 0),
|
||||
'net' => (int) ($revenue->total_revenue ?? 0) - $deduction - $hpp,
|
||||
'net_gudang' => (int) round($netGudang),
|
||||
'net_ecer' => (int) round($netEcer),
|
||||
'net_warehouse' => (int) round($netWarehouse),
|
||||
'net_retail' => (int) round($netRetail),
|
||||
'deduction' => $deduction,
|
||||
];
|
||||
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ProductStockQuality;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('stok_opname_items', function (Blueprint $table) {
|
||||
$table->dropForeign(['stok_opname_id']);
|
||||
$table->dropForeign(['product_variant_id']);
|
||||
$table->dropUnique('stok_opname_items_stok_opname_id_product_variant_id_unique');
|
||||
|
||||
$table->enum('stock_quality', ProductStockQuality::values())->default(ProductStockQuality::GOOD->value)->after('product_variant_id');
|
||||
$table->unique(['stok_opname_id', 'product_variant_id', 'stock_quality'], 'stok_opname_items_opname_variant_quality_unique');
|
||||
|
||||
$table->foreign('stok_opname_id')->references('id')->on('stok_opnames')->cascadeOnDelete();
|
||||
$table->foreign('product_variant_id')->references('id')->on('product_variants')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('stok_opname_items', function (Blueprint $table) {
|
||||
$table->dropForeign(['stok_opname_id']);
|
||||
$table->dropForeign(['product_variant_id']);
|
||||
$table->dropUnique('stok_opname_items_opname_variant_quality_unique');
|
||||
|
||||
$table->dropColumn('stock_quality');
|
||||
$table->unique(['stok_opname_id', 'product_variant_id']);
|
||||
|
||||
$table->foreign('stok_opname_id')->references('id')->on('stok_opnames')->cascadeOnDelete();
|
||||
$table->foreign('product_variant_id')->references('id')->on('product_variants')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -97,8 +97,8 @@ const props = defineProps<{
|
||||
month: string;
|
||||
total: number;
|
||||
net: number;
|
||||
net_gudang: number;
|
||||
net_ecer: number;
|
||||
net_warehouse: number;
|
||||
net_retail: number;
|
||||
deduction: number;
|
||||
}>;
|
||||
expenseSummary: {
|
||||
@ -200,8 +200,8 @@ type MonthlyRevenueData = {
|
||||
month: string;
|
||||
total: number;
|
||||
net: number;
|
||||
net_gudang: number;
|
||||
net_ecer: number;
|
||||
net_warehouse: number;
|
||||
net_retail: number;
|
||||
deduction: number;
|
||||
};
|
||||
|
||||
@ -214,11 +214,11 @@ const revenueChartConfig = {
|
||||
label: 'Bersih',
|
||||
color: '#22c55e',
|
||||
},
|
||||
net_gudang: {
|
||||
net_warehouse: {
|
||||
label: 'Total Gudang',
|
||||
color: '#10b981',
|
||||
},
|
||||
net_ecer: {
|
||||
net_retail: {
|
||||
label: 'Total Ecer',
|
||||
color: '#06b6d4',
|
||||
},
|
||||
@ -229,16 +229,16 @@ const revenueChartConfig = {
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const revenueTotals = computed(() => {
|
||||
const net_gudang = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_gudang ?? 0), 0);
|
||||
const net_ecer = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_ecer ?? 0), 0);
|
||||
const net_warehouse = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_warehouse ?? 0), 0);
|
||||
const net_retail = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_retail ?? 0), 0);
|
||||
return {
|
||||
total: props.revenueSummary.total_revenue,
|
||||
net:
|
||||
props.revenueSummary.total_revenue -
|
||||
props.revenueSummary.total_deduction -
|
||||
props.profitMetrics.hpp,
|
||||
net_gudang,
|
||||
net_ecer,
|
||||
net_warehouse,
|
||||
net_retail,
|
||||
deduction: props.revenueSummary.total_deduction,
|
||||
};
|
||||
});
|
||||
@ -248,7 +248,7 @@ const visibleRevenueCharts = computed(() => {
|
||||
const isCashier = hasRole('cashier');
|
||||
|
||||
if (isOwner) {
|
||||
return ['total', 'net', 'net_gudang', 'net_ecer', 'deduction'] as const;
|
||||
return ['total', 'net', 'net_warehouse', 'net_retail', 'deduction'] as const;
|
||||
}
|
||||
|
||||
if (isCashier) {
|
||||
@ -416,14 +416,14 @@ function revenueTooltip(d: any) {
|
||||
` : ''}
|
||||
${isOwner ? `
|
||||
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
||||
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net_gudang.color}"></span>
|
||||
<span class="text-muted-foreground">${revenueChartConfig.net_gudang.label}</span>
|
||||
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net_gudang)}</span>
|
||||
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net_warehouse.color}"></span>
|
||||
<span class="text-muted-foreground">${revenueChartConfig.net_warehouse.label}</span>
|
||||
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net_warehouse)}</span>
|
||||
</p>
|
||||
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
||||
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net_ecer.color}"></span>
|
||||
<span class="text-muted-foreground">${revenueChartConfig.net_ecer.label}</span>
|
||||
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net_ecer)}</span>
|
||||
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net_retail.color}"></span>
|
||||
<span class="text-muted-foreground">${revenueChartConfig.net_retail.label}</span>
|
||||
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net_retail)}</span>
|
||||
</p>
|
||||
` : ''}
|
||||
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { NumberInput } from '@/components/form/number-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { groupedTableRowNumber } from '@/lib/grouped-table';
|
||||
import {
|
||||
stokOpnameDifference,
|
||||
@ -39,56 +40,74 @@ const emit = defineEmits<{
|
||||
<div class="bg-muted/60 border-b px-4 py-2.5">
|
||||
<span class="text-sm font-semibold">{{ group.product_name }}</span>
|
||||
</div>
|
||||
<table class="w-full text-sm">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="text-muted-foreground h-9 w-12 px-4 text-center text-xs font-medium">No.</th>
|
||||
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Varian</th>
|
||||
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Sistem</th>
|
||||
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Stok Fisik</th>
|
||||
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium">Selisih</th>
|
||||
<tr class="border-b bg-muted/20">
|
||||
<th class="text-muted-foreground h-9 w-12 px-4 text-center text-xs font-medium border-r">No.</th>
|
||||
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium border-r">Varian</th>
|
||||
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium border-r w-28">Kualitas</th>
|
||||
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium border-r w-28">Stok Sistem</th>
|
||||
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium border-r w-32">Stok Fisik</th>
|
||||
<th class="text-muted-foreground h-9 px-4 text-right text-xs font-medium border-r w-28">Selisih</th>
|
||||
<th class="text-muted-foreground h-9 px-4 text-left text-xs font-medium">Catatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, rIdx) in group.rows"
|
||||
:key="row.variant_id"
|
||||
class="border-b transition-colors last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<td class="text-muted-foreground p-3 text-center">
|
||||
{{ groupedTableRowNumber(group.startIndex + 1, rIdx) }}
|
||||
</td>
|
||||
<td class="p-3">{{ row.variant_name }}</td>
|
||||
<td class="p-3 text-right tabular-nums">
|
||||
{{ row.system_stock_formatted ?? row.system_stock }}
|
||||
</td>
|
||||
<td class="p-3 text-right">
|
||||
<NumberInput
|
||||
:model-value="row.physical_stock"
|
||||
class="ml-auto w-24 text-right tabular-nums"
|
||||
@update:model-value="val => {
|
||||
row.physical_stock = val === '' ? 0 : Number(val);
|
||||
emit('row-change');
|
||||
}"
|
||||
/>
|
||||
</td>
|
||||
<td class="p-3 text-right tabular-nums">
|
||||
<span
|
||||
:class="stokOpnameDifferenceClass(stokOpnameDifference(row.physical_stock, row.system_stock))"
|
||||
<template v-for="(vGroup, vIdx) in group.variants" :key="vGroup.variant_id">
|
||||
<tr
|
||||
v-for="(qRow, qIdx) in vGroup.qualities"
|
||||
:key="`${vGroup.variant_id}_${qRow.quality_key}`"
|
||||
class="border-b hover:bg-muted/10"
|
||||
>
|
||||
<td
|
||||
v-if="qIdx === 0"
|
||||
rowspan="3"
|
||||
class="text-muted-foreground p-3 text-center border-r border-b align-middle font-medium"
|
||||
>
|
||||
{{ stokOpnameDifferenceText(stokOpnameDifference(row.physical_stock, row.system_stock)) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<Input
|
||||
v-model="row.notes"
|
||||
placeholder="Catatan..."
|
||||
class="w-full min-w-[120px]"
|
||||
@input="emit('row-change')"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{{ groupedTableRowNumber(group.startIndex + 1, vIdx) }}
|
||||
</td>
|
||||
<td
|
||||
v-if="qIdx === 0"
|
||||
rowspan="3"
|
||||
class="p-3 border-r border-b align-middle font-semibold bg-muted/5"
|
||||
>
|
||||
{{ vGroup.variant_name }}
|
||||
</td>
|
||||
<td class="p-3 border-r border-b text-left font-medium">
|
||||
<Badge :variant="qRow.quality_key === 'good' ? 'outline' : (qRow.quality_key === 'retail' ? 'secondary' : 'destructive')" class="text-xs font-normal">
|
||||
{{ qRow.quality_label }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="p-3 border-r border-b text-right tabular-nums">
|
||||
{{ qRow.system_stock_formatted ?? qRow.system_stock }}
|
||||
</td>
|
||||
<td class="p-3 border-r border-b text-right">
|
||||
<NumberInput
|
||||
:model-value="qRow.row_ref.physical_stock"
|
||||
class="ml-auto w-24 text-right tabular-nums h-8 text-xs"
|
||||
@update:model-value="val => {
|
||||
qRow.row_ref.physical_stock = val === '' ? 0 : Number(val);
|
||||
emit('row-change');
|
||||
}"
|
||||
/>
|
||||
</td>
|
||||
<td class="p-3 border-r border-b text-right tabular-nums font-semibold">
|
||||
<span
|
||||
:class="stokOpnameDifferenceClass(stokOpnameDifference(qRow.row_ref.physical_stock, qRow.system_stock))"
|
||||
>
|
||||
{{ stokOpnameDifferenceText(stokOpnameDifference(qRow.row_ref.physical_stock, qRow.system_stock)) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3 border-b">
|
||||
<Input
|
||||
v-model="qRow.row_ref.notes"
|
||||
placeholder="Catatan..."
|
||||
class="w-full h-8 text-xs"
|
||||
@input="emit('row-change')"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -14,30 +14,54 @@ export function buildStokOpnameVariantRows(
|
||||
catalog: CatalogProduct[],
|
||||
existingItems: StokOpnameExistingItem[] = [],
|
||||
): StokOpnameVariantRow[] {
|
||||
const existingMap = new Map<number, { physical_stock: number; notes: string }>();
|
||||
const existingMap = new Map<string, { physical_stock: number; notes: string }>();
|
||||
|
||||
for (const item of existingItems) {
|
||||
existingMap.set(item.product_variant_id, {
|
||||
const key = `${item.product_variant_id}_${item.stock_quality}`;
|
||||
existingMap.set(key, {
|
||||
physical_stock: item.physical_stock,
|
||||
notes: item.notes ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
const rows: StokOpnameVariantRow[] = [];
|
||||
const qualities = [
|
||||
{ key: 'good', label: 'Bagus' },
|
||||
{ key: 'retail', label: 'Eceran' },
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
] as const;
|
||||
|
||||
for (const product of catalog) {
|
||||
for (const variant of product.variants) {
|
||||
const existing = existingMap.get(variant.id);
|
||||
for (const quality of qualities) {
|
||||
const key = `${variant.id}_${quality.key}`;
|
||||
const existing = existingMap.get(key);
|
||||
|
||||
rows.push({
|
||||
product_name: product.name,
|
||||
variant_id: variant.id,
|
||||
variant_name: variant.name,
|
||||
system_stock: variant.stock,
|
||||
system_stock_formatted: variant.stock_formatted,
|
||||
physical_stock: existing?.physical_stock ?? 0,
|
||||
notes: existing?.notes ?? '',
|
||||
});
|
||||
let systemStock = 0;
|
||||
let systemStockFormatted = '0';
|
||||
if (quality.key === 'good') {
|
||||
systemStock = variant.stock;
|
||||
systemStockFormatted = variant.stock_formatted ?? '0';
|
||||
} else if (quality.key === 'retail') {
|
||||
systemStock = variant.retail_stock ?? 0;
|
||||
systemStockFormatted = variant.retail_stock_formatted ?? '0';
|
||||
} else if (quality.key === 'reject') {
|
||||
systemStock = variant.reject_stock ?? 0;
|
||||
systemStockFormatted = variant.reject_stock_formatted ?? '0';
|
||||
}
|
||||
|
||||
rows.push({
|
||||
product_name: product.name,
|
||||
variant_id: variant.id,
|
||||
variant_name: `${variant.name} (${quality.label})`,
|
||||
variant_name_raw: variant.name,
|
||||
stock_quality: quality.key,
|
||||
system_stock: systemStock,
|
||||
system_stock_formatted: systemStockFormatted,
|
||||
physical_stock: existing?.physical_stock ?? systemStock,
|
||||
notes: existing?.notes ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,9 +90,10 @@ export function useStokOpnameForm(options: {
|
||||
|
||||
const itemsPayload = computed(() =>
|
||||
variantRows.value
|
||||
.filter((row) => row.physical_stock > 0 || row.notes.trim() !== '')
|
||||
.filter((row) => row.physical_stock !== row.system_stock || row.notes.trim() !== '')
|
||||
.map((row) => ({
|
||||
product_variant_id: row.variant_id,
|
||||
stock_quality: row.stock_quality,
|
||||
physical_stock: row.physical_stock,
|
||||
notes: row.notes || null,
|
||||
})),
|
||||
@ -76,19 +101,46 @@ export function useStokOpnameForm(options: {
|
||||
|
||||
const groupedProducts = computed<StokOpnameProductGroup[]>(() => {
|
||||
const groups: StokOpnameProductGroup[] = [];
|
||||
let currentProduct = '';
|
||||
let currentGroup: StokOpnameProductGroup | null = null;
|
||||
let idx = 0;
|
||||
const productMap = new Map<string, Map<number, { variant_name: string; rows: StokOpnameVariantRow[] }>>();
|
||||
|
||||
for (const row of variantRows.value) {
|
||||
if (row.product_name !== currentProduct) {
|
||||
currentProduct = row.product_name;
|
||||
currentGroup = { product_name: row.product_name, rows: [], startIndex: idx };
|
||||
groups.push(currentGroup);
|
||||
if (!productMap.has(row.product_name)) {
|
||||
productMap.set(row.product_name, new Map());
|
||||
}
|
||||
const variantMap = productMap.get(row.product_name)!;
|
||||
if (!variantMap.has(row.variant_id)) {
|
||||
variantMap.set(row.variant_id, { variant_name: row.variant_name_raw || row.variant_name, rows: [] });
|
||||
}
|
||||
variantMap.get(row.variant_id)!.rows.push(row);
|
||||
}
|
||||
|
||||
let variantIndex = 0;
|
||||
for (const [productName, variantMap] of productMap.entries()) {
|
||||
const productGroup: StokOpnameProductGroup = {
|
||||
product_name: productName,
|
||||
variants: [],
|
||||
startIndex: variantIndex,
|
||||
};
|
||||
|
||||
for (const [variantId, vData] of variantMap.entries()) {
|
||||
productGroup.variants.push({
|
||||
variant_id: variantId,
|
||||
variant_name: vData.variant_name,
|
||||
qualities: vData.rows.map((row) => {
|
||||
const qualityLabel = row.stock_quality === 'good' ? 'Bagus' : (row.stock_quality === 'retail' ? 'Eceran' : 'Reject');
|
||||
return {
|
||||
quality_key: row.stock_quality,
|
||||
quality_label: qualityLabel,
|
||||
system_stock: row.system_stock,
|
||||
system_stock_formatted: row.system_stock_formatted,
|
||||
row_ref: row,
|
||||
};
|
||||
}),
|
||||
});
|
||||
variantIndex++;
|
||||
}
|
||||
|
||||
currentGroup!.rows.push(row);
|
||||
idx++;
|
||||
groups.push(productGroup);
|
||||
}
|
||||
|
||||
return groups;
|
||||
|
||||
@ -54,6 +54,53 @@ const paginationSummary = usePaginationSummary(() => props.pagination, showingCo
|
||||
function rowNumber(index: number): number {
|
||||
return groupedTableRowNumber(props.firstItem, index);
|
||||
}
|
||||
|
||||
interface GroupedVariantItem {
|
||||
variant_id: number;
|
||||
variant_name: string;
|
||||
product_name: string;
|
||||
qualities: Array<{
|
||||
id: number;
|
||||
quality_label: string;
|
||||
quality_key: string;
|
||||
system_stock: number;
|
||||
physical_stock: number;
|
||||
difference: number;
|
||||
notes: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
function getGroupedItems(items: any[]): GroupedVariantItem[] {
|
||||
const groups: Record<string, GroupedVariantItem> = {};
|
||||
|
||||
items.forEach((item) => {
|
||||
const variantName = item.product_variant?.name || item.variant_name.replace(/\s*\([^)]*\)$/, '');
|
||||
const key = `${item.product_variant_id}`;
|
||||
|
||||
if (!groups[key]) {
|
||||
groups[key] = {
|
||||
variant_id: item.product_variant_id,
|
||||
variant_name: variantName,
|
||||
product_name: item.product_name,
|
||||
qualities: [],
|
||||
};
|
||||
}
|
||||
|
||||
const label = item.stock_quality === 'good' ? 'Bagus' : (item.stock_quality === 'retail' ? 'Eceran' : 'Reject');
|
||||
|
||||
groups[key].qualities.push({
|
||||
id: item.id,
|
||||
quality_key: item.stock_quality,
|
||||
quality_label: label,
|
||||
system_stock: item.system_stock,
|
||||
physical_stock: item.physical_stock,
|
||||
difference: item.difference,
|
||||
notes: item.notes,
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(groups);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -107,47 +154,72 @@ function rowNumber(index: number): number {
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-12 text-center">No.</TableHead>
|
||||
<TableHead>Produk</TableHead>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead class="text-right">Stok Sistem</TableHead>
|
||||
<TableHead class="text-right">Stok Fisik</TableHead>
|
||||
<TableHead class="text-right">Selisih</TableHead>
|
||||
<TableRow class="bg-muted/10">
|
||||
<TableHead class="w-12 text-center border-r">No.</TableHead>
|
||||
<TableHead class="border-r">Produk</TableHead>
|
||||
<TableHead class="border-r">Varian</TableHead>
|
||||
<TableHead class="border-r w-28">Kualitas</TableHead>
|
||||
<TableHead class="text-right border-r w-28">Stok Sistem</TableHead>
|
||||
<TableHead class="text-right border-r w-28">Stok Fisik</TableHead>
|
||||
<TableHead class="text-right border-r w-28">Selisih</TableHead>
|
||||
<TableHead>Catatan</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="!stokOpname.items?.length" :key="`${stokOpname.id}-empty`">
|
||||
<TableCell :colspan="7" class="text-muted-foreground text-center">
|
||||
<TableCell :colspan="8" class="text-muted-foreground text-center">
|
||||
Belum ada item
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="(item, rIdx) in stokOpname.items" :key="item.id">
|
||||
<TableCell class="text-center text-muted-foreground tabular-nums">
|
||||
{{ rIdx + 1 }}
|
||||
</TableCell>
|
||||
<TableCell class="font-medium">
|
||||
{{ item.product_name }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ item.variant_name }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right tabular-nums">
|
||||
{{ item.system_stock }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right tabular-nums">
|
||||
{{ item.physical_stock }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right tabular-nums">
|
||||
<span :class="stokOpnameDifferenceClass(stokOpnameDifference(item.physical_stock, item.system_stock))">
|
||||
{{ stokOpnameDifferenceText(stokOpnameDifference(item.physical_stock, item.system_stock)) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{{ item.notes || '-' }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<template v-else v-for="(vGroup, vIdx) in getGroupedItems(stokOpname.items)" :key="vGroup.variant_id">
|
||||
<TableRow
|
||||
v-for="(qRow, qIdx) in vGroup.qualities"
|
||||
:key="qRow.id"
|
||||
class="border-b hover:bg-muted/10"
|
||||
>
|
||||
<td
|
||||
v-if="qIdx === 0"
|
||||
:rowspan="vGroup.qualities.length"
|
||||
class="text-center text-muted-foreground tabular-nums border-r align-middle font-medium"
|
||||
>
|
||||
{{ vIdx + 1 }}
|
||||
</td>
|
||||
<td
|
||||
v-if="qIdx === 0"
|
||||
:rowspan="vGroup.qualities.length"
|
||||
class="font-semibold border-r align-middle"
|
||||
>
|
||||
{{ vGroup.product_name }}
|
||||
</td>
|
||||
<td
|
||||
v-if="qIdx === 0"
|
||||
:rowspan="vGroup.qualities.length"
|
||||
class="font-medium border-r align-middle bg-muted/5"
|
||||
>
|
||||
{{ vGroup.variant_name }}
|
||||
</td>
|
||||
|
||||
<td class="p-3 border-r text-left font-medium">
|
||||
<Badge :variant="qRow.quality_key === 'good' ? 'outline' : (qRow.quality_key === 'retail' ? 'secondary' : 'destructive')" class="text-xs font-normal">
|
||||
{{ qRow.quality_label }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="p-3 border-r text-right tabular-nums">
|
||||
{{ qRow.system_stock }}
|
||||
</td>
|
||||
<td class="p-3 border-r text-right tabular-nums">
|
||||
{{ qRow.physical_stock }}
|
||||
</td>
|
||||
<td class="p-3 border-r text-right tabular-nums font-semibold">
|
||||
<span :class="stokOpnameDifferenceClass(qRow.difference)">
|
||||
{{ stokOpnameDifferenceText(qRow.difference) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3 text-muted-foreground">
|
||||
{{ qRow.notes || '-' }}
|
||||
</td>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@ -22,6 +22,7 @@ export type StokOpnameItem = {
|
||||
product_variant_id: number;
|
||||
variant_name: string;
|
||||
product_name: string;
|
||||
stock_quality: 'good' | 'retail' | 'reject';
|
||||
system_stock: number;
|
||||
physical_stock: number;
|
||||
difference: number;
|
||||
@ -45,6 +46,7 @@ export type StokOpnameFormData = {
|
||||
notes: string;
|
||||
items: Array<{
|
||||
product_variant_id: number;
|
||||
stock_quality: 'good' | 'retail' | 'reject';
|
||||
physical_stock: number;
|
||||
notes: string;
|
||||
}>;
|
||||
@ -55,6 +57,10 @@ export type CatalogVariant = {
|
||||
name: string;
|
||||
stock: number;
|
||||
stock_formatted?: string;
|
||||
retail_stock?: number;
|
||||
retail_stock_formatted?: string;
|
||||
reject_stock?: number;
|
||||
reject_stock_formatted?: string;
|
||||
};
|
||||
|
||||
export type CatalogProduct = {
|
||||
@ -69,20 +75,35 @@ export type StokOpnameVariantRow = {
|
||||
product_name: string;
|
||||
variant_id: number;
|
||||
variant_name: string;
|
||||
variant_name_raw?: string;
|
||||
stock_quality: 'good' | 'retail' | 'reject';
|
||||
system_stock: number;
|
||||
system_stock_formatted?: string;
|
||||
physical_stock: number;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export type StokOpnameVariantGroup = {
|
||||
variant_id: number;
|
||||
variant_name: string;
|
||||
qualities: Array<{
|
||||
quality_key: 'good' | 'retail' | 'reject';
|
||||
quality_label: string;
|
||||
system_stock: number;
|
||||
system_stock_formatted?: string;
|
||||
row_ref: StokOpnameVariantRow;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type StokOpnameProductGroup = {
|
||||
product_name: string;
|
||||
rows: StokOpnameVariantRow[];
|
||||
variants: StokOpnameVariantGroup[];
|
||||
startIndex: number;
|
||||
};
|
||||
|
||||
export type StokOpnameExistingItem = {
|
||||
product_variant_id: number;
|
||||
stock_quality: 'good' | 'retail' | 'reject';
|
||||
physical_stock: number;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user