add profit report

This commit is contained in:
Hesabix 2025-11-02 12:23:19 +00:00
parent 9074059abd
commit 9ad415a23b
6 changed files with 720 additions and 2 deletions

View file

@ -0,0 +1,333 @@
<?php
namespace App\Controller;
use App\Entity\Business;
use App\Service\Access;
use App\Service\Jdate;
use App\Service\PluginService;
use App\Service\SellReportService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class ProfitReportController extends AbstractController
{
use ProfitTrendHelper;
#[Route('/api/profit/report/summary', name: 'app_profit_report_summary', methods: ['GET'])]
public function getProfitSummary(
Request $request,
Access $access,
EntityManagerInterface $entityManager,
SellReportService $sellReportService,
PluginService $pluginService,
Jdate $jdate
): JsonResponse {
// دسترسی مشابه گزارش فروش
$acc = $access->hasRole('sell');
if (!$acc) {
throw $this->createAccessDeniedException();
}
// بررسی فعال بودن پلاگین accpro
if (!$pluginService->isActive('accpro', $acc['bid'])) {
return $this->json([
'result' => 0,
'message' => 'پلاگین accpro فعال نیست'
], 403);
}
// دریافت اطلاعات کسب و کار
$business = $entityManager->getRepository(Business::class)->find($acc['bid']);
if (!$business) {
return $this->json([
'result' => 0,
'message' => 'کسب و کار یافت نشد'
], 404);
}
// پارامترها
$startDate = $request->query->get('startDate');
$endDate = $request->query->get('endDate');
$groupBy = $request->query->get('groupBy', 'day');
$customerId = $request->query->get('customerId');
$status = $request->query->get('status');
$includeTrend = filter_var($request->query->get('includeTrend', '1'), FILTER_VALIDATE_BOOLEAN);
// وضعیت فقط در صورت فعال بودن تایید دو مرحله‌ای
if ($status && !$business->isRequireTwoStepApproval()) {
$status = null;
}
// پیش فرض تاریخ‌ها در صورت عدم ارسال (شمسی)
if (!$startDate) {
$startDate = $jdate->jdate('Y/m/01', time());
}
if (!$endDate) {
$endDate = $jdate->jdate('Y/m/d', time());
}
try {
// خلاصه فروش و سود ناخالص از سرویس موجود
$sellSummary = $sellReportService->getSellSummary(
$acc['bid'],
$acc['year'],
$acc['money'],
$startDate,
$endDate,
$groupBy,
$customerId ? (int) $customerId : null,
$status
);
$totalSalesAmount = (float) ($sellSummary['totalAmount'] ?? 0);
$totalGrossProfit = (float) ($sellSummary['totalProfit'] ?? 0);
// جمع هزینه‌های تایید شده در بازه
$costTotal = (float) ($entityManager->createQueryBuilder()
->select('COALESCE(SUM(r.bs), 0) as total')
->from('App\\Entity\\HesabdariDoc', 'd')
->join('d.hesabdariRows', 'r')
->where('d.bid = :bid')
->andWhere('d.money = :money')
->andWhere('d.type = :type')
->andWhere('d.year = :year')
->andWhere('d.isApproved = :isApproved')
->andWhere('r.bs != 0')
->andWhere('d.date BETWEEN :start AND :end')
->setParameter('bid', $acc['bid'])
->setParameter('money', $acc['money'])
->setParameter('type', 'cost')
->setParameter('year', $acc['year'])
->setParameter('isApproved', true)
->setParameter('start', $startDate)
->setParameter('end', $endDate)
->getQuery()
->getSingleScalarResult());
// جمع درآمدهای غیرعملیاتی تایید شده در بازه
$otherIncomeTotal = (float) ($entityManager->createQueryBuilder()
->select('COALESCE(SUM(r.bs), 0) as total')
->from('App\\Entity\\HesabdariDoc', 'd')
->join('d.hesabdariRows', 'r')
->where('d.bid = :bid')
->andWhere('d.money = :money')
->andWhere('d.type = :type')
->andWhere('d.year = :year')
->andWhere('d.isApproved = :isApproved')
->andWhere('r.bs != 0')
->andWhere('d.date BETWEEN :start AND :end')
->setParameter('bid', $acc['bid'])
->setParameter('money', $acc['money'])
->setParameter('type', 'income')
->setParameter('year', $acc['year'])
->setParameter('isApproved', true)
->setParameter('start', $startDate)
->setParameter('end', $endDate)
->getQuery()
->getSingleScalarResult());
$netProfit = $totalGrossProfit + $otherIncomeTotal - $costTotal;
$trend = null;
if ($includeTrend) {
// ترند سود ناخالص فروش (با محاسبه سود هر سند) و سود خالص
$trend = $this->buildTrend(
$entityManager,
$sellReportService,
$acc['bid'],
$acc['year'],
$acc['money'],
$startDate,
$endDate,
$groupBy,
$customerId ? (int) $customerId : null,
$status
);
}
return $this->json([
'result' => 1,
'data' => [
'totalSalesAmount' => (int) round($totalSalesAmount),
'totalGrossProfit' => (int) round($totalGrossProfit),
'totalOperatingCosts' => (int) round($costTotal),
'totalOtherIncome' => (int) round($otherIncomeTotal),
'netProfit' => (int) round($netProfit),
'startDate' => $startDate,
'endDate' => $endDate,
'trend' => $trend
]
]);
} catch (\Exception $e) {
return $this->json([
'result' => 0,
'message' => $e->getMessage()
], 500);
}
}
}
// کمکی‌ها: ساخت ترند سود بر اساس گروه‌بندی
namespace App\Controller;
use App\Entity\HesabdariDoc;
use App\Entity\HesabdariRow;
use Doctrine\ORM\EntityManagerInterface;
trait ProfitTrendHelper
{
private function buildTrend(
EntityManagerInterface $entityManager,
$sellReportService,
$business,
$year,
$money,
string $startDate,
string $endDate,
string $groupBy,
?int $customerId,
?string $status
): array {
// فروش های تایید شده در بازه
$qb = $entityManager->createQueryBuilder()
->select('d')
->from(HesabdariDoc::class, 'd')
->where('d.bid = :bid')
->andWhere('d.year = :year')
->andWhere('d.money = :money')
->andWhere('d.type = :type')
->andWhere('d.date BETWEEN :start AND :end')
->setParameter('bid', $business)
->setParameter('year', $year)
->setParameter('money', $money)
->setParameter('type', 'sell')
->setParameter('start', $startDate)
->setParameter('end', $endDate);
// در صورت نیاز به تایید دو مرحله‌ای، فقط اسناد تایید شده
$qb->andWhere('d.isApproved = :isApproved')->setParameter('isApproved', true);
/** @var HesabdariDoc[] $sellDocs */
$sellDocs = $qb->getQuery()->getResult();
$grossByKey = [];
foreach ($sellDocs as $doc) {
// فیلتر مشتری در سطح ردیف‌ها
if ($customerId) {
$hasCustomer = false;
foreach ($doc->getHesabdariRows() as $row) {
if ($row->getPerson() && $row->getPerson()->getId() === $customerId) {
$hasCustomer = true;
break;
}
}
if (!$hasCustomer) {
continue;
}
}
$key = $this->groupKeyFromJalali($doc->getDate(), $groupBy);
if (!isset($grossByKey[$key])) {
$grossByKey[$key] = 0.0;
}
$grossByKey[$key] += $sellReportService->computeDocProfit($doc, $business);
}
// هزینه‌ها بر اساس تاریخ
$costRows = $entityManager->createQueryBuilder()
->select('d.date as date, SUM(r.bs) as total')
->from(HesabdariDoc::class, 'd')
->join('d.hesabdariRows', 'r')
->where('d.bid = :bid')
->andWhere('d.year = :year')
->andWhere('d.money = :money')
->andWhere('d.type = :type')
->andWhere('d.isApproved = :isApproved')
->andWhere('d.date BETWEEN :start AND :end')
->groupBy('d.date')
->setParameter('bid', $business)
->setParameter('year', $year)
->setParameter('money', $money)
->setParameter('type', 'cost')
->setParameter('isApproved', true)
->setParameter('start', $startDate)
->setParameter('end', $endDate)
->getQuery()->getArrayResult();
$costByKey = [];
foreach ($costRows as $row) {
$key = $this->groupKeyFromJalali($row['date'], $groupBy);
if (!isset($costByKey[$key])) $costByKey[$key] = 0.0;
$costByKey[$key] += (float) $row['total'];
}
// درآمدهای غیرعملیاتی
$incomeRows = $entityManager->createQueryBuilder()
->select('d.date as date, SUM(r.bs) as total')
->from(HesabdariDoc::class, 'd')
->join('d.hesabdariRows', 'r')
->where('d.bid = :bid')
->andWhere('d.year = :year')
->andWhere('d.money = :money')
->andWhere('d.type = :type')
->andWhere('d.isApproved = :isApproved')
->andWhere('d.date BETWEEN :start AND :end')
->groupBy('d.date')
->setParameter('bid', $business)
->setParameter('year', $year)
->setParameter('money', $money)
->setParameter('type', 'income')
->setParameter('isApproved', true)
->setParameter('start', $startDate)
->setParameter('end', $endDate)
->getQuery()->getArrayResult();
$incomeByKey = [];
foreach ($incomeRows as $row) {
$key = $this->groupKeyFromJalali($row['date'], $groupBy);
if (!isset($incomeByKey[$key])) $incomeByKey[$key] = 0.0;
$incomeByKey[$key] += (float) $row['total'];
}
// کلیدهای یکتا
$allKeys = array_unique(array_merge(array_keys($grossByKey), array_keys($costByKey), array_keys($incomeByKey)));
sort($allKeys);
$categories = [];
$grossSeries = [];
$netSeries = [];
foreach ($allKeys as $key) {
$categories[] = $key;
$gross = $grossByKey[$key] ?? 0.0;
$cost = $costByKey[$key] ?? 0.0;
$inc = $incomeByKey[$key] ?? 0.0;
$grossSeries[] = (int) round($gross);
$netSeries[] = (int) round($gross + $inc - $cost);
}
return [
'categories' => $categories,
'grossProfit' => $grossSeries,
'netProfit' => $netSeries,
];
}
private function groupKeyFromJalali(string $date, string $groupBy): string
{
// تاریخ به شکل Y/m/d
if ($groupBy === 'month') {
$parts = explode('/', $date);
return $parts[0] . '/' . $parts[1];
}
// پیش فرض: روزانه
return $date;
}
}

View file

@ -783,6 +783,63 @@ class SellReportService
return $profit; return $profit;
} }
/**
* محاسبه سود یک فاکتور (متد عمومی برای استفاده بیرونی)
*/
public function computeDocProfit(HesabdariDoc $doc, Business $business): float
{
$profit = 0;
$profitCalcType = $business->getProfitCalctype() ?? 'simple';
foreach ($doc->getHesabdariRows() as $row) {
if ($row->getCommodity() && $row->getCommdityCount()) {
$commodity = $row->getCommodity();
$count = $row->getCommdityCount();
$sellPrice = $row->getBs() / $count;
if ($profitCalcType === 'simple') {
$buyPrice = $commodity->getPriceBuy() ? (float) $commodity->getPriceBuy() : 0;
$profit += ($sellPrice - $buyPrice) * $count;
} elseif ($profitCalcType === 'lis') {
$lastBuyRow = $this->entityManager->getRepository(HesabdariRow::class)
->findOneBy([
'commodity' => $commodity,
'bs' => 0
], ['id' => 'DESC']);
if ($lastBuyRow && $lastBuyRow->getCommdityCount() > 0) {
$buyPrice = $lastBuyRow->getBd() / $lastBuyRow->getCommdityCount();
$profit += ($sellPrice - $buyPrice) * $count;
} else {
$profit += $row->getBs();
}
} else {
$buyRows = $this->entityManager->getRepository(HesabdariRow::class)
->findBy([
'commodity' => $commodity,
'bs' => 0
], ['id' => 'DESC']);
$totalBuyAmount = 0;
$totalBuyCount = 0;
foreach ($buyRows as $buyRow) {
$totalBuyAmount += $buyRow->getBd();
$totalBuyCount += $buyRow->getCommdityCount();
}
if ($totalBuyCount > 0) {
$avgBuyPrice = $totalBuyAmount / $totalBuyCount;
$profit += ($sellPrice - $avgBuyPrice) * $count;
} else {
$profit += $row->getBs();
}
}
}
}
return $profit;
}
/** /**
* محاسبه سود یک کالا * محاسبه سود یک کالا
*/ */

View file

@ -1,6 +1,6 @@
{ {
"name": "hesabix", "name": "hesabix",
"version": "0.49.8", "version": "0.60.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View file

@ -330,6 +330,16 @@ const router = createRouter({
'login': true 'login': true
} }
}, },
{
path: 'reports/profit',
name: 'profit_report',
component: () =>
import('../views/acc/reports/profit/ProfitReport.vue'),
meta: {
'title': 'گزارش سود',
'login': true
}
},
{ {
path: 'costs/list', path: 'costs/list',
name: 'costs_list', name: 'costs_list',

View file

@ -0,0 +1,317 @@
<template>
<v-toolbar color="toolbar" title="گزارش سود" flat>
<template v-slot:prepend>
<v-tooltip :text="$t('dialog.back')" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" @click="$router.back()" class="d-none d-sm-flex" variant="text" icon="mdi-arrow-right" />
</template>
</v-tooltip>
</template>
<v-spacer></v-spacer>
</v-toolbar>
<v-container class="pa-4">
<v-card variant="outlined" class="mb-4">
<v-card-title class="text-subtitle-1 font-weight-bold">
گزارش سود کسبوکار
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" sm="6" md="3">
<v-text-field
:model-value="formattedStartDate"
label="تاریخ شروع"
prepend-inner-icon="mdi-calendar-start"
readonly
@click="showStartDatePicker = true"
variant="outlined"
density="comfortable"
/>
<v-dialog v-model="showStartDatePicker" max-width="400">
<v-date-picker
v-model="gregorianStartDate"
:min="convertJalaliToGregorian(year.start)"
:max="convertJalaliToGregorian(year.end)"
locale="fa"
color="primary"
@update:model-value="(value) => { dateFilter.startDate = convertGregorianToJalali(value); gregorianStartDate = value; showStartDatePicker = false; }"
/>
</v-dialog>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-text-field
:model-value="formattedEndDate"
label="تاریخ پایان"
prepend-inner-icon="mdi-calendar-end"
readonly
@click="showEndDatePicker = true"
variant="outlined"
density="comfortable"
/>
<v-dialog v-model="showEndDatePicker" max-width="400">
<v-date-picker
v-model="gregorianEndDate"
:min="convertJalaliToGregorian(dateFilter.startDate || year.start)"
:max="convertJalaliToGregorian(year.end)"
locale="fa"
color="primary"
@update:model-value="(value) => { dateFilter.endDate = convertGregorianToJalali(value); gregorianEndDate = value; showEndDatePicker = false; }"
/>
</v-dialog>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-select
v-model="groupBy"
:items="groupByOptions"
item-title="text"
item-value="value"
label="گروه‌بندی"
variant="outlined"
density="comfortable"
/>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-text-field
v-model.number="filters.customerId"
label="شناسه مشتری (اختیاری)"
prepend-inner-icon="mdi-account"
type="number"
variant="outlined"
density="comfortable"
/>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-select
v-model="filters.status"
:items="statusOptions"
item-title="text"
item-value="value"
label="وضعیت تایید (در صورت فعال بودن)"
variant="outlined"
density="comfortable"
clearable
/>
</v-col>
<v-col cols="12" sm="6" md="3" class="d-flex align-center">
<v-btn color="primary" @click="loadData" :loading="loading" prepend-icon="mdi-refresh">بارگذاری</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<v-row>
<v-col cols="12" md="3">
<v-card class="pa-4" variant="outlined">
<div class="text-caption">کل فروش</div>
<div class="text-h5 mt-1">{{ formatNumber(summary.totalSalesAmount) }}</div>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card class="pa-4" variant="outlined">
<div class="text-caption">سود ناخالص</div>
<div class="text-h5 mt-1">{{ formatNumber(summary.totalGrossProfit) }}</div>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card class="pa-4" variant="outlined">
<div class="text-caption">هزینهها</div>
<div class="text-h5 mt-1">{{ formatNumber(summary.totalOperatingCosts) }}</div>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card class="pa-4" variant="outlined">
<div class="text-caption">درآمدهای دیگر</div>
<div class="text-h5 mt-1">{{ formatNumber(summary.totalOtherIncome) }}</div>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card class="pa-4" color="primary" variant="elevated">
<div class="text-caption">سود خالص</div>
<div class="text-h5 mt-1">{{ formatNumber(summary.netProfit) }}</div>
</v-card>
</v-col>
</v-row>
<v-card variant="outlined" class="mt-4">
<v-card-title class="text-subtitle-1 font-weight-bold">نمودار ترند سود</v-card-title>
<v-card-text>
<apexchart
type="line"
height="300"
:options="chartOptions"
:series="chartSeries"
/>
</v-card-text>
</v-card>
</v-container>
</template>
<script>
import axios from 'axios';
import VueApexCharts from 'vue3-apexcharts';
import moment from 'jalali-moment';
export default {
name: 'ProfitReport',
components: { apexchart: VueApexCharts },
data() {
return {
loading: false,
// تاریخ
dateFilter: {
startDate: '',
endDate: ''
},
year: {
start: '',
end: ''
},
gregorianStartDate: '',
gregorianEndDate: '',
showStartDatePicker: false,
showEndDatePicker: false,
// گروهبندی
groupBy: 'day',
groupByOptions: [
{ text: 'روزانه', value: 'day' },
{ text: 'هفتگی', value: 'week' },
{ text: 'ماهانه', value: 'month' }
],
// فیلترها
filters: {
customerId: null,
status: null
},
statusOptions: [
{ text: 'تایید شده', value: 'approved' },
{ text: 'پیش‌نمایش', value: 'preview' }
],
// خلاصه
summary: {
totalSalesAmount: 0,
totalGrossProfit: 0,
totalOperatingCosts: 0,
totalOtherIncome: 0,
netProfit: 0
},
// نمودار
chartOptions: {
chart: { type: 'line', fontFamily: 'Vazir, sans-serif' },
xaxis: { categories: [], labels: { style: { fontFamily: 'Vazir, sans-serif' } } },
yaxis: { labels: { formatter: (v) => new Intl.NumberFormat('fa-IR').format(v) } },
tooltip: { y: { formatter: (v) => new Intl.NumberFormat('fa-IR').format(v) + ' ریال' } },
theme: { mode: 'light' }
},
chartSeries: [
{ name: 'سود ناخالص', data: [] },
{ name: 'سود خالص', data: [] }
]
}
},
computed: {
formattedStartDate() {
return this.dateFilter.startDate ? this.formatDateForDisplay(this.dateFilter.startDate) : '';
},
formattedEndDate() {
return this.dateFilter.endDate ? this.formatDateForDisplay(this.dateFilter.endDate) : '';
}
},
watch: {
'dateFilter.startDate'() { if (this.dateFilter.startDate && this.dateFilter.endDate) { this.syncGregorian(); this.loadData(); }},
'dateFilter.endDate'() { if (this.dateFilter.startDate && this.dateFilter.endDate) { this.syncGregorian(); this.loadData(); }}
},
async mounted() {
await this.loadYearBounds();
await this.loadData();
},
methods: {
async loadYearBounds() {
try {
// دریافت محدوده سال مالی از API مشابه SellReport
const yearResponse = await axios.get('/api/year/get');
this.year = yearResponse.data || this.year;
// مقداردهی پیشفرض فیلتر تاریخ بر اساس سال مالی
this.dateFilter.startDate = this.year.start;
this.dateFilter.endDate = this.year.end;
this.gregorianStartDate = this.convertJalaliToGregorian(this.dateFilter.startDate);
this.gregorianEndDate = this.convertJalaliToGregorian(this.dateFilter.endDate);
} catch (e) {}
},
syncGregorian() {
this.gregorianStartDate = this.convertJalaliToGregorian(this.dateFilter.startDate);
this.gregorianEndDate = this.convertJalaliToGregorian(this.dateFilter.endDate);
},
async loadData() {
this.loading = true;
try {
const params = {
startDate: this.dateFilter.startDate || undefined,
endDate: this.dateFilter.endDate || undefined,
groupBy: this.groupBy,
includeTrend: true,
customerId: this.filters?.customerId || undefined,
status: this.filters?.status || undefined
};
const { data } = await axios.get('/api/profit/report/summary', { params });
if (data && data.result === 1) {
this.summary = data.data || this.summary;
if (data.data && data.data.trend) {
this.chartOptions = { ...this.chartOptions, xaxis: { ...this.chartOptions.xaxis, categories: data.data.trend.categories || [] } };
this.chartSeries = [
{ name: 'سود ناخالص', data: data.data.trend.grossProfit || [] },
{ name: 'سود خالص', data: data.data.trend.netProfit || [] }
];
}
}
} catch (e) {
} finally {
this.loading = false;
}
},
// کمکیها (همان الگوی SellReport)
formatNumber(number) {
return new Intl.NumberFormat('fa-IR').format(number || 0);
},
formatDateForDisplay(dateString) {
if (!dateString) return '';
try {
if (typeof dateString === 'string' && dateString.includes('/')) {
// ورودی جلالی است: همان را نمایش بده
return dateString;
}
// در غیر اینصورت تلاش به تبدیل به جلالی خوانا
const jalali = moment(dateString, 'YYYY-MM-DD').format('jYYYY/jMM/jDD');
return jalali;
} catch (e) {
return dateString;
}
},
convertJalaliToGregorian(jalaliDate) {
if (!jalaliDate) return '';
try {
if (typeof jalaliDate === 'string' && jalaliDate.includes('/')) {
const gregorian = moment(jalaliDate, 'jYYYY/jMM/jDD').format('YYYY-MM-DD');
return gregorian;
}
return jalaliDate;
} catch (e) {
return jalaliDate;
}
},
convertGregorianToJalali(gregorianDate) {
try {
const jalali = moment(gregorianDate, 'YYYY-MM-DD').format('jYYYY/jMM/jDD');
return jalali;
} catch (e) {
return gregorianDate;
}
}
}
}
</script>
<style scoped>
</style>

View file

@ -142,7 +142,8 @@ export default {
{ text: this.$t('dialog.explore_accounts'), to: '/acc/reports/acc/explore_accounts' } { text: this.$t('dialog.explore_accounts'), to: '/acc/reports/acc/explore_accounts' }
], ],
salesReports: [ salesReports: [
{ text: 'گزارش فروش', to: '/acc/reports/sell' } { text: 'گزارش فروش', to: '/acc/reports/sell' },
{ text: 'گزارش سود', to: '/acc/reports/profit' }
] ]
} }
}, },