<?php
namespace App\Controller\Api\Service;
use App\Entity\BranchDepartments;
use App\Entity\Branches;
use App\Entity\InvoiceType;
use App\Entity\ProjectOrderBillings;
use App\Entity\ProjectOrderExpensePositions;
use App\Entity\ProjectOrderMetrics;
use App\Entity\ProjectOrders;
use App\Entity\ProjectOrderTaskJobs;
use App\Entity\ProjectOrderTasks;
use App\Entity\ProjectOrderUndertakings;
use App\Entity\ProjectOwner;
use App\Entity\ProjectOwnerContactPersons;
use App\Entity\Projects;
use App\Entity\WorkerActivities;
use App\Entity\WorkerTimesheet;
use App\Repository\ProjectOrdersRepository;
use App\Service\AisDate;
use App\Service\Calculators\ProjectOrderCalculator;
use App\Service\OrderLaborCostPdfService;
use App\Service\OrderDependency\OrderTimesheetMutator;
use App\Context\Metrics\MetricContextFactory;
use App\Service\FileNumberInterface;
use App\Service\SerializeService\BranchSerialize;
use App\Service\SerializeService\ProjectSerialize;
use App\Service\SerializeService\ReferenceSerialize;
use App\Service\ValidatorService\EntityHydratorValidator;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\EntityManagerInterface;
use phpDocumentor\Reflection\Types\This;
use PHPUnit\Util\Exception;
use Symfony\Component\HttpFoundation\InputBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
# use App\Service\Calculators\CalculatorService;
class ProjectOrderService extends AbstractControllerService
{
private ?Projects $project = null;
private BranchSerialize $branchSerialize;
private ?UserInterface $user;
private EntityHydratorValidator $entityHydratorValidator;
private ProjectOrderCalculator $projectOrderCalculator;
private FileNumberInterface $fileNumber;
private MetricContextFactory $metricService;
private OrderLaborCostPdfService $laborCostPdfService;
private OrderTimesheetMutator $timesheetMutator;
private string $orderForceDeletePassword;
# private CalculatorService $calculatorService;
public function __construct(
EntityManagerInterface $manager,
AisDate $aisDate,
TranslatorInterface $translator,
ProjectSerialize $projectSerialize,
ReferenceSerialize $referenceSerialize,
BranchSerialize $branchSerialize,
Security $user,
EntityHydratorValidator $entityHydratorValidator,
ProjectOrderCalculator $projectOrderCalculator,
FileNumberInterface $fileNumber,
MetricContextFactory $metricService,
Security $security,
OrderLaborCostPdfService $laborCostPdfService,
OrderTimesheetMutator $timesheetMutator,
string $orderForceDeletePassword
) {
parent::__construct($manager, $aisDate, $translator, $referenceSerialize, $security);
$this->branchSerialize = $branchSerialize;
$this->user = $user->getUser();
$this->entityHydratorValidator = $entityHydratorValidator;
$this->projectOrderCalculator = $projectOrderCalculator;
$this->fileNumber = $fileNumber;
$this->metricService = $metricService;
$this->laborCostPdfService = $laborCostPdfService;
$this->timesheetMutator = $timesheetMutator;
$this->orderForceDeletePassword = $orderForceDeletePassword;
# $this->calculatorService = $calculatorService;
}
public function setProject(Projects $project): self {
$this->project = $project;
return $this;
}
/**
* @throws \Exception
*/
public function fetchAll(): array {
if(!is_null($this->project)){
// return $this->project->getProjectOrders()->toArray();
return $this->findByProgress();
}
// return $this->manager->getRepository(ProjectOrders::class)->findAll();
/**@var $repo ProjectOrdersRepository*/
$repo = $this->manager->getRepository(ProjectOrders::class);
return $repo->fetchOrders($this->security->getUser());
}
public function findAllNotPaymentStatus(): array {
/**@var $repo ProjectOrdersRepository*/
$repo = $this->manager->getRepository(ProjectOrders::class);
return $repo->findAllNotPaymentStatus();
}
/**
* fatiralandirma hesaplanmis olanlar
*/
public function fetchCalculated(?ProjectOwner $customer): array {
/**@var $repo ProjectOrdersRepository*/
$repo = $this->manager->getRepository(ProjectOrders::class);
if($customer){
return $repo->findWithCalculated($customer);
}
return $repo->findAllWithCalculated();
}
/**
* @throws \Exception
*/
// public function findByProgress(string $type, int $from = 0, int $to = 100): array {
public function findByProgress(): array {
$filterModel = $this->entityHydratorValidator->createInputBag($this->params->get('progressFilterModel'));
$from = !empty($filterModel->get('range')->from) ? $filterModel->get('range')->from : null;
$to = !empty($filterModel->get('range')->to) ? $filterModel->get('range')->to : null;
// Symbolik
// $role = $filterModel->get('role');
/**@var $projectOrdersRepository ProjectOrdersRepository*/
$projectOrdersRepository = $this->entityManager->getRepository(ProjectOrders::class);
return $projectOrdersRepository->findOrdersByProgressStatusV3( $this->security->getUser(), $this->project, $from, $to );
}
/**
* ProjectOrder işlemini gerçekleştirir.
*
* Asıl entity (ProjectOrder) service içinde setEntity() ile set edilir.
* Yardımcı entity (Project) parametre olarak verilir.
*
* @param Projects $project Yardımcı entity.
* @return ProjectOrders|null İşlem sonucu dönen ProjectOrder entity.
* @throws \Exception
*/
public function upsert(Projects $project): ?ProjectOrders {
if($this->entity && !$this->entity instanceof ProjectOrders ){
throw new \LogicException('Entity should be ProjectOrders instance');
}
if(!$this->entity){
$this->entity = new ProjectOrders();
$this->entity->setProject($project);
$uniqueFileNumber = $this->fileNumber->createUniqueFileNumber(ProjectOrders::class, [
"criteria" => ["project" => $project],
"prefix" => [$project->getProjectId()],
"separator" => "-"
]);
$this->entity->setOrderNr($uniqueFileNumber);
$this->entity->setCreatedAt(new \DateTimeImmutable('now'));
// TODO Set Created At ekle
}
# $calculatorService = $this->calculatorService->orderCalculator($this->entity);
#dd($this->params);
// README Params will replace value automatically from number (id) to Entity Return either Input Bag or $params already is referenced
// Invoice Type has been replaced
$this->resolveRelation('invoice_type', $this->params, InvoiceType::class);
// order_primary_lead
$this->resolveRelation('order_primary_lead', $this->params, WorkerActivities::class);
// contact_person
$this->resolveRelation('contact_person', $this->params, ProjectOwnerContactPersons::class);
// order_branch
$this->resolveRelation('order_branch', $this->params, Branches::class);
// branch_department
$this->resolveRelation('branch_department', $this->params, BranchDepartments::class);
#dd($this->params);
// Validation project_owner_order_nr & project
/**
* Project (entity ) validation icin gerekli ve olmadigi icin
* InputBag e bunu ert ediyoruz
* $project aslinda bir entity array degil!
*/
$this->params->set('project', $project );
if($this->params->get('project_owner_order_nr')){
$validation = $this->entityHydratorValidator
->setEntity($this->entity)
->setParams($this->params)
->validate([
'project_owner_order_nr', 'project'
], 1,
[
"project" => "project_id"
]);
if(!is_null($validation)){
$this->setCreateException(HttpStatusInterface::SERVICE_UNAVAILABLE, $validation);
return null;
}
}
#dd($this->params->keys());
// Project given already by new Order !!!⚠️
$excludedFields = [
'order_alternate_leads',
'project_order_tasks',
'project',
'takeover',
'order_billings',
'contact_persons',
// works_plan: salt-okunur ilişki (FE'ye @ref ile gider); BindService yönetir,
// generic hydrator array/string ile setWorksPlan edemez → hidrasyona girmez.
'works_plan'
];
$nestedFields = array_diff($this->params->keys(), $excludedFields);
$this->entityHydratorValidator
->setParams($this->params)
->setEntity($this->entity)
->populateEntityFields($nestedFields);
// Alternate Leads
$this->syncOrderAlternateLeads();
// Tamamlama kuralı ihlali (today < start_at) → işlemi reddet, hiçbir şey flush etme.
try {
$this->syncOrderTasks($this->entity, $this->params);
} catch (\DomainException $domainException) {
$this->setCreateException(HttpStatusInterface::BAD_REQUEST, $domainException->getMessage());
return null;
}
// This update must after Tasks Update!!!
// While Progress with Task related!
$this->metricService->orderMetrics($this->entity)->syncProgressMetrics();
try{
$this->manager->persist($this->entity);
$this->manager->flush();
$this->manager->refresh($this->entity);
} catch (\Exception $exception){
$this->setCreateException( HttpStatusInterface::BAD_REQUEST, $exception->getMessage());
}
return $this->entity;
}
/**
* TODO Use this Never (bu kisim Mtric service üzerinden OrderCalcuator icinde yazildi!)
* @deprecated
* @see use OrderCalculator/progress instead
*/
public function syncOrderProgress_deprecated_here(ProjectOrders $projectOrder): float
{
$totalProgress = 0.0;
foreach ($projectOrder->getProjectOrderTasks() ?? [] as $task) {
// Tamamlanmış job'ların ağırlık toplamı
$completedFeedbacks = 0.0;
foreach ($task->getTaskJobs() ?? [] as $job) {
if (
!empty($job->getStartAt()) &&
!empty($job->getEndAt())
) {
$completedFeedbacks += $job->getJobWeight() ?? 0;
}
}
$taskWeight = $task->getProgressWeight() ?? 0;
// Task'in projeye gerçek katkısı
$taskContribution = ($completedFeedbacks * $taskWeight) / 100;
// JS Math.round(x * 100) / 100 eşdeğeri
$totalProgress += round($taskContribution, 2);
}
// Son rounding
$nextProgress = round($totalProgress, 2);
return $nextProgress; // örn: 67.79
}
// private function syncOrderAlternateLeads(ProjectOrders $projectOrder, $params): void
/**
* @throws \Exception
*/
# private function syncOrderAlternateLeads(ProjectOrders $projectOrder, $params): void
private function syncOrderAlternateLeads(): void
{
// Check The Entity is Expected ad Assign
$this->entity = $this->entityHydratorValidator->assertHydratedEntity(ProjectOrders::class, $this->entity);
// 🔹 1. Get Old IDs
$oldAlternateIds = $this->entity->getOrderAlternateLeads()->map(fn($p) => $p->getId())->toArray();
// 🔹 2. Get new IDs from request And Convert to Input Bag
$newAlternateIds = $this->entityHydratorValidator->createInputBag($this->params->get('order_alternate_leads'));
// Take only Id's
$newAlternateIds = !is_null($newAlternateIds) ? array_map(fn($item) => $item->id, $newAlternateIds->all()) : [];
// Define Differer
$differ = $this->syncDifferer($newAlternateIds, $oldAlternateIds);
// 🔹 Remove branches that are no longer assigned
foreach ($differ['toRemove'] as $id) {
$found = $this->entity->getOrderAlternateLeads()->filter(fn($p) => $p->getId() === $id)->first();
if ($found) {
$this->entity->removeOrderAlternateLead($found);
}
}
// 🔹 Add new branches
foreach ($differ['toAdd'] as $id) {
$found = $this->entityManager->getRepository(WorkerActivities::class)->find($id);
if ($found) {
$this->entity->addOrderAlternateLead($found);
}
}
}
/**
* @throws \Exception
*/
private function syncOrderTasks(ProjectOrders $projectOrder, $params){
$this->entity = $this->entityHydratorValidator->assertHydratedEntity(ProjectOrders::class, $this->entity);
# $tasks = $params->get('project_order_tasks');
# if(!$tasks && !$projectOrder->getId()) return;
// This JSON String converted to Input Bag (Each item is converted to stdClass)
$tasksBags = $this->entityHydratorValidator->createInputBag($params->get('project_order_tasks'));
/**
* Clone lazim
* Foreach içi: her task kendi createInputBag ile InputBag’e dönüşüyor
* Burada her task elemanı (stdClass) InputBag’e çevriliyor
* InputBag constructor’ı şöyle davranır:
* function __construct($data) {
* $this->parameters = (array) $data; // 👉 stdClass → array cast 👈
* }
* Oyuzden clone lamak lazim
*/
if(is_null($tasksBags)) return;
$originalBags = clone $tasksBags;
foreach ($tasksBags as $taskBag) {
// Each of task Convert to Input Bag fot Hydrator
// Task each props prepared for Hydrator
$taskBag = $this->entityHydratorValidator->createInputBag($taskBag);
// No validation need -> direct to populate
// 1️⃣ Task Entity
if($taskBag->get('isNew')){
$taskEntity = new ProjectOrderTasks();
} else {
/**
* Doctrine Collection için PHP 7.4’te filter dışında gerçek bir find operatörü yok.
* Criteria kullanicaz
* filter gibi tüm koleksiyonu dolaşmaz (özellikle PersistentCollection için)
* Doctrine tarafından desteklenen “find” oldugunu ögrendim
* ❌ 👉$projectOrder->getProjectOrderTasks()->filter( fn(ProjectOrderTasks $taskEntity) => $taskEntity->getId() === $task->id)->first();
*/
$criteria = Criteria::create()
->where(Criteria::expr()->eq('id', $taskBag->get('id')))
->setMaxResults(1);
$taskEntity = $projectOrder->getProjectOrderTasks()->matching($criteria)->first();
}
// Resolve pure data responsible_person to Entity !!!
$this->resolveRelation('responsible_person', $taskBag, WorkerActivities::class);
/**
* ❌❌⚠️⚠️⚠️ TASK FULFILLMENT PROCESSING FROM OUTSIDE EXCLUDE THIS
* Resolve pure data task_fulfillment data to Entity !!!
* $this->resolveRelation('task_fulfillment', $inputBag, ProjectOrderTaskFulfillment::class);
* task_fulfillment Processing extra
*/
$excludedFields = ["task_jobs", "maxValue", 'task_fulfillment', '__reorder__', 'execution_scope', 'task_job_completed_weight', 'real_days', 'details'];
$nestedFields = array_diff($taskBag->keys(), $excludedFields);
$this->entityHydratorValidator
->setEntity($taskEntity)
->setParams($taskBag)
->populateEntityFields($nestedFields);
// Sync Task Jobs (Convert string to Object via createInputBag)
$taskJobs = $this->entityHydratorValidator->createInputBag($taskBag->get('task_jobs'));
$taskEntity = $this->syncProjectOrderTaskJobs($taskEntity, $taskJobs);
if($taskEntity->getId() === 39){
#$this->manager->refresh($taskEntity);
#dd($taskEntity);
}
$projectOrder->addProjectOrderTask($taskEntity);
}
// REMOVE PROCESS
$oldTasks = $projectOrder->getProjectOrderTasks()->map( fn (ProjectOrderTasks $task ) => $task->getId() )->toArray();
$actuallyTasks = array_filter($originalBags->all(), fn($item) => gettype($item) === "array" ? !array_key_exists('isNew', $item) : $item);
$actuallyTasks = array_map(fn($item) => $item->id, $actuallyTasks);
$differer = $this->syncDifferer($actuallyTasks ?? [], $oldTasks );
// To Remove
foreach ($differer["toRemove"] as $id) {
$projectOrderTask = $this->manager->getRepository(ProjectOrderTasks::class)->find($id);
$projectOrder->removeProjectOrderTask($projectOrderTask);
}
// Update only nested metrics
$this->metricService->orderMetrics($projectOrder)->syncProgressMetrics();
$this->metricService->projectMetrics($projectOrder->getProject())->syncProgressMetrics();
}
/**
* Access from anywhere
* @param ProjectOrderTasks $taskEntity ;
* @param array|InputBag $task_jobs
* @throws \Exception
*/
/**
* Tek job için tamamlama uygunluğu (live kontrol). Kalıcı (planlı) start_at'e göre
* karar verir → checkbox/Zeitachse lokal değişiklikle baypas edilemez; sunucu kalıcı
* durumu kontrol eder. start_at FE'de min-date (Zeitachse) için döner.
*
* @return array
*/
public function checkJobCompletion(ProjectOrderTaskJobs $job): array
{
$block = $job->getCompletionBlock();
// Plan-bağlıda start otoritesi plan (getCompletionBlock ile tutarlı) → mesaj/tarih de plandan.
$planJob = $job->getSourcePlanJob();
$start = $planJob !== null ? $planJob->getStartAt() : $job->getStartAt();
$message = '';
if ($block !== null) {
$title = $job->getJobDescription() ?: $this->translator->trans('Job');
$message = $this->translator->trans('Job "%title%" has not started yet (starts %date%) and cannot be completed.', [
'%title%' => $title,
'%date%' => $start ? $start->format('d.m.Y') : ''
]);
}
return [
'allowed' => $block === null,
'start_at' => $start ? $start->format('Y-m-d') : null,
'message' => $message
];
}
public function syncProjectOrderTaskJobs (ProjectOrderTasks $taskEntity,InputBag $task_jobs): ProjectOrderTasks {
$jobsBags = $task_jobs;
$toAddEntities = [];
foreach ($jobsBags as $jobBag) {
$jobBag = $this->entityHydratorValidator->createInputBag($jobBag);
// 1️⃣ Job Entity
if($jobBag->get('isNew')){
$jobEntity = new ProjectOrderTaskJobs();
} else {
/**
* Doctrine Collection için PHP 7.4’te filter dışında gerçek bir find operatörü yok.
* Criteria kullanicaz
* filter gibi tüm koleksiyonu dolaşmaz (özellikle PersistentCollection için)
* Doctrine tarafından desteklenen “find” oldugunu ögrendim
* ❌ 👉$projectOrder->getProjectOrderTasks()->filter( fn(ProjectOrderTasks $taskEntity) => $taskEntity->getId() === $task->id)->first();
*/
$criteria = Criteria::create()
->where(Criteria::expr()->eq('id', $jobBag->get('id')))
->setMaxResults(1);
$jobEntity = $taskEntity->getTaskJobs()->matching($criteria)->first();
}
// Plan-bağlı job: SCHEDULE otoritesi PLAN. FE payload stale olabilir (plan başka bir
// yerde değiştirilmiş, order reload edilmemiş) → start_at/duration order payload'undan
// DEĞİL, source_plan_job'tan (canlı DB) alınır. Aksi halde stale start_at, tamamlama
// anında haksız "başlamadı" bloğu üretir (kullanıcı bug'ı: plan 23'e çekildi ama order
// payload 24 gönderiyordu → blok).
$sourcePlanJob = (!$jobBag->get('isNew') && $jobEntity instanceof ProjectOrderTaskJobs)
? $jobEntity->getSourcePlanJob()
: null;
// ⚠️ No RelationResolve
// source_plan_job: WorksPlan materialization mapping'i (ManyToOne) — generic hydrator
// array ile set edemez; sadece Materializer/back-sync yönetir, payload'dan hidrasyona girmez.
// locked: tamamlama kilidi server-kontrollü (plan otoritesi) → FE'den gelse de hidrasyona girmez.
$excludedFields = ["maxValue", "__reorder__", 'status', 'timeline', 'task_index', 'source_plan_job', 'locked'];
// Plan-bağlı job'da schedule alanları plandan tazelenecek → stale payload hidrasyona girmesin.
if ($sourcePlanJob !== null) {
$excludedFields[] = 'start_at';
$excludedFields[] = 'duration_days';
}
$nestedFields = array_diff($jobBag->keys(), $excludedFields);
$this->entityHydratorValidator
->setEntity($jobEntity)
->setParams($jobBag)
->populateEntityFields($nestedFields);
// Not: order job 'locked' tutmaz; tamamlanınca PLAN job'u kilitlenir (OrderEndAtBackSyncListener,
// end_at → source_plan_job.locked=1). Order, plan lock'unu getLocked() ile read-through okur.
// Plan otoritesi: schedule'ı plandan tazele (stale FE payload'unu geçersiz kıl).
if ($sourcePlanJob !== null) {
$jobEntity->setStartAt($sourcePlanJob->getStartAt());
$jobEntity->setDurationDays($sourcePlanJob->getDurationDays());
}
// 🔒 Tamamlama kuralı (hard block): başlangıç günü gelmeden (today < start_at)
// bir job tamamlanamaz. start_at cascade ile dependency'yi içerdiğinden tarih
// kontrolü yeterli. (FE canlı job-completion-check endpoint'i ile de kontrol eder.)
$start = $jobEntity->getStartAt();
$end = $jobEntity->getEndAt();
// Tarih-string karşılaştırması: start_at saat bileşeniyle gelebilir; bugün == başlangıç
// günü ise tamamlanabilir (sadece gelecekte ise bloke).
$todayStr = (new \DateTimeImmutable('today'))->format('Y-m-d');
if ($end !== null && $start !== null && $start->format('Y-m-d') > $todayStr) {
$title = $jobEntity->getJobDescription() ?: $this->translator->trans('Job');
throw new \DomainException(
$this->translator->trans('Job "%title%" has not started yet (starts %date%) and cannot be completed.', [
'%title%' => $title,
'%date%' => $start->format('d.m.Y')
])
);
}
$toAddEntities[] = $jobEntity;
}
$oldJobs = $taskEntity->getTaskJobs()->map(fn(ProjectOrderTaskJobs $job) => $job->getId())->toArray();
$actuallyJobs = array_map( fn($item) => $item->id, array_filter(
$task_jobs->all(),
fn($item) => is_array($item) ? empty($item['isNew']) : (!isset($item->isNew) || $item->isNew === false)
));
$differer = $this->syncDifferer($actuallyJobs, $oldJobs );
if($taskEntity->getId()===39){
#dd($actuallyJobs, $differer);
}
// Önce “toRemove” entity’lerini topla, sonra sil
$toRemoveEntities = [];
foreach ($differer['toRemove'] as $id) {
$criteria = Criteria::create()
->where(Criteria::expr()->eq('id', $id))
->setMaxResults(1);
$jobEntity = $taskEntity->getTaskJobs()->matching($criteria)->first();
if ($jobEntity) {
$toRemoveEntities[] = $jobEntity;
}
}
#SORUN VAR!! Add&Remove yapildiginda sorun cikyior belkide FE de sorun var!!!
// Şimdi ayrı döngü ile remove
foreach ($toRemoveEntities as $jobEntity) {
$taskEntity->removeProjectOrderTaskJob($jobEntity);
}
// To Add
foreach ($toAddEntities as $jobEntity) {
$taskEntity->addProjectOrderTaskJob($jobEntity);
}
# sleep(1);
// $this->manager->refresh($taskEntity);
return $taskEntity;
}
/**
* @deprecated
* use 2. Level (upsertVoucher) no Subscriber
* @throws \Exception
*/
public function upsertVoucherWithSubscriber(ProjectOrders $projectOrder, ?ProjectOrderUndertakings $undertaking, InputBag $params): ProjectOrderUndertakings {
#$doctrineSubscriber = "onFlush"; // prePersist|onFlush
// TODO !Important READ ME
/**
* Buradaki onFlush Secimi (Kullanimi) DoctrineSubscriber icinde onFlush Methodunu Tetikliyor
* Bu Method Insert & Update Degisiklikllerini algiliyor
*/
$doctrineSubscriber = "onFlush"; // prePersist|onFlush
if(!$undertaking){
$undertaking = new ProjectOrderUndertakings();
if($doctrineSubscriber === "prePersist"){
$undertaking->setProjectOrder($projectOrder);
}
$undertaking->setCreatedAt(new \DateTimeImmutable('now'));
}
$this->entityHydratorValidator->setEntity($undertaking)->setParams($params)->populateEntityFields([
'sent_at',
// 'completed_at', This by Incoming Budget
'position_nr', 'price'
]);
// Doctrine Subscriber onFlush
if($doctrineSubscriber === "onFlush"){
$projectOrder->addProjectOrderUndertaking($undertaking);
}
// Update Metrics Managed Over Subscriber
// $projectOrder->getProjectOrderMetrics()->setTotalUndertakings()
#dd(12);
try{
if($doctrineSubscriber === "onFlush"){
$this->manager->persist($projectOrder);
$this->manager->flush();
$this->manager->refresh($projectOrder);
}
if($doctrineSubscriber === "prePersist"){
$this->manager->persist($undertaking);
$this->manager->flush();
$this->manager->refresh($undertaking);
}
} catch (\Exception $exception){
$this->setCreateException( HttpStatusInterface::BAD_REQUEST, $exception->getMessage());
}
return $undertaking;
}
/**
* TODO Deprecated
* @deprecated
*/
public function upsertVoucher(ProjectOrders $projectOrder, ?ProjectOrderUndertakings $undertaking, InputBag $params): ?ProjectOrderUndertakings {
if(!$undertaking){
$undertaking = new ProjectOrderUndertakings();
$undertaking->setCreatedAt(new \DateTimeImmutable('now'));
}
// Bu sekilide Entity de control edilebilyor
$params->set('project_order', $projectOrder );
$validation = $this->entityHydratorValidator
->setEntity($undertaking)
->setParams($params)
->validate(['project_order','position_nr'], 1 );
#dd($validation);
if( !is_null($validation) ){
$this->setCreateException( HttpStatusInterface::SERVICE_UNAVAILABLE, $validation );
return null;
}
// Use validator fields
$this->entityHydratorValidator->setEntity($undertaking)->setParams($params)->populateEntityFields(['sent_at', 'price']);
$projectOrder->addProjectOrderUndertaking($undertaking);
$this->projectOrderCalculator->updateProjectOrderMetrics($projectOrder);
try{
$this->manager->persist($projectOrder);
$this->manager->flush();
} catch (\Exception $exception){
$this->setCreateException( HttpStatusInterface::BAD_REQUEST, $exception->getMessage());
}
#dd($undertaking);
return $undertaking;
}
/**
* TODO Deprecated
* @deprecated
* @throws \Exception
*/
public function deleteVoucher(?ProjectOrderUndertakings $undertaking): ?ProjectOrderMetrics
{
$em = $this->manager;
try {
// Transaction Remove with Update Metrics Pretty & Clean
return $em->wrapInTransaction(function () use ($undertaking, $em) {
$projectOrder = $undertaking->getProjectOrder();
$projectOrder->removeProjectOrderUndetaking($undertaking);
// $calculator = $this->projectOrderCalculator
// ->calculateOrderPayments($projectOrder);
$this->projectOrderCalculator
->updateProjectOrderMetrics($projectOrder);
// $projectOrder->getProjectOrderMetrics()->setTotalUndertakings($calculator['undertakingTotal']);
// $projectOrder->getProjectOrderMetrics()->setReceivedUndertakings($calculator['undertakingReceived']);
// $projectOrder->getProjectOrderMetrics()->setOpenUndertakings($calculator['undertakingOpen']);
$em->persist($projectOrder);
$em->flush();
return $projectOrder->getProjectOrderMetrics();
});
} catch (\Exception $exception) {
$this->setCreateException($exception->getCode(), $exception->getMessage());
return null;
}
}
/**
* @throws \ReflectionException
*/
public function syncBillings(): ?ProjectOrders {
$billings = $this->params->get('billings');
if(!$this->entity instanceof ProjectOrders){
throw new \Exception(sprintf("Parent entity should be instance of ProjectOrders, %s given", ( new \ReflectionClass($this->entity))->name ));
}
$billingBags = $this->entityHydratorValidator->createInputBag($billings);
$previousBills = $this->entity->getOrderBillings()->map(fn(ProjectOrderBillings $item) => $item->getId())->toArray();
$onlyNews = array_filter(
$billingBags->all(),
fn($item) => is_array($item) ? empty($item['isNew']) : (!isset($item->isNew) || $item->isNew === false)
);
$actuallyBills = array_map( fn($item) => is_array($item) ? $item['id'] : $item->id, $onlyNews);
#dd($actuallyBills);
$differer = $this->syncDifferer($actuallyBills, $previousBills );
$toAddEntities = $toRemoveEntities = [];
// TO REMOVE
foreach ($differer['toRemove'] as $id) {
$criteria = Criteria::create()
->where(Criteria::expr()->eq('id', $id))
->setMaxResults(1);
$billingEntity = $this->entity->getOrderBillings()->matching($criteria)->first();
if ($billingEntity) {
$this->entity->removeProjectOrderBilling($billingEntity);
}
}
foreach ($billingBags as $index => $billingBag ) {
$billingBag = $this->entityHydratorValidator->createInputBag($billingBag);
// 1️⃣ Job Entity
if($billingBag->get('isNew')){
$billingEntity = new ProjectOrderBillings();
#$billingEntity->setProjectOrder($this->entity);
$billingEntity->setCreatedAt(new \DateTimeImmutable('now'));
} else {
/**
* Doctrine Collection için PHP 7.4’te filter dışında gerçek bir find operatörü yok.
* Criteria kullanicaz
* filter gibi tüm koleksiyonu dolaşmaz (özellikle PersistentCollection için)
* Doctrine tarafından desteklenen “find” oldugunu ögrendim
* ❌ 👉$projectOrder->getProjectOrderTasks()->filter( fn(ProjectOrderTasks $taskEntity) => $taskEntity->getId() === $task->id)->first();
*/
$criteria = Criteria::create()
->where(Criteria::expr()->eq('id', $billingBag->get('id')))
->setMaxResults(1);
$billingEntity = $this->entity->getOrderBillings()->matching($criteria)->first();
}
// ⚠️ No RelationResolve
$excludedFields = ["invoice_positions", "sent_at", 'quantity', 'created_at', 'task_index', 'id', 'paid_amount'];
$nestedFields = array_diff($billingBag->keys(), $excludedFields);
$billingBag->set('project_order', $this->entity);
// Always value is 1
$billingBag->set('quantity', 1);
$validation = $this->entityHydratorValidator
->setEntity($billingEntity)
->setParams($billingBag)
// ->setRequired(['position_nr'])
->validate([
'project_order', 'position_nr'
], 1, [
"project_order" => "order_nr"
]);
if( !is_null($validation) ){
$this->setCreateException( HttpStatusInterface::SERVICE_UNAVAILABLE, $validation );
return null;
}
$billingEntity = $this->entityHydratorValidator
->setEntity($billingEntity)
->setParams($billingBag)
->populateEntityFields($nestedFields);
$this->entity->addProjectOrderBilling($billingEntity);
}
// Update only metrics affected by a new billing
$this->metricService->orderMetrics($this->entity)->syncBillingMetrics();
// Sync Project
$this->metricService->projectMetrics($this->entity->getProject())->syncBillingMetrics();
// // Or (Just target)
// $calculatorService = $this->calculatorService->orderCalculator($this->entity);
// $this->entity->getProjectOrderMetrics()
// ->setOpenBillings($calculatorService->billings()["Total"])
// ->setPaidBillings($calculatorService->billings()["Paid"])
// ->setOpenBillings($calculatorService->billings()["Open"]);
try{
$this->manager->persist($this->entity);
$this->manager->flush();
$this->manager->refresh($this->entity);
} catch (\Exception $exception){
$this->setCreateException( HttpStatusInterface::BAD_REQUEST, $exception->getMessage());
}
return $this->entity;
}
/**
* @throws \ReflectionException
*/
public function recalculateMetrics(){
if(!$this->entity instanceof ProjectOrders){
throw new Exception("Entity for syncMetrics should be instance of ProjectOrders" . (new \ReflectionClass($this->entity))->name . ' given!');
}
$this->metricService->orderMetrics($this->entity)->syncAll();
try{
$this->manager->persist($this->entity);
$this->manager->flush();
$this->manager->refresh($this->entity);
} catch (\Exception $exception){
$this->setCreateException( HttpStatusInterface::BAD_REQUEST, $exception->getMessage());
}
return $this->entity;
}
/**
* @throws \ReflectionException
* @return array{"classic": ArrayCollection<ProjectOrderExpensePositions>, "stock": ArrayCollection}
*/
public function orderExpenses(): array
{
if(!$this->entity instanceof ProjectOrders){
throw new Exception("Entity for syncMetrics should be instance of ProjectOrders" . (new \ReflectionClass($this->entity))->name . ' given!');
}
$criteria = Criteria::create()
->orderBy(['id' => Criteria::DESC]);
return [
"classic" => $this->entity->getProjectOrderExpensePositions()->matching($criteria),
"stock" => $this->entity->getStockTransactions()->matching($criteria)
];
}
/**
* Order silme/tasima oncesi kullaniciya gosterilecek deger-bazli ozet.
* Sadece toplamlar (harcama, stok cikisi, worktime saat, billing) doner.
*
* @throws \ReflectionException
*/
public function dependencySummary(): array
{
$this->entity = $this->entityHydratorValidator->assertHydratedEntity(ProjectOrders::class, $this->entity);
/** @var ProjectOrders $order */
$order = $this->entity;
// Harcama pozisyonlari -> (amount / (pe ?? 1)) * buying_price
$expenseTotal = 0.0;
$expensePositions = $order->getProjectOrderExpensePositions();
foreach ($expensePositions as $position) {
$amount = floatval($position->getAmount());
$pe = $position->getPriceUnit();
$divisor = (!is_null($pe) && floatval($pe) != 0.0) ? floatval($pe) : 1.0;
$expenseTotal += ($amount / $divisor) * floatval($position->getBuyingPrice());
}
// Stok cikislari -> quantity * buying_price
$stockTotal = 0.0;
$stockTransactions = $order->getStockTransactions();
foreach ($stockTransactions as $transaction) {
$stockTotal += floatval($transaction->getQuantity()) * floatval($transaction->getBuyingPrice());
}
// Billing -> quantity * price
$billingTotal = 0.0;
$billings = $order->getOrderBillings();
foreach ($billings as $billing) {
$billingTotal += floatval($billing->getQuantity()) * floatval($billing->getPrice());
}
// Worktime saatleri (timesheet JSON'dan)
$worktimeHours = $this->timesheetMutator->sumOrderHours($order->getId());
// Kesilmis faturaya bagli pozisyonlar -> tutar (Banner/Balken)
$invoicePositions = $order->getInvoicePositions();
$invoicedCount = $invoicePositions->count();
$invoiceTotal = 0.0;
foreach ($invoicePositions as $invoicePosition) {
$invoiceTotal += floatval($invoicePosition->getPositionAmount());
}
// Sifre gate: worktime / billing / invoice en az biri varsa silme sifre ister.
// Admin USER_ROLE dahil herkes icin ayni — bypass yok ([[reference_destructive_op_pattern]]).
$requiresPassword = $worktimeHours > 0
|| $billings->count() > 0
|| $invoicedCount > 0;
// Banner verisi: proje + owner
$project = $order->getProject();
$owner = $project ? $project->getOwner() : null;
// Tasi hedefi: SISTEMDEKI TUM order'lar (proje/ownership filtresi YOK) — projeler arasi tasimaya izinli.
// Hafif: entity hydrate etmeden id + order_nr + proje nr cekilir.
$rows = $this->manager->createQuery(
'SELECT o.id AS id, o.order_nr AS order_nr, p.project_id AS project_nr, p.name AS project_name '
. 'FROM App\Entity\ProjectOrders o JOIN o.project p ORDER BY o.id DESC'
)->getArrayResult();
$selfId = $order->getId();
$moveableOrders = [];
foreach ($rows as $r) {
if ((int) $r['id'] === $selfId) {
continue;
}
$moveableOrders[] = [
"id" => (int) $r['id'],
"order_nr" => $r['order_nr'],
"project_nr" => $r['project_nr'],
"project_name" => $r['project_name']
];
}
return [
"order" => [
"id" => $order->getId(),
"order_nr" => $order->getOrderNr(),
"project_owner_order_nr" => $order->getProjectOwnerOrderNr()
],
"project" => [
"nr" => $project ? $project->getProjectId() : null,
"name" => $project ? $project->getName() : null
],
"owner" => [
"company" => $owner ? $owner->getCompany() : null
],
"moveable_orders" => $moveableOrders,
"expense" => [
"count" => $expensePositions->count(),
"total" => round($expenseTotal, 2)
],
"stock" => [
"count" => $stockTransactions->count(),
"total" => round($stockTotal, 2)
],
"worktime" => [
"hours" => round($worktimeHours, 2)
],
"billing" => [
"count" => $billings->count(),
"total" => round($billingTotal, 2)
],
"invoice" => [
"count" => $invoicedCount,
"total" => round($invoiceTotal, 2)
],
"tasks" => [
"count" => $order->getProjectOrderTasks()->count()
],
"invoiced" => [
"count" => $invoicedCount,
"blocks_delete" => $requiresPassword
],
"delete_requires_password" => $requiresPassword,
"password_reasons" => [
"worktime" => $worktimeHours > 0,
"billing" => $billings->count() > 0,
"invoiced" => $invoicedCount > 0
]
];
}
/**
* Order'i ve butun bagimliliklarini cascade siler.
* Kesilmis bir faturaya bagli pozisyonu varsa silmez (blok) -> kullanici Tasi kullanmali.
* Stok dengesi Doctrine subscriber tarafindan ORM remove ile otomatik duzeltilir.
*
* @throws \ReflectionException
*/
public function cascadeDelete(bool $force = false, ?string $password = null): array
{
$this->entity = $this->entityHydratorValidator->assertHydratedEntity(ProjectOrders::class, $this->entity);
/** @var ProjectOrders $order */
$order = $this->entity;
$hasInvoicePositions = $order->getInvoicePositions()->count() > 0;
$hasBillings = $order->getOrderBillings()->count() > 0;
$hasWorktime = $this->timesheetMutator->sumOrderHours($order->getId()) > 0;
$requiresPassword = $hasInvoicePositions || $hasBillings || $hasWorktime;
// Guard: worktime / billing / invoiced position en az biri varsa silme sifre ister.
// Admin USER_ROLE dahil herkes icin ayni — bypass yok ([[reference_destructive_op_pattern]]).
if ($requiresPassword) {
if (!$force) {
$this->setCreateException(
HttpStatusInterface::BAD_REQUEST,
$this->translator->trans('This order has tracked time, billings or invoiced positions. Force-delete requires the override password.')
);
return [];
}
if ($this->orderForceDeletePassword === '' || !hash_equals($this->orderForceDeletePassword, (string) $password)) {
$this->setCreateException(
HttpStatusInterface::UNAUTHORIZED,
$this->translator->trans('Invalid force-delete password.')
);
return [];
}
}
$orderId = $order->getId();
$orderNr = $order->getOrderNr();
$project = $order->getProject(); // metrik resync icin order silinmeden once yakala
$em = $this->manager;
try {
return $em->wrapInTransaction(function () use ($order, $orderId, $orderNr, $em, $force, $hasInvoicePositions, $project) {
// 1) Timesheet JSON temizligi (FK degil, JSON key)
$affectedTimesheetColumns = $this->timesheetMutator->removeOrderFromTimesheets($orderId);
// 1.5) Force + faturali ise: kesilmis faturaya bagli invoice_positions'i da sil
// (order->invoice_positions FK'sinde cascade-remove yok, elle kaldirilmali)
if ($force && $hasInvoicePositions) {
foreach ($order->getInvoicePositions()->toArray() as $invoicePosition) {
$em->remove($invoicePosition);
}
}
// 2) FK'li ve cascade-remove'u olmayan bagimlilari elle sil
// (ORM remove -> subscriber'lar, ozellikle stok dengesi, tetiklenir)
foreach ($order->getWorkerTimesheetPayments()->toArray() as $payment) {
$em->remove($payment);
}
foreach ($order->getProjectOrderExtraCosts()->toArray() as $extraCost) {
$em->remove($extraCost);
}
foreach ($order->getProjectOrderExpensePositions()->toArray() as $position) {
$em->remove($position);
}
foreach ($order->getStockTransactions()->toArray() as $stockTransaction) {
$em->remove($stockTransaction);
}
foreach ($order->getStockIssues()->toArray() as $stockIssue) {
$em->remove($stockIssue);
}
foreach ($order->getProjectOrderTasks()->toArray() as $task) {
$em->remove($task);
}
foreach ($order->getOrderBillings()->toArray() as $billing) {
$em->remove($billing);
}
// 3) Order'i sil -> Feedback, ProjectOrderInvoices, Undertakings, Metrics
// cascade={"remove"} oldugundan otomatik gider.
$em->remove($order);
$em->flush();
// Order silindi -> projenin rollup metrikleri stale kalmasin. Order'in
// kendi metrikleri cascade ile gitti; proje toplamlari kalan order'lar
// uzerinden yeniden hesaplanmali (move akimiyla simetrik).
$sourceProject = null;
if ($project) {
$this->metricService->projectMetrics($project)->syncAll();
$em->flush();
// Order silindikten sonra proje order'siz kaldi mi? (FE: bos projeyi sil teklif eder)
$remainingOrders = $this->manager->getRepository(ProjectOrders::class)->count(['project' => $project->getId()]);
$sourceProject = [
"id" => $project->getId(),
"name" => $project->getName(),
"empty" => $remainingOrders === 0
];
}
return [
"id" => $orderId,
"order_nr" => $orderNr,
"affected_timesheet_columns" => $affectedTimesheetColumns,
"source_project" => $sourceProject
];
});
} catch (\Exception $exception) {
$this->setCreateException(HttpStatusInterface::BAD_REQUEST, $exception->getMessage());
return [];
}
}
/**
* Order'i ve TUM bagimliliklarini streamed (adim adim) force-delete eder. GERI DONUS YOK.
*
* Tek order: sayilar silmeden ONCE okunur, mevcut cascadeDelete(force, password) ile silinir
* (expenses/billings/invoices/stock + timesheet JSON temizligi orada, tek transaction),
* sonra her bagimlilik tipi ayri madde olarak emit edilir (FE checklist'i isaretler).
* Fatura bagliysa (blocks_delete) gecerli override sifresi zorunludur; degilse sifresiz silinir.
*/
public function forceDeleteStreamed(?string $password): StreamedResponse
{
/** @var ProjectOrders $order */
$order = $this->entity;
$trans = $this->translator;
$response = new StreamedResponse(function () use ($order, $password, $trans) {
// SSE: tampon temizle, her echo aninda gitsin
while (ob_get_level() > 0) { ob_end_flush(); }
ob_implicit_flush(true);
$emit = function (string $event, array $data) {
echo "event: {$event}\n";
echo "data: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
};
if (!$order instanceof ProjectOrders) {
$emit('custom_error', ['message' => $trans->trans('Order not found'), 'severity' => 'error']);
return;
}
// Silmeden ONCE sayilari oku (cascadeDelete sonrasi gider).
$nr = $order->getOrderNr();
$expCnt = $order->getProjectOrderExpensePositions()->count();
$billCnt = $order->getOrderBillings()->count();
$invCnt = $order->getInvoicePositions()->count();
$stockCnt = $order->getStockTransactions()->count();
$worktimeHours = $this->timesheetMutator->sumOrderHours($order->getId());
// worktime / billing / invoiced position en az biri varsa override sifresi zorunlu.
// Admin USER_ROLE dahil herkes icin ayni — bypass yok.
$requiresPassword = $invCnt > 0 || $billCnt > 0 || $worktimeHours > 0;
if ($requiresPassword) {
if ($this->orderForceDeletePassword === '' || !hash_equals($this->orderForceDeletePassword, (string) $password)) {
$emit('custom_error', ['message' => $trans->trans('Invalid force-delete password.'), 'severity' => 'error']);
return;
}
}
$totalSteps = 1; // check
if ($expCnt > 0) { $totalSteps++; }
if ($billCnt > 0) { $totalSteps++; }
if ($invCnt > 0) { $totalSteps++; }
if ($stockCnt > 0) { $totalSteps++; }
if ($worktimeHours > 0) { $totalSteps++; }
$totalSteps++; // order removed
$emit('progress', ['message' => $trans->trans('Dependencies checked'), 'severity' => 'success', 'key' => 'check', 'total' => $totalSteps]);
// Mevcut cascade silme servisi (expenses · billings · invoices · stock · worktime JSON) — tek transaction.
$result = $this->cascadeDelete($requiresPassword, $password);
if ($this->getException()) {
$emit('custom_error', ['message' => $this->getException()->getMessage(), 'severity' => 'error']);
return;
}
if ($expCnt > 0) {
$emit('progress', ['message' => $nr . ' — ' . $trans->trans('expenses removed') . ' (' . $expCnt . ')', 'severity' => 'success', 'key' => 'expenses', 'count' => $expCnt]);
}
if ($billCnt > 0) {
$emit('progress', ['message' => $nr . ' — ' . $trans->trans('billings removed') . ' (' . $billCnt . ')', 'severity' => 'success', 'key' => 'billings', 'count' => $billCnt]);
}
if ($invCnt > 0) {
$emit('progress', ['message' => $nr . ' — ' . $trans->trans('invoices removed') . ' (' . $invCnt . ')', 'severity' => 'success', 'key' => 'invoices', 'count' => $invCnt]);
}
if ($stockCnt > 0) {
$emit('progress', ['message' => $nr . ' — ' . $trans->trans('stock removed') . ' (' . $stockCnt . ')', 'severity' => 'success', 'key' => 'stock', 'count' => $stockCnt]);
}
if ($worktimeHours > 0) {
$emit('progress', ['message' => $nr . ' — ' . $trans->trans('worktime cleared'), 'severity' => 'success', 'key' => 'worktime']);
}
$emit('progress', ['message' => $nr . ' — ' . $trans->trans('order removed'), 'severity' => 'success', 'key' => 'order', 'order_id' => isset($result['id']) ? $result['id'] : null]);
$emit('end', ['severity' => 'success', 'message' => $trans->trans('Order and all dependencies deleted'), 'source_project' => isset($result['source_project']) ? $result['source_project'] : null]);
});
$response->headers->set('Content-Type', 'text/event-stream');
$response->headers->set('Cache-Control', 'no-cache');
$response->headers->set('X-Accel-Buffering', 'no');
$response->headers->set('Connection', 'keep-alive');
return $response;
}
/**
* Order'in butun bagimliliklarini (expense, stok, billing, fatura poz., task, timesheet JSON ...)
* hedef order'a tasir. Iki order ayni projeye ait olmali.
*
* @throws \ReflectionException
*/
public function moveAllDependenciesTo(?ProjectOrders $to): array
{
$this->entity = $this->entityHydratorValidator->assertHydratedEntity(ProjectOrders::class, $this->entity);
/** @var ProjectOrders $from */
$from = $this->entity;
if (!$to) {
$this->setCreateException(HttpStatusInterface::NOT_FOUND, $this->translator->trans('Target order not found'));
return [];
}
if ($from->getId() === $to->getId()) {
$this->setCreateException(HttpStatusInterface::NOT_ACCEPTABLE, $this->translator->trans('Source and target order must be different'));
return [];
}
// NOT: Projeler arasi tasimaya izin verildi (kullanici karari) — ayni-proje guard'i kaldirildi.
// Bagimliliklar (expense/stok/timesheet JSON dahil) hedef order'a, dolayisiyla hedefin projesine gecer.
$em = $this->manager;
try {
return $em->wrapInTransaction(function () use ($from, $to, $em) {
// 1) Timesheet JSON tasima
$affectedTimesheetColumns = $this->timesheetMutator->moveOrderInTimesheets($from, $to);
// 2) FK'li bagimlilari hedef order'a yeniden bagla
foreach ($from->getWorkerTimesheetPayments()->toArray() as $payment) {
$payment->setProjectOrder($to);
$em->persist($payment);
}
foreach ($from->getProjectOrderExtraCosts()->toArray() as $extraCost) {
$extraCost->setProjectOrder($to);
$em->persist($extraCost);
}
foreach ($from->getProjectOrderExpensePositions()->toArray() as $position) {
$position->setProjectOrder($to);
$em->persist($position);
}
foreach ($from->getStockTransactions()->toArray() as $stockTransaction) {
$stockTransaction->setProjectOrder($to);
$em->persist($stockTransaction);
}
foreach ($from->getStockIssues()->toArray() as $stockIssue) {
$stockIssue->setProjectOrder($to);
$em->persist($stockIssue);
}
foreach ($from->getProjectOrderTasks()->toArray() as $task) {
$task->setProjectOrder($to);
$em->persist($task);
}
foreach ($from->getOrderBillings()->toArray() as $billing) {
$billing->setProjectOrder($to);
$em->persist($billing);
}
foreach ($from->getProjectOrderFeedback()->toArray() as $feedback) {
$feedback->setProjectOrder($to);
$em->persist($feedback);
}
foreach ($from->getProjectOrderUndertakings()->toArray() as $undertaking) {
$undertaking->setProjectOrder($to);
$em->persist($undertaking);
}
foreach ($from->getProjectOrderInvoices()->toArray() as $invoice) {
$invoice->setProjectOrder($to);
$em->persist($invoice);
}
foreach ($from->getInvoicePositions()->toArray() as $invoicePosition) {
$invoicePosition->setProjectOrder($to);
$em->persist($invoicePosition);
}
$em->flush();
// Reassign sonrasi $from/$to'nun in-memory koleksiyonlari STALE kalir:
// pozisyonlarin owning-side'i ($position->project_order) degisti ama
// $from->getProjectOrderExpensePositions() hala eski elemanlari tutar.
// Metrik calculator bu koleksiyonu okudugu icin DB'den tazelemezsek
// $from metrigi (ve dolayisiyla projesi) stale kalir.
$em->refresh($from);
$em->refresh($to);
// 3) Metrikleri her iki order icin yeniden hesapla
$this->metricService->orderMetrics($from)->syncAll();
$this->metricService->orderMetrics($to)->syncAll();
// Proje rollup metriklerini de yeniden hesapla. Cross-project move'da
// kaynak proje (eksilir) ve hedef proje (artar) toplamlari degisir;
// orderMetrics->syncAll() sadece order seviyesine dokundugu icin proje
// seviyesi stale kalirdi. Ayni projeyse id dedup ile tek kez sync edilir.
$affectedProjects = [];
foreach ([$from->getProject(), $to->getProject()] as $project) {
if ($project) {
$affectedProjects[$project->getId()] = $project;
}
}
foreach ($affectedProjects as $project) {
$this->metricService->projectMetrics($project)->syncAll();
}
$em->persist($from);
$em->persist($to);
$em->flush();
return [
"from" => ["id" => $from->getId(), "order_nr" => $from->getOrderNr()],
"to" => ["id" => $to->getId(), "order_nr" => $to->getOrderNr()],
"affected_timesheet_columns" => $affectedTimesheetColumns
];
});
} catch (\Exception $exception) {
$this->setCreateException(HttpStatusInterface::BAD_REQUEST, $exception->getMessage());
return [];
}
}
/**
* Seçilen sipariş için tüm zamanlarda çalışılan saatleri ve maliyetleri döndürür.
* Timesheet satırları repository SQL filtresi ile önceden daraltılır;
* PHP'de yalnızca ilgili satırlar işlenir.
*
* @throws \ReflectionException
* @throws \Exception
*/
public function laborDetails(): array {
$this->entity = $this->entityHydratorValidator->assertHydratedEntity(ProjectOrders::class, $this->entity);
/** @var ProjectOrders $order */
$order = $this->entity;
$orderId = $order->getId();
$orderKey = (string) $orderId; // JSON decode sonrası key'ler string olur
$filterBranchId = $this->params->get('branch') !== null ? (int) $this->params->get('branch') : null;
/** @var \App\Repository\WorkerTimesheetRepository $repo */
$repo = $this->manager->getRepository(WorkerTimesheet::class);
$timesheets = $repo->findTimesheetsByOrder($orderId, $order->getOrderNr() ?? '');
$monthNamesShortGerman = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
$collected_in_orders = [];
$orderName = '';
foreach ($timesheets as $timesheet) {
for ($m = 1; $m <= 12; $m++) {
for ($d = 1; $d <= 31; $d++) {
$col = "m{$m}_d{$d}";
$dayRaw = $timesheet[$col] ?? null;
if (!$dayRaw) continue;
$day = json_decode($dayRaw, true);
if (!is_array($day)) continue;
if (($day['absenceShortKey'] ?? null) !== 'P') continue;
if (!isset($day['projects'])) continue;
foreach ($day['projects'] as $project) {
if (!isset($project['roles'])) continue;
$costPerHour = (float) ($project['final']['cost_per_hour'] ?? 0);
// En yetkili (en düşük role numarası) ve bu order'a sahip role'u bul
$bestRoleEntry = null;
$bestRoleLevel = PHP_INT_MAX;
foreach ($project['roles'] as $roleEntry) {
if (!isset($roleEntry['orders'][$orderKey])) continue;
$level = (int) ($roleEntry['role'] ?? PHP_INT_MAX);
if ($level < $bestRoleLevel) {
$bestRoleLevel = $level;
$bestRoleEntry = $roleEntry;
}
}
if ($bestRoleEntry === null) continue;
$roleEntry = $bestRoleEntry;
{
$orderValue = $roleEntry['orders'][$orderKey];
$orderName = $orderValue['order'] ?? $orderName;
if (isset($orderValue['branches'])) {
foreach ($orderValue['branches'] as $branchId => $branchValue) {
if ($filterBranchId !== null && (int) $branchId !== $filterBranchId) continue;
$time = (float) ($branchValue['time'] ?? 0);
if ($time <= 0) continue;
$collected_in_orders[] = [
'id' => implode('_', [$timesheet['activity_id'], $orderId, $m, $d, $branchId]),
'worked_at' => sprintf('%d-%02d-%02d', $timesheet['year'], $m, $d),
'employee' => trim($timesheet['name'] . ' ' . $timesheet['surname']),
'branch' => $branchValue['branch'] ?? 'Unknown',
'order' => $orderName,
'order_id' => $orderId,
'year' => (int) $timesheet['year'],
'month_name' => $monthNamesShortGerman[$m - 1],
'month_numeric' => $m,
'info' => [
'time' => $time,
'price_per_hour' => $costPerHour,
'cost' => round($time * $costPerHour, 2),
'branch' => $branchValue['branch'] ?? 'Unknown',
],
];
}
} else {
// Branch atanmamış — order değerinden direkt al
$time = (float) ($orderValue['time'] ?? 0);
if ($time <= 0) continue;
$collected_in_orders[] = [
'id' => implode('_', [$timesheet['activity_id'], $orderId, $m, $d]),
'worked_at' => sprintf('%d-%02d-%02d', $timesheet['year'], $m, $d),
'employee' => trim($timesheet['name'] . ' ' . $timesheet['surname']),
'branch' => 'Not assigned',
'order' => $orderName,
'order_id' => $orderId,
'year' => (int) $timesheet['year'],
'month_name' => $monthNamesShortGerman[$m - 1],
'month_numeric' => $m,
'info' => [
'time' => $time,
'price_per_hour' => $costPerHour,
'cost' => round($time * $costPerHour, 2),
'branch' => 'Not assigned',
],
];
}
}
}
}
}
}
// Toplam özet
$summaryHours = array_sum(array_map(fn($r) => $r['info']['time'], $collected_in_orders));
$summaryCost = array_sum(array_map(fn($r) => $r['info']['cost'], $collected_in_orders));
// Proje branch'leri (frontend'deki Branch filtresi için)
$projectBranches = [];
foreach ($order->getProject()->getBranches() as $branch) {
$projectBranches[] = ['id' => $branch->getId(), 'name' => $branch->getName()];
}
return [
'order_name' => $orderName,
'collected_in_orders' => $collected_in_orders,
'project_branches' => $projectBranches,
'summary_hours' => round($summaryHours, 2),
'summary_cost' => round($summaryCost, 2),
];
}
public function laborDetailsPdf(string $printedBy): ?array
{
$laborData = $this->laborDetails();
if ($this->getException() !== null) {
return null;
}
/** @var ProjectOrders $order */
$order = $this->entity;
$orderInfo = [
'order_nr' => $order->getProjectOwnerOrderNr() !== null ? $order->getProjectOwnerOrderNr() : (string) $order->getId(),
'order_name' => $laborData['order_name'] ?? '',
];
$chartLineB64 = $this->params->get('chart_line') !== null ? $this->params->get('chart_line') : null;
try {
$base64 = $this->laborCostPdfService->generate(
$orderInfo,
$laborData['collected_in_orders'] ?? [],
$printedBy,
$chartLineB64
);
} catch (\Exception $e) {
$this->setCreateException(500, $e->getMessage());
return null;
}
return [
'pdf_base64' => $base64,
'filename' => preg_replace('/[^a-z0-9_]/i', '_', $orderInfo['order_nr']) . '_labor_cost.pdf',
];
}
}