<?php
namespace App\Controller\Api\Service;
use App\Context\Metrics\MetricContextFactory;
use App\Entity\AccessRoles;
use App\Entity\Branches;
use App\Entity\Countries;
use App\Entity\ProjectOrderMetrics;
use App\Entity\ProjectOrders;
use App\Entity\Projects;
use App\Entity\ProjectStakeholders;
use App\Entity\TimesheetStatus;
use App\Entity\Vendor;
use App\Entity\VendorContactPersons;
use App\Entity\Worker;
use App\Entity\WorkerActivities;
use App\Entity\WorkerInProject;
use App\Entity\WorkerTimesheet;
use App\Entity\WorkerTimesheetHistories;
use App\Enum\SeverityInterface;
use App\Repository\WorkerActivitiesRepository;
use App\Repository\WorkerTimesheetRepository;
use App\Service\AisDate;
use App\Service\FileUploader;
use App\Service\SerializeService\ReferenceSerialize;
use App\Service\TimesheetInterface\TimesheetHistoryInterface;
use App\Service\TimesheetInterface\TimesheetReaderInterface;
use App\Service\TimesheetInterface\TimesheetWriterInterface;
use App\Service\ValidatorService\EntityHydratorValidator;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\EntityManagerInterface;
use phpDocumentor\Reflection\Types\This;
use Symfony\Component\HttpFoundation\FileBag;
use Symfony\Component\HttpFoundation\InputBag;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
class TimesheetService extends AbstractControllerService
{
// TODO #88: Bir çalışanın bir gündeki toplam P (çalışma) saati bu değeri aşamaz.
// Sınır aşılınca upsert BATCH'i atomik reddedilir (aşağıdaki assertDailyHourCap).
const MAX_DAILY_WORK_HOURS = 10;
// Cumartesi ayrı ve daha düşük tavana tabidir (kullanıcı kararı 2026-07-29):
// Pzt–Cuma en fazla 10 saat, CUMARTESİ en fazla 6 saat. Pazar'a zaten yazım yapılmaz.
const MAX_SATURDAY_WORK_HOURS = 6;
private EntityHydratorValidator $entityHydratorValidator;
private TimesheetReaderInterface $timesheetReader;
private TimesheetWriterInterface $timesheetWriter;
private TimesheetHistoryInterface $timesheetHistory;
private MetricContextFactory $metricContextFactory;
/**
* Timesheet Status and Order Metrics Update
*
* Each employee can declare work for a specific day. When work is declared,
* the project and order for that day are known, allowing calculation of
* spent cost and time metrics for that order.
*
* If the timesheet entry is later changed (e.g., from "P" – Presence – to "S" – Sick),
* the previously recorded order must be updated accordingly, because the actual
* work has changed and the spent time/cost for that order may be reduced.
*
* In such cases, the employee's previous status (history) is used to locate
* the corresponding order and recalculate its metrics, ensuring that order-level
* spent cost and time values remain accurate.
*
* Additionally:
* - When processing hours or "S" entries, the same employee and day may have
* multiple entries for the same order.
* - To avoid redundant database queries, the physical Order entities should
* be stored in memory (e.g., via a Factory pattern).
* - Each order is instantiated only once; subsequent occurrences on the same
* day or for the same employee reuse the existing entity.
* - This ensures that metrics calculation references a single instance of the order,
* avoiding inconsistencies and improving performance.
*
* Status Legend:
* P - Presence (worked)
* S - Sick
* H - Holiday
* FreeLeave - Unpaid / optional leave
*/
private array $orderCache;
/**
* Similar Information like $orderFactory
*/
private array $projectCache;
public function __construct(
EntityManagerInterface $manager,
AisDate $aisDate,
TranslatorInterface $translator,
ReferenceSerialize $referenceSerialize,
EntityHydratorValidator $entityHydratorValidator,
TimesheetReaderInterface $timesheetReader,
TimesheetWriterInterface $timesheetWriter,
Security $security,
TimesheetHistoryInterface $timesheetHistory,
MetricContextFactory $metricContextFactory
)
{
parent::__construct($manager, $aisDate, $translator, $referenceSerialize, $security);
$this->referenceSerialize = $referenceSerialize;
$this->entityHydratorValidator = $entityHydratorValidator;
$this->timesheetReader = $timesheetReader;
$this->timesheetWriter = $timesheetWriter;
$this->timesheetHistory = $timesheetHistory;
$this->metricContextFactory = $metricContextFactory;
// Initialize
$this->orderCache = [];
$this->projectCache = [];
}
public function fetchTimesheetEmployees(int $y): array
{
$employees = $this->manager->getRepository(WorkerTimesheet::class)->findBy(["year" => $y]);
return $employees;
}
/**
*
*
* Senaryolar:
*
* Durum domainExistsInPrev timeChanged isChanged Comment?
* İlk kez saat giriliyor false — false Hayır ✓
* Aynı domain, aynı saat true false false Hayır ✓
* Aynı domain, farklı saat true true true Evet ✓
* Farklı domain, aynı gün false true false Hayır ✓ (BUG FIX)
* @return array{m: string|int, dataset: array}
*@throws \Exception
*/
public function findByPayload(?array $activityIds = null): array
{
$monthData = $this->params->get('month');
$yearData = $this->params->get('year');
$projectId = filter_var($this->params->get('project'), FILTER_VALIDATE_INT);;
$orderId = filter_var($this->params->get('order'), FILTER_VALIDATE_INT);
$branchId = filter_var($this->params->get('branch'), FILTER_VALIDATE_INT);
if(!is_null($monthData) && !is_null($yearData)){
$monthData = $this->entityHydratorValidator->createInputBag($monthData);
$yearData = $this->entityHydratorValidator->createInputBag($yearData);
$m = $monthData->get('displayId');
$y = $yearData->get('displayId');
}
else {
$today = new \DateTimeImmutable('now');
$m = $today->format('m');
$y = $today->format('Y');
}
// WICHTIG: Falls Spaltennamen keine führende Null haben (m2 statt m02)
$searchMonth = (int)$m;
/**@var $repo WorkerTimesheetRepository*/
$repo = $this->manager->getRepository(WorkerTimesheet::class);
// $collection = $repo->findByDateYearAndMonth($y, $m);
$collection = $repo->findByPeriodAndDomain($y, $m, $projectId, $activityIds);
$timesheetStatuses = new ArrayCollection($this->manager->getRepository(TimesheetStatus::class)->findAll());
$finalResults = [];
foreach ($collection as $index => $row) {
// Regex angepasst: ^m2_d... (ohne führende Null bei Bedarf)
$daysOnly = array_filter($row, function($key) use ($searchMonth) {
return preg_match("/^m{$searchMonth}_d\d+$/", $key);
}, ARRAY_FILTER_USE_KEY);
$processedDays = [];
$totalActive = 0;
$totalInactive = 0;
$revisedDays = [];
foreach ($daysOnly as $dayKey => $dayData) {
if(!is_null($row['timesheet_revisions']) && array_key_exists($dayKey, $row['timesheet_revisions'])){
$revisedDays[] = $dayKey;
}
if(is_null($dayData)) continue;
// Falls Doctrine kein auto-decode macht:
if (is_string($dayData)) {
$dayData = json_decode($dayData, true);
}
if (empty($dayData)) {
// $processedDays[$dayKey] = ['absenceShortKey' => null, 'time' => 0, 'dayInfo' => []];
continue;
}
$time = 0;
$shortKey = $dayData['absenceShortKey'] ?? '';
if ($shortKey === 'P') {
foreach ($dayData['projects'] as $project) {
// DÜZELTME: Sadece bir proje filtresi VARSA ve uyuşmuyorsa atla.
// Eğer $projectId null ise bu if'e hiç girmez ve hesaplamaya devam eder.
if ($projectId && $project['project'] !== $projectId) {
continue;
}
if (!$orderId) {
// Eğer spesifik bir sipariş (order) seçilmediyse, projenin tüm saatlerini ekle
$time += array_sum(array_values($project['orders_total']) ?? []);
} else {
// Sipariş filtresi varsa kontrol et
if (isset($project['orders_total'][$orderId])) {
if (!$branchId) {
// Şube seçilmediyse siparişin toplamını ekle
$time += $project['orders_total'][$orderId];
} else {
// Şube seçildiyse derinlere in
foreach ($project['roles'] as $role) {
if (isset($role['orders'][$orderId]['branches'][$branchId])) {
$time += $role['orders'][$orderId]['branches'][$branchId]['time'];
}
}
}
}
}
}
$totalActive += $time;
} elseif (in_array($shortKey, ['S', 'H'])) {
// Falls 'time' im JSON als String vorliegt (z.B. "8.0"), floatval nutzen
$time = floatval($dayData['info']['time'] ?? 0);
$totalInactive += $time;
}
elseif (in_array($shortKey, ['HNP'])) {
// Falls 'time' im JSON als String vorliegt (z.B. "8.0"), floatval nutzen
$time = floatval( 0);
}
$criteria = Criteria::create()->where(
Criteria::expr()->eq("absence_short_key", $shortKey)
)->setMaxResults(1);
$colorsetEntity = $timesheetStatuses->matching($criteria)->first();
$colorset = $this->referenceSerialize->setEntity($colorsetEntity)->setGroups([
"timsheet_status@base"
])->render();
// $totalActive += $time;
$projectsLength = 0;
if(array_key_exists('projects', $dayData)){
$projectsLength = count($dayData['projects']);
}
$processedDays[$dayKey] = [
'absenceShortKey' => $shortKey,
'time' => $time,
'dayInfo' => $dayData,
'colorset' => $colorset,
'projectsLength' => $projectsLength
];
}
// 2. KRİTİK NOKTA: Orijinal satırdan (row) işlediğimiz gün kolonlarını çıkartıyoruz
// Böylece row içinde sadece ID, Worker Name gibi sabit veriler kalıyor.
$cleanRow = array_diff_key($row, $daysOnly);
// 3. Sabit veriler ile senin yeni oluşturduğun 'days' ve 'totalMonthTime'ı birleştir
$finalResults[] = array_merge($cleanRow, [
'days' => $processedDays,
'totalActive' => $totalActive, // array_sum($totalActive),
'totalInactive' => $totalInactive,
'finalTotal' => abs($totalInactive) + abs($totalActive),
'revisedDays' => $revisedDays,
'period' => ["month" => $m, "year" => $y]
]);
}
#dd();
#dd($m);
#dd($finalResults);
return [
"m" => $m,
"dataset" => $finalResults
];
}
/**
* Belirtilen tarih aralığına göre timesheet kolonlarını dinamik olarak oluşturur
* ve sadece ilgili yıl/ay/gün aralığına ait verileri sorgulamak için kullanılır.
*/
public function findByDateRange() {
$fromAt = $this->params->get('from_at') ? new \DateTime($this->params->get('from_at')) : null;
$toAt = $this->params->get('to_at') ? new \DateTime($this->params->get('to_at')) : null;
$dateRange = null;
$fromYear = $toYear = $fromMonth = $toMonth = $fromDay = $toDay = null;
if ($fromAt && $toAt) {
$dateRange = [
"from" => $fromAt->format('d.m.Y'),
"to" => $toAt->format('d.m.Y'),
"diff" => $fromAt->diff($toAt)->days + 1,
];
$fromYear = (int) $fromAt->format('Y');
$toYear = (int) $toAt->format('Y');
$fromMonth = (int) $fromAt->format('n');
$toMonth = (int) $toAt->format('n');
$fromDay = (int) $fromAt->format('j');
$toDay = (int) $toAt->format('j');
}
$availableCols = [];
$buildAllColumns = function (): array {
$cols = [];
for ($m = 1; $m <= 12; $m++) {
for ($d = 1; $d <= 31; $d++) {
$cols[] = "ts.m{$m}_d{$d}";
}
}
return $cols;
};
if ($fromYear && $toYear) {
// farklı yıl → full scan
if ($fromYear !== $toYear) {
$availableCols = $buildAllColumns();
} else {
// aynı yıl
for ($m = $fromMonth; $m <= $toMonth; $m++) {
$startDay = ($m === $fromMonth) ? $fromDay : 1;
$endDay = ($m === $toMonth) ? $toDay : 31;
for ($d = $startDay; $d <= $endDay; $d++) {
$availableCols[] = "ts.m{$m}_d{$d}";
}
}
}
} else {
// fallback: full scan
$availableCols = $buildAllColumns();
}
$tss = $this->manager
->getRepository(WorkerTimesheet::class)
->findByDateRange($fromYear, $toYear, $availableCols);
return [
"collection" => $tss,
"date_range" => $dateRange
];
}
public function unregisteredEmployees(): array {
$periodBag = $this->entityHydratorValidator->createInputBag($this->params->get('period'));
$domainBag = $this->entityHydratorValidator->createInputBag($this->params->get('domain'));
// Senaryo 2: Domain'de proje seçiliyse o projeye bağlı olmayanları getir
$projectId = null;
if ($domainBag && !empty($domainBag->get('project')->id)) {
$projectId = (int) $domainBag->get('project')->id;
}
/**@var $repo WorkerTimesheetRepository*/
$repo = $this->manager->getRepository(WorkerTimesheet::class);
$dataset = [];
if (!empty($periodBag->get('year')->displayId)) {
$dataset = $repo->findUnregisteredEmployees($periodBag->get('year')->displayId, $projectId);
}
$m = (new \DateTimeImmutable('now'))->format('m');
if (!empty($periodBag->get('month')->displayId)) {
$m = $periodBag->get('month')->displayId;
}
return [
"m" => $m,
'dataset' => $dataset
];
}
/**
*
* @return array{m: string, dataset: ArrayCollection<WorkerTimesheet>}
*/
public function syncRegisterEmployees(): array {
$employees = $this->params->get('employees');
$projectId = $this->params->get('project');
$employeesBag = $this->entityHydratorValidator->createInputBag($employees);
$periodBag = $this->entityHydratorValidator->createInputBag($this->params->get('period'));
if (!empty($periodBag->get('year')->displayId)) {
$y = $periodBag->get('year')->displayId;
}
if (!empty($periodBag->get('month')->displayId)) {
$m = $periodBag->get('month')->displayId;
}
if($projectId){
$projectEntity = $this->manager->getRepository(Projects::class)->find($projectId);
$stakeholders = $projectEntity->getStakeholders();
}
$collection = new ArrayCollection([]);
if(!$employeesBag->count()){
$this->setCreateException(HttpStatusInterface::UNAUTHORIZED, $this->translator->trans('No any employee'));
}
foreach ($employeesBag as $employeeBag) {
$employeeActivity = $this->manager->getRepository(WorkerActivities::class)->find($employeeBag->id);
// SECTION Timesheet exists
$criteria = Criteria::create()->where(Criteria::expr()->eq('year', $y))->setMaxResults(1);
$timesheet = $employeeActivity->getWorkerTimesheets()->matching($criteria)->first();
if(!$timesheet){
$timesheet = new WorkerTimesheet();
$timesheet->setYear($y)->setCreatedAt(new \DateTimeImmutable('now'));
$employeeActivity->addWorkerTimesheet($timesheet);
}
$collection->add($employeeActivity);
if(!$projectId){
continue;
}
$partner = $employeeActivity->getPartner();
$employeeStartAt = new \DateTimeImmutable($employeeBag->employee_beginn_to_project);
// SECTION Stakeholder exits
$criteria = Criteria::create()
->where(Criteria::expr()->eq('partner', $partner))
->andWhere(Criteria::expr()->eq('project', $projectEntity))
->andWhere(Criteria::expr()->eq('end_at', null))
->setMaxResults(1);
$stakeholder = $stakeholders->matching($criteria)->first();
#dd($employeeBag);
if(!$stakeholder){
$partnerStartAt = new \DateTimeImmutable($employeeBag->partner_beginn_to_project);
$stakeholder = new ProjectStakeholders();
$stakeholder
->setProject($projectEntity)
->setCreatedAt(new \DateTimeImmutable('now'))
->setPartner($partner);
$projectEntity->addStakeholder($stakeholder);
$stakeholder->setStartAt($partnerStartAt);
$this->manager->persist($stakeholder); // 👈 BURASI KRİTİK: Yeni nesneyi Doctrine'e tanıt
}
// SECTION Employee exits
$criteria = Criteria::create()
->where(Criteria::expr()->eq('worker_activity', $employeeActivity))
->andWhere(Criteria::expr()->eq('project', $stakeholder))
->andWhere(Criteria::expr()->eq('end_at', null))
->setMaxResults(1);
$wip = $stakeholder->getWorkerInProjects()->matching($criteria)->first();
if(!$wip){
// Any Case Create New WIP
$wip = new WorkerInProject();
$wip
->setCreatedAt(new \DateTimeImmutable('now'))
->setWorkerActivity($employeeActivity)
->setCostPerHour($employeeActivity->getHourlyRate());
$stakeholder->addWorkerInProject($wip);
$wip->setStartAt($employeeStartAt);
$this->manager->persist($wip);
}
}
try{
$this->manager->flush();
} catch (\Exception $exception){
// throw new \Exception($exception->getMessage());
$this->setCreateException($exception->getCode(), $exception->getMessage());
}
return [
"m" => $m,
'dataset' => $collection
];
}
/**
* @return ArrayCollection
* @throws \Exception
*/
/**
* TODO #88 — Gün-toplam 10 saat cap kontrolü (kod yazmadan reddet).
* Batch'teki HER (çalışan, gün) P girişi için: [o günün mevcut DİĞER domain P-toplamı]
* + [gelen saat] > 10 ise hata listesine ekler. Yalnız P; S/H/HNP/R kapsam dışı.
* Transaction'dan ÖNCE çağrılır → aşımda hiç yazım olmaz.
* @return string[] boş ise sınır aşımı yok
*/
/**
* Gün alanının (m{ay}_d{gün}) ISO haftagünü: 1=Pazartesi .. 7=Pazar.
* Alan/yıl çözülemezse null döner — çağıran taraf güvenli davranışa düşer.
*/
private function weekdayForField(?string $field, $year): ?int
{
if(!$field || !$year || !preg_match('/^m(\d+)_d(\d+)$/', $field, $matches)){
return null;
}
$month = (int)$matches[1];
$day = (int)$matches[2];
if(!checkdate($month, $day, (int)$year)){
return null;
}
return (int)date('N', strtotime(sprintf('%04d-%02d-%02d', (int)$year, $month, $day)));
}
/**
* Gün alanına göre o günün çalışma tavanı: Cumartesi 6, diğer günler 10.
* Çözülemezse güvenli tarafta kalınır (genel tavan uygulanır).
* NOT: Pazar burada 10 döner ama Pazar'a yazım zaten mutlak yasaktır (assertDailyHourCap
* tavan hesabından önce reddeder) — tavan meselesi değildir.
*/
private function dailyCapForField(?string $field, $year): float
{
return ($this->weekdayForField($field, $year) === 6)
? self::MAX_SATURDAY_WORK_HOURS
: self::MAX_DAILY_WORK_HOURS;
}
private function assertDailyHourCap($attendances, ?InputBag $domain, ?InputBag $period): array
{
$errors = [];
$year = ($period && !empty($period->get('year')->displayId)) ? $period->get('year')->displayId : null;
$domainOrderId = ($domain && !empty($domain->get('order')->id)) ? $domain->get('order')->id : null;
$domainBranchId = ($domain && !empty($domain->get('branch')->id)) ? $domain->get('branch')->id : null;
$tsCache = [];
foreach($attendances as $attendanceRaw){
$bag = $this->entityHydratorValidator->createInputBag($attendanceRaw);
// Yalnız P (çalışma) saati sınırlanır; status günleri (S/H/HNP) ve reset (R) kapsam dışı.
if($bag->get('nextAbsence') !== ABSENCE_PRESENT){
continue;
}
$incoming = floatval($bag->get('time'));
$employeeId = !empty($bag->get('employee')->id) ? $bag->get('employee')->id : null;
$field = $attendanceRaw->field ?? null; // m{month}_d{day}
if(!$employeeId || !$field){
continue;
}
// Çalışanın yıl timesheet'i (cache'li)
$cacheKey = $employeeId . '_' . $year;
if(!array_key_exists($cacheKey, $tsCache)){
$activity = $this->manager->getRepository(WorkerActivities::class)->find($employeeId);
$ts = $activity
? $this->manager->getRepository(WorkerTimesheet::class)->findOneBy(['worker_activity' => $activity, 'year' => $year])
: null;
$tsCache[$cacheKey] = [
'ts' => $ts,
'name' => $activity ? $activity->getWorker()->getFullName() : ('#' . $employeeId)
];
}
$ts = $tsCache[$cacheKey]['ts'];
// PAZAR: çalışma saati yazımı MUTLAK YASAK (kullanıcı kararı 2026-07-29).
// Tavan meselesi değil — kaç saat olursa olsun reddedilir, bu yüzden saat
// hesabından ÖNCE bakılır. Yalnız P kapsamda; statü günleri (S/H/HNP) etkilenmez.
if($this->weekdayForField($field, $year) === 7){
$dayLabel = str_replace(['m', '_d'], ['', '.'], $field); // m7_d19 → 7.19
$errors[] = $tsCache[$cacheKey]['name'] . ' (' . $dayLabel . '): '
. $this->translator->trans('working hours cannot be entered on Sunday');
continue;
}
// Mevcut gün-JSON'u (varsa)
$existingDayData = [];
if($ts){
$getter = 'get' . strtoupper(preg_replace('/_/', '', $field));
if(method_exists($ts, $getter)){
$existingDayData = $ts->{$getter}() ?? [];
if(is_string($existingDayData)){
$existingDayData = json_decode($existingDayData, true) ?? [];
}
}
}
// Bu domain'in eski leaf'i EZİLECEK → toplamdan düşülür, sonra gelen saat eklenir.
$existingOther = $this->sumDayPresentTime($existingDayData, $domainOrderId, $domainBranchId);
$projected = $existingOther + $incoming;
// Tavan güne göre değişir: Cumartesi 6, diğer günler 10.
$dailyCap = $this->dailyCapForField($field, $year);
if($projected > $dailyCap){
$dayLabel = str_replace(['m', '_d'], ['', '.'], $field); // m5_d12 → 5.12
$errors[] = $tsCache[$cacheKey]['name'] . ' (' . $dayLabel . '): '
. rtrim(rtrim(number_format($projected, 1, '.', ''), '0'), '.') . 'h'
. ' / max ' . rtrim(rtrim(number_format($dailyCap, 1, '.', ''), '0'), '.') . 'h';
}
}
return $errors;
}
/**
* Bir gün-JSON'undaki toplam P (çalışma) saatini döner. $excludeOrderId+$excludeBranchId
* verilirse o domain'in leaf'i toplamdan düşülür (upsert onu ezeceği için).
*/
private function sumDayPresentTime(?array $dayData, ?int $excludeOrderId, ?int $excludeBranchId): float
{
if(empty($dayData) || ($dayData['absenceShortKey'] ?? '') !== 'P' || empty($dayData['projects'])){
return 0.0;
}
$sum = 0.0;
foreach($dayData['projects'] as $project){
foreach(($project['orders_total'] ?? []) as $orderTotal){
$sum += floatval($orderTotal);
}
}
// Ezilecek domain leaf'ini çıkar (sadece ilgili order+branch)
if($excludeOrderId && $excludeBranchId){
foreach($dayData['projects'] as $project){
foreach(($project['roles'] ?? []) as $role){
if(isset($role['orders'][$excludeOrderId]['branches'][$excludeBranchId]['time'])){
$sum -= floatval($role['orders'][$excludeOrderId]['branches'][$excludeBranchId]['time']);
}
}
}
}
return $sum;
}
public function upsert(): ?ArrayCollection
{
/**
* Bu kisim karisik
* User role sunu deme kiyirou bu islem yapabilme seviyesi var yani
* bu oslemi yapbilenler
* System level seviyesi en dusuk
* App Users Bu orta seviye systemi ezer
* Desktop users Bu güclu seviye App I ezer
* TODO Burasi yenilanacak!!! Yani Level e cevrilecek user_access_roles table var o table adu user_levels olmali ve colonlari user_id ✅ access_role_id => access_module olmali
* !!! Important user islem yapbilmesi icin tanimlanmis olmali
* SECTION UserRole ℹ️ User User Role Level!!!
* @var $userRole AccessRoles
*/
$userRole =$this->security->getUser()->getUserAccessedRoles()->filter(function(/**@var $item AccessRoles*/$item){
return $item->getAccessType()->getName() === 'Timesheet';
})->first();
if(!$userRole){
$this->setCreateException(HttpStatusInterface::UNAUTHORIZED, $this->translator->trans("Required access level for user, not defined!") );
return null;
}
// SECTION * Timesheet Status All
$timesheetStatus = new ArrayCollection($this->manager->getRepository(TimesheetStatus::class)->findAll());
// SECTION Safety Guard!!!
if(!$this->userUpsertAccess($userRole, $timesheetStatus)){
return null;
}
$successItems = new ArrayCollection([]);
$errorItems = new ArrayCollection([]);
$attendances = $this->entityHydratorValidator->createInputBag($this->params->get('collection'));
$domain = $this->entityHydratorValidator->createInputBag($this->params->get('domain'));
$period = $this->entityHydratorValidator->createInputBag($this->params->get('period'));
#dd($domain, $period, $attendances);
// SECTION 10-Stunden-Cap (TODO #88) — GERÇEK GATE.
// Batch'teki herhangi bir (çalışan, gün) için P toplamı 10'u aşıyorsa HİÇBİR ŞEY yazma.
// Transaction'dan ÖNCE çalışır → aşımda hiçbir kayıt oluşmaz, mevcut rollback'siz-return
// latent bug'ına da bulaşmaz (batch atomik reddedilir, 406 döner).
$capErrors = $this->assertDailyHourCap($attendances, $domain, $period);
if(count($capErrors)){
$this->setCreateException(
Response::HTTP_UNPROCESSABLE_ENTITY,
// Başlıkta sabit sayı YOK: tavan güne göre değişiyor (hafta içi 10, Cumartesi 6)
// ve Pazar'da mutlak yasak var. Hangi satırın neden reddedildiği listede yazıyor.
$this->translator->trans('Daily working hour limit exceeded') . ' — ' . implode(' · ', $capErrors)
);
return null;
}
$this->manager->beginTransaction();
// SECTION Period Parsed
$month = !empty($period->get('month')->displayId) ? $period->get('month')->displayId : null;
$year = !empty($period->get('month')->displayId) ? $period->get('month')->displayId : null;
// Revision Section
$employeeCachedHistoryEntities = [];
// SECTION Entity Resolver's
$project = null;
$projectOrders = new ArrayCollection([]);
$projectBranches = new ArrayCollection([]);
if($domain && $domain->get('project')){
$projectId = $domain->get('project')->id;
$project = $this->manager->getRepository(Projects::class)->find($projectId);
$projectOrders = $project->getProjectOrders();
$projectBranches = $project->getBranches();
}
// SECTION Entities
// * Timesheet
$all_timesheet = new ArrayCollection($this->manager->getRepository(WorkerTimesheet::class)->findAll());
// SECTION Cashed Employee Activities
$cachedWorkerActivities = new ArrayCollection();
// SECTION Loop Attendances
foreach ($attendances as $attendanceRaw) {
$localError = false;
$attendanceBag = $this->entityHydratorValidator->createInputBag($attendanceRaw);
// SECTION Prepare Setter & Getter
$property = $attendanceRaw->field;
$property = preg_replace('/_/', '', $property);
$getterMethod = 'get' . strtoupper($property);
$setterMethod = 'set' . strtoupper($property);
// SECTION Prepare & Cache Employee Entity [R]
$employeeActivityId = !empty($attendanceBag->get('employee')->id) ? $attendanceBag->get('employee')->id : null;
$employeeActivityEntity = $cachedWorkerActivities->filter(fn($item) => $item->getId() === $employeeActivityId )->first();
if(!$employeeActivityEntity){
$employeeActivityEntity = $this->manager->getRepository(WorkerActivities::class)->find($employeeActivityId);
$cachedWorkerActivities->add($employeeActivityEntity);
}
// SECTION Timesheet FILTER Query
$tsC = Criteria::create()
->where(Criteria::expr()->eq('year', !empty($period->get('year')->displayId) ? $period->get('year')->displayId : null ))
->andWhere(Criteria::expr()->eq('worker_activity', $employeeActivityEntity ))
->setMaxResults(1);
// SECTION [R, C] Timesheet Entity
$timesheet = $all_timesheet->matching($tsC)->first();
if(!$timesheet){
$timesheet = new WorkerTimesheet();
$timesheet
->setCreatedAt(new \DateTimeImmutable('now'))
->setWorkerActivity($employeeActivityEntity);
$this->manager->persist($timesheet);
}
// Day Getter
$dayData = $timesheet->{$getterMethod}() ?? [];
// SECTION GET Attendance
$nextAbsence = $attendanceBag->get('nextAbsence');
// WARNING If Clean not more need another process!!!
/**
* This process like reset but not more comment need, just clean this day
*/
if($nextAbsence === ABSENCE_PRISTINE){
$timesheet->{$setterMethod}([]);
$this->manager->persist($timesheet);
continue;
}
// SECTION WorkerInProject [R]
$wip = null;
$branch = null;
$order = null;
if( $project ){
$wip = $employeeActivityEntity->getWorkerInProjects()->filter(function (/**@var WorkerInProject $wip*/ $wip ) use ($project){
return $wip->getProject()->getProject()->getId()
=== $project->getId();
})->first();
// SECTION FILTER ORDER
$domainOrderId = !empty($domain->get('order')->id) ? $domain->get('order')->id : null;
$oC = Criteria::create()->where(Criteria::expr()->eq('id', $domainOrderId))->setMaxResults(1);
$order = $projectOrders->matching($oC) ? $projectOrders->matching($oC)->first() : null;
// SECTION FILTER BRANCH
$domainBranchId = !empty($domain->get('branch')->id) ? $domain->get('branch')->id : null;
$bC = Criteria::create()->where(Criteria::expr()->eq('id', $domainBranchId))->setMaxResults(1);
$branch = $projectBranches->matching($bC) ? $projectBranches->matching($bC)->first() : null;
// WARNING Bu islemi neden yapiyorum ?? ( Add This worker to Order as assign if already not assigned )
if( $nextAbsence === ABSENCE_PRESENT ) {
// TODO ? Employee already Assigned to this order (Employee work for this order) -> Assignation (Using Target ??)
$aOC = Criteria::create()->where(Criteria::expr()->eq('id', $order));
$isAssigned = $employeeActivityEntity->getAssignedOrders()->matching($aOC)->first();
if(!$isAssigned){
$employeeActivityEntity->addAssignedOrder($order);
}
}
}
// SECTION TimesheetStatus
$tssC = Criteria::create()->where(Criteria::expr()->eq('absence_short_key', $nextAbsence))->setMaxResults(1);
$tss = $timesheetStatus->matching($tssC)->first();
// SECTION Errors
if(!$employeeActivityEntity->getHourlyRate()){
$localError = true;
$errorItems->add($this->translator->trans("Missing hourly rate for") . " " . $employeeActivityEntity->getWorker()->getFullName());
}
if(!$employeeActivityEntity->getDailyWorkingHours()){
$localError = true;
$errorItems->add($this->translator->trans("Missing daily working hours for") . " " . $employeeActivityEntity->getWorker()->getFullName());
}
if($nextAbsence === ABSENCE_PRESENT && (!$project || !$wip)){
$this->setCreateException(
Response::HTTP_UNPROCESSABLE_ENTITY,
$employeeActivityEntity->getWorker()->getFullName() . " " . $this->translator->trans("cannot write P: activity is not linked to a project")
);
return null;
}
if(!$localError){
$this->timesheetWriter
->setWorkerActivity($employeeActivityEntity)
->setHourlyRate($employeeActivityEntity->getHourlyRate())
->setDailyWorkingHours($employeeActivityEntity->getDailyWorkingHours())
->setMonth($month)
->setUser( $this->security->getUser() )
->setManagerRole($userRole)
->setTimesheetStatus( $tss )
// TODO ProjectStakeholder should replace with workerInProject project access over workerInProject
->writerPathManager( $wip, $branch, $order )
->setData( $dayData );
$upsertDayData = null;
// [P]
if( $nextAbsence === ABSENCE_PRESENT ) {
$time = $attendanceBag->get('time');
// TODO Add Order to Cache if not exists
$this->orderCache[$order->getId()] = $order;
// $this->metricsOrderCollector($dayData);
$upsertDayData = $this->timesheetWriter->addTime($time)->extendResultRolesBasic();
}
// [R]
else if( $nextAbsence === ABSENCE_RESET ){
// SECTION Order Collector
$this->metricsOrderCollector($dayData);
$upsertDayData = $this->timesheetWriter->reset()->extendResultRolesBasic();
}
// [S, H, HNP]
else {
// SECTION Order Collector
$this->metricsOrderCollector($dayData);
$upsertDayData = $this->timesheetWriter->updateStatus()->extendResultRolesBasic();
}
$timesheet->{$setterMethod}( $upsertDayData );
$this->manager->persist( $timesheet );
$successItems->add($timesheet);
// SECTION History (Revision)
$this->timesheetRevision(
$attendanceBag,
$tss,
$employeeActivityEntity,
$employeeCachedHistoryEntities,
$timesheet,
$project,
$order,
$branch
);
}
}
if(count($errorItems)){
$this->setCreateException(HttpStatusInterface::UNAUTHORIZED, implode(" ", $errorItems->toArray()) );
return null;
}
// SECTION Metrics (super Step )
/**@var ProjectOrders $order */
foreach ($this->orderCache as $order) {
$this->metricContextFactory->orderMetrics($order)->syncLaborMetrics();
}
foreach ($this->projectCache as $project ) {
$this->metricContextFactory->projectMetrics($project)->syncLaborMetrics();
}
#try{
$this->manager->commit();
$this->manager->flush();
#} catch (\Exception $exception){
# $this->manager->rollback();
# $this->setCreateException($exception->getCode(), $this->exception->getMessage());
# return null;
#}
// Update Metrics
// $this->metricContextFactory->orderMetrics()
return $successItems;
}
private function metricsOrderCollector(?array $dayData) {
if(count($dayData)){
$jsonProjects = [];
if(array_key_exists('projects', $dayData)){
$jsonProjects = $dayData['projects'];
}
foreach ($jsonProjects as $jsonProject) {
// $minRole = min(array_column($project['roles'], 'role'));
$roles = $jsonProject['roles'];
$minRoleEntry = array_reduce($roles, fn($carry, $item) => ($carry === null || $item['role'] < $carry['role']) ? $item : $carry);
$jsonOrderIds = array_keys($minRoleEntry['orders']);
#dump($jsonOrderIds);
foreach ($jsonOrderIds as $jsonOrderId ) {
if(!isset($this->orderCache[$jsonOrderId])){
/**@var ProjectOrders $_order*/
$_order = $this->manager->getRepository(ProjectOrders::class)->find($jsonOrderId);
$this->orderCache[$jsonOrderId] = $_order;
// Add project
$_project = $_order->getProject();
if(!isset($this->projectCache[$_project->getId()])){
$this->projectCache[$_project->getId()] = $_project;
}
}
// $oc = Criteria::create()->where(Criteria::expr()->eq('id', $jsonOrderId))->setMaxResults(1);
// $orderEntity = $this->orderFactory->matching($oc)->first();
// if(!$orderEntity){
// $orderReal = $this->manager->getRepository(ProjectOrders::class)->find($jsonOrderId);
// $this->orderFactory->add($orderReal);
// }
}
}
}
}
private function userUpsertAccess(?AccessRoles $userRole, ?ArrayCollection $timesheetStatusCollection): bool {
if( !$this->security->getUser()->getName() && !$this->security->getUser()->getSurname() ){
$this->setCreateException(
Response::HTTP_NOT_ACCEPTABLE,
$this->translator->trans('You cannot proceed with the transaction as your name or surname is not specified in your account.!')
);
return false;
}
if( !$userRole ){
$this->setCreateException(
Response::HTTP_NOT_ACCEPTABLE,
$this->translator->trans('Access role required for this operation.')
);
return false;
}
if( !count($timesheetStatusCollection) ){
$this->setCreateException(
Response::HTTP_NOT_ACCEPTABLE,
$this->translator->trans('This process cannot continue as no status has been found in the collection. Please manage the time tracking status before proceeding.')
);
return false;
}
return true;
}
private function timesheetRevision(
InputBag $attendanceBag,
TimesheetStatus $timesheetStatus,
WorkerActivities $workerActivity,
array &$cachedHistoryEntities,
WorkerTimesheet $timesheet,
?Projects $project,
?ProjectOrders $projectOrder,
?Branches $branch
): void {
$dayFiled = $attendanceBag->get('field');
$reason = $attendanceBag->get('reason');
$comment = $attendanceBag->get('comment');
$prev = $attendanceBag->get('prevAbsence');
$next = $attendanceBag->get('nextAbsence');
// SECTION No Required History
// "initial" = ilk kez yazılıyor, "similar" = aynı gün farklı domain (değişiklik yok) → history/comment gerekmez
if($reason === "initial" || $reason === "similar"){
return;
}
if(!$comment){
$this->setCreateException(HttpStatusInterface::UNAUTHORIZED, "Comment required");
return;
}
if( !array_key_exists($timesheet->getId(), $cachedHistoryEntities) ){
$historyInstance = $timesheet->getWorkerTimesheetHistories();
if(is_null($historyInstance)){
$historyInstance = new WorkerTimesheetHistories();
$historyInstance
->setWorkerTimesheet($timesheet)
->setCreatedAt(new \DateTimeImmutable('now'));
}
$cachedHistoryEntities[$timesheet->getId()] = $historyInstance;
}
$property = preg_replace('/_/', '', $dayFiled);
$getterMethod = 'get' . strtoupper($property);
$newDayLog = $attendanceBag;// $timesheet->{$getterMethod}();
// Set History Entity into Interface
$this->timesheetHistory->setCachedEmployeeHistoryEntity($cachedHistoryEntities[$timesheet->getId()]);
// Init Interface with params
$this->timesheetHistory->init( $timesheet, $timesheetStatus, $newDayLog, $dayFiled, $next, $project, $projectOrder, $branch);
// upsert
$this->timesheetHistory->upsert();
}
public function movement(): WorkerTimesheet {
// TODO This process will create
#$sourceEntity = $this->manager->getRepository(WorkerTimesheet::class)->find(1);
#$targetEntity = $this->manager->getRepository(WorkerTimesheet::class)->find(1);
#$sourceFields = ['m1_d1'];
#$sourceFields = ['m1_d1'];
#$property = $attendanceRaw->field;
#$property = preg_replace('/_/', '', $property);
#$getterMethod = 'get' . strtoupper($property);
#$setterMethod = 'set' . strtoupper($property);
return new WorkerTimesheet();
}
}