feat: enhance monthly revenue calculations in AnalysisService to include stock quality breakdown; update Analysis.vue to display net revenue for gudang and ecer categories, improving data visibility for users

This commit is contained in:
Yoga Pangestu 2026-07-05 12:59:10 +07:00
parent 150a969086
commit 3a8a6982ec
3 changed files with 103 additions and 29 deletions

View File

@ -599,7 +599,7 @@ function () use ($cutting, $validated): void {
foreach ($materials as $materialData) {
$combinationId = $materialData['combination_id'];
if ($combinationId !== null) {
if (!isset($combinationGroups[$combinationId])) {
if (! isset($combinationGroups[$combinationId])) {
$combinationGroups[$combinationId] = [
'materials' => [],
'material_result' => $materialData['combination_material_result'] ?? null,
@ -622,7 +622,7 @@ function () use ($cutting, $validated): void {
$newCombinationId = $materialData['combination_id'] !== null
? $combinationIdMap[$materialData['combination_id']] ?? null
: null;
$cutting->materials()->create([
'raw_material_price_id' => $materialData['raw_material_price_id'],
'material_usage' => $materialData['material_usage'],

View File

@ -5,6 +5,7 @@
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\OrderStatus;
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use App\Enums\Role;
use App\Models\Attendance;
use App\Models\CashAccount;
@ -285,7 +286,7 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
->groupBy('month_key')
->map(fn ($orders) => $orders->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0)));
$monthlyHpp = OrderItem::query()
$monthlyItemsData = OrderItem::query()
->join('orders', 'order_items.order_id', '=', 'orders.id')
->leftJoin('product_prices', function ($join) {
$join->on('order_items.product_variant_id', '=', 'product_prices.variant_id')
@ -297,11 +298,13 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
->selectRaw("
DATE_FORMAT(orders.created_at, '%Y-%m') as month_key,
order_items.stock_quality,
SUM(order_items.subtotal) as subtotal,
COALESCE(SUM(order_items.quantity * product_prices.price), 0) as hpp
")
->groupBy('month_key')
->groupBy('month_key', 'order_items.stock_quality')
->get()
->pluck('hpp', 'month_key');
->groupBy('month_key');
if ($monthlyData->isEmpty()) {
return [];
@ -319,13 +322,42 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
$revenue = $monthlyData->firstWhere('month_key', $key);
$fees = $monthlyFees->get($key, 0);
$discount = (int) ($revenue->total_discount ?? 0);
$hpp = (int) ($monthlyHpp->get($key, 0));
$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'
);
$ecerItem = $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);
$rejectSubtotal = (int) ($rejectItem?->subtotal ?? 0);
$rejectHpp = (int) ($rejectItem?->hpp ?? 0);
$totalItemsSubtotal = $gudangSubtotal + $ecerSubtotal + $rejectSubtotal;
$hpp = $gudangHpp + $ecerHpp + $rejectHpp;
$gudangDeduction = 0;
$ecerDeduction = 0;
if ($totalItemsSubtotal > 0) {
$gudangDeduction = ($gudangSubtotal / $totalItemsSubtotal) * $deduction;
$ecerDeduction = ($ecerSubtotal / $totalItemsSubtotal) * $deduction;
}
$netGudang = $gudangSubtotal - $gudangDeduction - $gudangHpp;
$netEcer = $ecerSubtotal - $ecerDeduction - $ecerHpp;
$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),
'deduction' => $deduction,
];

View File

@ -97,6 +97,8 @@ const props = defineProps<{
month: string;
total: number;
net: number;
net_gudang: number;
net_ecer: number;
deduction: number;
}>;
expenseSummary: {
@ -198,6 +200,8 @@ type MonthlyRevenueData = {
month: string;
total: number;
net: number;
net_gudang: number;
net_ecer: number;
deduction: number;
};
@ -210,20 +214,57 @@ const revenueChartConfig = {
label: 'Bersih',
color: '#22c55e',
},
net_gudang: {
label: 'Total Gudang',
color: '#10b981',
},
net_ecer: {
label: 'Total Ecer',
color: '#06b6d4',
},
deduction: {
label: 'Potongan',
color: '#f97316',
},
} satisfies ChartConfig;
const revenueTotals = computed(() => ({
total: props.revenueSummary.total_revenue,
net:
props.revenueSummary.total_revenue -
props.revenueSummary.total_deduction -
props.profitMetrics.hpp,
deduction: props.revenueSummary.total_deduction,
}));
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);
return {
total: props.revenueSummary.total_revenue,
net:
props.revenueSummary.total_revenue -
props.revenueSummary.total_deduction -
props.profitMetrics.hpp,
net_gudang,
net_ecer,
deduction: props.revenueSummary.total_deduction,
};
});
const visibleRevenueCharts = computed(() => {
const isOwner = hasAnyRole(['owner', 'developer']);
const isCashier = hasRole('cashier');
if (isOwner) {
return ['total', 'net', 'net_gudang', 'net_ecer', 'deduction'] as const;
}
if (isCashier) {
return ['total', 'deduction'] as const;
}
return ['total', 'net', 'deduction'] as const;
});
const revenueYAccessors = computed(() => {
return visibleRevenueCharts.value.map((chart) => (d: MonthlyRevenueData) => d[chart]);
});
const revenueColors = computed(() => {
return visibleRevenueCharts.value.map((chart) => revenueChartConfig[chart].color);
});
// Expense Chart
type MonthlyExpenseData = {
@ -356,6 +397,7 @@ const barSelector = GroupedBar.selectors.bar;
function revenueTooltip(d: any) {
const item = d as MonthlyRevenueData;
const isOwner = hasAnyRole(['owner', 'developer']);
const isCashier = hasRole('cashier');
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.6">
@ -372,6 +414,18 @@ function revenueTooltip(d: any) {
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net)}</span>
</p>
` : ''}
${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>
</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>
</p>
` : ''}
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.deduction.color}"></span>
<span class="text-muted-foreground">${revenueChartConfig.deduction.label}</span>
@ -655,9 +709,7 @@ watch([startDate, endDate], () => {
<CardTitle>Pendapatan</CardTitle>
</div>
<div class="flex">
<div v-for="chart in (
!hasRole('cashier') ? ['total', 'net', 'deduction'] : ['total', 'deduction']
) as const" :key="chart"
<div v-for="chart in visibleRevenueCharts" :key="chart"
class="flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
<span class="text-xs text-muted-foreground">
{{ revenueChartConfig[chart].label }}
@ -671,9 +723,7 @@ watch([startDate, endDate], () => {
<CardContent class="px-2 sm:p-6">
<div v-if="monthlyRevenue.length > 0">
<div class="mb-3 flex flex-wrap items-center justify-center gap-4">
<div v-for="chart in (
!hasRole('cashier') ? ['total', 'net', 'deduction'] : ['total', 'deduction']
) as const" :key="chart" class="flex items-center gap-1.5">
<div v-for="chart in visibleRevenueCharts" :key="chart" class="flex items-center gap-1.5">
<span class="size-2.5 rounded-full" :style="{
backgroundColor:
revenueChartConfig[chart].color,
@ -686,15 +736,7 @@ watch([startDate, endDate], () => {
<ChartContainer :config="revenueChartConfig" class="aspect-auto h-[300px] w-full">
<VisXYContainer :data="monthlyRevenue" :y-domain="[0, undefined]">
<VisGroupedBar :x="(_d: MonthlyRevenueData, i: number) => i
" :y="[
(d: MonthlyRevenueData) => d.total,
...(!hasRole('cashier') ? [(d: MonthlyRevenueData) => d.net] : []),
(d: MonthlyRevenueData) => d.deduction,
]" :color="[
revenueChartConfig.total.color,
...(!hasRole('cashier') ? [revenueChartConfig.net.color] : []),
revenueChartConfig.deduction.color,
]" :bar-padding="0.1" :group-padding="0.2" :rounded-corners="4" />
" :y="revenueYAccessors" :color="revenueColors" :bar-padding="0.1" :group-padding="0.2" :rounded-corners="4" />
<VisAxis type="x" :x="(_d: MonthlyRevenueData, i: number) => i
" :tick-line="false" :domain-line="false" :grid-line="false" :tick-format="(d: number) =>
monthlyRevenue[d]?.month ?? ''