<?php
namespace App\Entity;
use App\Exception\CurrencyException;
use App\Exception\InvalidArgumentException;
use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Exception;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* @ORM\Entity(
* repositoryClass="App\Repository\OfferRepository"
* )
* @ORM\Table(
* name="offer",
* options={"collate"="utf8_swedish_ci"}
* )
* @ORM\HasLifecycleCallbacks
*/
class Offer implements EntityInterface, LoggableInterface, SalesDocumentInterface, SalesDocumentDiscountInterface
{
use DocumentTrait;
use LoggableTrait;
use SalesItemListTrait;
const NUMBER_PREFIX = 'OF';
const STATUS_DRAFT = 'draft';
const STATUS_SENT = 'sent';
const STATUS_ORDERED = 'ordered';
const STATUS_EXPIRED = 'expired';
const STATUS_REJECTED = 'rejected';
/**
* @ORM\Id
* @ORM\Column(
* type="integer"
* )
* @ORM\GeneratedValue(
* strategy="AUTO"
* )
*
* @var int|null
*/
protected ?int $id = null;
/**
* @ORM\Column(
* name="append_general_terms_and_conditions",
* type="boolean"
* )
*
* @var bool
*/
protected bool $appendGeneralTermsAndConditions = true;
/**
* @ORM\OneToOne(
* targetEntity="CompanyDocumentAddress",
* cascade={"persist","remove"},
* orphanRemoval=true
* )
* @ORM\JoinColumn(
* name="buyer_id"
* )
* @Assert\Valid
*
* @var CompanyDocumentAddress|null
*/
protected ?CompanyDocumentAddress $buyer = null;
/**
* @ORM\Column(
* type="boolean"
* )
*
* @var bool
*/
protected bool $containsCoverSheet = false;
/**
* @ORM\OneToMany(
* targetEntity="CoverSheetRow",
* mappedBy="offer",
* cascade={"persist", "remove"},
* orphanRemoval=true
* )
* @orm\OrderBy({"positions" = "ASC"})
*
* @var Collection<CoverSheetRow>
*/
protected Collection $coverSheetRows;
/**
* @ORM\OneToOne(
* targetEntity="CompanyDocumentAddress",
* cascade={"persist","remove"},
* orphanRemoval=true
* )
* @ORM\JoinColumn(
* name="delivery_address_id"
* )
* @Assert\Valid
*
* @var CompanyDocumentAddress|null
*/
protected ?CompanyDocumentAddress $deliveryAddress = null;
/**
* @ORM\OneToMany(
* targetEntity="DocumentVersionOffer",
* mappedBy="offer",
* cascade={"persist","remove"}
* )
* @ORM\OrderBy({"created" = "DESC"})
*
* @var Collection<DocumentVersionOffer>
*/
protected Collection $documentVersions;
/**
* @ORM\Column(
* name="estimated_lead_time",
* type="integer",
* nullable=true
* )
* @Assert\Range(
* min=0
* )
*
* @var int|null
*/
protected ?int $estimatedLeadTime = null;
/**
* @ORM\Column(
* name="estimated_lead_time_note",
* type="string",
* length=100,
* nullable=true
* )
*
* @var string|null
*/
protected ?string $estimatedLeadTimeNote = 'from order';
/**
* @ORM\Column(
* type="string",
* length=5000,
* nullable=true
* )
*
* @var string|null
*/
protected ?string $notes = null;
/**
* Log entries is a One-To-Many, Unidirectional association with Join Table
*
* @ORM\ManyToMany(
* targetEntity="LogEntry",
* cascade={"persist","remove"},
* orphanRemoval=true
* )
* @ORM\JoinTable(
* name="offer_log_entry",
* joinColumns={
* @ORM\JoinColumn(
* name="offer_id",
* referencedColumnName="id",
* onDelete="cascade"
* )
* },
* inverseJoinColumns={
* @ORM\JoinColumn(
* name="log_entry_id",
* referencedColumnName="id",
* unique=true,
* onDelete="cascade"
* )
* }
* )
* @ORM\OrderBy({"time" = "ASC"})
* @Assert\Valid
*
* @var Collection<LogEntry>
*/
protected $logEntries;
/**
* Note! this property is deprecated and should be removed.
* @deprecated
*
* @ORM\Column(
* name="opening_date",
* type="date",
* nullable=true
* )
*
* @var DateTime|null
*/
protected ?DateTime $openingDate = null;
/**
* @ORM\OneToOne(
* targetEntity="SalesCase",
* inversedBy="offer"
* )
* @ORM\JoinColumn(
* name="sales_case_id",
* referencedColumnName="id",
* onDelete="cascade"
* )
* @Assert\NotNull
*
* @var SalesCase
*/
protected SalesCase $salesCase;
/**
* @ORM\ManyToMany(
* targetEntity="SalesItem",
* cascade={"persist","remove"},
* orphanRemoval=true,
* fetch="EXTRA_LAZY"
* )
* @ORM\JoinTable(
* name="offer_sales_item",
* joinColumns={
* @ORM\JoinColumn(
* name="offer_id",
* referencedColumnName="id",
* onDelete="cascade"
* )
* },
* inverseJoinColumns={
* @ORM\JoinColumn(
* name="sales_item_id",
* referencedColumnName="id",
* unique=true,
* onDelete="cascade"
* )
* }
* )
* @ORM\OrderBy({"position" = "ASC"})
* @Assert\Valid
*
* @var Collection<SalesItem>
*/
protected Collection $salesItems;
/**
* @var bool
*/
protected bool $salesItemsPopulated = false;
/**
* @ORM\Column(
* name="signature_date",
* type="date",
* nullable=true
* )
*
* @var DateTime|null
*/
protected ?DateTime $signatureDate = null;
/**
* @ORM\Column(
* name="signature_details",
* type="string",
* length=500,
* nullable=true
* )
*
* @var string|null
*/
protected ?string $signatureDetails = null;
/**
* @ORM\Column(
* name="signature_name",
* type="string",
* length=200,
* nullable=true
* )
*
* @var string|null
*/
protected ?string $signatureName = null;
/**
* @ORM\Column(
* name="signature_place",
* type="string",
* length=200,
* nullable=true
* )
*
* @var string|null
*/
protected ?string $signaturePlace = 'Helsinki, Finland';
/**
* @ORM\Column(
* name="terms_of_delivery",
* type="string",
* length=500,
* nullable=true
* )
*
* @var string|null
*/
protected ?string $termsOfDelivery = null;
/**
* @ORM\ManyToMany(
* targetEntity="TermsOfPaymentRow",
* cascade={"persist","remove"},
* orphanRemoval=true
* )
* @ORM\JoinTable(
* name="offer_terms_of_payment_row",
* joinColumns={
* @ORM\JoinColumn(
* name="offer_id",
* referencedColumnName="id",
* onDelete="cascade"
* )
* },
* inverseJoinColumns={
* @ORM\JoinColumn(
* name="terms_of_payment_row_id",
* referencedColumnName="id",
* unique=true,
* onDelete="cascade"
* )
* }
* )
* @Assert\Valid
*
* @var Collection<TermsOfPaymentRow>
*/
protected Collection $termsOfPaymentRows;
/**
* @ORM\Column(
* type="text",
* nullable=true
* )
*
* @var string|null
*/
protected ?string $text = null;
/**
* @ORM\Column(
* type="string",
* length=500,
* nullable=true
* )
*
* @var string|null
*/
protected ?string $transportation = null;
/**
* @ORM\Column(
* name="valid_until",
* type="date",
* nullable=true
* )
*
* @var DateTime|null
*/
protected ?DateTime $validUntil = null;
/**
* @ORM\Column(
* type="text",
* nullable=true
* )
*
* @var string|null
*/
protected ?string $warranty = null;
public function __construct(SalesCase $salesCase)
{
$this->salesCase = $salesCase;
$this->date = new DateTime();
$this->documentVersions = new ArrayCollection();
$this->logEntries = new ArrayCollection();
$this->salesItems = new ArrayCollection();
$this->termsOfPaymentRows = new ArrayCollection();
}
/**
* @return string
*/
public function __toString(): string
{
return $this->getNumber() . ($this->salesCase->getCompany() !== null ? ' (' . $this->salesCase->getCompany()->getName() . ')' : '');
}
/**
* @param CoverSheetRow $coverSheetRow
*/
public function addCoverSheetRow(CoverSheetRow $coverSheetRow): void
{
$this->coverSheetRows->add($coverSheetRow);
}
/**
* @param DocumentVersionOffer $documentVersion
*
* @throws InvalidArgumentException
*/
public function addDocumentVersion(DocumentVersionOffer $documentVersion): void
{
if ($documentVersion->getOffer() !== $this) {
throw new InvalidArgumentException('Document version has mismatching offer document.');
}
$this->documentVersions->add($documentVersion);
}
/**
* @param TermsOfPaymentRow $row
*/
public function addTermsOfPaymentRow(TermsOfPaymentRow $row): void
{
$this->termsOfPaymentRows->add($row);
}
/**
* @return bool
*/
public function getAppendGeneralTermsAndConditions(): bool
{
return $this->appendGeneralTermsAndConditions;
}
/**
* @return CompanyDocumentAddress|null
*/
public function getBuyer(): ?CompanyDocumentAddress
{
return $this->buyer;
}
/**
* @return Collection<CoverSheetRow>
*/
public function getCoverSheetRows(): Collection
{
// Sort cover sheet rows
try {
$iterator = $this->coverSheetRows->getIterator();
$iterator->uasort(
function (CoverSheetRow $a, CoverSheetRow $b)
{
return ($a->parsePositions()[0] < $b->parsePositions()[0]) ? -1 : 1;
}
);
return new ArrayCollection(iterator_to_array($iterator));
} catch (Exception $e) {
// Ignore
}
}
/**
* @return CompanyDocumentAddress|null
*/
public function getDeliveryAddress(): ?CompanyDocumentAddress
{
return $this->deliveryAddress;
}
/**
* @return Collection<DocumentVersionOffer>
*/
public function getDocumentVersions(): Collection
{
return $this->documentVersions;
}
/**
* @return int|null
*/
public function getEstimatedLeadTime(): ?int
{
return $this->estimatedLeadTime;
}
/**
* @return null|string
*/
public function getEstimatedLeadTimeNote(): ?string
{
return $this->estimatedLeadTimeNote;
}
/**
* Calculate offer gross margin based on item specific unit and purchase prices.
*
* @return float
*
* @throws CurrencyException
* @throws Exception
*/
public function getGrossMargin(): float
{
$totalCostOfSales = $this->getTotalPurchaseValue();
if (($totalSales = $this->getValueNet()) <= 0) {
throw new Exception('No sales value.');
}
return ($totalSales - $totalCostOfSales) / $totalSales;
}
/**
* @return int|null
*/
public function getId(): ?int
{
return $this->id;
}
/**
* @return null|string
*/
public function getNotes(): ?string
{
return $this->notes;
}
/**
* @return string
*/
public function getNumber(): string
{
return self::NUMBER_PREFIX . str_pad($this->salesCase->getId(), 6, '0', STR_PAD_LEFT);
}
/**
* @return DateTime|null
*/
public function getOpeningDate(): ?DateTime
{
return $this->openingDate;
}
/**
* @return string
*/
public function getPreviewFileName(): string
{
return 'Offer - ' . $this->getNumber() . ' - Preview ' . date('Y-m-d') . '.pdf';
}
/**
* @return SalesCase
*/
public function getSalesCase(): SalesCase
{
return $this->salesCase;
}
/**
* @return Collection<SalesItem>
*/
public function getSalesItems(): Collection
{
if (!$this->salesItemsPopulated) {
$currency = $this->getCurrency();
foreach ($this->salesItems as $salesItem) {
$salesItem->setCurrency($currency);
$salesItem->setCurrencyExchangeRate($this->currencyExchangeRate);
}
$this->salesItemsPopulated = true;
}
return $this->salesItems;
}
/**
* @return DateTime|null
*/
public function getSignatureDate(): ?DateTime
{
return $this->signatureDate;
}
/**
* @return string|null
*/
public function getSignatureDetails(): ?string
{
return $this->signatureDetails;
}
/**
* @return null|string
*/
public function getSignatureName(): ?string
{
return $this->signatureName;
}
/**
* @return null|string
*/
public function getSignaturePlace(): ?string
{
return $this->signaturePlace;
}
/**
* @return array<string>
*/
public static function getStatusChoices(): array
{
return [
self::STATUS_DRAFT,
self::STATUS_SENT,
self::STATUS_REJECTED,
];
}
/**
* @return string
*/
public function getStatusForReport(): string
{
if (null !== $orderConfirmation = $this->getSalesCase()->getOrderConfirmation()) {
if (!$orderConfirmation->isDraft()) {
return self::STATUS_ORDERED;
}
}
if ($this->isExpired()) {
return self::STATUS_EXPIRED;
}
return $this->getStatus();
}
/**
* @return null|string
*/
public function getTermsOfDelivery(): ?string
{
return $this->termsOfDelivery;
}
/**
* @return Collection<TermsOfPaymentRow>
*/
public function getTermsOfPaymentRows(): Collection
{
return $this->termsOfPaymentRows;
}
/**
* @return null|string
*/
public function getText(): ?string
{
return $this->text;
}
/**
* @TODO: currency
*
* @return int
*
* @throws Exception
*/
public function getTotalPurchaseValue(): int
{
$total = 0;
$noPurchasePrice = [];
foreach ($this->salesItems as $salesItem) {
if ($salesItem->isPciSubItem()) {
continue;
}
if ($salesItem->isPciItem() && null === $salesItem->getPurchasePrice()) {
$purchasePricePci = $salesItem->getPurchasePrice();
if (null === $purchasePricePci) {
// Calculate PCI purchase price from sub-items
$purchasePricePci = 0;
foreach ($this->salesItems as $itemTemp) {
if ($itemTemp->isPciSubItem()) {
if ($itemTemp->getPciPosition() === $salesItem->getPosition()) {
if (null !== $purchasePriceSub = $itemTemp->getPurchasePrice()) {
$purchasePricePci += $itemTemp->getQuantity() * $purchasePriceSub;
} else {
$purchasePricePci = null;
break;
}
}
}
}
$salesItem->setPurchasePrice($purchasePricePci);
}
}
if (null === $purchasePrice = $salesItem->getPurchasePrice()) {
$noPurchasePrice[] = $salesItem->getPositionDisplay();
} else {
$total += $purchasePrice * $salesItem->getQuantity();
}
}
if (count($noPurchasePrice) > 0) {
throw new Exception('Positions ' . implode(', ', $noPurchasePrice) . ' have no purchase price.');
}
return $total;
}
/**
* @return null|string
*/
public function getTransportation(): ?string
{
return $this->transportation;
}
/**
* @return DateTime|null
*/
public function getValidUntil(): ?DateTime
{
return $this->validUntil;
}
/**
* @return null|string
*/
public function getWarranty(): ?string
{
return $this->warranty;
}
/**
* @return bool
*/
public function isContainsCoverSheet(): bool
{
return $this->containsCoverSheet;
}
/**
* @return bool
*/
public function isCurrencyExchangeRateLocked(): bool
{
return !in_array(
$this->status,
[
self::STATUS_DRAFT,
]
);
}
/**
* @return bool
*/
public function isDraft(): bool
{
return $this->getStatus() == self::STATUS_DRAFT;
}
/**
* @return bool
*/
public function isExpired(): bool
{
return $this->validUntil < new DateTime();
}
/**
* @return bool
*/
public function isRejected(): bool
{
return $this->getStatus() == self::STATUS_REJECTED;
}
/**
* @return bool
*/
public function isSent(): bool
{
return $this->getStatus() == self::STATUS_SENT;
}
/**
* @ORM\PostLoad
*/
public function postLoad(): void
{
foreach ($this->salesItems as $salesItem) {
if (!$salesItem->isPciItem()) {
continue;
}
// Calculate PCI purchase price from sub-items
$purchasePrice = 0;
foreach ($this->salesItems as $itemTemp) {
if ($itemTemp->isPciSubItem()) {
if ($itemTemp->getPciPosition() === $salesItem->getPosition()) {
if (null !== $purchasePriceSub = $itemTemp->getPurchasePrice()) {
$purchasePrice += $itemTemp->getQuantity() * $purchasePriceSub;
} else {
$purchasePrice = null;
break;
}
}
}
}
$salesItem->setPurchasePrice($purchasePrice);
}
}
/**
* @param CoverSheetRow $coverSheetRow
*/
public function removeCoverSheetRow(CoverSheetRow $coverSheetRow): void
{
$this->coverSheetRows->removeElement($coverSheetRow);
}
/**
* @param TermsOfPaymentRow $row
*/
public function removeTermsOfPaymentRow(TermsOfPaymentRow $row): void
{
$this->termsOfPaymentRows->removeElement($row);
}
/**
* @param bool $appendGeneralTermsAndConditions
*/
public function setAppendGeneralTermsAndConditions(bool $appendGeneralTermsAndConditions): void
{
$this->appendGeneralTermsAndConditions = $appendGeneralTermsAndConditions;
}
/**
* @param CompanyDocumentAddress|null $buyer
*/
public function setBuyer(?CompanyDocumentAddress $buyer): void
{
$this->buyer = $buyer;
}
/**
* @param bool $containsCoverSheet
*/
public function setContainsCoverSheet(bool $containsCoverSheet): void
{
$this->containsCoverSheet = $containsCoverSheet;
}
/**
* @param CompanyDocumentAddress|null $deliveryAddress
*/
public function setDeliveryAddress(?CompanyDocumentAddress $deliveryAddress): void
{
$this->deliveryAddress = $deliveryAddress;
}
/**
* @param int|null $estimatedLeadTime
*/
public function setEstimatedLeadTime(?int $estimatedLeadTime): void
{
$this->estimatedLeadTime = $estimatedLeadTime;
}
/**
* @param string|null $estimatedLeadTimeNote
*/
public function setEstimatedLeadTimeNote(?string $estimatedLeadTimeNote): void
{
$this->estimatedLeadTimeNote = $estimatedLeadTimeNote;
}
/**
* @param string|null $notes
*/
public function setNotes(?string $notes): void
{
$this->notes = $notes;
}
/**
* @param DateTime|null $openingDate
*/
public function setOpeningDate(?DateTime $openingDate): void
{
$this->openingDate = $openingDate;
}
/**
* @param DateTime|null $signatureDate
*/
public function setSignatureDate(?DateTime $signatureDate): void
{
$this->signatureDate = $signatureDate;
}
/**
* @param string|null $signatureDetails
*/
public function setSignatureDetails(?string $signatureDetails): void
{
$this->signatureDetails = $signatureDetails;
}
/**
* @param string|null $signatureName
*/
public function setSignatureName(?string $signatureName): void
{
$this->signatureName = $signatureName;
}
/**
* @param string|null $signaturePlace
*/
public function setSignaturePlace(?string $signaturePlace): void
{
$this->signaturePlace = $signaturePlace;
}
/**
* @param string|null $termsOfDelivery
*/
public function setTermsOfDelivery(?string $termsOfDelivery): void
{
$this->termsOfDelivery = $termsOfDelivery;
}
/**
* @param string|null $text
*/
public function setText(?string $text): void
{
$this->text = $text;
}
/**
* @param string|null $transportation
*/
public function setTransportation(?string $transportation): void
{
$this->transportation = $transportation;
}
/**
* @param DateTime|null $validUntil
*/
public function setValidUntil(?DateTime $validUntil): void
{
$this->validUntil = $validUntil;
}
/**
* @param string|null $warranty
*/
public function setWarranty(?string $warranty): void
{
$this->warranty = $warranty;
}
/**
* @Assert\Callback
*
* @param ExecutionContextInterface $context
*/
public function validate(ExecutionContextInterface $context): void
{
// Validate currency exchange rate
if ($this->getCurrency() != 'EUR' && $this->getCurrencyExchangeRate() === null) {
$context->buildViolation('You must give an exchange rate for a currency other than EUR')
->atPath('currencyExchangeRate')
->addViolation()
;
}
// Validate cover sheet rows
if ($this->isContainsCoverSheet()) {
$salesItemPositions = [];
foreach ($this->getSalesItems() as $item) {
if (!$item->isPciSubItem()) {
$salesItemPositions[] = (int) $item->getPositionDisplay();
}
}
$coverSheetPositions = [];
foreach ($this->getCoverSheetRows() as $row) {
$coverSheetPositions = array_merge($coverSheetPositions, $row->parsePositions());
}
$missingPositions = [];
$extraPositions = [];
foreach ($salesItemPositions as $salesItemPosition) {
if (!in_array($salesItemPosition, $coverSheetPositions)) {
$missingPositions[] = $salesItemPosition;
}
}
foreach ($coverSheetPositions as $coverSheetPosition) {
if (!in_array($coverSheetPosition, $salesItemPositions)) {
$extraPositions[] = $coverSheetPosition;
}
}
if (count($missingPositions) > 0) {
$context->buildViolation('The cover sheet is missing position' . (count($missingPositions) == 1 ? '' : 's') . ' ' . implode(', ', $missingPositions) . '.')
->atPath('containsCoverSheet')
->addViolation()
;
}
if (count($extraPositions) > 0) {
$context->buildViolation('The cover sheet has extra position' . (count($extraPositions) == 1 ? '' : 's') . ' ' . implode(', ', $extraPositions) . '.')
->atPath('containsCoverSheet')
->addViolation()
;
}
}
}
}