--- /dev/null
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Http\Controllers\Controller;
+
+class ExtranetTotalsController extends Controller
+{
+ protected function index()
+ {
+ $data = cache('extranet_totals');
+ $currentYear = date('Y');
+
+ $chartRevenue = [];
+ $chartRevenuePrevision = [];
+ foreach ($data['years'] as $year => $amount) {
+ $chartRevenue[] = ['year' => $year, 'amount' => $amount];
+ $chartRevenuePrevision[] = ['year', $year, 'amount' => $currentYear == $year ? $data['pendingProjects'] : ($year == $currentYear - 1 ? 0 : ("NaN"))];
+ }
+
+ return view('extranet.totals', ['currentYear'=>$currentYear,'chart_revenue' => $chartRevenue, 'chart_revenue_prevision' => $chartRevenuePrevision, 'data' => $data]);
+ }
+}
use App\Models\User;
use Cubedesigners\UserDatabase\Permissions;
use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class ProcessTotals extends Base
{
-
-
protected $projects = [];
protected $invoices = [];
protected $invoicesByProject = [];
protected $companyYears = [];
protected $unpaidYears = [];
protected $unpaid = [];
+ protected $totalYears = [];
+ protected $totalMonths = [];
+ protected $totalQuarters = [];
+ protected $projects_budget = [];
+ protected $unpaidTotals = [];
+ protected $pendingProjects = 0;
+ protected $totalCategories = [];
const firstYear = 2006;
protected $lastProject = [];
public function handle()
{
$this->processInvoices();
+ $this->processProjects();
$this->processCompanies();
$this->processFluidbookCounts();
Artisan::command('ws:precache', function () {
});
+
+ cache()->forever('extranet_totals',
+ [
+ 'pendingProjects' => $this->pendingProjects,
+ 'years' => $this->totalYears,
+ 'months' => $this->totalMonths,
+ 'quarters' => $this->totalQuarters,
+ 'currentYear' => $this->totalYears[date('Y')],
+ 'currentYearProjection' => $this->totalYears[date('Y')] + $this->pendingProjects,
+ 'unpaid' => $this->unpaidTotals,
+ 'categories' => $this->totalCategories,
+ ]
+ );
}
protected function processInvoices()
{
+ $d30 = time() - (30 * 3600 * 24);
+ $d90 = time() - (90 * 3600 * 24);
+ $d365 = time() - (365 * 3600 * 24);
+
+ $this->unpaidTotals = ['all' => 0, 'd30' => 0, 'd90' => 0, 'd365' => 0];
+
foreach (DB::table(self::$_wstable . '.factures')->whereIn('status', [1, 2])->get() as $e) {
+ $year = date('Y', $e->date_creation);
+ $month = date('m', $e->date_creation);
+ $quarter = ceil($month / 3);
$this->invoices[$e->facture_id] =
- ['status' => $e->status, 'amount' => $e->total_ht, 'project' => $e->projet, 'year' => date('Y', $e->date_creation), 'paid' => $e->status == 2];
+ ['status' => $e->status, 'amount' => $e->total_ht, 'project' => $e->projet, 'year' => $year, 'month' => $year . '-' . $month, 'quarter' => $year . '-Q' . $quarter, 'paid' => $e->status == 2];
+
+ if ($e->status == 1) {
+ $this->unpaidTotals['all'] += $e->total_ht;
+ if ($e->date_creation < $d30) {
+ $this->unpaidTotals['d30'] += $e->total_ht;
+ if ($e->date_creation < $d90) {
+ $this->unpaidTotals['d90'] += $e->total_ht;
+ if ($e->date_creation < $d365) {
+ $this->unpaidTotals['d365'] += $e->total_ht;
+ }
+ }
+ }
+ }
+
+ if (!isset($this->totalYears[$year])) {
+ $this->totalYears[$year] = 0;
+ }
+ $this->totalYears[$year] += $e->total_ht;
+ $m = $year . '-' . $month;
+ if (!isset($this->totalMonths[$m])) {
+ $this->totalMonths[$m] = 0;
+ }
+ $this->totalMonths[$m] += $e->total_ht;
+
+ $q = $year . '-Q' . $quarter;
+ if (!isset($this->totalQuarters[$q])) {
+ $this->totalQuarters[$q] = 0;
+ }
+ $this->totalQuarters[$q] += $e->total_ht;
if (!isset($this->invoicesByProject[$e->projet])) {
$this->invoicesByProject[$e->projet] = [];
}
$this->invoicesByProject[$e->projet][] = $e->facture_id;
}
+
+ ksort($this->totalYears);
+ ksort($this->totalMonths);
+ ksort($this->totalQuarters);
}
}
foreach (DB::table(self::$_wstable . '.projets')->get() as $e) {
-
if (!isset($this->companyOfUser[$e->client])) {
continue;
}
}
}
+ protected function processProjects()
+ {
+ $currentYear = date('Y');
+
+ $projectsYears = [];
+ foreach (DB::table(self::$_wstable . '.projets')->get() as $e) {
+ $projectsYears[$e->projet_id] = date('Y', $e->date_creation);
+ }
+
+ foreach (DB::table(self::$_wstable . '.taches')->get() as $e) {
+ if (!isset($this->projects_budget[$e->projet])) {
+ $this->projects_budget[$e->projet] = 0;
+ }
+ if (isset($projectsYears[$e->projet])) {
+ $y = $projectsYears[$e->projet];
+ if (!isset($this->totalCategories[$e->categorie])) {
+ $this->totalCategories[$e->categorie] = ['total' => 0, 'ratio' => 0, 'years' => []];
+ }
+ if (!isset($this->totalCategories[$e->categorie]['years'][$y])) {
+ $this->totalCategories[$e->categorie]['years'][$y] = ['amount' => 0, 'ratio' => 0];
+ }
+ $this->totalCategories[$e->categorie]['years'][$y]['amount'] += $e->budget;
+ $this->totalCategories[$e->categorie]['total'] += $e->budget;
+ }
+
+
+ $this->projects_budget[$e->projet] += $e->budget;
+ }
+
+ foreach ($this->totalCategories as $categorie => $c) {
+ if (!$c['total']) {
+ continue;
+ }
+ foreach ($c['years'] as $year => $cc) {
+ $yearTotal = $this->totalYears[$year];
+ if ($year == $currentYear) {
+ $yearTotal += $this->pendingProjects;
+ }
+ $this->totalCategories[$categorie]['years'][$year]['ratio'] = $cc['amount'] / $yearTotal;
+ }
+ }
+
+ // projects older than 18 month won't be invoices I suppose
+ $limit = time() - (3600 * 24 * 365 * 1.5);
+ foreach (DB::table(self::$_wstable . '.projets')->where('date_creation', '>', $limit)->where('status', 0)->get() as $e) {
+ $alreadyInvoiced = 0;
+ if (isset($this->invoicesByProject[$e->projet_id])) {
+ foreach ($this->invoicesByProject[$e->projet_id] as $invoice_id) {
+ $alreadyInvoiced += $this->invoices[$invoice_id]['amount'];
+ }
+ }
+
+ $this->pendingProjects += max(0, ($this->projects_budget[$e->projet_id] ?? 0) - $alreadyInvoiced);
+ }
+
+ ksort($this->totalCategories);
+ }
+
+
}
}
-
if (!function_exists('us_path')) {
function us_path($path = '')
{
}
if (!function_exists('us_protected_path')) {
- function us_protected_path($path='')
+ function us_protected_path($path = '')
{
return us_path('protected/' . $path);
}
}
+
+if (!function_exists('format_ke')) {
+ function format_ke($number)
+ {
+ if (!$number) {
+ return '-';
+ }
+ return round($number / 1000) . ' K€';
+ }
+}
+
+if (!function_exists('format_pct')) {
+ function format_pct($number)
+ {
+ if (!$number) {
+ return '';
+ }
+ return number_format($number * 100, 2, '.', ',') . ' %';
+ }
+}
--- /dev/null
+{{-- __('!! Extranet') --}}
+@extends(backpack_view('blank'))
+
+@php
+ $categories = array(0 => __('Non défini'), 1 => __('Gestion de projet'),
+ 2 => __('Design Web'), 3 => __('Design Industriel'), 4 => __('Print'),
+ 5 => __('Newsletter'), 6 => __('Développement PHP'), 7 => __('Développement Flash'),
+ 8 => __('Fluidbook'), 9 => __('Formation'), 10 => __('Administratif'), 11 => __('Divers'),
+ 12 => __('Intégration HTML'), 13 => __('Motion design'), 14 => __('Design graphique'),
+ 15 => __('Bandeaux de pub'), 16 => __('Applications mobiles'), 17 => __('Prise de vue photo/vidéo'), 18 => __('Hébergement'));
+@endphp
+
+@section('content')
+ <table id="crudTable"
+ class="bg-white table table-striped table-hover nowrap rounded shadow-xs border-xs mt-2 dataTable dtr-inline">
+ <thead>
+ <tr role="row">
+ <th colspan="3"><h3
+ style="position: relative;left:-10px">{{__('Détails de l\'année :year',['year'=>date('Y')])}}</h3>
+ </th>
+ <th>{{__('Impayés')}}</th>
+ <th>{{format_ke($data['unpaid']['all'])}}</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td></td>
+ <td>{{__('Projets en cours')}}</td>
+ <td>{{format_ke($data['pendingProjects'])}}</td>
+ <td>{{__('Impayés de plus de 30 jours')}}</td>
+ <td>{{format_ke($data['unpaid']['d30'])}}</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td></td>
+ <td>{{__('Chiffre d\'affaire')}}</td>
+ <td>{{format_ke($data['currentYear'])}}</td>
+ <td>{{__('Impayés de plus de 90 jours')}}</td>
+ <td>{{format_ke($data['unpaid']['d90'])}}</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td></td>
+ <td>{{__('Prévision de chiffre d\'affaire')}}</td>
+ <td>{{format_ke($data['currentYearProjection'])}}</td>
+ <td>{{__('Impayés de plus d\'un an')}}</td>
+ <td>{{format_ke($data['unpaid']['d365'])}}</td>
+ <td></td>
+ </tr>
+ </tbody>
+ </table>
+
+ <h3>{{__('Évolution du chiffre d\'affaire')}}</h3>
+ <div style="height: 300px; ">
+ <canvas id="revenue-chart"></canvas>
+ </div>
+
+ <table id="crudTable"
+ class="bg-white table table-striped table-hover nowrap rounded shadow-xs border-xs mt-2 dataTable dtr-inline">
+ <thead>
+ <tr role="row">
+ <th colspan="4"><h3
+ style="position: relative;left:-10px">{{__('Chiffre d\'affaire trimestriel')}}</h3>
+ </th>
+ <th>T1</th>
+ <th>T2</th>
+ <th>T3</th>
+ <th>T4</th>
+ <th><em>{{__('Total')}}</em></th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ @for($y=$currentYear;$y>=2009;$y--)
+ <tr>
+ <td></td>
+ <td colspan="3"><strong>{{$y}}</strong></td>
+ @for($i=1;$i<=4;$i++)
+ <td>{{format_ke($data['quarters'][$y.'-Q'.$i]??0)}}</td>
+ @endfor
+ <td><em>{{format_ke($data['years'][$y])}}</em></td>
+ <td></td>
+ </tr>
+ @endfor
+ </tbody>
+ </table>
+
+ <h3>{{__('Chiffre d\'affaire mensuel')}}</h3>
+ <table id="crudTable"
+ class="bg-white table table-striped table-hover nowrap rounded shadow-xs border-xs mt-2 dataTable dtr-inline"
+ style="table-layout: fixed;">
+ <thead>
+ <tr role="row">
+ <th style="width: 1px"></th>
+ <th style="width: 7.14%;">{{__('Année')}}</th>
+ <th style="width: 7.14%;">{{__('Janvier')}}</th>
+ <th style="width: 7.14%;">{{__('Février')}}</th>
+ <th style="width: 7.14%;">{{__('Mars')}}</th>
+ <th style="width: 7.14%;">{{__('Avril')}}</th>
+ <th style="width: 7.14%;">{{__('Mai')}}</th>
+ <th style="width: 7.14%;">{{__('Juin')}}</th>
+ <th style="width: 7.14%;">{{__('Juillet')}}</th>
+ <th style="width: 7.14%;">{{__('Août')}}</th>
+ <th style="width: 7.14%;">{{__('Septembre')}}</th>
+ <th style="width: 7.14%;">{{__('Octobre')}}</th>
+ <th style="width: 7.14%;">{{__('Novembre')}}</th>
+ <th style="width: 7.14%;">{{__('Décembre')}}</th>
+ <th style="width: 7.14%;"><em>{{__('Total')}}</em></th>
+ </tr>
+ </thead>
+ <tbody>
+ @for($y=$currentYear;$y>=2009;$y--)
+ <tr>
+ <td></td>
+ <td><strong>{{$y}}</strong></td>
+ @for($i=1;$i<=12;$i++)
+ <td>{{format_ke($data['months'][$y.'-'.($i>=10?$i:'0'.$i)]??0)}}</td>
+ @endfor
+ <td><em>{{format_ke($data['years'][$y])}}</em></td>
+ </tr>
+ @endfor
+ </tbody>
+ </table>
+
+ <h3>{{__('Répartition du chiffre d\'affaire par catégorie')}}</h3>
+ <table id="crudTable"
+ class="bg-white table table-striped table-hover nowrap rounded shadow-xs border-xs mt-2 dataTable dtr-inline"
+ style="table-layout: fixed;">
+ <thead>
+ <tr role="row">
+ <th style="width: 1px"></th>
+ <th style="width: 7.14%;">{{__('Catégorie')}}</th>
+ @for($y=$currentYear-11;$y<=$currentYear;$y++)
+ <th style="width: 7.14%;">{{$y}}</th>
+ @endfor
+ <th style="width: 7.14%;"><em>{{__('Total')}}</em></th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach($categories as $c=>$label)
+ <tr>
+ <td></td>
+ <td><strong>{{$label}}</strong></td>
+ @for($y=$currentYear-11;$y<=$currentYear;$y++)
+ <td>{{format_ke($data['categories'][$c]['years'][$y]['amount']??0)}}<br>{{format_pct($data['categories'][$c]['years'][$y]['ratio']??0)}}</td>
+ @endfor
+ <td>{{format_ke($data['categories'][$c]['total']??0)}}</td>
+ <td></td>
+ </tr>
+ @endforeach
+ </tbody>
+ </table>
+@endsection
+
+@push('after_scripts')
+ {{-- Charting library --}}
+ <script src=" https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js "></script>
+ <script>
+ const revenue = @json($chart_revenue);
+ const prevision = @json($chart_revenue_prevision);
+
+ Chart.defaults.font.family = "Source Sans Pro";
+ //=== Render Chart
+ new Chart(
+ document.getElementById('revenue-chart'),
+ {
+ type: 'line',
+ data: {
+ labels: revenue.map(row => row.year),
+ datasets: [
+ {
+ label: "{!! __('Chiffre d\'affaire annuel')!!}",
+ data: revenue.map(row => row.amount),
+ cubicInterpolationMode: 'monotone',
+ tension: 0.1,
+ fill: 'origin',
+ },
+ {
+ label: "{!! __('Prévision')!!}",
+ data: prevision.map(row => row.amount),
+ cubicInterpolationMode: 'monotone',
+ tension: 0.1,
+ fill: 'stack',
+ }
+ ]
+
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ maxBarThickness: 20, // Prevent bars being ridiculously wide when there isn't much data
+ scales: {
+ x: {stacked: true},
+ y: {
+ type: 'linear', // only linear but allow scale type registration. This allows extensions to exist solely for log scale for instance
+ position: 'left',
+ stacked: true,
+ min: 0,
+ },
+ },
+ plugins: {
+ tooltip: {
+ callbacks: {
+ label: (item) =>
+ `${item.dataset.label}: ${item.formattedValue} €`,
+ },
+ },
+ },
+ }
+ }
+ );
+ </script>
+@endpush
</ul>
</li>
@endcan
+@canany(['extranet:totals'])
+ <li {!! sidebarState('extranet') !!}><a class='nav-link nav-dropdown-toggle' href='#'><i
+ class='nav-icon las la-piggy-bank'></i>{{__('Extranet')}}</a>
+ <ul class='nav-dropdown-items'>
+ @can('extranet:totals')
+ <li class='nav-item'><a class='nav-link' href='{{ backpack_url('extranet/totals') }}'><i
+ class='nav-icon las la-chart-bar'></i>
+ <span>{{__('Chiffres')}}</span></a></li>
+ @endcan
+ </ul>
+ </li>
+@endcan
@canany(['team-leave:read','team-overtime:read','extranet:manage_emails'])
<li {!! sidebarState('team') !!}><a class='nav-link nav-dropdown-toggle' href='#'><i
Route::get('tasks/countUnread', 'TasksController@countUnread');
Route::match(['get'], 'storage/{path?}', 'StorageController@storage')->where(['path' => '.*']);
Route::delete('tasks/notification/{id}', 'TasksController@deleteNotification');
+ Route::get('extranet/totals', 'ExtranetTotalsController@index');
});
Route::group([