src/Controller/Api/Service/ProjectOrderService.php line 135

Open in your IDE?
  1. <?php
  2. namespace App\Controller\Api\Service;
  3. use App\Entity\BranchDepartments;
  4. use App\Entity\Branches;
  5. use App\Entity\InvoiceType;
  6. use App\Entity\ProjectOrderBillings;
  7. use App\Entity\ProjectOrderExpensePositions;
  8. use App\Entity\ProjectOrderMetrics;
  9. use App\Entity\ProjectOrders;
  10. use App\Entity\ProjectOrderTaskJobs;
  11. use App\Entity\ProjectOrderTasks;
  12. use App\Entity\ProjectOrderUndertakings;
  13. use App\Entity\ProjectOwner;
  14. use App\Entity\ProjectOwnerContactPersons;
  15. use App\Entity\Projects;
  16. use App\Entity\WorkerActivities;
  17. use App\Entity\WorkerTimesheet;
  18. use App\Repository\ProjectOrdersRepository;
  19. use App\Service\AisDate;
  20. use App\Service\Calculators\ProjectOrderCalculator;
  21. use App\Service\OrderLaborCostPdfService;
  22. use App\Service\OrderDependency\OrderTimesheetMutator;
  23. use App\Context\Metrics\MetricContextFactory;
  24. use App\Service\FileNumberInterface;
  25. use App\Service\SerializeService\BranchSerialize;
  26. use App\Service\SerializeService\ProjectSerialize;
  27. use App\Service\SerializeService\ReferenceSerialize;
  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 PHPUnit\Util\Exception;
  34. use Symfony\Component\HttpFoundation\InputBag;
  35. use Symfony\Component\HttpFoundation\StreamedResponse;
  36. use Symfony\Component\Security\Core\Security;
  37. use Symfony\Component\Security\Core\User\UserInterface;
  38. use Symfony\Contracts\Translation\TranslatorInterface;
  39. use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
  40. # use App\Service\Calculators\CalculatorService;
  41. class ProjectOrderService extends AbstractControllerService
  42. {
  43.     private ?Projects $project null;
  44.     private BranchSerialize $branchSerialize;
  45.     private ?UserInterface $user;
  46.     private EntityHydratorValidator $entityHydratorValidator;
  47.     private ProjectOrderCalculator $projectOrderCalculator;
  48.     private FileNumberInterface $fileNumber;
  49.     private MetricContextFactory $metricService;
  50.     private OrderLaborCostPdfService $laborCostPdfService;
  51.     private OrderTimesheetMutator $timesheetMutator;
  52.     private string $orderForceDeletePassword;
  53.     # private CalculatorService $calculatorService;
  54.     public function __construct(
  55.         EntityManagerInterface $manager,
  56.         AisDate $aisDate,
  57.         TranslatorInterface $translator,
  58.         ProjectSerialize $projectSerialize,
  59.         ReferenceSerialize $referenceSerialize,
  60.         BranchSerialize $branchSerialize,
  61.         Security $user,
  62.         EntityHydratorValidator $entityHydratorValidator,
  63.         ProjectOrderCalculator $projectOrderCalculator,
  64.         FileNumberInterface $fileNumber,
  65.         MetricContextFactory $metricService,
  66.         Security $security,
  67.         OrderLaborCostPdfService $laborCostPdfService,
  68.         OrderTimesheetMutator $timesheetMutator,
  69.         string $orderForceDeletePassword
  70.     ) {
  71.         parent::__construct($manager$aisDate$translator$referenceSerialize$security);
  72.         $this->branchSerialize $branchSerialize;
  73.         $this->user $user->getUser();
  74.         $this->entityHydratorValidator $entityHydratorValidator;
  75.         $this->projectOrderCalculator $projectOrderCalculator;
  76.         $this->fileNumber $fileNumber;
  77.         $this->metricService $metricService;
  78.         $this->laborCostPdfService $laborCostPdfService;
  79.         $this->timesheetMutator $timesheetMutator;
  80.         $this->orderForceDeletePassword $orderForceDeletePassword;
  81.         # $this->calculatorService = $calculatorService;
  82.     }
  83.     public function setProject(Projects $project): self {
  84.         $this->project $project;
  85.         return $this;
  86.     }
  87.     /**
  88.      * @throws \Exception
  89.      */
  90.     public function fetchAll(): array {
  91.         if(!is_null($this->project)){
  92.             // return $this->project->getProjectOrders()->toArray();
  93.             return $this->findByProgress();
  94.         }
  95.         // return $this->manager->getRepository(ProjectOrders::class)->findAll();
  96.         /**@var $repo ProjectOrdersRepository*/
  97.         $repo $this->manager->getRepository(ProjectOrders::class);
  98.         return $repo->fetchOrders($this->security->getUser());
  99.     }
  100.     public function findAllNotPaymentStatus(): array {
  101.         /**@var $repo ProjectOrdersRepository*/
  102.         $repo $this->manager->getRepository(ProjectOrders::class);
  103.         return $repo->findAllNotPaymentStatus();
  104.     }
  105.     /**
  106.      * fatiralandirma hesaplanmis olanlar
  107.     */
  108.     public function fetchCalculated(?ProjectOwner $customer): array {
  109.         /**@var $repo ProjectOrdersRepository*/
  110.         $repo $this->manager->getRepository(ProjectOrders::class);
  111.         if($customer){
  112.             return $repo->findWithCalculated($customer);
  113.         }
  114.         return $repo->findAllWithCalculated();
  115.     }
  116.     /**
  117.      * @throws \Exception
  118.      */
  119.     // public function findByProgress(string $type, int $from = 0, int $to = 100): array {
  120.     public function findByProgress(): array {
  121.         $filterModel $this->entityHydratorValidator->createInputBag($this->params->get('progressFilterModel'));
  122.         $from = !empty($filterModel->get('range')->from) ? $filterModel->get('range')->from null;
  123.         $to = !empty($filterModel->get('range')->to) ? $filterModel->get('range')->to null;
  124.         // Symbolik
  125.         // $role = $filterModel->get('role');
  126.         /**@var $projectOrdersRepository ProjectOrdersRepository*/
  127.         $projectOrdersRepository $this->entityManager->getRepository(ProjectOrders::class);
  128.         return $projectOrdersRepository->findOrdersByProgressStatusV3$this->security->getUser(), $this->project$from$to );
  129.     }
  130.     /**
  131.      * ProjectOrder işlemini gerçekleştirir.
  132.      *
  133.      * Asıl entity (ProjectOrder) service içinde setEntity() ile set edilir.
  134.      * Yardımcı entity (Project) parametre olarak verilir.
  135.      *
  136.      * @param Projects $project Yardımcı entity.
  137.      * @return ProjectOrders|null İşlem sonucu dönen ProjectOrder entity.
  138.      * @throws \Exception
  139.      */
  140.     public function upsert(Projects $project): ?ProjectOrders {
  141.         if($this->entity && !$this->entity instanceof ProjectOrders ){
  142.             throw new \LogicException('Entity should be ProjectOrders instance');
  143.         }
  144.         if(!$this->entity){
  145.             $this->entity = new ProjectOrders();
  146.             $this->entity->setProject($project);
  147.             $uniqueFileNumber $this->fileNumber->createUniqueFileNumber(ProjectOrders::class, [
  148.                 "criteria" => ["project" => $project],
  149.                 "prefix" => [$project->getProjectId()],
  150.                 "separator" => "-"
  151.             ]);
  152.             $this->entity->setOrderNr($uniqueFileNumber);
  153.             $this->entity->setCreatedAt(new \DateTimeImmutable('now'));
  154.             // TODO Set Created At ekle
  155.         }
  156.         # $calculatorService = $this->calculatorService->orderCalculator($this->entity);
  157.         #dd($this->params);
  158.         // README Params will replace value automatically from number (id) to Entity Return either Input Bag or $params already is referenced
  159.         // Invoice Type has been replaced
  160.         $this->resolveRelation('invoice_type'$this->paramsInvoiceType::class);
  161.         // order_primary_lead
  162.         $this->resolveRelation('order_primary_lead'$this->paramsWorkerActivities::class);
  163.         // contact_person
  164.         $this->resolveRelation('contact_person'$this->paramsProjectOwnerContactPersons::class);
  165.         // order_branch
  166.         $this->resolveRelation('order_branch'$this->paramsBranches::class);
  167.         // branch_department
  168.         $this->resolveRelation('branch_department'$this->paramsBranchDepartments::class);
  169.         #dd($this->params);
  170.         // Validation project_owner_order_nr & project
  171.         /**
  172.          * Project (entity ) validation icin gerekli ve olmadigi icin
  173.          * InputBag e bunu ert ediyoruz
  174.          * $project aslinda bir entity array degil!
  175.         */
  176.         $this->params->set('project'$project );
  177.         if($this->params->get('project_owner_order_nr')){
  178.             $validation $this->entityHydratorValidator
  179.                 ->setEntity($this->entity)
  180.                 ->setParams($this->params)
  181.                 ->validate([
  182.                     'project_owner_order_nr''project'
  183.                 ], 1,
  184.                 [
  185.                     "project" => "project_id"
  186.                 ]);
  187.             if(!is_null($validation)){
  188.                 $this->setCreateException(HttpStatusInterface::SERVICE_UNAVAILABLE$validation);
  189.                 return null;
  190.             }
  191.         }
  192.         #dd($this->params->keys());
  193.         // Project given already by new Order !!!⚠️
  194.         $excludedFields = [
  195.             'order_alternate_leads',
  196.             'project_order_tasks',
  197.             'project',
  198.             'takeover',
  199.             'order_billings',
  200.             'contact_persons',
  201.             // works_plan: salt-okunur ilişki (FE'ye @ref ile gider); BindService yönetir,
  202.             // generic hydrator array/string ile setWorksPlan edemez → hidrasyona girmez.
  203.             'works_plan'
  204.         ];
  205.         $nestedFields array_diff($this->params->keys(), $excludedFields);
  206.         $this->entityHydratorValidator
  207.             ->setParams($this->params)
  208.             ->setEntity($this->entity)
  209.             ->populateEntityFields($nestedFields);
  210.         // Alternate Leads
  211.         $this->syncOrderAlternateLeads();
  212.         // Tamamlama kuralı ihlali (today < start_at) → işlemi reddet, hiçbir şey flush etme.
  213.         try {
  214.             $this->syncOrderTasks($this->entity$this->params);
  215.         } catch (\DomainException $domainException) {
  216.             $this->setCreateException(HttpStatusInterface::BAD_REQUEST$domainException->getMessage());
  217.             return null;
  218.         }
  219.         // This update must after Tasks Update!!!
  220.         // While Progress with Task related!
  221.         $this->metricService->orderMetrics($this->entity)->syncProgressMetrics();
  222.         try{
  223.             $this->manager->persist($this->entity);
  224.             $this->manager->flush();
  225.             $this->manager->refresh($this->entity);
  226.         } catch (\Exception $exception){
  227.             $this->setCreateExceptionHttpStatusInterface::BAD_REQUEST$exception->getMessage());
  228.         }
  229.         return $this->entity;
  230.     }
  231.     /**
  232.      * TODO Use this Never (bu kisim Mtric service üzerinden OrderCalcuator icinde yazildi!)
  233.      * @deprecated
  234.      * @see use OrderCalculator/progress instead
  235.     */
  236.     public function syncOrderProgress_deprecated_here(ProjectOrders $projectOrder): float
  237.     {
  238.         $totalProgress 0.0;
  239.         foreach ($projectOrder->getProjectOrderTasks() ?? [] as $task) {
  240.             // Tamamlanmış job'ların ağırlık toplamı
  241.             $completedFeedbacks 0.0;
  242.             foreach ($task->getTaskJobs() ?? [] as $job) {
  243.                 if (
  244.                     !empty($job->getStartAt()) &&
  245.                     !empty($job->getEndAt())
  246.                 ) {
  247.                     $completedFeedbacks += $job->getJobWeight() ?? 0;
  248.                 }
  249.             }
  250.             $taskWeight $task->getProgressWeight() ?? 0;
  251.             // Task'in projeye gerçek katkısı
  252.             $taskContribution = ($completedFeedbacks $taskWeight) / 100;
  253.             // JS Math.round(x * 100) / 100 eşdeğeri
  254.             $totalProgress += round($taskContribution2);
  255.         }
  256.         // Son rounding
  257.         $nextProgress round($totalProgress2);
  258.         return $nextProgress// örn: 67.79
  259.     }
  260.     // private function syncOrderAlternateLeads(ProjectOrders $projectOrder, $params): void
  261.     /**
  262.      * @throws \Exception
  263.      */
  264.     # private function syncOrderAlternateLeads(ProjectOrders $projectOrder, $params): void
  265.     private function syncOrderAlternateLeads(): void
  266.     {
  267.         // Check The Entity is Expected ad Assign
  268.         $this->entity $this->entityHydratorValidator->assertHydratedEntity(ProjectOrders::class, $this->entity);
  269.         // 🔹 1. Get Old IDs
  270.         $oldAlternateIds $this->entity->getOrderAlternateLeads()->map(fn($p) => $p->getId())->toArray();
  271.         // 🔹 2. Get new IDs from request And Convert to Input Bag
  272.         $newAlternateIds    $this->entityHydratorValidator->createInputBag($this->params->get('order_alternate_leads'));
  273.         // Take only Id's
  274.         $newAlternateIds    = !is_null($newAlternateIds) ? array_map(fn($item) => $item->id$newAlternateIds->all()) : [];
  275.         // Define Differer
  276.         $differ             $this->syncDifferer($newAlternateIds$oldAlternateIds);
  277.         // 🔹 Remove branches that are no longer assigned
  278.         foreach ($differ['toRemove'] as $id) {
  279.             $found $this->entity->getOrderAlternateLeads()->filter(fn($p) => $p->getId() === $id)->first();
  280.             if ($found) {
  281.                 $this->entity->removeOrderAlternateLead($found);
  282.             }
  283.         }
  284.         // 🔹 Add new branches
  285.         foreach ($differ['toAdd'] as $id) {
  286.             $found $this->entityManager->getRepository(WorkerActivities::class)->find($id);
  287.             if ($found) {
  288.                 $this->entity->addOrderAlternateLead($found);
  289.             }
  290.         }
  291.     }
  292.     /**
  293.      * @throws \Exception
  294.      */
  295.     private function syncOrderTasks(ProjectOrders $projectOrder$params){
  296.         $this->entity $this->entityHydratorValidator->assertHydratedEntity(ProjectOrders::class, $this->entity);
  297.         # $tasks = $params->get('project_order_tasks');
  298.         # if(!$tasks && !$projectOrder->getId()) return;
  299.         // This JSON String converted to Input Bag (Each item is converted to stdClass)
  300.         $tasksBags $this->entityHydratorValidator->createInputBag($params->get('project_order_tasks'));
  301.         /**
  302.          * Clone lazim
  303.          * Foreach içi: her task kendi createInputBag ile InputBag’e dönüşüyor
  304.          * Burada her task elemanı (stdClass) InputBag’e çevriliyor
  305.          * InputBag constructor’ı şöyle davranır:
  306.          * function __construct($data) {
  307.          * $this->parameters = (array) $data; // 👉 stdClass → array cast 👈
  308.          * }
  309.          * Oyuzden clone lamak lazim
  310.         */
  311.         if(is_null($tasksBags)) return;
  312.         $originalBags = clone $tasksBags;
  313.         foreach ($tasksBags as $taskBag) {
  314.             // Each of task Convert to Input Bag fot Hydrator
  315.             // Task each props prepared for Hydrator
  316.             $taskBag $this->entityHydratorValidator->createInputBag($taskBag);
  317.             // No validation need -> direct to populate
  318.             // 1️⃣ Task Entity
  319.             if($taskBag->get('isNew')){
  320.                 $taskEntity = new ProjectOrderTasks();
  321.             } else {
  322.                 /**
  323.                  * Doctrine Collection için PHP 7.4’te filter dışında gerçek bir find operatörü yok.
  324.                  * Criteria kullanicaz
  325.                  * filter gibi tüm koleksiyonu dolaşmaz (özellikle PersistentCollection için)
  326.                  * Doctrine tarafından desteklenen “find” oldugunu ögrendim
  327.                  * ❌ 👉$projectOrder->getProjectOrderTasks()->filter( fn(ProjectOrderTasks $taskEntity) => $taskEntity->getId() === $task->id)->first();
  328.                  */
  329.                 $criteria Criteria::create()
  330.                     ->where(Criteria::expr()->eq('id'$taskBag->get('id')))
  331.                     ->setMaxResults(1);
  332.                 $taskEntity $projectOrder->getProjectOrderTasks()->matching($criteria)->first();
  333.             }
  334.             // Resolve pure data responsible_person to Entity !!!
  335.             $this->resolveRelation('responsible_person'$taskBagWorkerActivities::class);
  336.             /**
  337.              * ❌❌⚠️⚠️⚠️ TASK FULFILLMENT PROCESSING FROM OUTSIDE EXCLUDE THIS
  338.              * Resolve pure data task_fulfillment data to Entity !!!
  339.              * $this->resolveRelation('task_fulfillment', $inputBag, ProjectOrderTaskFulfillment::class);
  340.              * task_fulfillment Processing extra
  341.             */
  342.             $excludedFields = ["task_jobs""maxValue"'task_fulfillment''__reorder__''execution_scope''task_job_completed_weight''real_days''details'];
  343.             $nestedFields array_diff($taskBag->keys(), $excludedFields);
  344.             $this->entityHydratorValidator
  345.                 ->setEntity($taskEntity)
  346.                 ->setParams($taskBag)
  347.                 ->populateEntityFields($nestedFields);
  348.             // Sync Task Jobs (Convert string to Object via createInputBag)
  349.             $taskJobs $this->entityHydratorValidator->createInputBag($taskBag->get('task_jobs'));
  350.             $taskEntity $this->syncProjectOrderTaskJobs($taskEntity$taskJobs);
  351.             if($taskEntity->getId() === 39){
  352.                 #$this->manager->refresh($taskEntity);
  353.                 #dd($taskEntity);
  354.             }
  355.             $projectOrder->addProjectOrderTask($taskEntity);
  356.         }
  357.         // REMOVE PROCESS
  358.         $oldTasks       $projectOrder->getProjectOrderTasks()->map( fn (ProjectOrderTasks $task ) => $task->getId() )->toArray();
  359.         $actuallyTasks  array_filter($originalBags->all(), fn($item) => gettype($item) === "array" ? !array_key_exists('isNew'$item) : $item);
  360.         $actuallyTasks  array_map(fn($item) => $item->id$actuallyTasks);
  361.         $differer       $this->syncDifferer($actuallyTasks ?? [], $oldTasks );
  362.         // To Remove
  363.         foreach ($differer["toRemove"] as $id) {
  364.             $projectOrderTask $this->manager->getRepository(ProjectOrderTasks::class)->find($id);
  365.             $projectOrder->removeProjectOrderTask($projectOrderTask);
  366.         }
  367.         // Update only nested metrics
  368.         $this->metricService->orderMetrics($projectOrder)->syncProgressMetrics();
  369.         $this->metricService->projectMetrics($projectOrder->getProject())->syncProgressMetrics();
  370.     }
  371.     /**
  372.      * Access from anywhere
  373.      * @param ProjectOrderTasks $taskEntity ;
  374.      * @param array|InputBag $task_jobs
  375.      * @throws \Exception
  376.      */
  377.     /**
  378.      * Tek job için tamamlama uygunluğu (live kontrol). Kalıcı (planlı) start_at'e göre
  379.      * karar verir → checkbox/Zeitachse lokal değişiklikle baypas edilemez; sunucu kalıcı
  380.      * durumu kontrol eder. start_at FE'de min-date (Zeitachse) için döner.
  381.      *
  382.      * @return array
  383.      */
  384.     public function checkJobCompletion(ProjectOrderTaskJobs $job): array
  385.     {
  386.         $block $job->getCompletionBlock();
  387.         // Plan-bağlıda start otoritesi plan (getCompletionBlock ile tutarlı) → mesaj/tarih de plandan.
  388.         $planJob $job->getSourcePlanJob();
  389.         $start $planJob !== null $planJob->getStartAt() : $job->getStartAt();
  390.         $message '';
  391.         if ($block !== null) {
  392.             $title $job->getJobDescription() ?: $this->translator->trans('Job');
  393.             $message $this->translator->trans('Job "%title%" has not started yet (starts %date%) and cannot be completed.', [
  394.                 '%title%' => $title,
  395.                 '%date%' => $start $start->format('d.m.Y') : ''
  396.             ]);
  397.         }
  398.         return [
  399.             'allowed' => $block === null,
  400.             'start_at' => $start $start->format('Y-m-d') : null,
  401.             'message' => $message
  402.         ];
  403.     }
  404.     public function syncProjectOrderTaskJobs (ProjectOrderTasks $taskEntity,InputBag $task_jobs): ProjectOrderTasks {
  405.         $jobsBags $task_jobs;
  406.         $toAddEntities = [];
  407.         foreach ($jobsBags as  $jobBag) {
  408.             $jobBag $this->entityHydratorValidator->createInputBag($jobBag);
  409.             // 1️⃣ Job Entity
  410.             if($jobBag->get('isNew')){
  411.                 $jobEntity = new ProjectOrderTaskJobs();
  412.             } else {
  413.                 /**
  414.                  * Doctrine Collection için PHP 7.4’te filter dışında gerçek bir find operatörü yok.
  415.                  * Criteria kullanicaz
  416.                  * filter gibi tüm koleksiyonu dolaşmaz (özellikle PersistentCollection için)
  417.                  * Doctrine tarafından desteklenen “find” oldugunu ögrendim
  418.                  * ❌ 👉$projectOrder->getProjectOrderTasks()->filter( fn(ProjectOrderTasks $taskEntity) => $taskEntity->getId() === $task->id)->first();
  419.                  */
  420.                 $criteria Criteria::create()
  421.                     ->where(Criteria::expr()->eq('id'$jobBag->get('id')))
  422.                     ->setMaxResults(1);
  423.                 $jobEntity $taskEntity->getTaskJobs()->matching($criteria)->first();
  424.             }
  425.             // Plan-bağlı job: SCHEDULE otoritesi PLAN. FE payload stale olabilir (plan başka bir
  426.             // yerde değiştirilmiş, order reload edilmemiş) → start_at/duration order payload'undan
  427.             // DEĞİL, source_plan_job'tan (canlı DB) alınır. Aksi halde stale start_at, tamamlama
  428.             // anında haksız "başlamadı" bloğu üretir (kullanıcı bug'ı: plan 23'e çekildi ama order
  429.             // payload 24 gönderiyordu → blok).
  430.             $sourcePlanJob = (!$jobBag->get('isNew') && $jobEntity instanceof ProjectOrderTaskJobs)
  431.                 ? $jobEntity->getSourcePlanJob()
  432.                 : null;
  433.             // ⚠️ No RelationResolve
  434.             // source_plan_job: WorksPlan materialization mapping'i (ManyToOne) — generic hydrator
  435.             // array ile set edemez; sadece Materializer/back-sync yönetir, payload'dan hidrasyona girmez.
  436.             // locked: tamamlama kilidi server-kontrollü (plan otoritesi) → FE'den gelse de hidrasyona girmez.
  437.             $excludedFields = ["maxValue""__reorder__"'status''timeline''task_index''source_plan_job''locked'];
  438.             // Plan-bağlı job'da schedule alanları plandan tazelenecek → stale payload hidrasyona girmesin.
  439.             if ($sourcePlanJob !== null) {
  440.                 $excludedFields[] = 'start_at';
  441.                 $excludedFields[] = 'duration_days';
  442.             }
  443.             $nestedFields array_diff($jobBag->keys(), $excludedFields);
  444.             $this->entityHydratorValidator
  445.                 ->setEntity($jobEntity)
  446.                 ->setParams($jobBag)
  447.                 ->populateEntityFields($nestedFields);
  448.             // Not: order job 'locked' tutmaz; tamamlanınca PLAN job'u kilitlenir (OrderEndAtBackSyncListener,
  449.             // end_at → source_plan_job.locked=1). Order, plan lock'unu getLocked() ile read-through okur.
  450.             // Plan otoritesi: schedule'ı plandan tazele (stale FE payload'unu geçersiz kıl).
  451.             if ($sourcePlanJob !== null) {
  452.                 $jobEntity->setStartAt($sourcePlanJob->getStartAt());
  453.                 $jobEntity->setDurationDays($sourcePlanJob->getDurationDays());
  454.             }
  455.             // 🔒 Tamamlama kuralı (hard block): başlangıç günü gelmeden (today < start_at)
  456.             // bir job tamamlanamaz. start_at cascade ile dependency'yi içerdiğinden tarih
  457.             // kontrolü yeterli. (FE canlı job-completion-check endpoint'i ile de kontrol eder.)
  458.             $start $jobEntity->getStartAt();
  459.             $end $jobEntity->getEndAt();
  460.             // Tarih-string karşılaştırması: start_at saat bileşeniyle gelebilir; bugün == başlangıç
  461.             // günü ise tamamlanabilir (sadece gelecekte ise bloke).
  462.             $todayStr = (new \DateTimeImmutable('today'))->format('Y-m-d');
  463.             if ($end !== null && $start !== null && $start->format('Y-m-d') > $todayStr) {
  464.                 $title $jobEntity->getJobDescription() ?: $this->translator->trans('Job');
  465.                 throw new \DomainException(
  466.                     $this->translator->trans('Job "%title%" has not started yet (starts %date%) and cannot be completed.', [
  467.                         '%title%' => $title,
  468.                         '%date%' => $start->format('d.m.Y')
  469.                     ])
  470.                 );
  471.             }
  472.             $toAddEntities[] = $jobEntity;
  473.         }
  474.         $oldJobs      $taskEntity->getTaskJobs()->map(fn(ProjectOrderTaskJobs $job) => $job->getId())->toArray();
  475.         $actuallyJobs array_map( fn($item) => $item->idarray_filter(
  476.             $task_jobs->all(),
  477.             fn($item) => is_array($item) ? empty($item['isNew']) : (!isset($item->isNew) || $item->isNew === false)
  478.         ));
  479.         $differer       $this->syncDifferer($actuallyJobs$oldJobs );
  480.         if($taskEntity->getId()===39){
  481.             #dd($actuallyJobs, $differer);
  482.         }
  483.         // Önce “toRemove” entity’lerini topla, sonra sil
  484.         $toRemoveEntities = [];
  485.         foreach ($differer['toRemove'] as $id) {
  486.             $criteria Criteria::create()
  487.                 ->where(Criteria::expr()->eq('id'$id))
  488.                 ->setMaxResults(1);
  489.             $jobEntity $taskEntity->getTaskJobs()->matching($criteria)->first();
  490.             if ($jobEntity) {
  491.                 $toRemoveEntities[] = $jobEntity;
  492.             }
  493.         }
  494.         #SORUN VAR!! Add&Remove yapildiginda sorun cikyior belkide FE de sorun var!!!
  495.         // Şimdi ayrı döngü ile remove
  496.         foreach ($toRemoveEntities as $jobEntity) {
  497.             $taskEntity->removeProjectOrderTaskJob($jobEntity);
  498.         }
  499.         // To Add
  500.         foreach ($toAddEntities as $jobEntity) {
  501.             $taskEntity->addProjectOrderTaskJob($jobEntity);
  502.         }
  503.         # sleep(1);
  504.         // $this->manager->refresh($taskEntity);
  505.         return $taskEntity;
  506.     }
  507.         /**
  508.      * @deprecated
  509.      * use 2. Level (upsertVoucher) no Subscriber
  510.      * @throws \Exception
  511.      */
  512.     public function upsertVoucherWithSubscriber(ProjectOrders $projectOrder, ?ProjectOrderUndertakings $undertakingInputBag $params): ProjectOrderUndertakings {
  513.         #$doctrineSubscriber = "onFlush"; // prePersist|onFlush
  514.         // TODO !Important READ ME
  515.         /**
  516.          * Buradaki onFlush Secimi (Kullanimi) DoctrineSubscriber icinde onFlush Methodunu Tetikliyor
  517.          * Bu Method  Insert & Update Degisiklikllerini algiliyor
  518.         */
  519.         $doctrineSubscriber "onFlush"// prePersist|onFlush
  520.         if(!$undertaking){
  521.             $undertaking = new ProjectOrderUndertakings();
  522.             if($doctrineSubscriber === "prePersist"){
  523.                 $undertaking->setProjectOrder($projectOrder);
  524.             }
  525.             $undertaking->setCreatedAt(new \DateTimeImmutable('now'));
  526.         }
  527.         $this->entityHydratorValidator->setEntity($undertaking)->setParams($params)->populateEntityFields([
  528.             'sent_at',
  529.             // 'completed_at', This by Incoming Budget
  530.             'position_nr''price'
  531.         ]);
  532.         // Doctrine Subscriber onFlush
  533.         if($doctrineSubscriber === "onFlush"){
  534.             $projectOrder->addProjectOrderUndertaking($undertaking);
  535.         }
  536.         // Update Metrics Managed Over Subscriber
  537.         // $projectOrder->getProjectOrderMetrics()->setTotalUndertakings()
  538.         #dd(12);
  539.         try{
  540.             if($doctrineSubscriber === "onFlush"){
  541.                 $this->manager->persist($projectOrder);
  542.                 $this->manager->flush();
  543.                 $this->manager->refresh($projectOrder);
  544.             }
  545.             if($doctrineSubscriber === "prePersist"){
  546.                 $this->manager->persist($undertaking);
  547.                 $this->manager->flush();
  548.                 $this->manager->refresh($undertaking);
  549.             }
  550.         } catch (\Exception $exception){
  551.             $this->setCreateExceptionHttpStatusInterface::BAD_REQUEST$exception->getMessage());
  552.         }
  553.         return $undertaking;
  554.     }
  555.     /**
  556.      * TODO Deprecated
  557.      * @deprecated
  558.     */
  559.     public function upsertVoucher(ProjectOrders $projectOrder, ?ProjectOrderUndertakings $undertakingInputBag $params): ?ProjectOrderUndertakings {
  560.         if(!$undertaking){
  561.             $undertaking = new ProjectOrderUndertakings();
  562.             $undertaking->setCreatedAt(new \DateTimeImmutable('now'));
  563.         }
  564.         // Bu sekilide Entity de control edilebilyor
  565.         $params->set('project_order'$projectOrder );
  566.         $validation $this->entityHydratorValidator
  567.             ->setEntity($undertaking)
  568.             ->setParams($params)
  569.             ->validate(['project_order','position_nr'], );
  570.         #dd($validation);
  571.         if( !is_null($validation) ){
  572.             $this->setCreateExceptionHttpStatusInterface::SERVICE_UNAVAILABLE$validation );
  573.             return null;
  574.         }
  575.         // Use validator fields
  576.         $this->entityHydratorValidator->setEntity($undertaking)->setParams($params)->populateEntityFields(['sent_at''price']);
  577.         $projectOrder->addProjectOrderUndertaking($undertaking);
  578.         $this->projectOrderCalculator->updateProjectOrderMetrics($projectOrder);
  579.         try{
  580.             $this->manager->persist($projectOrder);
  581.             $this->manager->flush();
  582.         } catch (\Exception $exception){
  583.             $this->setCreateExceptionHttpStatusInterface::BAD_REQUEST$exception->getMessage());
  584.         }
  585.         #dd($undertaking);
  586.         return $undertaking;
  587.     }
  588.     /**
  589.      * TODO Deprecated
  590.      * @deprecated
  591.      * @throws \Exception
  592.      */
  593.     public function deleteVoucher(?ProjectOrderUndertakings $undertaking): ?ProjectOrderMetrics
  594.     {
  595.         $em $this->manager;
  596.         try {
  597.             // Transaction Remove with Update Metrics Pretty & Clean
  598.             return $em->wrapInTransaction(function () use ($undertaking$em) {
  599.                 $projectOrder $undertaking->getProjectOrder();
  600.                 $projectOrder->removeProjectOrderUndetaking($undertaking);
  601. //                $calculator = $this->projectOrderCalculator
  602. //                    ->calculateOrderPayments($projectOrder);
  603.                 $this->projectOrderCalculator
  604.                     ->updateProjectOrderMetrics($projectOrder);
  605. //                $projectOrder->getProjectOrderMetrics()->setTotalUndertakings($calculator['undertakingTotal']);
  606. //                $projectOrder->getProjectOrderMetrics()->setReceivedUndertakings($calculator['undertakingReceived']);
  607. //                $projectOrder->getProjectOrderMetrics()->setOpenUndertakings($calculator['undertakingOpen']);
  608.                 $em->persist($projectOrder);
  609.                 $em->flush();
  610.                 return $projectOrder->getProjectOrderMetrics();
  611.             });
  612.         } catch (\Exception $exception) {
  613.             $this->setCreateException($exception->getCode(), $exception->getMessage());
  614.             return null;
  615.         }
  616.     }
  617.     /**
  618.      * @throws \ReflectionException
  619.      */
  620.     public function syncBillings(): ?ProjectOrders {
  621.         $billings $this->params->get('billings');
  622.         if(!$this->entity instanceof ProjectOrders){
  623.             throw new \Exception(sprintf("Parent entity should be instance of ProjectOrders, %s given", ( new \ReflectionClass($this->entity))->name ));
  624.         }
  625.         $billingBags $this->entityHydratorValidator->createInputBag($billings);
  626.         $previousBills $this->entity->getOrderBillings()->map(fn(ProjectOrderBillings $item) => $item->getId())->toArray();
  627.         $onlyNews array_filter(
  628.             $billingBags->all(),
  629.             fn($item) => is_array($item) ? empty($item['isNew']) : (!isset($item->isNew) || $item->isNew === false)
  630.         );
  631.         $actuallyBills array_map( fn($item) => is_array($item) ? $item['id'] : $item->id$onlyNews);
  632.         #dd($actuallyBills);
  633.         $differer $this->syncDifferer($actuallyBills$previousBills );
  634.         $toAddEntities $toRemoveEntities = [];
  635.         // TO REMOVE
  636.         foreach ($differer['toRemove'] as $id) {
  637.             $criteria Criteria::create()
  638.                 ->where(Criteria::expr()->eq('id'$id))
  639.                 ->setMaxResults(1);
  640.             $billingEntity $this->entity->getOrderBillings()->matching($criteria)->first();
  641.             if ($billingEntity) {
  642.                 $this->entity->removeProjectOrderBilling($billingEntity);
  643.             }
  644.         }
  645.         foreach ($billingBags as $index => $billingBag ) {
  646.             $billingBag $this->entityHydratorValidator->createInputBag($billingBag);
  647.             // 1️⃣ Job Entity
  648.             if($billingBag->get('isNew')){
  649.                 $billingEntity = new ProjectOrderBillings();
  650.                 #$billingEntity->setProjectOrder($this->entity);
  651.                 $billingEntity->setCreatedAt(new \DateTimeImmutable('now'));
  652.             } else {
  653.                 /**
  654.                  * Doctrine Collection için PHP 7.4’te filter dışında gerçek bir find operatörü yok.
  655.                  * Criteria kullanicaz
  656.                  * filter gibi tüm koleksiyonu dolaşmaz (özellikle PersistentCollection için)
  657.                  * Doctrine tarafından desteklenen “find” oldugunu ögrendim
  658.                  * ❌ 👉$projectOrder->getProjectOrderTasks()->filter( fn(ProjectOrderTasks $taskEntity) => $taskEntity->getId() === $task->id)->first();
  659.                  */
  660.                 $criteria Criteria::create()
  661.                     ->where(Criteria::expr()->eq('id'$billingBag->get('id')))
  662.                     ->setMaxResults(1);
  663.                 $billingEntity $this->entity->getOrderBillings()->matching($criteria)->first();
  664.             }
  665.             // ⚠️ No RelationResolve
  666.             $excludedFields = ["invoice_positions""sent_at"'quantity''created_at''task_index''id''paid_amount'];
  667.             $nestedFields array_diff($billingBag->keys(), $excludedFields);
  668.             $billingBag->set('project_order'$this->entity);
  669.             // Always value is 1
  670.             $billingBag->set('quantity'1);
  671.             $validation $this->entityHydratorValidator
  672.                 ->setEntity($billingEntity)
  673.                 ->setParams($billingBag)
  674.                 // ->setRequired(['position_nr'])
  675.                 ->validate([
  676.                     'project_order''position_nr'
  677.                 ], 1, [
  678.                     "project_order" => "order_nr"
  679.                 ]);
  680.             if( !is_null($validation) ){
  681.                 $this->setCreateExceptionHttpStatusInterface::SERVICE_UNAVAILABLE$validation );
  682.                 return null;
  683.             }
  684.             $billingEntity $this->entityHydratorValidator
  685.                 ->setEntity($billingEntity)
  686.                 ->setParams($billingBag)
  687.                 ->populateEntityFields($nestedFields);
  688.             $this->entity->addProjectOrderBilling($billingEntity);
  689.         }
  690.         // Update only metrics affected by a new billing
  691.         $this->metricService->orderMetrics($this->entity)->syncBillingMetrics();
  692.         // Sync Project
  693.         $this->metricService->projectMetrics($this->entity->getProject())->syncBillingMetrics();
  694. //        // Or (Just target)
  695. //        $calculatorService = $this->calculatorService->orderCalculator($this->entity);
  696. //        $this->entity->getProjectOrderMetrics()
  697. //            ->setOpenBillings($calculatorService->billings()["Total"])
  698. //            ->setPaidBillings($calculatorService->billings()["Paid"])
  699. //            ->setOpenBillings($calculatorService->billings()["Open"]);
  700.         try{
  701.             $this->manager->persist($this->entity);
  702.             $this->manager->flush();
  703.             $this->manager->refresh($this->entity);
  704.         } catch (\Exception $exception){
  705.             $this->setCreateExceptionHttpStatusInterface::BAD_REQUEST$exception->getMessage());
  706.         }
  707.         return $this->entity;
  708.     }
  709.     /**
  710.      * @throws \ReflectionException
  711.      */
  712.     public function recalculateMetrics(){
  713.         if(!$this->entity instanceof ProjectOrders){
  714.             throw new Exception("Entity for syncMetrics should be instance of ProjectOrders" . (new \ReflectionClass($this->entity))->name ' given!');
  715.         }
  716.         $this->metricService->orderMetrics($this->entity)->syncAll();
  717.         try{
  718.             $this->manager->persist($this->entity);
  719.             $this->manager->flush();
  720.             $this->manager->refresh($this->entity);
  721.         } catch (\Exception $exception){
  722.             $this->setCreateExceptionHttpStatusInterface::BAD_REQUEST$exception->getMessage());
  723.         }
  724.         return $this->entity;
  725.     }
  726.     /**
  727.      * @throws \ReflectionException
  728.      * @return array{"classic": ArrayCollection<ProjectOrderExpensePositions>, "stock": ArrayCollection}
  729.      */
  730.     public function orderExpenses(): array
  731.     {
  732.         if(!$this->entity instanceof ProjectOrders){
  733.             throw new Exception("Entity for syncMetrics should be instance of ProjectOrders" . (new \ReflectionClass($this->entity))->name ' given!');
  734.         }
  735.         $criteria Criteria::create()
  736.             ->orderBy(['id' => Criteria::DESC]);
  737.         return [
  738.             "classic" => $this->entity->getProjectOrderExpensePositions()->matching($criteria),
  739.             "stock" => $this->entity->getStockTransactions()->matching($criteria)
  740.         ];
  741.     }
  742.     /**
  743.      * Order silme/tasima oncesi kullaniciya gosterilecek deger-bazli ozet.
  744.      * Sadece toplamlar (harcama, stok cikisi, worktime saat, billing) doner.
  745.      *
  746.      * @throws \ReflectionException
  747.      */
  748.     public function dependencySummary(): array
  749.     {
  750.         $this->entity $this->entityHydratorValidator->assertHydratedEntity(ProjectOrders::class, $this->entity);
  751.         /** @var ProjectOrders $order */
  752.         $order $this->entity;
  753.         // Harcama pozisyonlari -> (amount / (pe ?? 1)) * buying_price
  754.         $expenseTotal 0.0;
  755.         $expensePositions $order->getProjectOrderExpensePositions();
  756.         foreach ($expensePositions as $position) {
  757.             $amount floatval($position->getAmount());
  758.             $pe $position->getPriceUnit();
  759.             $divisor = (!is_null($pe) && floatval($pe) != 0.0) ? floatval($pe) : 1.0;
  760.             $expenseTotal += ($amount $divisor) * floatval($position->getBuyingPrice());
  761.         }
  762.         // Stok cikislari -> quantity * buying_price
  763.         $stockTotal 0.0;
  764.         $stockTransactions $order->getStockTransactions();
  765.         foreach ($stockTransactions as $transaction) {
  766.             $stockTotal += floatval($transaction->getQuantity()) * floatval($transaction->getBuyingPrice());
  767.         }
  768.         // Billing -> quantity * price
  769.         $billingTotal 0.0;
  770.         $billings $order->getOrderBillings();
  771.         foreach ($billings as $billing) {
  772.             $billingTotal += floatval($billing->getQuantity()) * floatval($billing->getPrice());
  773.         }
  774.         // Worktime saatleri (timesheet JSON'dan)
  775.         $worktimeHours $this->timesheetMutator->sumOrderHours($order->getId());
  776.         // Kesilmis faturaya bagli pozisyonlar -> tutar (Banner/Balken)
  777.         $invoicePositions $order->getInvoicePositions();
  778.         $invoicedCount $invoicePositions->count();
  779.         $invoiceTotal 0.0;
  780.         foreach ($invoicePositions as $invoicePosition) {
  781.             $invoiceTotal += floatval($invoicePosition->getPositionAmount());
  782.         }
  783.         // Sifre gate: worktime / billing / invoice en az biri varsa silme sifre ister.
  784.         // Admin USER_ROLE dahil herkes icin ayni — bypass yok ([[reference_destructive_op_pattern]]).
  785.         $requiresPassword $worktimeHours 0
  786.             || $billings->count() > 0
  787.             || $invoicedCount 0;
  788.         // Banner verisi: proje + owner
  789.         $project $order->getProject();
  790.         $owner $project $project->getOwner() : null;
  791.         // Tasi hedefi: SISTEMDEKI TUM order'lar (proje/ownership filtresi YOK) — projeler arasi tasimaya izinli.
  792.         // Hafif: entity hydrate etmeden id + order_nr + proje nr cekilir.
  793.         $rows $this->manager->createQuery(
  794.             'SELECT o.id AS id, o.order_nr AS order_nr, p.project_id AS project_nr, p.name AS project_name '
  795.             'FROM App\Entity\ProjectOrders o JOIN o.project p ORDER BY o.id DESC'
  796.         )->getArrayResult();
  797.         $selfId $order->getId();
  798.         $moveableOrders = [];
  799.         foreach ($rows as $r) {
  800.             if ((int) $r['id'] === $selfId) {
  801.                 continue;
  802.             }
  803.             $moveableOrders[] = [
  804.                 "id" => (int) $r['id'],
  805.                 "order_nr" => $r['order_nr'],
  806.                 "project_nr" => $r['project_nr'],
  807.                 "project_name" => $r['project_name']
  808.             ];
  809.         }
  810.         return [
  811.             "order" => [
  812.                 "id" => $order->getId(),
  813.                 "order_nr" => $order->getOrderNr(),
  814.                 "project_owner_order_nr" => $order->getProjectOwnerOrderNr()
  815.             ],
  816.             "project" => [
  817.                 "nr" => $project $project->getProjectId() : null,
  818.                 "name" => $project $project->getName() : null
  819.             ],
  820.             "owner" => [
  821.                 "company" => $owner $owner->getCompany() : null
  822.             ],
  823.             "moveable_orders" => $moveableOrders,
  824.             "expense" => [
  825.                 "count" => $expensePositions->count(),
  826.                 "total" => round($expenseTotal2)
  827.             ],
  828.             "stock" => [
  829.                 "count" => $stockTransactions->count(),
  830.                 "total" => round($stockTotal2)
  831.             ],
  832.             "worktime" => [
  833.                 "hours" => round($worktimeHours2)
  834.             ],
  835.             "billing" => [
  836.                 "count" => $billings->count(),
  837.                 "total" => round($billingTotal2)
  838.             ],
  839.             "invoice" => [
  840.                 "count" => $invoicedCount,
  841.                 "total" => round($invoiceTotal2)
  842.             ],
  843.             "tasks" => [
  844.                 "count" => $order->getProjectOrderTasks()->count()
  845.             ],
  846.             "invoiced" => [
  847.                 "count" => $invoicedCount,
  848.                 "blocks_delete" => $requiresPassword
  849.             ],
  850.             "delete_requires_password" => $requiresPassword,
  851.             "password_reasons" => [
  852.                 "worktime" => $worktimeHours 0,
  853.                 "billing"  => $billings->count() > 0,
  854.                 "invoiced" => $invoicedCount 0
  855.             ]
  856.         ];
  857.     }
  858.     /**
  859.      * Order'i ve butun bagimliliklarini cascade siler.
  860.      * Kesilmis bir faturaya bagli pozisyonu varsa silmez (blok) -> kullanici Tasi kullanmali.
  861.      * Stok dengesi Doctrine subscriber tarafindan ORM remove ile otomatik duzeltilir.
  862.      *
  863.      * @throws \ReflectionException
  864.      */
  865.     public function cascadeDelete(bool $force false, ?string $password null): array
  866.     {
  867.         $this->entity $this->entityHydratorValidator->assertHydratedEntity(ProjectOrders::class, $this->entity);
  868.         /** @var ProjectOrders $order */
  869.         $order $this->entity;
  870.         $hasInvoicePositions $order->getInvoicePositions()->count() > 0;
  871.         $hasBillings         $order->getOrderBillings()->count() > 0;
  872.         $hasWorktime         $this->timesheetMutator->sumOrderHours($order->getId()) > 0;
  873.         $requiresPassword    $hasInvoicePositions || $hasBillings || $hasWorktime;
  874.         // Guard: worktime / billing / invoiced position en az biri varsa silme sifre ister.
  875.         // Admin USER_ROLE dahil herkes icin ayni — bypass yok ([[reference_destructive_op_pattern]]).
  876.         if ($requiresPassword) {
  877.             if (!$force) {
  878.                 $this->setCreateException(
  879.                     HttpStatusInterface::BAD_REQUEST,
  880.                     $this->translator->trans('This order has tracked time, billings or invoiced positions. Force-delete requires the override password.')
  881.                 );
  882.                 return [];
  883.             }
  884.             if ($this->orderForceDeletePassword === '' || !hash_equals($this->orderForceDeletePassword, (string) $password)) {
  885.                 $this->setCreateException(
  886.                     HttpStatusInterface::UNAUTHORIZED,
  887.                     $this->translator->trans('Invalid force-delete password.')
  888.                 );
  889.                 return [];
  890.             }
  891.         }
  892.         $orderId $order->getId();
  893.         $orderNr $order->getOrderNr();
  894.         $project $order->getProject(); // metrik resync icin order silinmeden once yakala
  895.         $em $this->manager;
  896.         try {
  897.             return $em->wrapInTransaction(function () use ($order$orderId$orderNr$em$force$hasInvoicePositions$project) {
  898.                 // 1) Timesheet JSON temizligi (FK degil, JSON key)
  899.                 $affectedTimesheetColumns $this->timesheetMutator->removeOrderFromTimesheets($orderId);
  900.                 // 1.5) Force + faturali ise: kesilmis faturaya bagli invoice_positions'i da sil
  901.                 //      (order->invoice_positions FK'sinde cascade-remove yok, elle kaldirilmali)
  902.                 if ($force && $hasInvoicePositions) {
  903.                     foreach ($order->getInvoicePositions()->toArray() as $invoicePosition) {
  904.                         $em->remove($invoicePosition);
  905.                     }
  906.                 }
  907.                 // 2) FK'li ve cascade-remove'u olmayan bagimlilari elle sil
  908.                 //    (ORM remove -> subscriber'lar, ozellikle stok dengesi, tetiklenir)
  909.                 foreach ($order->getWorkerTimesheetPayments()->toArray() as $payment) {
  910.                     $em->remove($payment);
  911.                 }
  912.                 foreach ($order->getProjectOrderExtraCosts()->toArray() as $extraCost) {
  913.                     $em->remove($extraCost);
  914.                 }
  915.                 foreach ($order->getProjectOrderExpensePositions()->toArray() as $position) {
  916.                     $em->remove($position);
  917.                 }
  918.                 foreach ($order->getStockTransactions()->toArray() as $stockTransaction) {
  919.                     $em->remove($stockTransaction);
  920.                 }
  921.                 foreach ($order->getStockIssues()->toArray() as $stockIssue) {
  922.                     $em->remove($stockIssue);
  923.                 }
  924.                 foreach ($order->getProjectOrderTasks()->toArray() as $task) {
  925.                     $em->remove($task);
  926.                 }
  927.                 foreach ($order->getOrderBillings()->toArray() as $billing) {
  928.                     $em->remove($billing);
  929.                 }
  930.                 // 3) Order'i sil -> Feedback, ProjectOrderInvoices, Undertakings, Metrics
  931.                 //    cascade={"remove"} oldugundan otomatik gider.
  932.                 $em->remove($order);
  933.                 $em->flush();
  934.                 // Order silindi -> projenin rollup metrikleri stale kalmasin. Order'in
  935.                 // kendi metrikleri cascade ile gitti; proje toplamlari kalan order'lar
  936.                 // uzerinden yeniden hesaplanmali (move akimiyla simetrik).
  937.                 $sourceProject null;
  938.                 if ($project) {
  939.                     $this->metricService->projectMetrics($project)->syncAll();
  940.                     $em->flush();
  941.                     // Order silindikten sonra proje order'siz kaldi mi? (FE: bos projeyi sil teklif eder)
  942.                     $remainingOrders $this->manager->getRepository(ProjectOrders::class)->count(['project' => $project->getId()]);
  943.                     $sourceProject = [
  944.                         "id"    => $project->getId(),
  945.                         "name"  => $project->getName(),
  946.                         "empty" => $remainingOrders === 0
  947.                     ];
  948.                 }
  949.                 return [
  950.                     "id" => $orderId,
  951.                     "order_nr" => $orderNr,
  952.                     "affected_timesheet_columns" => $affectedTimesheetColumns,
  953.                     "source_project" => $sourceProject
  954.                 ];
  955.             });
  956.         } catch (\Exception $exception) {
  957.             $this->setCreateException(HttpStatusInterface::BAD_REQUEST$exception->getMessage());
  958.             return [];
  959.         }
  960.     }
  961.     /**
  962.      * Order'i ve TUM bagimliliklarini streamed (adim adim) force-delete eder. GERI DONUS YOK.
  963.      *
  964.      * Tek order: sayilar silmeden ONCE okunur, mevcut cascadeDelete(force, password) ile silinir
  965.      * (expenses/billings/invoices/stock + timesheet JSON temizligi orada, tek transaction),
  966.      * sonra her bagimlilik tipi ayri madde olarak emit edilir (FE checklist'i isaretler).
  967.      * Fatura bagliysa (blocks_delete) gecerli override sifresi zorunludur; degilse sifresiz silinir.
  968.      */
  969.     public function forceDeleteStreamed(?string $password): StreamedResponse
  970.     {
  971.         /** @var ProjectOrders $order */
  972.         $order $this->entity;
  973.         $trans $this->translator;
  974.         $response = new StreamedResponse(function () use ($order$password$trans) {
  975.             // SSE: tampon temizle, her echo aninda gitsin
  976.             while (ob_get_level() > 0) { ob_end_flush(); }
  977.             ob_implicit_flush(true);
  978.             $emit = function (string $event, array $data) {
  979.                 echo "event: {$event}\n";
  980.                 echo "data: " json_encode($dataJSON_UNESCAPED_UNICODE) . "\n\n";
  981.                 flush();
  982.             };
  983.             if (!$order instanceof ProjectOrders) {
  984.                 $emit('custom_error', ['message' => $trans->trans('Order not found'), 'severity' => 'error']);
  985.                 return;
  986.             }
  987.             // Silmeden ONCE sayilari oku (cascadeDelete sonrasi gider).
  988.             $nr            $order->getOrderNr();
  989.             $expCnt        $order->getProjectOrderExpensePositions()->count();
  990.             $billCnt       $order->getOrderBillings()->count();
  991.             $invCnt        $order->getInvoicePositions()->count();
  992.             $stockCnt      $order->getStockTransactions()->count();
  993.             $worktimeHours $this->timesheetMutator->sumOrderHours($order->getId());
  994.             // worktime / billing / invoiced position en az biri varsa override sifresi zorunlu.
  995.             // Admin USER_ROLE dahil herkes icin ayni — bypass yok.
  996.             $requiresPassword $invCnt || $billCnt || $worktimeHours 0;
  997.             if ($requiresPassword) {
  998.                 if ($this->orderForceDeletePassword === '' || !hash_equals($this->orderForceDeletePassword, (string) $password)) {
  999.                     $emit('custom_error', ['message' => $trans->trans('Invalid force-delete password.'), 'severity' => 'error']);
  1000.                     return;
  1001.                 }
  1002.             }
  1003.             $totalSteps 1// check
  1004.             if ($expCnt 0)        { $totalSteps++; }
  1005.             if ($billCnt 0)       { $totalSteps++; }
  1006.             if ($invCnt 0)        { $totalSteps++; }
  1007.             if ($stockCnt 0)      { $totalSteps++; }
  1008.             if ($worktimeHours 0) { $totalSteps++; }
  1009.             $totalSteps++; // order removed
  1010.             $emit('progress', ['message' => $trans->trans('Dependencies checked'), 'severity' => 'success''key' => 'check''total' => $totalSteps]);
  1011.             // Mevcut cascade silme servisi (expenses · billings · invoices · stock · worktime JSON) — tek transaction.
  1012.             $result $this->cascadeDelete($requiresPassword$password);
  1013.             if ($this->getException()) {
  1014.                 $emit('custom_error', ['message' => $this->getException()->getMessage(), 'severity' => 'error']);
  1015.                 return;
  1016.             }
  1017.             if ($expCnt 0) {
  1018.                 $emit('progress', ['message' => $nr ' — ' $trans->trans('expenses removed') . ' (' $expCnt ')''severity' => 'success''key' => 'expenses''count' => $expCnt]);
  1019.             }
  1020.             if ($billCnt 0) {
  1021.                 $emit('progress', ['message' => $nr ' — ' $trans->trans('billings removed') . ' (' $billCnt ')''severity' => 'success''key' => 'billings''count' => $billCnt]);
  1022.             }
  1023.             if ($invCnt 0) {
  1024.                 $emit('progress', ['message' => $nr ' — ' $trans->trans('invoices removed') . ' (' $invCnt ')''severity' => 'success''key' => 'invoices''count' => $invCnt]);
  1025.             }
  1026.             if ($stockCnt 0) {
  1027.                 $emit('progress', ['message' => $nr ' — ' $trans->trans('stock removed') . ' (' $stockCnt ')''severity' => 'success''key' => 'stock''count' => $stockCnt]);
  1028.             }
  1029.             if ($worktimeHours 0) {
  1030.                 $emit('progress', ['message' => $nr ' — ' $trans->trans('worktime cleared'), 'severity' => 'success''key' => 'worktime']);
  1031.             }
  1032.             $emit('progress', ['message' => $nr ' — ' $trans->trans('order removed'), 'severity' => 'success''key' => 'order''order_id' => isset($result['id']) ? $result['id'] : null]);
  1033.             $emit('end', ['severity' => 'success''message' => $trans->trans('Order and all dependencies deleted'), 'source_project' => isset($result['source_project']) ? $result['source_project'] : null]);
  1034.         });
  1035.         $response->headers->set('Content-Type''text/event-stream');
  1036.         $response->headers->set('Cache-Control''no-cache');
  1037.         $response->headers->set('X-Accel-Buffering''no');
  1038.         $response->headers->set('Connection''keep-alive');
  1039.         return $response;
  1040.     }
  1041.     /**
  1042.      * Order'in butun bagimliliklarini (expense, stok, billing, fatura poz., task, timesheet JSON ...)
  1043.      * hedef order'a tasir. Iki order ayni projeye ait olmali.
  1044.      *
  1045.      * @throws \ReflectionException
  1046.      */
  1047.     public function moveAllDependenciesTo(?ProjectOrders $to): array
  1048.     {
  1049.         $this->entity $this->entityHydratorValidator->assertHydratedEntity(ProjectOrders::class, $this->entity);
  1050.         /** @var ProjectOrders $from */
  1051.         $from $this->entity;
  1052.         if (!$to) {
  1053.             $this->setCreateException(HttpStatusInterface::NOT_FOUND$this->translator->trans('Target order not found'));
  1054.             return [];
  1055.         }
  1056.         if ($from->getId() === $to->getId()) {
  1057.             $this->setCreateException(HttpStatusInterface::NOT_ACCEPTABLE$this->translator->trans('Source and target order must be different'));
  1058.             return [];
  1059.         }
  1060.         // NOT: Projeler arasi tasimaya izin verildi (kullanici karari) — ayni-proje guard'i kaldirildi.
  1061.         // Bagimliliklar (expense/stok/timesheet JSON dahil) hedef order'a, dolayisiyla hedefin projesine gecer.
  1062.         $em $this->manager;
  1063.         try {
  1064.             return $em->wrapInTransaction(function () use ($from$to$em) {
  1065.                 // 1) Timesheet JSON tasima
  1066.                 $affectedTimesheetColumns $this->timesheetMutator->moveOrderInTimesheets($from$to);
  1067.                 // 2) FK'li bagimlilari hedef order'a yeniden bagla
  1068.                 foreach ($from->getWorkerTimesheetPayments()->toArray() as $payment) {
  1069.                     $payment->setProjectOrder($to);
  1070.                     $em->persist($payment);
  1071.                 }
  1072.                 foreach ($from->getProjectOrderExtraCosts()->toArray() as $extraCost) {
  1073.                     $extraCost->setProjectOrder($to);
  1074.                     $em->persist($extraCost);
  1075.                 }
  1076.                 foreach ($from->getProjectOrderExpensePositions()->toArray() as $position) {
  1077.                     $position->setProjectOrder($to);
  1078.                     $em->persist($position);
  1079.                 }
  1080.                 foreach ($from->getStockTransactions()->toArray() as $stockTransaction) {
  1081.                     $stockTransaction->setProjectOrder($to);
  1082.                     $em->persist($stockTransaction);
  1083.                 }
  1084.                 foreach ($from->getStockIssues()->toArray() as $stockIssue) {
  1085.                     $stockIssue->setProjectOrder($to);
  1086.                     $em->persist($stockIssue);
  1087.                 }
  1088.                 foreach ($from->getProjectOrderTasks()->toArray() as $task) {
  1089.                     $task->setProjectOrder($to);
  1090.                     $em->persist($task);
  1091.                 }
  1092.                 foreach ($from->getOrderBillings()->toArray() as $billing) {
  1093.                     $billing->setProjectOrder($to);
  1094.                     $em->persist($billing);
  1095.                 }
  1096.                 foreach ($from->getProjectOrderFeedback()->toArray() as $feedback) {
  1097.                     $feedback->setProjectOrder($to);
  1098.                     $em->persist($feedback);
  1099.                 }
  1100.                 foreach ($from->getProjectOrderUndertakings()->toArray() as $undertaking) {
  1101.                     $undertaking->setProjectOrder($to);
  1102.                     $em->persist($undertaking);
  1103.                 }
  1104.                 foreach ($from->getProjectOrderInvoices()->toArray() as $invoice) {
  1105.                     $invoice->setProjectOrder($to);
  1106.                     $em->persist($invoice);
  1107.                 }
  1108.                 foreach ($from->getInvoicePositions()->toArray() as $invoicePosition) {
  1109.                     $invoicePosition->setProjectOrder($to);
  1110.                     $em->persist($invoicePosition);
  1111.                 }
  1112.                 $em->flush();
  1113.                 // Reassign sonrasi $from/$to'nun in-memory koleksiyonlari STALE kalir:
  1114.                 // pozisyonlarin owning-side'i ($position->project_order) degisti ama
  1115.                 // $from->getProjectOrderExpensePositions() hala eski elemanlari tutar.
  1116.                 // Metrik calculator bu koleksiyonu okudugu icin DB'den tazelemezsek
  1117.                 // $from metrigi (ve dolayisiyla projesi) stale kalir.
  1118.                 $em->refresh($from);
  1119.                 $em->refresh($to);
  1120.                 // 3) Metrikleri her iki order icin yeniden hesapla
  1121.                 $this->metricService->orderMetrics($from)->syncAll();
  1122.                 $this->metricService->orderMetrics($to)->syncAll();
  1123.                 // Proje rollup metriklerini de yeniden hesapla. Cross-project move'da
  1124.                 // kaynak proje (eksilir) ve hedef proje (artar) toplamlari degisir;
  1125.                 // orderMetrics->syncAll() sadece order seviyesine dokundugu icin proje
  1126.                 // seviyesi stale kalirdi. Ayni projeyse id dedup ile tek kez sync edilir.
  1127.                 $affectedProjects = [];
  1128.                 foreach ([$from->getProject(), $to->getProject()] as $project) {
  1129.                     if ($project) {
  1130.                         $affectedProjects[$project->getId()] = $project;
  1131.                     }
  1132.                 }
  1133.                 foreach ($affectedProjects as $project) {
  1134.                     $this->metricService->projectMetrics($project)->syncAll();
  1135.                 }
  1136.                 $em->persist($from);
  1137.                 $em->persist($to);
  1138.                 $em->flush();
  1139.                 return [
  1140.                     "from" => ["id" => $from->getId(), "order_nr" => $from->getOrderNr()],
  1141.                     "to" => ["id" => $to->getId(), "order_nr" => $to->getOrderNr()],
  1142.                     "affected_timesheet_columns" => $affectedTimesheetColumns
  1143.                 ];
  1144.             });
  1145.         } catch (\Exception $exception) {
  1146.             $this->setCreateException(HttpStatusInterface::BAD_REQUEST$exception->getMessage());
  1147.             return [];
  1148.         }
  1149.     }
  1150.     /**
  1151.      * Seçilen sipariş için tüm zamanlarda çalışılan saatleri ve maliyetleri döndürür.
  1152.      * Timesheet satırları repository SQL filtresi ile önceden daraltılır;
  1153.      * PHP'de yalnızca ilgili satırlar işlenir.
  1154.      *
  1155.      * @throws \ReflectionException
  1156.      * @throws \Exception
  1157.      */
  1158.     public function laborDetails(): array {
  1159.         $this->entity $this->entityHydratorValidator->assertHydratedEntity(ProjectOrders::class, $this->entity);
  1160.         /** @var ProjectOrders $order */
  1161.         $order   $this->entity;
  1162.         $orderId $order->getId();
  1163.         $orderKey = (string) $orderId// JSON decode sonrası key'ler string olur
  1164.         $filterBranchId $this->params->get('branch') !== null ? (int) $this->params->get('branch') : null;
  1165.         /** @var \App\Repository\WorkerTimesheetRepository $repo */
  1166.         $repo       $this->manager->getRepository(WorkerTimesheet::class);
  1167.         $timesheets $repo->findTimesheetsByOrder($orderId$order->getOrderNr() ?? '');
  1168.         $monthNamesShortGerman = ['Jan''Feb''Mär''Apr''Mai''Jun''Jul''Aug''Sep''Okt''Nov''Dez'];
  1169.         $collected_in_orders   = [];
  1170.         $orderName             '';
  1171.         foreach ($timesheets as $timesheet) {
  1172.             for ($m 1$m <= 12$m++) {
  1173.                 for ($d 1$d <= 31$d++) {
  1174.                     $col    "m{$m}_d{$d}";
  1175.                     $dayRaw $timesheet[$col] ?? null;
  1176.                     if (!$dayRaw) continue;
  1177.                     $day json_decode($dayRawtrue);
  1178.                     if (!is_array($day))                           continue;
  1179.                     if (($day['absenceShortKey'] ?? null) !== 'P') continue;
  1180.                     if (!isset($day['projects']))                  continue;
  1181.                     foreach ($day['projects'] as $project) {
  1182.                         if (!isset($project['roles'])) continue;
  1183.                         $costPerHour = (float) ($project['final']['cost_per_hour'] ?? 0);
  1184.                         // En yetkili (en düşük role numarası) ve bu order'a sahip role'u bul
  1185.                         $bestRoleEntry null;
  1186.                         $bestRoleLevel PHP_INT_MAX;
  1187.                         foreach ($project['roles'] as $roleEntry) {
  1188.                             if (!isset($roleEntry['orders'][$orderKey])) continue;
  1189.                             $level = (int) ($roleEntry['role'] ?? PHP_INT_MAX);
  1190.                             if ($level $bestRoleLevel) {
  1191.                                 $bestRoleLevel $level;
  1192.                                 $bestRoleEntry $roleEntry;
  1193.                             }
  1194.                         }
  1195.                         if ($bestRoleEntry === null) continue;
  1196.                         $roleEntry  $bestRoleEntry;
  1197.                         {
  1198.                             $orderValue $roleEntry['orders'][$orderKey];
  1199.                             $orderName  $orderValue['order'] ?? $orderName;
  1200.                             if (isset($orderValue['branches'])) {
  1201.                                 foreach ($orderValue['branches'] as $branchId => $branchValue) {
  1202.                                     if ($filterBranchId !== null && (int) $branchId !== $filterBranchId) continue;
  1203.                                     $time = (float) ($branchValue['time'] ?? 0);
  1204.                                     if ($time <= 0) continue;
  1205.                                     $collected_in_orders[] = [
  1206.                                         'id'            => implode('_', [$timesheet['activity_id'], $orderId$m$d$branchId]),
  1207.                                         'worked_at'     => sprintf('%d-%02d-%02d'$timesheet['year'], $m$d),
  1208.                                         'employee'      => trim($timesheet['name'] . ' ' $timesheet['surname']),
  1209.                                         'branch'        => $branchValue['branch'] ?? 'Unknown',
  1210.                                         'order'         => $orderName,
  1211.                                         'order_id'      => $orderId,
  1212.                                         'year'          => (int) $timesheet['year'],
  1213.                                         'month_name'    => $monthNamesShortGerman[$m 1],
  1214.                                         'month_numeric' => $m,
  1215.                                         'info'          => [
  1216.                                             'time'           => $time,
  1217.                                             'price_per_hour' => $costPerHour,
  1218.                                             'cost'           => round($time $costPerHour2),
  1219.                                             'branch'         => $branchValue['branch'] ?? 'Unknown',
  1220.                                         ],
  1221.                                     ];
  1222.                                 }
  1223.                             } else {
  1224.                                 // Branch atanmamış — order değerinden direkt al
  1225.                                 $time = (float) ($orderValue['time'] ?? 0);
  1226.                                 if ($time <= 0) continue;
  1227.                                 $collected_in_orders[] = [
  1228.                                     'id'            => implode('_', [$timesheet['activity_id'], $orderId$m$d]),
  1229.                                     'worked_at'     => sprintf('%d-%02d-%02d'$timesheet['year'], $m$d),
  1230.                                     'employee'      => trim($timesheet['name'] . ' ' $timesheet['surname']),
  1231.                                     'branch'        => 'Not assigned',
  1232.                                     'order'         => $orderName,
  1233.                                     'order_id'      => $orderId,
  1234.                                     'year'          => (int) $timesheet['year'],
  1235.                                     'month_name'    => $monthNamesShortGerman[$m 1],
  1236.                                     'month_numeric' => $m,
  1237.                                     'info'          => [
  1238.                                         'time'           => $time,
  1239.                                         'price_per_hour' => $costPerHour,
  1240.                                         'cost'           => round($time $costPerHour2),
  1241.                                         'branch'         => 'Not assigned',
  1242.                                     ],
  1243.                                 ];
  1244.                             }
  1245.                         }
  1246.                     }
  1247.                 }
  1248.             }
  1249.         }
  1250.         // Toplam özet
  1251.         $summaryHours array_sum(array_map(fn($r) => $r['info']['time'], $collected_in_orders));
  1252.         $summaryCost  array_sum(array_map(fn($r) => $r['info']['cost'], $collected_in_orders));
  1253.         // Proje branch'leri (frontend'deki Branch filtresi için)
  1254.         $projectBranches = [];
  1255.         foreach ($order->getProject()->getBranches() as $branch) {
  1256.             $projectBranches[] = ['id' => $branch->getId(), 'name' => $branch->getName()];
  1257.         }
  1258.         return [
  1259.             'order_name'          => $orderName,
  1260.             'collected_in_orders' => $collected_in_orders,
  1261.             'project_branches'    => $projectBranches,
  1262.             'summary_hours'       => round($summaryHours2),
  1263.             'summary_cost'        => round($summaryCost2),
  1264.         ];
  1265.     }
  1266.     public function laborDetailsPdf(string $printedBy): ?array
  1267.     {
  1268.         $laborData $this->laborDetails();
  1269.         if ($this->getException() !== null) {
  1270.             return null;
  1271.         }
  1272.         /** @var ProjectOrders $order */
  1273.         $order $this->entity;
  1274.         $orderInfo = [
  1275.             'order_nr'   => $order->getProjectOwnerOrderNr() !== null $order->getProjectOwnerOrderNr() : (string) $order->getId(),
  1276.             'order_name' => $laborData['order_name'] ?? '',
  1277.         ];
  1278.         $chartLineB64 $this->params->get('chart_line') !== null $this->params->get('chart_line') : null;
  1279.         try {
  1280.             $base64 $this->laborCostPdfService->generate(
  1281.                 $orderInfo,
  1282.                 $laborData['collected_in_orders'] ?? [],
  1283.                 $printedBy,
  1284.                 $chartLineB64
  1285.             );
  1286.         } catch (\Exception $e) {
  1287.             $this->setCreateException(500$e->getMessage());
  1288.             return null;
  1289.         }
  1290.         return [
  1291.             'pdf_base64' => $base64,
  1292.             'filename'   => preg_replace('/[^a-z0-9_]/i''_'$orderInfo['order_nr']) . '_labor_cost.pdf',
  1293.         ];
  1294.     }
  1295. }