<?php
namespace App\Controller\Api\v3;
use App\Controller\Api\Service\TaskExecutionService;
use App\Entity\ProjectOrderTaskFulfillment;
use App\Entity\ProjectOrderTasks;
use App\Enum\ReferenceEnum;
use App\Enum\SeverityInterface;
use App\Service\SerializeService\BranchSerialize;
use App\Service\SerializeService\OrderTaskFulfillmentSerialize;
use App\Service\SerializeService\ProjectOrderTaskFulfillmentMessageSerialize;
use App\Service\SerializeService\ReferenceSerialize;
use App\Service\SerializeService\VendorSerialize;
use App\Service\StreamingService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* TaskExecution — Order Task → [Execute] → Servise devret (ERP tarafı, oturumlu
* kullanıcı). Ağ içi (X-Internal-Token) uçlar için bkz. TaskExecutionInternalController.
* Eski Fulfillment akışına (OrderTaskFulfillmentController) DOKUNULMADI.
*
* Yanıt sözleşmesi te-frontend ile 2026-08-10'da donduruldu (bkz. FE
* `Tasks/types/task.execution.types.ts` + `hooks/useTaskExecution.tsx`):
* - execution/upsert/publish yanıtlarında entity anahtarı **`execution`** (taskExecution DEĞİL)
* - `open` yanıtı execution'ın YANINDA `branches`/`vendor`/`reasons`'ı da BUNDLE eder
* (FE `needed-collections`'ı bu turda ayrıca ÇAĞIRMIYOR — open tek seferde yeterli olsun diye)
*
* @Route("/api/v3/task-execution")
*/
class TaskExecutionController extends AbstractController
{
/**
* K4 — find-or-create draft. execution + wizard'ın ihtiyaç duyduğu master-data
* (branches/vendor/reasons) TEK yanıtta döner (FE sözleşmesi).
*
* @Route("/open/{task_id}", name="api_v3_task_execution_open", methods={"POST"})
* @ParamConverter("task", options={"mapping": {"task_id": "id"}})
* @IsGranted("SECTION:order:task:u")
*/
public function open(
ProjectOrderTasks $task,
TranslatorInterface $translator,
TaskExecutionService $service,
OrderTaskFulfillmentSerialize $serialize,
BranchSerialize $branchSerialize,
VendorSerialize $vendorSerialize,
ReferenceSerialize $referenceSerialize
): Response {
try {
$execution = $service->open($task);
} catch (\DomainException $e) {
// İş kuralı reddi (ownership) — 403 KULLANMA: FE'de HttpRequest 403'ü "oturum düştü"
// sayıp window.location.reload() yapıyor (HttpRequest.tsx:189). upsert/publish ile
// tutarlı: 409 → FE snackbar gösterir, sayfa reload OLMAZ.
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_CONFLICT);
}
return $this->json([
'message' => $translator->trans('Task execution ready'),
'severity' => SeverityInterface::SUCCESS,
// serializeFullPreview — SummaryView (published/readOnly ekran) progress ring + job
// breakdown çizsin diye execution_progress (@progress) + task.task_jobs[] taşır.
'taskExecution' => $serialize->setEntity($execution)->serializeFullPreview(),
'branches' => $this->serializeBranches($service, $branchSerialize),
'vendor' => $vendorSerialize->setEntity($service->fetchActiveVendors())->serializeFull(),
'reasons' => $referenceSerialize->setEntity($service->fetchAllReasons())->serializeCustomCore(ReferenceEnum::TASK_FULFILLMENT_REASON),
// Layout B "canlı brief" eklentisi — order'a şimdiye kadar yüklenen/harcanan maliyet
// (EUR). Kaynak: project_order_metrics.spent_costs, SADECE READ. Şema/hesap DEĞİŞMEDİ.
'order_spent' => $service->getOrderSpentCost($task),
], Response::HTTP_OK);
}
/**
* @Route("/upsert/{id}", name="api_v3_task_execution_upsert", methods={"POST"})
* @ParamConverter("execution", options={"mapping": {"id": "id"}})
* @IsGranted("SECTION:order:task:u")
*/
public function upsert(
?ProjectOrderTaskFulfillment $execution,
Request $request,
TranslatorInterface $translator,
TaskExecutionService $service,
OrderTaskFulfillmentSerialize $serialize
): Response {
if (!$execution) {
return $this->json([
'message' => $translator->trans('Task execution not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
// work_documents dosyalari icin AbstractControllerService protokolu ($this->files).
$service->setFiles($request->files);
try {
$execution = $service->upsert($execution, $request->request);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_CONFLICT);
}
return $this->json([
'message' => $translator->trans('Task execution updated successfully'),
'severity' => SeverityInterface::SUCCESS,
'taskExecution' => $serialize->setEntity($execution)->serializeFull(),
], Response::HTTP_OK);
}
/**
* K7 — yayin SONRASI dokuman ekle/sil. task/order owner published bir execution'a bile
* is-plani dokumani ekleyebilir (K5 istisnasi — dokuman ilerleme degil). Yalniz work_documents
* islenir. Ownership ihlali → 409 (403 KULLANMA, FE reload eder).
*
* @Route("/documents/{id}", name="api_v3_task_execution_documents", methods={"POST"})
* @ParamConverter("execution", options={"mapping": {"id": "id"}})
* @IsGranted("SECTION:order:task:u")
*/
public function uploadDocuments(
?ProjectOrderTaskFulfillment $execution,
Request $request,
TranslatorInterface $translator,
TaskExecutionService $service,
OrderTaskFulfillmentSerialize $serialize
): Response {
if (!$execution) {
return $this->json([
'message' => $translator->trans('Task execution not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
$service->setFiles($request->files);
try {
$execution = $service->syncExecutionDocuments($execution, $request->request);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_CONFLICT);
}
return $this->json([
'message' => $translator->trans('Documents updated successfully'),
'severity' => SeverityInterface::SUCCESS,
// K7 dokuman degisimi published/readOnly ekranda olur — progress ring + task_jobs
// aci kalmasin diye open ile ayni serializeFullPreview kullanilir.
'taskExecution' => $serialize->setEntity($execution)->serializeFullPreview(),
], Response::HTTP_OK);
}
/**
* K5 — yayınla. K3/K4 zorunlu alanları eksikse 422, zaten yayınlıysa 409.
*
* @Route("/publish/{id}", name="api_v3_task_execution_publish", methods={"POST"})
* @ParamConverter("execution", options={"mapping": {"id": "id"}})
* @IsGranted("SECTION:order:task:u")
*/
public function publish(
?ProjectOrderTaskFulfillment $execution,
TranslatorInterface $translator,
TaskExecutionService $service,
OrderTaskFulfillmentSerialize $serialize
): Response {
if (!$execution) {
return $this->json([
'message' => $translator->trans('Task execution not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
try {
$execution = $service->publish($execution);
} catch (\DomainException $e) {
$alreadyPublished = strpos($e->getMessage(), 'already published') !== false;
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], $alreadyPublished ? Response::HTTP_CONFLICT : Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->json([
'message' => $translator->trans('Task execution published successfully'),
'severity' => SeverityInterface::SUCCESS,
'taskExecution' => $serialize->setEntity($execution)->serializeFull(),
], Response::HTTP_OK);
}
/**
* K9 — GERİ ÇEK (rollback) + TAM SIFIRLA. PENDING/DECLINED (yayınlanmış ama KABUL EDİLMEMİŞ)
* execution'ı kayıt + fiziksel dosyalarıyla TAMAMEN siler → "hiç execution olmamış" hale
* (taslağa döndürme DEĞİL). ACCEPTED ise 409. FE bu ucu bir confirmation dialog'unun arkasına koyar.
*
* @Route("/rollback/{id}", name="api_v3_task_execution_rollback", methods={"POST"})
* @ParamConverter("execution", options={"mapping": {"id": "id"}})
* @IsGranted("SECTION:order:task:u")
*/
public function rollback(
?ProjectOrderTaskFulfillment $execution,
TranslatorInterface $translator,
TaskExecutionService $service
): Response {
if (!$execution) {
return $this->json([
'message' => $translator->trans('Task execution not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
try {
$service->rollbackAndReset($execution);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_CONFLICT);
}
return $this->json([
'message' => $translator->trans('Task execution rolled back'),
'severity' => SeverityInterface::SUCCESS,
], Response::HTTP_OK);
}
/**
* Chat-K1/K2 (karar 59) — OWNER mesaj LİSTESİ + iki taraflı unread sayaç. Mesaj İÇERİĞİ NS'ten
* GEÇMEZ (Chat-K1) — bu uç kimlik-doğrulamalı (session) tek kaynaktır. `serializeFullWithJobs`/
* `serializeFullPreview` payload'ına DOKUNULMADI, mesajlar execution objesine ENJEKTE EDİLMEZ.
*
* @Route("/message/list/{execution_id}", name="api_v3_task_execution_message_list", methods={"POST"})
* @ParamConverter("execution", options={"mapping": {"execution_id": "id"}})
* @IsGranted("SECTION:order:task:r")
*/
public function messageList(
?ProjectOrderTaskFulfillment $execution,
TranslatorInterface $translator,
TaskExecutionService $service
): Response {
if (!$execution) {
return $this->json([
'message' => $translator->trans('Task execution not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
try {
$payload = $service->listMessages($execution);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_CONFLICT);
}
return $this->json($payload, Response::HTTP_OK);
}
/**
* Chat-K1 — OWNER mesaj GÖNDER. Body: `message` (ZORUNLU, boş → 422). sender_side=owner,
* sender_name = currentActor() (WorkerActivities→Worker) full_name. NS EMIT BURADA YAPILMAZ —
* FE emit eder (Chat-K1: içerik NS'ten geçmez, yalnız sinyal).
*
* @Route("/message/send/{execution_id}", name="api_v3_task_execution_message_send", methods={"POST"})
* @ParamConverter("execution", options={"mapping": {"execution_id": "id"}})
* @IsGranted("SECTION:order:task:u")
*/
public function messageSend(
?ProjectOrderTaskFulfillment $execution,
Request $request,
TranslatorInterface $translator,
TaskExecutionService $service,
ProjectOrderTaskFulfillmentMessageSerialize $messageSerialize
): Response {
if (!$execution) {
return $this->json([
'message' => $translator->trans('Task execution not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
try {
$message = $service->sendMessage($execution, $request->request->get('message'));
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->json([
'message' => $translator->trans('Message sent'),
'severity' => SeverityInterface::SUCCESS,
'chat_message' => $messageSerialize->setEntity($message)->serializeCore(),
], Response::HTTP_OK);
}
/**
* Chat-K2 — OWNER "okundu" işaretle. owner_last_read_at = now.
*
* @Route("/message/read/{execution_id}", name="api_v3_task_execution_message_read", methods={"POST"})
* @ParamConverter("execution", options={"mapping": {"execution_id": "id"}})
* @IsGranted("SECTION:order:task:u")
*/
public function messageRead(
?ProjectOrderTaskFulfillment $execution,
TranslatorInterface $translator,
TaskExecutionService $service
): Response {
if (!$execution) {
return $this->json([
'message' => $translator->trans('Task execution not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
try {
$service->markMessagesRead($execution);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_CONFLICT);
}
return $this->json([
'message' => $translator->trans('Messages marked as read'),
'severity' => SeverityInterface::SUCCESS,
], Response::HTTP_OK);
}
/**
* App-level GLOBAL mesaj bildirimi — oturumdaki order lead'in TÜM YAYINLANMIŞ execution'larını
* NS oda anahtarlarıyla (`scope` + `contact_person_id` → `te:<scope>:<contactPersonId>`, T58)
* döner. FE bu listeyle TÜM odalara abone olur, `task_execution_message` sinyalinde snackbar
* gösterir ("{task_title} için yeni mesaj"). Owner filtresi Service'te (assertOwnership'in LİSTE
* hali) — FE'de gizlemek YETMEZ.
*
* @Route("/owner/executions", name="api_v3_task_execution_owner_executions", methods={"POST"})
* @IsGranted("SECTION:order:task:r")
*/
public function ownerExecutions(TaskExecutionService $service): Response
{
return $this->json($service->ownerExecutionRooms(), Response::HTTP_OK);
}
/**
* "Liste Click Preview" — ERP owner iş planı dokümanını ÖNİZLER (inline stream). Session-auth;
* ownership Service::resolveOwnedWorkDocument'te (order lead) — başka order'ın dokümanı sızmaz.
* Okuma yetkisi (task:r) yeter; salt görüntüleme.
*
* @Route("/work-document/{id}", name="api_v3_task_execution_work_document", methods={"GET"})
* @IsGranted("SECTION:order:task:r")
*/
public function workDocument(
int $id,
TaskExecutionService $service,
TranslatorInterface $translator
): Response {
try {
$resolved = $service->resolveOwnedWorkDocument($id);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$response = new BinaryFileResponse($resolved['path']);
$response->headers->set('Content-Type', $resolved['mime']);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $resolved['name']);
return $response;
}
/**
* Wizard'in ihtiyaç duyduğu 3 koleksiyon ayrı ayrı (streamed). `open` bunları zaten
* bundle ettiği için FE bu turda bu ucu ÇAĞIRMIYOR — sözleşmede kayıtlı dursun diye var
* (ör. dialog dışı bir ekran ileride ihtiyaç duyarsa).
*
* @Route("/needed-collections", name="api_v3_task_execution_needed_collections")
* @IsGranted("SECTION:order:task:u")
*/
public function neededCollections(
TaskExecutionService $service,
TranslatorInterface $translator,
BranchSerialize $branchSerialize,
VendorSerialize $vendorSerialize,
ReferenceSerialize $referenceSerialize
): StreamedResponse {
$streamingService = new StreamingService([
'localText' => ['completed' => $translator->trans('Completed')],
]);
$streamingService->appendStream(
'branches',
'Branches loaded',
function () use ($service, $branchSerialize) {
return $this->serializeBranches($service, $branchSerialize);
}
);
$streamingService->appendStream(
'vendors',
'Active vendors loaded',
function () use ($service, $vendorSerialize) {
return $vendorSerialize->setEntity($service->fetchActiveVendors())->serializeFull();
}
);
$streamingService->appendStream(
'reasons',
'Reasons loaded',
function () use ($service, $referenceSerialize) {
return $referenceSerialize->setEntity($service->fetchAllReasons())->serializeCustomCore(ReferenceEnum::TASK_FULFILLMENT_REASON);
}
);
// Layout B "canlı brief" eklentisi — kişi başına AKTİF execution sayısı ("N aktif iş").
// {branch_contact:{[workerActivityId]:n}, vendor_contact:{[vendorContactPersonId]:n}}
$streamingService->appendStream(
'active_task_counts',
'Active task counts loaded',
function () use ($service) {
return $service->fetchActiveTaskCounts();
}
);
return $streamingService->flush();
}
private function serializeBranches(TaskExecutionService $service, BranchSerialize $branchSerialize): ?array
{
return $branchSerialize->setEntity($service->fetchAllBranches())->setGroups([
'branch@core',
'branch@responsible',
'worker_activity@core',
'worker_activity@worker',
'worker@core',
])->render();
}
}