<?php
namespace App\Entity;
use App\Repository\ProjectOrderTaskFulfillmentRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
/**
* @ORM\Entity(repositoryClass=ProjectOrderTaskFulfillmentRepository::class)
*/
class ProjectOrderTaskFulfillment
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Groups({
* "project_order_task_fulfillment@ref",
* "project_order_task_fulfillment@base",
* "project_order_task_fulfillment@core"
* })
*/
private $id;
/**
* @ORM\ManyToOne(targetEntity=Vendor::class, inversedBy="project_order_task_fulfillments")
* @ORM\JoinColumn(nullable=true, options={"comment": "Use this data if scope:External & work-natue: support&production"})
* @Groups({
* "project_order_task_fulfillment@vendor"
* })
*/
private $vendor;
/**
* @ORM\ManyToOne(targetEntity=VendorContactPersons::class, inversedBy="project_order_task_fulfillments")
* @ORM\JoinColumn(nullable=true, options={"comment": "Use this data if scope:External & work-natue: support&production"})
* @Groups({
* "project_order_task_fulfillment@contact_person"
* })
*/
private $vendor_contact_person;
/**
* @ORM\Column(type="text", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $work_content;
/**
* 2026-08-10 karar: yeni TaskExecution akisi reason sormuyor (wizard'da yok) ->
* nullable=true yapildi. Eski Fulfillment dialog'unun `reason` zorunlu tuttugu
* senaryo hala calisir (alan opsiyonel oldu, zorunlulugu FE tarafinda kaldi).
* @ORM\ManyToOne(targetEntity=TaskFulfillmentReasons::class, inversedBy="project_order_task_fulfillments")
* @ORM\JoinColumn(nullable=true)
* @Groups({
* "project_order_task_fulfillment@reason"
* })
*/
private $reason;
// "project_order_task_fulfillment@task"
/**
* @ORM\OneToOne(targetEntity=ProjectOrderTasks::class, inversedBy="task_fulfillment")
* @ORM\JoinColumn(nullable=false, onDelete="CASCADE")
* @Groups({
* "project_order_task_fulfillment@task"
* })
*/
private $task;
/**
* @ORM\Column(type="boolean", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $is_linked;
/**
* @ORM\Column(type="date_immutable", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $planned_start_at;
/**
* @ORM\Column(type="date_immutable", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $planned_end_at;
/**
* @ORM\Column(type="date_immutable", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $actual_end_at;
/**
* @ORM\Column(type="date_immutable", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $delivery_at;
/**
* @ORM\Column(type="text", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $delivery_note;
/**
* @ORM\Column(type="float", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $rating;
/**
* @ORM\Column(type="float", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $cost;
/**
* @ORM\Column(type="string", length=64, nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $contract_reference_nr;
/**
* @ORM\ManyToOne(targetEntity=ContractTypes::class, inversedBy="project_order_task_fulfillments")
* @Groups({
* "project_order_task_fulfillment@contract_type"
* })
*/
private $contract_type;
/**
* @ORM\OneToMany(targetEntity=ProjectOrderTaskFulfillmentWorkDocs::class, mappedBy="project_order_task_fulfillment", cascade={"persist"}, orphanRemoval=true)
* @Groups({
* "project_order_task_fulfillment@work_documents"
* })
*/
private $work_documents;
/**
* Gerekçeli tarih revizyon zinciri (kullanıcı kararı 2026-08-11). GÜNCEL tarih = EN SON revizyon;
* committed_* buradan türetilir. İlk satır = kabul (reason null), sonrakiler gerekçeli revizyon.
*
* @ORM\OneToMany(targetEntity=ProjectOrderTaskFulfillmentDateRevision::class, mappedBy="project_order_task_fulfillment", cascade={"persist"}, orphanRemoval=true)
* @ORM\OrderBy({"created_at" = "ASC", "id" = "ASC"})
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $date_revisions;
/**
* @ORM\Column(type="task_execution_scope", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core",
* "project_order_task_fulfillment@progress"
* })
*/
private $execution_scope;
/**
* @ORM\Column(type="task_execution_work_nature", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $execution_work_nature;
/**
* @ORM\ManyToOne(targetEntity=Branches::class, inversedBy="project_order_task_fulfillments")
* @ORM\JoinColumn(options={"comment": "Use this data if fulfillment is internal&production"})
* @Groups({
* "project_order_task_fulfillment@branch"
* })
*/
private $branch;
/**
* @ORM\ManyToOne(targetEntity=WorkerActivities::class, inversedBy="project_order_task_fulfillments")
* @ORM\JoinColumn(options={"comment": "Use this data if fulfillment is internal&production"})
* @Groups({
* "project_order_task_fulfillment@branch_contact_person"
* })
*/
private $branch_contact_person;
/**
* TaskExecution akisi durumu: draft (K4 — task icin acilan taslak, henuz servise
* verilmedi) | published (K5 — servise devredildi, bundan sonra tek yazar servistir).
* Default'un TEK kaynagi burasi (property default) — Doctrine Type sadece gecerli
* degerleri dogrular, varsayilan atamaz.
* @ORM\Column(type="task_execution_status", nullable=false, options={"default": "draft"})
* @Groups({
* "project_order_task_fulfillment@core",
* "project_order_task_fulfillment@progress"
* })
*/
private $status = 'draft';
/**
* Servise ne zaman devredildi (publish anindaki timestamp).
* @ORM\Column(type="datetime_immutable", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $published_at;
/**
* K8 — icra eden kisi (vendor_contact_person / branch_contact_person) isi KABUL ettiginde
* (servis tarafindaki "Is kabul edildi" butonu) doldurulan timestamp. Kabul = published +
* accepted_at dolu. Yeni status ENUM degeri EKLENMEDI (karar 11: zengin status reddedildi);
* accepted_at aynasi published_at gibi tutulur. Yalniz servis (ag-ici accept ucu) yazar
* (K5 — yayin sonrasi tek yazar servis). Silme kilidi bu alana bagli: dolu ise task silinemez.
* @ORM\Column(type="datetime_immutable", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core",
* "project_order_task_fulfillment@progress"
* })
*/
private $accepted_at;
/**
* K8-accept — icra eden kisi isi REDDETTIGINDE (servis "Reddet" butonu) dolan timestamp.
* Alt-durum: published + accepted_at NULL + declined_at NULL = Pending; accepted_at dolu =
* Accepted; declined_at dolu = Declined. Yalniz servis yazar (K5). Reddedilen is geri
* sahibine doner → SILINEBILIR (yalniz accepted silme kilidi tetikler, declined DEGIL).
* @ORM\Column(type="datetime_immutable", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core",
* "project_order_task_fulfillment@progress"
* })
*/
private $declined_at;
/**
* PEER BATCH (2026-08-16) — RED gerekçe KATEGORİSİ. `reason_category` ile aynı deseni
* kullanır (App\Enum\TaskExecutionReasonCategoryEnum — supply/personnel/weather/
* scope_change/other, tarih revizyonuyla AYNI enum, yeni bir tane açılmadı). Opsiyonel —
* decline BE'de zorunlu tutulmuyor (K8-decline zaten idempotent bir aksiyon). `@core`'da
* → ERP owner reddin sebebini görebilsin.
* @ORM\Column(type="task_execution_reason_category", nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $decline_reason_category;
/**
* PEER BATCH (2026-08-16) — RED gerekçesi serbest metin. `decline_reason_category`'nin
* YANINDA (kategori + serbest metin ikilisi, tarih revizyonuyla aynı desen). Yalnız
* icra eden (servis, internal decline ucu) yazar.
* @ORM\Column(type="string", length=1000, nullable=true)
* @Groups({
* "project_order_task_fulfillment@core"
* })
*/
private $decline_reason;
/**
* Task-bazlı owner⟷executor mesajlaşma zinciri (karar 59, Chat-K1/Chat-K2). created_at ile
* sıralı. ⚠️ Bilerek Groups anotasyonu YOK — mesaj İÇERİĞİ bu payload'a (serializeFull/
* serializeFullWithJobs/serializeFullPreview) ASLA karışmaz; ayrı uçlardan (message/list,
* internal/message/list, internal/messages/recent) gelir (Chat-K1).
* @ORM\OneToMany(targetEntity=ProjectOrderTaskFulfillmentMessage::class, mappedBy="execution", cascade={"persist"}, orphanRemoval=true)
* @ORM\OrderBy({"created_at" = "ASC", "id" = "ASC"})
*/
private $messages;
/**
* Chat-K2 (karar 59b) — owner tarafı mesajları EN SON ne zaman okudu. Geriye dönük
* üretilemez, bu yüzden BAŞTAN eklendi. 🪤 Bilerek `@core`'a KONMADI — executor bu alanı
* (owner'ın okuma izini) GÖRMEMELİ. Ayrı grup (`@read_state`); yalnız message/list +
* internal/message/list + internal/messages/recent servis-katmanı hesaplarında okunur.
* @ORM\Column(type="datetime_immutable", nullable=true)
* @Groups({
* "project_order_task_fulfillment@read_state"
* })
*/
private $owner_last_read_at;
/**
* Chat-K2 (karar 59b) — executor tarafı mesajları EN SON ne zaman okudu. Simetrik neden:
* owner bu alanı (executor'ın okuma izini) GÖRMEMELİ. Bkz. $owner_last_read_at.
* @ORM\Column(type="datetime_immutable", nullable=true)
* @Groups({
* "project_order_task_fulfillment@read_state"
* })
*/
private $executor_last_read_at;
public function __construct()
{
$this->work_documents = new ArrayCollection();
$this->date_revisions = new ArrayCollection();
$this->messages = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getVendor(): ?Vendor
{
return $this->vendor;
}
public function setVendor(?Vendor $vendor): self
{
$this->vendor = $vendor;
return $this;
}
public function getVendorContactPerson(): ?VendorContactPersons
{
return $this->vendor_contact_person;
}
public function setVendorContactPerson(?VendorContactPersons $vendor_contact_person): self
{
$this->vendor_contact_person = $vendor_contact_person;
return $this;
}
public function getWorkContent(): ?string
{
return $this->work_content;
}
public function setWorkContent(?string $work_content): self
{
$this->work_content = $work_content;
return $this;
}
public function getReason(): ?TaskFulfillmentReasons
{
return $this->reason;
}
public function setReason(?TaskFulfillmentReasons $reason): self
{
$this->reason = $reason;
return $this;
}
public function getTask(): ?ProjectOrderTasks
{
return $this->task;
}
public function setTask(ProjectOrderTasks $task): self
{
$this->task = $task;
return $this;
}
/**
* Order listesi çipi için execution ilerlemesi (% 0-100). Task'ın job'larından türer:
* Σ(biten job_weight) / Σ(job_weight) × 100 — biten = start_at VE end_at dolu (OrderCalculator
* ile aynı "done" tanımı). SANAL: DB kolonu DEĞİL, serialize anında hesaplanır. Yalnız
* `@progress` grubu (order listesi) ile döner → `@core` payload'ı şişmez, per-execution çip verisi.
*
* @Groups({
* "project_order_task_fulfillment@progress"
* })
* @SerializedName("execution_progress")
*/
public function getExecutionProgress(): ?float
{
$task = $this->task;
if ($task === null) {
return null;
}
$totalWeight = 0.0;
$doneWeight = 0.0;
foreach ($task->getTaskJobs() as $job) {
$weight = (float) $job->getJobWeight();
$totalWeight += $weight;
if ($job->getStartAt() !== null && $job->getEndAt() !== null) {
$doneWeight += $weight;
}
}
if ($totalWeight <= 0.0) {
return 0.0;
}
return round(($doneWeight / $totalWeight) * 100, 2);
}
public function isIsLinked(): ?bool
{
return $this->is_linked;
}
public function setIsLinked(?bool $is_linked): self
{
$this->is_linked = $is_linked;
return $this;
}
public function getPlannedStartAt(): ?\DateTimeImmutable
{
return $this->planned_start_at;
}
public function setPlannedStartAt(?\DateTimeImmutable $planned_start_at): self
{
$this->planned_start_at = $planned_start_at;
return $this;
}
public function getPlannedEndAt(): ?\DateTimeImmutable
{
return $this->planned_end_at;
}
public function setPlannedEndAt(?\DateTimeImmutable $planned_end_at): self
{
$this->planned_end_at = $planned_end_at;
return $this;
}
public function getActualEndAt(): ?\DateTimeImmutable
{
return $this->actual_end_at;
}
public function setActualEndAt(?\DateTimeImmutable $actual_end_at): self
{
$this->actual_end_at = $actual_end_at;
return $this;
}
public function getDeliveryAt(): ?\DateTimeImmutable
{
return $this->delivery_at;
}
public function setDeliveryAt(?\DateTimeImmutable $delivery_at): self
{
$this->delivery_at = $delivery_at;
return $this;
}
public function getDeliveryNote(): ?string
{
return $this->delivery_note;
}
public function setDeliveryNote(?string $delivery_note): self
{
$this->delivery_note = $delivery_note;
return $this;
}
public function getRating(): ?float
{
return $this->rating;
}
public function setRating(?float $rating): self
{
$this->rating = $rating;
return $this;
}
public function getCost(): ?float
{
return $this->cost;
}
public function setCost(?float $cost): self
{
$this->cost = $cost;
return $this;
}
public function getContractReferenceNr(): ?string
{
return $this->contract_reference_nr;
}
public function setContractReferenceNr(?string $contract_reference_nr): self
{
$this->contract_reference_nr = $contract_reference_nr;
return $this;
}
public function getContractType(): ?ContractTypes
{
return $this->contract_type;
}
public function setContractType(?ContractTypes $contract_type): self
{
$this->contract_type = $contract_type;
return $this;
}
/**
* @return Collection<int, ProjectOrderTaskFulfillmentWorkDocs>
*/
public function getWorkDocuments(): Collection
{
return $this->work_documents;
}
public function addProjectOrderTaskFulfillmentWorkDoc(ProjectOrderTaskFulfillmentWorkDocs $projectOrderTaskFulfillmentWorkDocs): self
{
if (!$this->work_documents->contains($projectOrderTaskFulfillmentWorkDocs)) {
$this->work_documents[] = $projectOrderTaskFulfillmentWorkDocs;
$projectOrderTaskFulfillmentWorkDocs->setProjectOrderTaskFulfillment($this);
}
return $this;
}
public function removeProjectOrderTaskFulfillmentWorkDoc(ProjectOrderTaskFulfillmentWorkDocs $projectOrderTaskFulfillmentWorkDocs): self
{
if ($this->work_documents->removeElement($projectOrderTaskFulfillmentWorkDocs)) {
// set the owning side to null (unless already changed)
if ($projectOrderTaskFulfillmentWorkDocs->getProjectOrderTaskFulfillment() === $this) {
$projectOrderTaskFulfillmentWorkDocs->setProjectOrderTaskFulfillment(null);
}
}
return $this;
}
/**
* @return Collection<int, ProjectOrderTaskFulfillmentDateRevision>
*/
public function getDateRevisions(): Collection
{
return $this->date_revisions;
}
public function addDateRevision(ProjectOrderTaskFulfillmentDateRevision $revision): self
{
if (!$this->date_revisions->contains($revision)) {
$this->date_revisions[] = $revision;
$revision->setProjectOrderTaskFulfillment($this);
}
return $this;
}
/** EN SON (created_at max) revizyon = güncel taahhüt. Zincir boşsa null. */
private function getLatestDateRevision(): ?ProjectOrderTaskFulfillmentDateRevision
{
$latest = null;
foreach ($this->date_revisions as $revision) {
if ($latest === null || $revision->getCreatedAt() >= $latest->getCreatedAt()) {
$latest = $revision;
}
}
return $latest;
}
/**
* Taahhüt edilen başlangıç = son revizyonun start_at'i (yoksa null). SANAL — zincirden türer.
*
* @Groups({
* "project_order_task_fulfillment@core"
* })
* @SerializedName("committed_start_at")
*/
public function getCommittedStartAt(): ?\DateTimeImmutable
{
$latest = $this->getLatestDateRevision();
return $latest !== null ? $latest->getStartAt() : null;
}
/**
* Taahhüt edilen bitiş = son revizyonun end_at'i (yoksa null). SANAL — zincirden türer.
*
* @Groups({
* "project_order_task_fulfillment@core"
* })
* @SerializedName("committed_end_at")
*/
public function getCommittedEndAt(): ?\DateTimeImmutable
{
$latest = $this->getLatestDateRevision();
return $latest !== null ? $latest->getEndAt() : null;
}
public function getExecutionScope(): ?string
{
return $this->execution_scope;
}
public function setExecutionScope(?string $execution_scope): self
{
$this->execution_scope = $execution_scope;
return $this;
}
public function getExecutionWorkNature()
{
return $this->execution_work_nature;
}
public function setExecutionWorkNature($execution_work_nature): void
{
$this->execution_work_nature = $execution_work_nature;
}
public function getBranch(): ?Branches
{
return $this->branch;
}
public function setBranch(?Branches $branch): self
{
$this->branch = $branch;
return $this;
}
public function getBranchContactPerson(): ?WorkerActivities
{
return $this->branch_contact_person;
}
public function setBranchContactPerson(?WorkerActivities $branch_contact_person): self
{
$this->branch_contact_person = $branch_contact_person;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getPublishedAt(): ?\DateTimeImmutable
{
return $this->published_at;
}
public function setPublishedAt(?\DateTimeImmutable $published_at): self
{
$this->published_at = $published_at;
return $this;
}
public function getAcceptedAt(): ?\DateTimeImmutable
{
return $this->accepted_at;
}
public function setAcceptedAt(?\DateTimeImmutable $accepted_at): self
{
$this->accepted_at = $accepted_at;
return $this;
}
/** K8 — kabul edildi mi (published + accepted_at dolu). Silme kilidi ve FE rozeti bunu okur. */
public function isAccepted(): bool
{
return $this->accepted_at !== null;
}
public function getDeclinedAt(): ?\DateTimeImmutable
{
return $this->declined_at;
}
public function setDeclinedAt(?\DateTimeImmutable $declined_at): self
{
$this->declined_at = $declined_at;
return $this;
}
/** K8-accept — reddedildi mi (declined_at dolu). Reddedilen is silinebilir (accepted degil). */
public function isDeclined(): bool
{
return $this->declined_at !== null;
}
public function getDeclineReasonCategory(): ?string
{
return $this->decline_reason_category;
}
public function setDeclineReasonCategory(?string $decline_reason_category): self
{
$this->decline_reason_category = $decline_reason_category;
return $this;
}
public function getDeclineReason(): ?string
{
return $this->decline_reason;
}
public function setDeclineReason(?string $decline_reason): self
{
$this->decline_reason = $decline_reason;
return $this;
}
/**
* @return Collection<int, ProjectOrderTaskFulfillmentMessage>
*/
public function getMessages(): Collection
{
return $this->messages;
}
public function addMessage(ProjectOrderTaskFulfillmentMessage $message): self
{
if (!$this->messages->contains($message)) {
$this->messages[] = $message;
$message->setExecution($this);
}
return $this;
}
public function getOwnerLastReadAt(): ?\DateTimeImmutable
{
return $this->owner_last_read_at;
}
public function setOwnerLastReadAt(?\DateTimeImmutable $owner_last_read_at): self
{
$this->owner_last_read_at = $owner_last_read_at;
return $this;
}
public function getExecutorLastReadAt(): ?\DateTimeImmutable
{
return $this->executor_last_read_at;
}
public function setExecutorLastReadAt(?\DateTimeImmutable $executor_last_read_at): self
{
$this->executor_last_read_at = $executor_last_read_at;
return $this;
}
}