src/Controller/Api/Service/TimesheetService.php line 565

Open in your IDE?
  1. <?php
  2. namespace App\Controller\Api\Service;
  3. use App\Context\Metrics\MetricContextFactory;
  4. use App\Entity\AccessRoles;
  5. use App\Entity\Branches;
  6. use App\Entity\Countries;
  7. use App\Entity\ProjectOrderMetrics;
  8. use App\Entity\ProjectOrders;
  9. use App\Entity\Projects;
  10. use App\Entity\ProjectStakeholders;
  11. use App\Entity\TimesheetStatus;
  12. use App\Entity\Vendor;
  13. use App\Entity\VendorContactPersons;
  14. use App\Entity\Worker;
  15. use App\Entity\WorkerActivities;
  16. use App\Entity\WorkerInProject;
  17. use App\Entity\WorkerTimesheet;
  18. use App\Entity\WorkerTimesheetHistories;
  19. use App\Enum\SeverityInterface;
  20. use App\Repository\WorkerActivitiesRepository;
  21. use App\Repository\WorkerTimesheetRepository;
  22. use App\Service\AisDate;
  23. use App\Service\FileUploader;
  24. use App\Service\SerializeService\ReferenceSerialize;
  25. use App\Service\TimesheetInterface\TimesheetHistoryInterface;
  26. use App\Service\TimesheetInterface\TimesheetReaderInterface;
  27. use App\Service\TimesheetInterface\TimesheetWriterInterface;
  28. use App\Service\ValidatorService\EntityHydratorValidator;
  29. use Doctrine\Common\Collections\ArrayCollection;
  30. use Doctrine\Common\Collections\Criteria;
  31. use Doctrine\ORM\EntityManagerInterface;
  32. use phpDocumentor\Reflection\Types\This;
  33. use Symfony\Component\HttpFoundation\FileBag;
  34. use Symfony\Component\HttpFoundation\InputBag;
  35. use Symfony\Component\HttpFoundation\Response;
  36. use Symfony\Component\Security\Core\Security;
  37. use Symfony\Contracts\Translation\TranslatorInterface;
  38. class TimesheetService extends AbstractControllerService
  39. {
  40.     // TODO #88: Bir çalışanın bir gündeki toplam P (çalışma) saati bu değeri aşamaz.
  41.     // Sınır aşılınca upsert BATCH'i atomik reddedilir (aşağıdaki assertDailyHourCap).
  42.     const MAX_DAILY_WORK_HOURS 10;
  43.     // Cumartesi ayrı ve daha düşük tavana tabidir (kullanıcı kararı 2026-07-29):
  44.     // Pzt–Cuma en fazla 10 saat, CUMARTESİ en fazla 6 saat. Pazar'a zaten yazım yapılmaz.
  45.     const MAX_SATURDAY_WORK_HOURS 6;
  46.     private EntityHydratorValidator $entityHydratorValidator;
  47.     private TimesheetReaderInterface $timesheetReader;
  48.     private TimesheetWriterInterface $timesheetWriter;
  49.     private TimesheetHistoryInterface $timesheetHistory;
  50.     private MetricContextFactory $metricContextFactory;
  51.     /**
  52.      * Timesheet Status and Order Metrics Update
  53.      *
  54.      * Each employee can declare work for a specific day. When work is declared,
  55.      * the project and order for that day are known, allowing calculation of
  56.      * spent cost and time metrics for that order.
  57.      *
  58.      * If the timesheet entry is later changed (e.g., from "P" – Presence – to "S" – Sick),
  59.      * the previously recorded order must be updated accordingly, because the actual
  60.      * work has changed and the spent time/cost for that order may be reduced.
  61.      *
  62.      * In such cases, the employee's previous status (history) is used to locate
  63.      * the corresponding order and recalculate its metrics, ensuring that order-level
  64.      * spent cost and time values remain accurate.
  65.      *
  66.      * Additionally:
  67.      *  - When processing hours or "S" entries, the same employee and day may have
  68.      *    multiple entries for the same order.
  69.      *  - To avoid redundant database queries, the physical Order entities should
  70.      *    be stored in memory (e.g., via a Factory pattern).
  71.      *  - Each order is instantiated only once; subsequent occurrences on the same
  72.      *    day or for the same employee reuse the existing entity.
  73.      *  - This ensures that metrics calculation references a single instance of the order,
  74.      *    avoiding inconsistencies and improving performance.
  75.      *
  76.      * Status Legend:
  77.      *  P  - Presence (worked)
  78.      *  S  - Sick
  79.      *  H  - Holiday
  80.      *  FreeLeave - Unpaid / optional leave
  81.      */
  82.     private array $orderCache;
  83.     /**
  84.      * Similar Information like $orderFactory
  85.     */
  86.     private array $projectCache;
  87.     public function __construct(
  88.         EntityManagerInterface $manager,
  89.         AisDate $aisDate,
  90.         TranslatorInterface $translator,
  91.         ReferenceSerialize $referenceSerialize,
  92.         EntityHydratorValidator $entityHydratorValidator,
  93.         TimesheetReaderInterface $timesheetReader,
  94.         TimesheetWriterInterface $timesheetWriter,
  95.         Security $security,
  96.         TimesheetHistoryInterface $timesheetHistory,
  97.         MetricContextFactory $metricContextFactory
  98.     )
  99.     {
  100.         parent::__construct($manager$aisDate$translator$referenceSerialize$security);
  101.         $this->referenceSerialize $referenceSerialize;
  102.         $this->entityHydratorValidator $entityHydratorValidator;
  103.         $this->timesheetReader $timesheetReader;
  104.         $this->timesheetWriter $timesheetWriter;
  105.         $this->timesheetHistory $timesheetHistory;
  106.         $this->metricContextFactory $metricContextFactory;
  107.         // Initialize
  108.         $this->orderCache     = [];
  109.         $this->projectCache   = [];
  110.     }
  111.     public function fetchTimesheetEmployees(int $y): array
  112.     {
  113.         $employees $this->manager->getRepository(WorkerTimesheet::class)->findBy(["year" => $y]);
  114.         return $employees;
  115.     }
  116.     /**
  117.      *
  118.      *
  119.      * Senaryolar:
  120.      *
  121.      * Durum    domainExistsInPrev    timeChanged    isChanged    Comment?
  122.      * İlk kez saat giriliyor    false    —    false    Hayır ✓
  123.      * Aynı domain, aynı saat    true    false    false    Hayır ✓
  124.      * Aynı domain, farklı saat    true    true    true    Evet ✓
  125.      * Farklı domain, aynı gün    false    true    false    Hayır ✓ (BUG FIX)
  126.      * @return array{m: string|int, dataset: array}
  127.      *@throws \Exception
  128.      */
  129.     public function findByPayload(?array $activityIds null): array
  130.     {
  131.         $monthData $this->params->get('month');
  132.         $yearData $this->params->get('year');
  133.         $projectId filter_var($this->params->get('project'), FILTER_VALIDATE_INT);;
  134.         $orderId filter_var($this->params->get('order'), FILTER_VALIDATE_INT);
  135.         $branchId filter_var($this->params->get('branch'), FILTER_VALIDATE_INT);
  136.         if(!is_null($monthData) && !is_null($yearData)){
  137.             $monthData $this->entityHydratorValidator->createInputBag($monthData);
  138.             $yearData $this->entityHydratorValidator->createInputBag($yearData);
  139.             $m $monthData->get('displayId');
  140.             $y $yearData->get('displayId');
  141.         }
  142.         else {
  143.             $today = new \DateTimeImmutable('now');
  144.             $m $today->format('m');
  145.             $y $today->format('Y');
  146.         }
  147.         // WICHTIG: Falls Spaltennamen keine führende Null haben (m2 statt m02)
  148.         $searchMonth = (int)$m;
  149.         /**@var $repo WorkerTimesheetRepository*/
  150.         $repo $this->manager->getRepository(WorkerTimesheet::class);
  151.         // $collection = $repo->findByDateYearAndMonth($y, $m);
  152.         $collection $repo->findByPeriodAndDomain($y$m$projectId$activityIds);
  153.         $timesheetStatuses = new ArrayCollection($this->manager->getRepository(TimesheetStatus::class)->findAll());
  154.         $finalResults = [];
  155.         foreach ($collection as $index => $row) {
  156.             // Regex angepasst: ^m2_d... (ohne führende Null bei Bedarf)
  157.             $daysOnly array_filter($row, function($key) use ($searchMonth) {
  158.                 return preg_match("/^m{$searchMonth}_d\d+$/"$key);
  159.             }, ARRAY_FILTER_USE_KEY);
  160.             $processedDays = [];
  161.             $totalActive 0;
  162.             $totalInactive 0;
  163.             $revisedDays = [];
  164.             foreach ($daysOnly as $dayKey => $dayData) {
  165.                 if(!is_null($row['timesheet_revisions']) && array_key_exists($dayKey$row['timesheet_revisions'])){
  166.                     $revisedDays[] = $dayKey;
  167.                 }
  168.                 if(is_null($dayData)) continue;
  169.                 // Falls Doctrine kein auto-decode macht:
  170.                 if (is_string($dayData)) {
  171.                     $dayData json_decode($dayDatatrue);
  172.                 }
  173.                 if (empty($dayData)) {
  174.                     // $processedDays[$dayKey] = ['absenceShortKey' => null, 'time' => 0, 'dayInfo' => []];
  175.                     continue;
  176.                 }
  177.                 $time 0;
  178.                 $shortKey $dayData['absenceShortKey'] ?? '';
  179.                 if ($shortKey === 'P') {
  180.                     foreach ($dayData['projects'] as $project) {
  181.                         // DÜZELTME: Sadece bir proje filtresi VARSA ve uyuşmuyorsa atla.
  182.                         // Eğer $projectId null ise bu if'e hiç girmez ve hesaplamaya devam eder.
  183.                         if ($projectId && $project['project'] !== $projectId) {
  184.                             continue;
  185.                         }
  186.                         if (!$orderId) {
  187.                             // Eğer spesifik bir sipariş (order) seçilmediyse, projenin tüm saatlerini ekle
  188.                             $time += array_sum(array_values($project['orders_total']) ?? []);
  189.                         } else {
  190.                             // Sipariş filtresi varsa kontrol et
  191.                             if (isset($project['orders_total'][$orderId])) {
  192.                                 if (!$branchId) {
  193.                                     // Şube seçilmediyse siparişin toplamını ekle
  194.                                     $time += $project['orders_total'][$orderId];
  195.                                 } else {
  196.                                     // Şube seçildiyse derinlere in
  197.                                     foreach ($project['roles'] as $role) {
  198.                                         if (isset($role['orders'][$orderId]['branches'][$branchId])) {
  199.                                             $time += $role['orders'][$orderId]['branches'][$branchId]['time'];
  200.                                         }
  201.                                     }
  202.                                 }
  203.                             }
  204.                         }
  205.                     }
  206.                     $totalActive += $time;
  207.                 } elseif (in_array($shortKey, ['S''H'])) {
  208.                     // Falls 'time' im JSON als String vorliegt (z.B. "8.0"), floatval nutzen
  209.                     $time floatval($dayData['info']['time'] ?? 0);
  210.                     $totalInactive += $time;
  211.                 }
  212.                 elseif (in_array($shortKey, ['HNP'])) {
  213.                     // Falls 'time' im JSON als String vorliegt (z.B. "8.0"), floatval nutzen
  214.                     $time floatval0);
  215.                 }
  216.                 $criteria Criteria::create()->where(
  217.                     Criteria::expr()->eq("absence_short_key"$shortKey)
  218.                 )->setMaxResults(1);
  219.                 $colorsetEntity $timesheetStatuses->matching($criteria)->first();
  220.                 $colorset $this->referenceSerialize->setEntity($colorsetEntity)->setGroups([
  221.                     "timsheet_status@base"
  222.                 ])->render();
  223.                 // $totalActive += $time;
  224.                 $projectsLength 0;
  225.                 if(array_key_exists('projects'$dayData)){
  226.                     $projectsLength count($dayData['projects']);
  227.                 }
  228.                 $processedDays[$dayKey] = [
  229.                     'absenceShortKey' => $shortKey,
  230.                     'time' => $time,
  231.                     'dayInfo' => $dayData,
  232.                     'colorset' => $colorset,
  233.                     'projectsLength' => $projectsLength
  234.                 ];
  235.             }
  236.             // 2. KRİTİK NOKTA: Orijinal satırdan (row) işlediğimiz gün kolonlarını çıkartıyoruz
  237.             // Böylece row içinde sadece ID, Worker Name gibi sabit veriler kalıyor.
  238.             $cleanRow array_diff_key($row$daysOnly);
  239.             // 3. Sabit veriler ile senin yeni oluşturduğun 'days' ve 'totalMonthTime'ı birleştir
  240.             $finalResults[] = array_merge($cleanRow, [
  241.                 'days' => $processedDays,
  242.                 'totalActive' => $totalActive// array_sum($totalActive),
  243.                 'totalInactive' => $totalInactive,
  244.                 'finalTotal' => abs($totalInactive) + abs($totalActive),
  245.                 'revisedDays' => $revisedDays,
  246.                 'period' => ["month" => $m"year" => $y]
  247.             ]);
  248.         }
  249.         #dd();
  250.         #dd($m);
  251.         #dd($finalResults);
  252.         return [
  253.             "m" => $m,
  254.             "dataset" => $finalResults
  255.         ];
  256.     }
  257.     /**
  258.      * Belirtilen tarih aralığına göre timesheet kolonlarını dinamik olarak oluşturur
  259.      * ve sadece ilgili yıl/ay/gün aralığına ait verileri sorgulamak için kullanılır.
  260.      */
  261.     public function findByDateRange() {
  262.         $fromAt $this->params->get('from_at') ? new \DateTime($this->params->get('from_at')) : null;
  263.         $toAt   $this->params->get('to_at') ? new \DateTime($this->params->get('to_at')) : null;
  264.         $dateRange null;
  265.         $fromYear $toYear $fromMonth $toMonth $fromDay $toDay null;
  266.         if ($fromAt && $toAt) {
  267.             $dateRange = [
  268.                 "from" => $fromAt->format('d.m.Y'),
  269.                 "to"   => $toAt->format('d.m.Y'),
  270.                 "diff" => $fromAt->diff($toAt)->days 1,
  271.             ];
  272.             $fromYear  = (int) $fromAt->format('Y');
  273.             $toYear    = (int) $toAt->format('Y');
  274.             $fromMonth = (int) $fromAt->format('n');
  275.             $toMonth   = (int) $toAt->format('n');
  276.             $fromDay   = (int) $fromAt->format('j');
  277.             $toDay     = (int) $toAt->format('j');
  278.         }
  279.         $availableCols = [];
  280.         $buildAllColumns = function (): array {
  281.             $cols = [];
  282.             for ($m 1$m <= 12$m++) {
  283.                 for ($d 1$d <= 31$d++) {
  284.                     $cols[] = "ts.m{$m}_d{$d}";
  285.                 }
  286.             }
  287.             return $cols;
  288.         };
  289.         if ($fromYear && $toYear) {
  290.             // farklı yıl → full scan
  291.             if ($fromYear !== $toYear) {
  292.                 $availableCols $buildAllColumns();
  293.             } else {
  294.                 // aynı yıl
  295.                 for ($m $fromMonth$m <= $toMonth$m++) {
  296.                     $startDay = ($m === $fromMonth) ? $fromDay 1;
  297.                     $endDay   = ($m === $toMonth) ? $toDay 31;
  298.                     for ($d $startDay$d <= $endDay$d++) {
  299.                         $availableCols[] = "ts.m{$m}_d{$d}";
  300.                     }
  301.                 }
  302.             }
  303.         } else {
  304.             // fallback: full scan
  305.             $availableCols $buildAllColumns();
  306.         }
  307.         $tss $this->manager
  308.             ->getRepository(WorkerTimesheet::class)
  309.             ->findByDateRange($fromYear$toYear$availableCols);
  310.         return [
  311.             "collection" => $tss,
  312.             "date_range" => $dateRange
  313.         ];
  314.     }
  315.     public function unregisteredEmployees(): array {
  316.         $periodBag $this->entityHydratorValidator->createInputBag($this->params->get('period'));
  317.         $domainBag $this->entityHydratorValidator->createInputBag($this->params->get('domain'));
  318.         // Senaryo 2: Domain'de proje seçiliyse o projeye bağlı olmayanları getir
  319.         $projectId null;
  320.         if ($domainBag && !empty($domainBag->get('project')->id)) {
  321.             $projectId = (int) $domainBag->get('project')->id;
  322.         }
  323.         /**@var $repo WorkerTimesheetRepository*/
  324.         $repo $this->manager->getRepository(WorkerTimesheet::class);
  325.         $dataset = [];
  326.         if (!empty($periodBag->get('year')->displayId)) {
  327.             $dataset $repo->findUnregisteredEmployees($periodBag->get('year')->displayId$projectId);
  328.         }
  329.         $m = (new \DateTimeImmutable('now'))->format('m');
  330.         if (!empty($periodBag->get('month')->displayId)) {
  331.             $m $periodBag->get('month')->displayId;
  332.         }
  333.         return [
  334.             "m" => $m,
  335.             'dataset' => $dataset
  336.         ];
  337.     }
  338.     /**
  339.      *
  340.      * @return array{m: string, dataset: ArrayCollection<WorkerTimesheet>}
  341.     */
  342.     public function syncRegisterEmployees(): array {
  343.         $employees      $this->params->get('employees');
  344.         $projectId      $this->params->get('project');
  345.         $employeesBag   $this->entityHydratorValidator->createInputBag($employees);
  346.         $periodBag      $this->entityHydratorValidator->createInputBag($this->params->get('period'));
  347.         if (!empty($periodBag->get('year')->displayId)) {
  348.             $y              $periodBag->get('year')->displayId;
  349.         }
  350.         if (!empty($periodBag->get('month')->displayId)) {
  351.             $m              $periodBag->get('month')->displayId;
  352.         }
  353.         if($projectId){
  354.             $projectEntity  $this->manager->getRepository(Projects::class)->find($projectId);
  355.             $stakeholders   $projectEntity->getStakeholders();
  356.         }
  357.         $collection     = new ArrayCollection([]);
  358.         if(!$employeesBag->count()){
  359.             $this->setCreateException(HttpStatusInterface::UNAUTHORIZED$this->translator->trans('No any employee'));
  360.         }
  361.         foreach ($employeesBag as $employeeBag) {
  362.             $employeeActivity $this->manager->getRepository(WorkerActivities::class)->find($employeeBag->id);
  363.             // SECTION Timesheet exists
  364.             $criteria Criteria::create()->where(Criteria::expr()->eq('year'$y))->setMaxResults(1);
  365.             $timesheet $employeeActivity->getWorkerTimesheets()->matching($criteria)->first();
  366.             if(!$timesheet){
  367.                 $timesheet = new WorkerTimesheet();
  368.                 $timesheet->setYear($y)->setCreatedAt(new \DateTimeImmutable('now'));
  369.                 $employeeActivity->addWorkerTimesheet($timesheet);
  370.             }
  371.             $collection->add($employeeActivity);
  372.             if(!$projectId){
  373.                 continue;
  374.             }
  375.             $partner $employeeActivity->getPartner();
  376.             $employeeStartAt = new \DateTimeImmutable($employeeBag->employee_beginn_to_project);
  377.             // SECTION Stakeholder exits
  378.             $criteria Criteria::create()
  379.                 ->where(Criteria::expr()->eq('partner'$partner))
  380.                 ->andWhere(Criteria::expr()->eq('project'$projectEntity))
  381.                 ->andWhere(Criteria::expr()->eq('end_at'null))
  382.                 ->setMaxResults(1);
  383.             $stakeholder $stakeholders->matching($criteria)->first();
  384.             #dd($employeeBag);
  385.             if(!$stakeholder){
  386.                 $partnerStartAt = new \DateTimeImmutable($employeeBag->partner_beginn_to_project);
  387.                 $stakeholder = new ProjectStakeholders();
  388.                 $stakeholder
  389.                     ->setProject($projectEntity)
  390.                     ->setCreatedAt(new \DateTimeImmutable('now'))
  391.                     ->setPartner($partner);
  392.                 $projectEntity->addStakeholder($stakeholder);
  393.                 $stakeholder->setStartAt($partnerStartAt);
  394.                 $this->manager->persist($stakeholder); // 👈 BURASI KRİTİK: Yeni nesneyi Doctrine'e tanıt
  395.             }
  396.             // SECTION Employee exits
  397.             $criteria Criteria::create()
  398.                 ->where(Criteria::expr()->eq('worker_activity'$employeeActivity))
  399.                 ->andWhere(Criteria::expr()->eq('project'$stakeholder))
  400.                 ->andWhere(Criteria::expr()->eq('end_at'null))
  401.                 ->setMaxResults(1);
  402.             $wip $stakeholder->getWorkerInProjects()->matching($criteria)->first();
  403.             if(!$wip){
  404.                 // Any Case Create New WIP
  405.                 $wip = new WorkerInProject();
  406.                 $wip
  407.                     ->setCreatedAt(new \DateTimeImmutable('now'))
  408.                     ->setWorkerActivity($employeeActivity)
  409.                     ->setCostPerHour($employeeActivity->getHourlyRate());
  410.                     $stakeholder->addWorkerInProject($wip);
  411.                     $wip->setStartAt($employeeStartAt);
  412.                     $this->manager->persist($wip);
  413.             }
  414.         }
  415.         try{
  416.             $this->manager->flush();
  417.         } catch (\Exception $exception){
  418.             // throw new \Exception($exception->getMessage());
  419.             $this->setCreateException($exception->getCode(), $exception->getMessage());
  420.         }
  421.         return [
  422.             "m" => $m,
  423.             'dataset' => $collection
  424.         ];
  425.     }
  426.     /**
  427.      * @return ArrayCollection
  428.      * @throws \Exception
  429.      */
  430.     /**
  431.      * TODO #88 — Gün-toplam 10 saat cap kontrolü (kod yazmadan reddet).
  432.      * Batch'teki HER (çalışan, gün) P girişi için: [o günün mevcut DİĞER domain P-toplamı]
  433.      * + [gelen saat] > 10 ise hata listesine ekler. Yalnız P; S/H/HNP/R kapsam dışı.
  434.      * Transaction'dan ÖNCE çağrılır → aşımda hiç yazım olmaz.
  435.      * @return string[] boş ise sınır aşımı yok
  436.      */
  437.     /**
  438.      * Gün alanının (m{ay}_d{gün}) ISO haftagünü: 1=Pazartesi .. 7=Pazar.
  439.      * Alan/yıl çözülemezse null döner — çağıran taraf güvenli davranışa düşer.
  440.      */
  441.     private function weekdayForField(?string $field$year): ?int
  442.     {
  443.         if(!$field || !$year || !preg_match('/^m(\d+)_d(\d+)$/'$field$matches)){
  444.             return null;
  445.         }
  446.         $month = (int)$matches[1];
  447.         $day   = (int)$matches[2];
  448.         if(!checkdate($month$day, (int)$year)){
  449.             return null;
  450.         }
  451.         return (int)date('N'strtotime(sprintf('%04d-%02d-%02d', (int)$year$month$day)));
  452.     }
  453.     /**
  454.      * Gün alanına göre o günün çalışma tavanı: Cumartesi 6, diğer günler 10.
  455.      * Çözülemezse güvenli tarafta kalınır (genel tavan uygulanır).
  456.      * NOT: Pazar burada 10 döner ama Pazar'a yazım zaten mutlak yasaktır (assertDailyHourCap
  457.      * tavan hesabından önce reddeder) — tavan meselesi değildir.
  458.      */
  459.     private function dailyCapForField(?string $field$year): float
  460.     {
  461.         return ($this->weekdayForField($field$year) === 6)
  462.             ? self::MAX_SATURDAY_WORK_HOURS
  463.             self::MAX_DAILY_WORK_HOURS;
  464.     }
  465.     private function assertDailyHourCap($attendances, ?InputBag $domain, ?InputBag $period): array
  466.     {
  467.         $errors = [];
  468.         $year = ($period && !empty($period->get('year')->displayId)) ? $period->get('year')->displayId null;
  469.         $domainOrderId  = ($domain && !empty($domain->get('order')->id)) ? $domain->get('order')->id null;
  470.         $domainBranchId = ($domain && !empty($domain->get('branch')->id)) ? $domain->get('branch')->id null;
  471.         $tsCache = [];
  472.         foreach($attendances as $attendanceRaw){
  473.             $bag $this->entityHydratorValidator->createInputBag($attendanceRaw);
  474.             // Yalnız P (çalışma) saati sınırlanır; status günleri (S/H/HNP) ve reset (R) kapsam dışı.
  475.             if($bag->get('nextAbsence') !== ABSENCE_PRESENT){
  476.                 continue;
  477.             }
  478.             $incoming   floatval($bag->get('time'));
  479.             $employeeId = !empty($bag->get('employee')->id) ? $bag->get('employee')->id null;
  480.             $field      $attendanceRaw->field ?? null// m{month}_d{day}
  481.             if(!$employeeId || !$field){
  482.                 continue;
  483.             }
  484.             // Çalışanın yıl timesheet'i (cache'li)
  485.             $cacheKey $employeeId '_' $year;
  486.             if(!array_key_exists($cacheKey$tsCache)){
  487.                 $activity $this->manager->getRepository(WorkerActivities::class)->find($employeeId);
  488.                 $ts $activity
  489.                     $this->manager->getRepository(WorkerTimesheet::class)->findOneBy(['worker_activity' => $activity'year' => $year])
  490.                     : null;
  491.                 $tsCache[$cacheKey] = [
  492.                     'ts'   => $ts,
  493.                     'name' => $activity $activity->getWorker()->getFullName() : ('#' $employeeId)
  494.                 ];
  495.             }
  496.             $ts $tsCache[$cacheKey]['ts'];
  497.             // PAZAR: çalışma saati yazımı MUTLAK YASAK (kullanıcı kararı 2026-07-29).
  498.             // Tavan meselesi değil — kaç saat olursa olsun reddedilir, bu yüzden saat
  499.             // hesabından ÖNCE bakılır. Yalnız P kapsamda; statü günleri (S/H/HNP) etkilenmez.
  500.             if($this->weekdayForField($field$year) === 7){
  501.                 $dayLabel str_replace(['m''_d'], ['''.'], $field); // m7_d19 → 7.19
  502.                 $errors[] = $tsCache[$cacheKey]['name'] . ' (' $dayLabel '): '
  503.                     $this->translator->trans('working hours cannot be entered on Sunday');
  504.                 continue;
  505.             }
  506.             // Mevcut gün-JSON'u (varsa)
  507.             $existingDayData = [];
  508.             if($ts){
  509.                 $getter 'get' strtoupper(preg_replace('/_/'''$field));
  510.                 if(method_exists($ts$getter)){
  511.                     $existingDayData $ts->{$getter}() ?? [];
  512.                     if(is_string($existingDayData)){
  513.                         $existingDayData json_decode($existingDayDatatrue) ?? [];
  514.                     }
  515.                 }
  516.             }
  517.             // Bu domain'in eski leaf'i EZİLECEK → toplamdan düşülür, sonra gelen saat eklenir.
  518.             $existingOther $this->sumDayPresentTime($existingDayData$domainOrderId$domainBranchId);
  519.             $projected     $existingOther $incoming;
  520.             // Tavan güne göre değişir: Cumartesi 6, diğer günler 10.
  521.             $dailyCap $this->dailyCapForField($field$year);
  522.             if($projected $dailyCap){
  523.                 $dayLabel str_replace(['m''_d'], ['''.'], $field); // m5_d12 → 5.12
  524.                 $errors[] = $tsCache[$cacheKey]['name'] . ' (' $dayLabel '): '
  525.                     rtrim(rtrim(number_format($projected1'.'''), '0'), '.') . 'h'
  526.                     ' / max ' rtrim(rtrim(number_format($dailyCap1'.'''), '0'), '.') . 'h';
  527.             }
  528.         }
  529.         return $errors;
  530.     }
  531.     /**
  532.      * Bir gün-JSON'undaki toplam P (çalışma) saatini döner. $excludeOrderId+$excludeBranchId
  533.      * verilirse o domain'in leaf'i toplamdan düşülür (upsert onu ezeceği için).
  534.      */
  535.     private function sumDayPresentTime(?array $dayData, ?int $excludeOrderId, ?int $excludeBranchId): float
  536.     {
  537.         if(empty($dayData) || ($dayData['absenceShortKey'] ?? '') !== 'P' || empty($dayData['projects'])){
  538.             return 0.0;
  539.         }
  540.         $sum 0.0;
  541.         foreach($dayData['projects'] as $project){
  542.             foreach(($project['orders_total'] ?? []) as $orderTotal){
  543.                 $sum += floatval($orderTotal);
  544.             }
  545.         }
  546.         // Ezilecek domain leaf'ini çıkar (sadece ilgili order+branch)
  547.         if($excludeOrderId && $excludeBranchId){
  548.             foreach($dayData['projects'] as $project){
  549.                 foreach(($project['roles'] ?? []) as $role){
  550.                     if(isset($role['orders'][$excludeOrderId]['branches'][$excludeBranchId]['time'])){
  551.                         $sum -= floatval($role['orders'][$excludeOrderId]['branches'][$excludeBranchId]['time']);
  552.                     }
  553.                 }
  554.             }
  555.         }
  556.         return $sum;
  557.     }
  558.     public function upsert(): ?ArrayCollection
  559.     {
  560.         /**
  561.          * Bu kisim karisik
  562.          * User role sunu deme kiyirou bu islem yapabilme seviyesi var yani
  563.          * bu oslemi yapbilenler
  564.          * System level seviyesi en dusuk
  565.          * App Users Bu orta seviye systemi ezer
  566.          * Desktop users Bu güclu seviye App I ezer
  567.          * 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
  568.          * !!! Important user islem yapbilmesi icin tanimlanmis olmali
  569.          * SECTION UserRole ℹ️ User User Role Level!!!
  570.          * @var $userRole AccessRoles
  571.          */
  572.         $userRole =$this->security->getUser()->getUserAccessedRoles()->filter(function(/**@var $item AccessRoles*/$item){
  573.             return $item->getAccessType()->getName() === 'Timesheet';
  574.         })->first();
  575.         if(!$userRole){
  576.             $this->setCreateException(HttpStatusInterface::UNAUTHORIZED$this->translator->trans("Required access level for user, not defined!") );
  577.             return null;
  578.         }
  579.         // SECTION * Timesheet Status All
  580.         $timesheetStatus            = new ArrayCollection($this->manager->getRepository(TimesheetStatus::class)->findAll());
  581.         // SECTION Safety Guard!!!
  582.         if(!$this->userUpsertAccess($userRole$timesheetStatus)){
  583.             return null;
  584.         }
  585.         $successItems               = new ArrayCollection([]);
  586.         $errorItems                 = new ArrayCollection([]);
  587.         $attendances $this->entityHydratorValidator->createInputBag($this->params->get('collection'));
  588.         $domain $this->entityHydratorValidator->createInputBag($this->params->get('domain'));
  589.         $period $this->entityHydratorValidator->createInputBag($this->params->get('period'));
  590.         #dd($domain, $period, $attendances);
  591.         // SECTION 10-Stunden-Cap (TODO #88) — GERÇEK GATE.
  592.         // Batch'teki herhangi bir (çalışan, gün) için P toplamı 10'u aşıyorsa HİÇBİR ŞEY yazma.
  593.         // Transaction'dan ÖNCE çalışır → aşımda hiçbir kayıt oluşmaz, mevcut rollback'siz-return
  594.         // latent bug'ına da bulaşmaz (batch atomik reddedilir, 406 döner).
  595.         $capErrors $this->assertDailyHourCap($attendances$domain$period);
  596.         if(count($capErrors)){
  597.             $this->setCreateException(
  598.                 Response::HTTP_UNPROCESSABLE_ENTITY,
  599.                 // Başlıkta sabit sayı YOK: tavan güne göre değişiyor (hafta içi 10, Cumartesi 6)
  600.                 // ve Pazar'da mutlak yasak var. Hangi satırın neden reddedildiği listede yazıyor.
  601.                 $this->translator->trans('Daily working hour limit exceeded') . ' — ' implode(' · '$capErrors)
  602.             );
  603.             return null;
  604.         }
  605.         $this->manager->beginTransaction();
  606.         // SECTION Period Parsed
  607.         $month  = !empty($period->get('month')->displayId) ? $period->get('month')->displayId null;
  608.         $year   = !empty($period->get('month')->displayId) ? $period->get('month')->displayId null;
  609.         // Revision Section
  610.         $employeeCachedHistoryEntities = [];
  611.         // SECTION Entity Resolver's
  612.         $project            null;
  613.         $projectOrders      = new ArrayCollection([]);
  614.         $projectBranches    = new ArrayCollection([]);
  615.         if($domain && $domain->get('project')){
  616.             $projectId          $domain->get('project')->id;
  617.             $project            $this->manager->getRepository(Projects::class)->find($projectId);
  618.             $projectOrders      $project->getProjectOrders();
  619.             $projectBranches    $project->getBranches();
  620.         }
  621.         // SECTION Entities
  622.         // * Timesheet
  623.         $all_timesheet              = new ArrayCollection($this->manager->getRepository(WorkerTimesheet::class)->findAll());
  624.         // SECTION Cashed Employee Activities
  625.         $cachedWorkerActivities     = new ArrayCollection();
  626.         // SECTION Loop Attendances
  627.         foreach ($attendances as $attendanceRaw) {
  628.             $localError false;
  629.             $attendanceBag $this->entityHydratorValidator->createInputBag($attendanceRaw);
  630.             // SECTION Prepare Setter & Getter
  631.             $property $attendanceRaw->field;
  632.             $property preg_replace('/_/'''$property);
  633.             $getterMethod 'get' strtoupper($property);
  634.             $setterMethod 'set' strtoupper($property);
  635.             // SECTION Prepare & Cache Employee Entity [R]
  636.             $employeeActivityId     = !empty($attendanceBag->get('employee')->id) ? $attendanceBag->get('employee')->id null;
  637.             $employeeActivityEntity $cachedWorkerActivities->filter(fn($item) => $item->getId() === $employeeActivityId )->first();
  638.             if(!$employeeActivityEntity){
  639.                 $employeeActivityEntity $this->manager->getRepository(WorkerActivities::class)->find($employeeActivityId);
  640.                 $cachedWorkerActivities->add($employeeActivityEntity);
  641.             }
  642.             // SECTION Timesheet FILTER Query
  643.             $tsC Criteria::create()
  644.                 ->where(Criteria::expr()->eq('year', !empty($period->get('year')->displayId) ? $period->get('year')->displayId null ))
  645.                 ->andWhere(Criteria::expr()->eq('worker_activity'$employeeActivityEntity ))
  646.                 ->setMaxResults(1);
  647.             // SECTION [R, C] Timesheet Entity
  648.             $timesheet $all_timesheet->matching($tsC)->first();
  649.             if(!$timesheet){
  650.                 $timesheet = new WorkerTimesheet();
  651.                 $timesheet
  652.                     ->setCreatedAt(new \DateTimeImmutable('now'))
  653.                     ->setWorkerActivity($employeeActivityEntity);
  654.                 $this->manager->persist($timesheet);
  655.             }
  656.             // Day Getter
  657.             $dayData $timesheet->{$getterMethod}() ?? [];
  658.             // SECTION GET Attendance
  659.             $nextAbsence    $attendanceBag->get('nextAbsence');
  660.             // WARNING If Clean not more need another process!!!
  661.             /**
  662.              * This process like reset but not more comment need, just clean this day
  663.             */
  664.             if($nextAbsence === ABSENCE_PRISTINE){
  665.                 $timesheet->{$setterMethod}([]);
  666.                 $this->manager->persist($timesheet);
  667.                 continue;
  668.             }
  669.             // SECTION WorkerInProject [R]
  670.             $wip null;
  671.             $branch null;
  672.             $order null;
  673.             if( $project ){
  674.                 $wip $employeeActivityEntity->getWorkerInProjects()->filter(function (/**@var WorkerInProject $wip*/ $wip ) use ($project){
  675.                     return $wip->getProject()->getProject()->getId()
  676.                         === $project->getId();
  677.                 })->first();
  678.                 // SECTION FILTER ORDER
  679.                 $domainOrderId  = !empty($domain->get('order')->id) ? $domain->get('order')->id null;
  680.                 $oC             Criteria::create()->where(Criteria::expr()->eq('id'$domainOrderId))->setMaxResults(1);
  681.                 $order          $projectOrders->matching($oC) ? $projectOrders->matching($oC)->first() : null;
  682.                 // SECTION FILTER BRANCH
  683.                 $domainBranchId = !empty($domain->get('branch')->id) ? $domain->get('branch')->id null;
  684.                 $bC             Criteria::create()->where(Criteria::expr()->eq('id'$domainBranchId))->setMaxResults(1);
  685.                 $branch         $projectBranches->matching($bC) ? $projectBranches->matching($bC)->first() : null;
  686.                 // WARNING Bu islemi neden yapiyorum ?? ( Add This worker to Order as assign if already not assigned )
  687.                 if( $nextAbsence === ABSENCE_PRESENT ) {
  688.                     // TODO ? Employee already Assigned to this order (Employee work for this order) -> Assignation (Using Target ??)
  689.                     $aOC Criteria::create()->where(Criteria::expr()->eq('id'$order));
  690.                     $isAssigned $employeeActivityEntity->getAssignedOrders()->matching($aOC)->first();
  691.                     if(!$isAssigned){
  692.                         $employeeActivityEntity->addAssignedOrder($order);
  693.                     }
  694.                 }
  695.             }
  696.             // SECTION TimesheetStatus
  697.             $tssC Criteria::create()->where(Criteria::expr()->eq('absence_short_key'$nextAbsence))->setMaxResults(1);
  698.             $tss  $timesheetStatus->matching($tssC)->first();
  699.             // SECTION Errors
  700.             if(!$employeeActivityEntity->getHourlyRate()){
  701.                 $localError true;
  702.                 $errorItems->add($this->translator->trans("Missing hourly rate for") . " " $employeeActivityEntity->getWorker()->getFullName());
  703.             }
  704.             if(!$employeeActivityEntity->getDailyWorkingHours()){
  705.                 $localError true;
  706.                 $errorItems->add($this->translator->trans("Missing daily working hours for") . " " $employeeActivityEntity->getWorker()->getFullName());
  707.             }
  708.             if($nextAbsence === ABSENCE_PRESENT && (!$project || !$wip)){
  709.                 $this->setCreateException(
  710.                     Response::HTTP_UNPROCESSABLE_ENTITY,
  711.                     $employeeActivityEntity->getWorker()->getFullName() . " " $this->translator->trans("cannot write P: activity is not linked to a project")
  712.                 );
  713.                 return null;
  714.             }
  715.             if(!$localError){
  716.                 $this->timesheetWriter
  717.                     ->setWorkerActivity($employeeActivityEntity)
  718.                     ->setHourlyRate($employeeActivityEntity->getHourlyRate())
  719.                     ->setDailyWorkingHours($employeeActivityEntity->getDailyWorkingHours())
  720.                     ->setMonth($month)
  721.                     ->setUser$this->security->getUser() )
  722.                     ->setManagerRole($userRole)
  723.                     ->setTimesheetStatus$tss )
  724.                     // TODO ProjectStakeholder should replace with workerInProject project access over workerInProject
  725.                     ->writerPathManager$wip$branch$order )
  726.                     ->setData$dayData );
  727.                 $upsertDayData null;
  728.                 // [P]
  729.                 if( $nextAbsence === ABSENCE_PRESENT ) {
  730.                     $time $attendanceBag->get('time');
  731.                     // TODO Add Order to Cache if not exists
  732.                     $this->orderCache[$order->getId()] = $order;
  733.                     // $this->metricsOrderCollector($dayData);
  734.                     $upsertDayData $this->timesheetWriter->addTime($time)->extendResultRolesBasic();
  735.                 }
  736.                 // [R]
  737.                 else if( $nextAbsence === ABSENCE_RESET ){
  738.                     // SECTION Order Collector
  739.                     $this->metricsOrderCollector($dayData);
  740.                     $upsertDayData $this->timesheetWriter->reset()->extendResultRolesBasic();
  741.                 }
  742.                 // [S, H, HNP]
  743.                 else {
  744.                     // SECTION Order Collector
  745.                     $this->metricsOrderCollector($dayData);
  746.                     $upsertDayData $this->timesheetWriter->updateStatus()->extendResultRolesBasic();
  747.                 }
  748.                 $timesheet->{$setterMethod}( $upsertDayData );
  749.                 $this->manager->persist$timesheet );
  750.                 $successItems->add($timesheet);
  751.                 // SECTION History (Revision)
  752.                 $this->timesheetRevision(
  753.                     $attendanceBag,
  754.                     $tss,
  755.                     $employeeActivityEntity,
  756.                     $employeeCachedHistoryEntities,
  757.                     $timesheet,
  758.                     $project,
  759.                     $order,
  760.                     $branch
  761.                 );
  762.             }
  763.         }
  764.         if(count($errorItems)){
  765.             $this->setCreateException(HttpStatusInterface::UNAUTHORIZEDimplode(" "$errorItems->toArray()) );
  766.             return null;
  767.         }
  768.         // SECTION Metrics (super Step  )
  769.         /**@var ProjectOrders $order */
  770.         foreach ($this->orderCache as $order) {
  771.             $this->metricContextFactory->orderMetrics($order)->syncLaborMetrics();
  772.         }
  773.         foreach ($this->projectCache as $project ) {
  774.             $this->metricContextFactory->projectMetrics($project)->syncLaborMetrics();
  775.         }
  776.         #try{
  777.             $this->manager->commit();
  778.             $this->manager->flush();
  779.         #} catch (\Exception $exception){
  780.         #    $this->manager->rollback();
  781.         #    $this->setCreateException($exception->getCode(), $this->exception->getMessage());
  782.         #    return null;
  783.         #}
  784.         // Update Metrics
  785.         // $this->metricContextFactory->orderMetrics()
  786.         return $successItems;
  787.     }
  788.     private function metricsOrderCollector(?array $dayData) {
  789.         if(count($dayData)){
  790.             $jsonProjects = [];
  791.             if(array_key_exists('projects'$dayData)){
  792.                 $jsonProjects $dayData['projects'];
  793.             }
  794.             foreach ($jsonProjects as $jsonProject) {
  795.                 // $minRole = min(array_column($project['roles'], 'role'));
  796.                 $roles $jsonProject['roles'];
  797.                 $minRoleEntry array_reduce($roles, fn($carry$item) => ($carry === null || $item['role'] < $carry['role']) ? $item $carry);
  798.                 $jsonOrderIds array_keys($minRoleEntry['orders']);
  799.                 #dump($jsonOrderIds);
  800.                 foreach ($jsonOrderIds as $jsonOrderId ) {
  801.                     if(!isset($this->orderCache[$jsonOrderId])){
  802.                         /**@var ProjectOrders $_order*/
  803.                         $_order $this->manager->getRepository(ProjectOrders::class)->find($jsonOrderId);
  804.                         $this->orderCache[$jsonOrderId] = $_order;
  805.                         // Add project
  806.                         $_project $_order->getProject();
  807.                         if(!isset($this->projectCache[$_project->getId()])){
  808.                             $this->projectCache[$_project->getId()] = $_project;
  809.                         }
  810.                     }
  811. //                    $oc = Criteria::create()->where(Criteria::expr()->eq('id', $jsonOrderId))->setMaxResults(1);
  812. //                    $orderEntity = $this->orderFactory->matching($oc)->first();
  813. //                    if(!$orderEntity){
  814. //                        $orderReal = $this->manager->getRepository(ProjectOrders::class)->find($jsonOrderId);
  815. //                        $this->orderFactory->add($orderReal);
  816. //                    }
  817.                 }
  818.             }
  819.         }
  820.     }
  821.     private function userUpsertAccess(?AccessRoles $userRole, ?ArrayCollection $timesheetStatusCollection): bool {
  822.         if( !$this->security->getUser()->getName() && !$this->security->getUser()->getSurname() ){
  823.             $this->setCreateException(
  824.                 Response::HTTP_NOT_ACCEPTABLE,
  825.                 $this->translator->trans('You cannot proceed with the transaction as your name or surname is not specified in your account.!')
  826.             );
  827.             return false;
  828.         }
  829.         if( !$userRole ){
  830.             $this->setCreateException(
  831.                 Response::HTTP_NOT_ACCEPTABLE,
  832.                 $this->translator->trans('Access role required for this operation.')
  833.             );
  834.             return false;
  835.         }
  836.         if( !count($timesheetStatusCollection) ){
  837.             $this->setCreateException(
  838.                 Response::HTTP_NOT_ACCEPTABLE,
  839.                 $this->translator->trans('This process cannot continue as no status has been found in the collection. Please manage the time tracking status before proceeding.')
  840.             );
  841.             return false;
  842.         }
  843.         return true;
  844.     }
  845.     private function timesheetRevision(
  846.         InputBag $attendanceBag,
  847.         TimesheetStatus $timesheetStatus,
  848.         WorkerActivities $workerActivity,
  849.         array &$cachedHistoryEntities,
  850.         WorkerTimesheet $timesheet,
  851.         ?Projects $project,
  852.         ?ProjectOrders $projectOrder,
  853.         ?Branches $branch
  854.     ): void {
  855.         $dayFiled $attendanceBag->get('field');
  856.         $reason $attendanceBag->get('reason');
  857.         $comment $attendanceBag->get('comment');
  858.         $prev $attendanceBag->get('prevAbsence');
  859.         $next $attendanceBag->get('nextAbsence');
  860.         // SECTION No Required History
  861.         // "initial" = ilk kez yazılıyor, "similar" = aynı gün farklı domain (değişiklik yok) → history/comment gerekmez
  862.         if($reason === "initial" || $reason === "similar"){
  863.             return;
  864.         }
  865.         if(!$comment){
  866.             $this->setCreateException(HttpStatusInterface::UNAUTHORIZED"Comment required");
  867.             return;
  868.         }
  869.         if( !array_key_exists($timesheet->getId(), $cachedHistoryEntities) ){
  870.             $historyInstance $timesheet->getWorkerTimesheetHistories();
  871.             if(is_null($historyInstance)){
  872.                 $historyInstance = new WorkerTimesheetHistories();
  873.                 $historyInstance
  874.                     ->setWorkerTimesheet($timesheet)
  875.                     ->setCreatedAt(new \DateTimeImmutable('now'));
  876.             }
  877.             $cachedHistoryEntities[$timesheet->getId()] = $historyInstance;
  878.         }
  879.         $property preg_replace('/_/'''$dayFiled);
  880.         $getterMethod 'get' strtoupper($property);
  881.         $newDayLog $attendanceBag;// $timesheet->{$getterMethod}();
  882.         // Set History Entity into Interface
  883.         $this->timesheetHistory->setCachedEmployeeHistoryEntity($cachedHistoryEntities[$timesheet->getId()]);
  884.         // Init Interface with params
  885.         $this->timesheetHistory->init$timesheet$timesheetStatus$newDayLog$dayFiled$next$project$projectOrder$branch);
  886.         // upsert
  887.         $this->timesheetHistory->upsert();
  888.     }
  889.     public function movement(): WorkerTimesheet {
  890.         // TODO This process will create
  891.         #$sourceEntity = $this->manager->getRepository(WorkerTimesheet::class)->find(1);
  892.         #$targetEntity = $this->manager->getRepository(WorkerTimesheet::class)->find(1);
  893.         #$sourceFields = ['m1_d1'];
  894.         #$sourceFields = ['m1_d1'];
  895.         #$property = $attendanceRaw->field;
  896.         #$property = preg_replace('/_/', '', $property);
  897.         #$getterMethod = 'get' . strtoupper($property);
  898.         #$setterMethod = 'set' . strtoupper($property);
  899.         return new WorkerTimesheet();
  900.     }
  901. }