diff --git a/app/Http/Requests/Admin/Manage/StokOpnameAutoSaveRequest.php b/app/Http/Requests/Admin/Manage/StokOpnameAutoSaveRequest.php index 91927fb..dec0f09 100644 --- a/app/Http/Requests/Admin/Manage/StokOpnameAutoSaveRequest.php +++ b/app/Http/Requests/Admin/Manage/StokOpnameAutoSaveRequest.php @@ -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'], ]; diff --git a/app/Http/Requests/Admin/Manage/StokOpnameRequest.php b/app/Http/Requests/Admin/Manage/StokOpnameRequest.php index 01b6cfa..b3cef96 100644 --- a/app/Http/Requests/Admin/Manage/StokOpnameRequest.php +++ b/app/Http/Requests/Admin/Manage/StokOpnameRequest.php @@ -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'], ]; diff --git a/app/Models/StokOpnameItem.php b/app/Models/StokOpnameItem.php index 6011946..6cedea3 100644 --- a/app/Models/StokOpnameItem.php +++ b/app/Models/StokOpnameItem.php @@ -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 { diff --git a/app/Services/Manage/StokOpnameService.php b/app/Services/Manage/StokOpnameService.php index 48a5ce7..fa4213b 100644 --- a/app/Services/Manage/StokOpnameService.php +++ b/app/Services/Manage/StokOpnameService.php @@ -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, diff --git a/app/Services/System/AnalysisService.php b/app/Services/System/AnalysisService.php index e442184..b3add16 100644 --- a/app/Services/System/AnalysisService.php +++ b/app/Services/System/AnalysisService.php @@ -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, ]; diff --git a/database/migrations/2026_07_05_124618_add_stock_quality_to_stok_opname_items_table.php b/database/migrations/2026_07_05_124618_add_stock_quality_to_stok_opname_items_table.php new file mode 100644 index 0000000..c2b5f6c --- /dev/null +++ b/database/migrations/2026_07_05_124618_add_stock_quality_to_stok_opname_items_table.php @@ -0,0 +1,45 @@ +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(); + }); + } +}; diff --git a/resources/js/pages/admin/Analysis.vue b/resources/js/pages/admin/Analysis.vue index cb24846..e335f68 100644 --- a/resources/js/pages/admin/Analysis.vue +++ b/resources/js/pages/admin/Analysis.vue @@ -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 ? `

- - ${revenueChartConfig.net_gudang.label} - Rp${formatRupiah(item.net_gudang)} + + ${revenueChartConfig.net_warehouse.label} + Rp${formatRupiah(item.net_warehouse)}

- - ${revenueChartConfig.net_ecer.label} - Rp${formatRupiah(item.net_ecer)} + + ${revenueChartConfig.net_retail.label} + Rp${formatRupiah(item.net_retail)}

` : ''}

diff --git a/resources/js/pages/admin/manage/stok-opnames/form/StokOpnameProductTable.vue b/resources/js/pages/admin/manage/stok-opnames/form/StokOpnameProductTable.vue index 8794b6f..090f924 100644 --- a/resources/js/pages/admin/manage/stok-opnames/form/StokOpnameProductTable.vue +++ b/resources/js/pages/admin/manage/stok-opnames/form/StokOpnameProductTable.vue @@ -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<{

{{ group.product_name }}
- +
- - - - - - + + + + + + + - - - - - - + - - + {{ groupedTableRowNumber(group.startIndex + 1, vIdx) }} + + + + + + + + +
No.VarianStok SistemStok FisikSelisih
No.VarianKualitasStok SistemStok FisikSelisih Catatan
- {{ groupedTableRowNumber(group.startIndex + 1, rIdx) }} - {{ row.variant_name }} - {{ row.system_stock_formatted ?? row.system_stock }} - - - - +
- {{ stokOpnameDifferenceText(stokOpnameDifference(row.physical_stock, row.system_stock)) }} - - - -
+ {{ vGroup.variant_name }} + + + {{ qRow.quality_label }} + + + {{ qRow.system_stock_formatted ?? qRow.system_stock }} + + + + + {{ stokOpnameDifferenceText(stokOpnameDifference(qRow.row_ref.physical_stock, qRow.system_stock)) }} + + + +
diff --git a/resources/js/pages/admin/manage/stok-opnames/form/useStokOpnameForm.ts b/resources/js/pages/admin/manage/stok-opnames/form/useStokOpnameForm.ts index 64567f5..d6160f0 100644 --- a/resources/js/pages/admin/manage/stok-opnames/form/useStokOpnameForm.ts +++ b/resources/js/pages/admin/manage/stok-opnames/form/useStokOpnameForm.ts @@ -14,30 +14,54 @@ export function buildStokOpnameVariantRows( catalog: CatalogProduct[], existingItems: StokOpnameExistingItem[] = [], ): StokOpnameVariantRow[] { - const existingMap = new Map(); + const existingMap = new Map(); 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(() => { const groups: StokOpnameProductGroup[] = []; - let currentProduct = ''; - let currentGroup: StokOpnameProductGroup | null = null; - let idx = 0; + const productMap = new Map>(); 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; diff --git a/resources/js/pages/admin/manage/stok-opnames/table/StokOpnameGroupedTable.vue b/resources/js/pages/admin/manage/stok-opnames/table/StokOpnameGroupedTable.vue index 0b68868..dd2695a 100644 --- a/resources/js/pages/admin/manage/stok-opnames/table/StokOpnameGroupedTable.vue +++ b/resources/js/pages/admin/manage/stok-opnames/table/StokOpnameGroupedTable.vue @@ -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 = {}; + + 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); +}