<?php
namespace App\Controller\Api\v3;
use App\Controller\Api\Service\MaterialRequestService;
use App\Entity\MaterialRequest;
use App\Entity\MaterialRequestDocument;
use App\Entity\ProjectOrderTaskFulfillment;
use App\Enum\SeverityInterface;
use App\Service\SerializeService\ItemGroupsSerialize;
use App\Service\SerializeService\MaterialRequestDocumentSerialize;
use App\Service\SerializeService\MaterialRequestSerialize;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* MaterialRequest — in-house (execution_scope=internal) task execution'inda responsible
* person'in actigi malzeme siparis talebi. PDF'ler sistem tarafindan PARSE EDILMEZ;
* Expenses sorumlusu her PDF'i gozle inceleyip PDF SEVIYESINDE karar verir.
*
* Yanit sozlesmesi (FE icin):
* - upsert/submit/documentUpload/documentDelete -> "materialRequest" (tam nesne, documents dahil)
* - documentUpload ayrica "document" (yeni eklenen PDF, tek basina) doner
* - review -> "document" (karar verilen PDF) + "materialRequest" (turetilmis header durumu)
* - listByExecution -> "materialRequests" (dizi)
* - neededCollections -> duz JSON "item_groups" (tek dataset — StreamedResponse DEGIL)
*
* Payload alan adlari raw entity property adiyla birebir (alias yasak):
* upsert: task_execution (id) · documentUpload: name, item_group (id), file · review: status, review_note
*
* 🪤 ASAGIDAKI IKI SATIRDA "@" YOK, BILEREK. Doctrine'in anotasyon okuyucusu
* docblock'taki HER "@Ad" dizisini GERCEK ANOTASYON sanar. Burada anlatim amacli
* yazilmis iki IsGranted vardi; parantezsiz olduklari icin SINIF SEVIYESINDE
* bos attribute listesiyle iki anotasyon uretiyorlardi ve bu
* controller'daki HER UC 403 donuyordu — voter'a bile ulasilmadan.
* (Gercek vaka 2026-08-20: gelen kutusu ucu ilk kez cagrildiginda ortaya cikti;
* bu controller daha once hic calistirilmamisti, o yuzden fark edilmemisti.)
* Anotasyonu metin icinde anmak gerekirse basina "@" KONMAZ.
*
* Requester tarafi (upsert/submit/document.*): IsGranted SECTION:order:task:u + BE'de
* gercek atama kontrolu (execution.branch_contact_person === oturumdaki worker).
* Reviewer tarafi (review): IsGranted MODULE:expense:u (mevcut permission deseni,
* ModulesAccessMap'te YENI key ACILMADI).
*
* @Route("/api/v3/material-request")
*/
class MaterialRequestController extends AbstractController
{
/**
* id null -> yeni draft talep (body: task_execution). id dolu -> mevcut taslak.
*
* @Route("/upsert/{id}", name="api_v3_material_request_upsert", methods={"POST"}, defaults={"id": null})
* @ParamConverter("materialRequest", options={"mapping": {"id": "id"}})
* @IsGranted("SECTION:order:task:u")
*/
public function upsert(
?MaterialRequest $materialRequest,
Request $request,
TranslatorInterface $translator,
MaterialRequestService $service,
MaterialRequestSerialize $serialize
): Response {
try {
$materialRequest = $service->setParams($request->request)->upsert($materialRequest);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_CONFLICT);
}
return $this->json([
'message' => $translator->trans('Material request saved successfully'),
'severity' => SeverityInterface::SUCCESS,
'materialRequest' => $serialize->setEntity($materialRequest)->serializeFull(),
], Response::HTTP_OK);
}
/**
* @Route("/document/upload/{request_id}", name="api_v3_material_request_document_upload", methods={"POST"})
* @ParamConverter("materialRequest", options={"mapping": {"request_id": "id"}})
* @IsGranted("SECTION:order:task:u")
*/
public function documentUpload(
?MaterialRequest $materialRequest,
Request $request,
TranslatorInterface $translator,
MaterialRequestService $service,
MaterialRequestSerialize $serialize,
MaterialRequestDocumentSerialize $documentSerialize
): Response {
if (!$materialRequest) {
return $this->json([
'message' => $translator->trans('Material request not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
try {
$document = $service
->setParams($request->request)
->setFiles($request->files)
->uploadDocument($materialRequest);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->json([
'message' => $translator->trans('Document uploaded successfully'),
'severity' => SeverityInterface::SUCCESS,
'document' => $documentSerialize->setEntity($document)->serializeFull(),
'materialRequest' => $serialize->setEntity($document->getMaterialRequest())->serializeFull(),
], Response::HTTP_OK);
}
/**
* @Route("/document/delete/{document_id}", name="api_v3_material_request_document_delete", methods={"POST"})
* @ParamConverter("document", options={"mapping": {"document_id": "id"}})
* @IsGranted("SECTION:order:task:u")
*/
public function documentDelete(
?MaterialRequestDocument $document,
TranslatorInterface $translator,
MaterialRequestService $service,
MaterialRequestSerialize $serialize
): Response {
if (!$document) {
return $this->json([
'message' => $translator->trans('Document not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
try {
$materialRequest = $service->deleteDocument($document);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_CONFLICT);
}
return $this->json([
'message' => $translator->trans('Document removed successfully'),
'severity' => SeverityInterface::SUCCESS,
'materialRequest' => $serialize->setEntity($materialRequest)->serializeFull(),
], Response::HTTP_OK);
}
/**
* draft -> submitted. En az 1 dokuman sarti (Service'te zorlanir).
*
* @Route("/submit/{id}", name="api_v3_material_request_submit", methods={"POST"})
* @ParamConverter("materialRequest", options={"mapping": {"id": "id"}})
* @IsGranted("SECTION:order:task:u")
*/
public function submit(
?MaterialRequest $materialRequest,
TranslatorInterface $translator,
MaterialRequestService $service,
MaterialRequestSerialize $serialize
): Response {
if (!$materialRequest) {
return $this->json([
'message' => $translator->trans('Material request not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
try {
$materialRequest = $service->submit($materialRequest);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->json([
'message' => $translator->trans('Material request submitted successfully'),
'severity' => SeverityInterface::SUCCESS,
'materialRequest' => $serialize->setEntity($materialRequest)->serializeFull(),
], Response::HTTP_OK);
}
/**
* Karar bildirimi e-postasini talep sahibine gonderir (body: language).
*
* AYRI UC, `review`'a GOMULU DEGIL: karar BELGE basina veriliyor, bildirim ise
* KAYIT BASINA tek ozet ve kullanicinin ONAYINA bagli (dili de o seciyor).
* Ikisini birlestirmek her belgede bir mail demek olurdu.
*
* @Route("/notify-decision/{request_id}", name="api_v3_material_request_notify_decision", methods={"POST"})
* @ParamConverter("materialRequest", options={"mapping": {"request_id": "id"}})
* @IsGranted("MODULE:expense:u")
*/
public function notifyDecision(
?MaterialRequest $materialRequest,
Request $request,
TranslatorInterface $translator,
MaterialRequestService $service,
MaterialRequestSerialize $serialize
): Response {
if (!$materialRequest) {
return $this->json([
'message' => $translator->trans('Material request not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
try {
$materialRequest = $service->setParams($request->request)->notifyDecision($materialRequest);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
/* Gonderilmediyse (alici yok / ayar kapali) `last_decision_mail_at` NULL kalir;
FE bunu okuyup kullaniciya durumu soyleyebilsin diye kayit yine donuyor. */
return $this->json([
'message' => $materialRequest->getLastDecisionMailAt() !== null
? $translator->trans('Email sent successfully')
: $translator->trans('No recipient has email notifications enabled'),
'severity' => $materialRequest->getLastDecisionMailAt() !== null
? SeverityInterface::SUCCESS
: SeverityInterface::WARNING,
'materialRequest' => $serialize->setEntity($materialRequest)->serializeFull(),
], Response::HTTP_OK);
}
/**
* Expenses sorumlusunun PDF-seviyesi karari (body: status + review_note).
*
* @Route("/review/{document_id}", name="api_v3_material_request_review", methods={"POST"})
* @ParamConverter("document", options={"mapping": {"document_id": "id"}})
* @IsGranted("MODULE:expense:u")
*/
public function review(
?MaterialRequestDocument $document,
Request $request,
TranslatorInterface $translator,
MaterialRequestService $service,
MaterialRequestSerialize $serialize,
MaterialRequestDocumentSerialize $documentSerialize
): Response {
if (!$document) {
return $this->json([
'message' => $translator->trans('Document not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
try {
$document = $service->setParams($request->request)->review($document);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->json([
'message' => $translator->trans('Decision saved successfully'),
'severity' => SeverityInterface::SUCCESS,
'document' => $documentSerialize->setEntity($document)->serializeFull(),
'materialRequest' => $serialize->setEntity($document->getMaterialRequest())->serializeFull(),
], Response::HTTP_OK);
}
/**
* Responsible person'in kendi execution'ina acilan talepleri takip etmesi icin.
* Atama filtresi BE'de (Service::listByExecution -> assertRequesterOwnership).
*
* FE (te-frontend) bu ucu duz `HttpRequest(..., "GET")` ile cagiriyor (tek dataset,
* CLAUDE "tek dataset -> HttpRequest" kurali) -> `methods` KISITLANMAZ (GET calisir).
*
* @Route("/list-by-execution/{execution_id}", name="api_v3_material_request_list_by_execution")
* @ParamConverter("execution", options={"mapping": {"execution_id": "id"}})
* @IsGranted("SECTION:order:task:u")
*/
public function listByExecution(
?ProjectOrderTaskFulfillment $execution,
TranslatorInterface $translator,
MaterialRequestService $service,
MaterialRequestSerialize $serialize
): Response {
if (!$execution) {
return $this->json([
'message' => $translator->trans('Task execution not found'),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_NOT_FOUND);
}
try {
$materialRequests = $service->listByExecution($execution);
} catch (\DomainException $e) {
return $this->json([
'message' => $translator->trans($e->getMessage()),
'severity' => SeverityInterface::ERROR,
], Response::HTTP_CONFLICT);
}
return $this->json([
'message' => $translator->trans('Material requests loaded'),
'severity' => SeverityInterface::SUCCESS,
'materialRequests' => array_map(
function (MaterialRequest $mr) use ($serialize) {
return $serialize->setEntity($mr)->serializeFull();
},
$materialRequests
),
], Response::HTTP_OK);
}
/**
* INCELEYEN GELEN KUTUSU — karar bekleyen talepler (ExpenseV2 toolbar'indaki
* "Malzeme talepleri" butonu bunu okur).
*
* 🪤 BU UC OLMADAN AKIS `submitted`'DA OLUYORDU: gonderilen talep hicbir yere
* dusmuyor, tek okuma ucu "execution'a gore listele" idi — yani inceleyenin
* once HANGI execution oldugunu bilmesi gerekiyordu. Bilmesinin bir yolu yoktu.
*
* Tek dataset -> duz `HttpRequest` (streamed masterData DEGIL), `methods`
* kisitlanmiyor (GET calisir).
*
* @Route("/list-pending", name="api_v3_material_request_list_pending")
* @IsGranted("MODULE:expense:u")
*/
public function listPending(
TranslatorInterface $translator,
MaterialRequestService $service,
MaterialRequestSerialize $serialize
): Response {
$materialRequests = $service->listPending();
return $this->json([
'message' => $translator->trans('Material requests loaded'),
'severity' => SeverityInterface::SUCCESS,
'materialRequests' => array_map(
function (MaterialRequest $mr) use ($serialize) {
// Gelen kutusu task/order kunyesini de ister — bkz. serializeForInbox.
return $serialize->setEntity($mr)->serializeForInbox();
},
$materialRequests
),
], Response::HTTP_OK);
}
/**
* Wizard'in ihtiyac duydugu tek koleksiyon: item_groups (kategori secimi).
*
* ⚠️ TEK dataset -> CLAUDE.md "tek dataset -> HttpRequest (normal JSON)" kurali:
* StreamedResponse DEGIL, duz Response. FE (te-frontend) bu ucu `HttpRequest(...,
* "GET")` ile cagirip `data.item_groups` okuyor — StreamedResponse (SSE metni) FE'de
* parse edilemezdi. Rol model: TaskExecutionController::neededCollections 3 koleksiyon
* bundle ettigi icin StreamedResponse'du; burada tek koleksiyon oldugu icin farkli.
*
* @Route("/needed-collections", name="api_v3_material_request_needed_collections")
* @IsGranted("SECTION:order:task:u")
*/
public function neededCollections(
MaterialRequestService $service,
TranslatorInterface $translator,
ItemGroupsSerialize $itemGroupsSerialize
): Response {
return $this->json([
'message' => $translator->trans('Item groups loaded'),
'severity' => SeverityInterface::SUCCESS,
'item_groups' => $itemGroupsSerialize->setEntity($service->fetchAllItemGroups())->serializeFull(),
], Response::HTTP_OK);
}
}