<?php
namespace App\Entity;
use App\Exception\CurrencyException;
use App\Exception\InvalidArgumentException;
use App\Exception\NoOrderConfirmationException;
use App\Model\Currency;
use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* @ORM\Entity(
* repositoryClass="App\Repository\SalesCaseRepository"
* )
* @ORM\Table(
* name="sales_case",
* options={"collate"="utf8_swedish_ci"}
* )
* @ORM\HasLifecycleCallbacks
*/
class SalesCase implements EntityInterface
{
const NUMBER_PREFIX = 'C';
const TYPE_OFFER = 'offer';
const TYPE_ORDER_CONFIRMATION = 'order_confirmation';
const TYPE_PURCHASE_ORDER = 'purchase_order';
/**
* @ORM\Id
* @ORM\Column(
* type="integer"
* )
* @ORM\GeneratedValue(
* strategy="AUTO"
* )
*
* @var int|null
*/
protected ?int $id = null;
/**
* @ORM\OneToMany(
* targetEntity="SalesCaseAttachment",
* mappedBy="salesCase",
* cascade={"persist","remove"},
* orphanRemoval=true
* )
* @ORM\OrderBy({"created" = "DESC"})
*
* @var Collection<SalesCaseAttachment>
*/
protected Collection $attachments;
/**
* @ORM\ManyToOne(
* targetEntity="Company",
* inversedBy="salesCases"
* )
* @ORM\JoinColumn(
* name="company_id",
* referencedColumnName="id"
* )
* @Assert\NotNull
*
* NOTE! There are sales cases in the database with no company set. For this null values need to be supported
* for already persisted sales cases. Remove in future.
*
* @var Company|null
*/
protected ?Company $company = null;
/**
* @ORM\Column(
* type="string",
* length=3
* )
* @Assert\Choice(
* callback={"\App\Model\Currency", "getCodeChoices"}
* )
*
* @var string
*/
protected string $currency = Currency::DEFAULT_CURRENCY;
/**
* This is used internally for checking if the value of currency is being changed and validation.
*
* @var string
*/
protected $currencyOriginal;
/**
* @ORM\Column(
* type="string",
* length=2000,
* nullable=true
* )
*
* @var string|null
*/
protected ?string $description = null;
/**
* @ORM\OneToMany(
* targetEntity="AbstractInvoice",
* mappedBy="salesCase",
* cascade={"persist","remove"}
* )
*
* @var Collection<AbstractInvoice>
*/
protected Collection $invoices;
/**
* @ORM\OneToMany(
* targetEntity="Notification",
* mappedBy="salesCase",
* cascade={"persist","remove"},
* orphanRemoval=true
* )
* @ORM\OrderBy({"time" = "ASC"})
* @Assert\Valid
*
* @var Collection<Notification>
*/
protected Collection $notifications;
/**
* @ORM\OneToOne(
* targetEntity="Offer",
* mappedBy="salesCase",
* cascade={"persist","remove"},
* fetch="EXTRA_LAZY"
* )
*
* @var Offer|null
*/
protected ?Offer $offer = null;
/**
* @ORM\Column(
* type="datetime"
* )
*
* @var DateTime
*/
protected DateTime $opened;
/**
* @ORM\OneToOne(
* targetEntity="OrderConfirmation",
* mappedBy="salesCase",
* cascade={"persist","remove"}
* )
*
* @var OrderConfirmation|null
*/
protected ?OrderConfirmation $orderConfirmation = null;
/**
* @ORM\OneToMany(
* targetEntity="PackingList",
* mappedBy="salesCase",
* cascade={"persist","remove"}
* )
*
* @var Collection<PackingList>
*/
protected Collection $packingLists;
/**
* @ORM\OneToMany(
* targetEntity="PurchaseOrder",
* mappedBy="salesCase",
* cascade={"persist","remove"}
* )
*
* @var Collection<PurchaseOrder>
*/
protected Collection $purchaseOrders;
/**
* @ORM\Column(
* type="string",
* length=20
* )
* @Assert\Choice(
* callback="getTypeChoices"
* )
*
* @var string
*/
protected string $type = self::TYPE_OFFER;
/**
* @param Company $company
* @param string $type
*/
public function __construct(Company $company, string $type = self::TYPE_OFFER)
{
$this->setCompany($company);
$this->setType($type);
$this->attachments = new ArrayCollection();
$this->invoices = new ArrayCollection();
$this->notifications = new ArrayCollection();
$this->packingLists = new ArrayCollection();
$this->purchaseOrders = new ArrayCollection();
$this->opened = new DateTime();
}
/**
* @param SalesCaseAttachment $attachment
*
* @throws InvalidArgumentException
*/
public function addAttachment(SalesCaseAttachment $attachment): void
{
if ($attachment->getSalesCase() !== $this) {
throw new InvalidArgumentException('Invalid sales case for attachment');
}
$this->attachments->add($attachment);
}
/**
* @param Notification $notification
*/
public function addNotification(Notification $notification): void
{
$notification->setSalesCase($this);
$this->notifications->add($notification);
}
/**
* @return SalesCaseAttachment[]|Collection
*/
public function getAttachments()
{
return $this->attachments;
}
/**
* @return BankAccount|null
*/
public function getBankAccount(): ?BankAccount
{
return $this->company->getBankAccount();
}
/**
* @return Company|null
*/
public function getCompany(): ?Company
{
return $this->company;
}
/**
* @param bool $defaultCurrency
*
* @return int
*
* @throws CurrencyException
*/
public function getCostOfSales(bool $defaultCurrency = false): int
{
$total = 0;
foreach ($this->getPurchaseOrders() as $purchaseOrder) {
if ($purchaseOrder->isSent()) {
$total += $purchaseOrder->getValueNet($defaultCurrency);
}
}
return $total;
}
/**
* @return string
*/
public function getCurrency(): string
{
return $this->currency;
}
/**
* @return null|string
*/
public function getDescription(): ?string
{
return $this->description;
}
/**
* @return float
*
* @throws NoOrderConfirmationException
* @throws CurrencyException
*/
public function getGrossMargin(): float
{
if (null === $orderConfirmation = $this->getOrderConfirmation()) {
throw new NoOrderConfirmationException('The sales case does not have an order confirmation.');
}
$sales = $orderConfirmation->getValueNet(false);
if ($sales == 0) {
return 0.0;
}
return ($sales - $this->getCostOfSales(false)) / $sales;
}
/**
* @return int|null
*/
public function getId(): ?int
{
return $this->id;
}
/**
* @return Collection<AbstractInvoice>
*/
public function getInvoices(): Collection
{
return $this->invoices;
}
/**
* @param bool $excludeDrafts
*
* @return Collection<CreditInvoice>
*/
public function getInvoicesOfTypeCreditInvoice(bool $excludeDrafts = false): Collection
{
return $this->invoices->filter(
function ($invoice) use ($excludeDrafts) {
/* @var AbstractInvoice $invoice */
if ($excludeDrafts && $invoice->isDraft()) {
return false;
}
return $invoice instanceof CreditInvoice;
}
);
}
/**
* @param bool $excludeDrafts
*
* @return Collection<DownPaymentInvoice>
*/
public function getInvoicesOfTypeDownPaymentInvoice(bool $excludeDrafts = false): Collection
{
return $this->invoices->filter(
function ($invoice) use ($excludeDrafts) {
/* @var AbstractInvoice $invoice */
if ($excludeDrafts && $invoice->isDraft()) {
return false;
}
return $invoice instanceof DownPaymentInvoice;
}
);
}
/**
* @param bool $excludeDrafts
*
* @return Collection<Invoice>
*/
public function getInvoicesOfTypeInvoice(bool $excludeDrafts = false): Collection
{
return $this->invoices->filter(
function ($invoice) use ($excludeDrafts) {
/* @var AbstractInvoice $invoice */
if ($excludeDrafts && $invoice->isDraft()) {
return false;
}
return $invoice instanceof Invoice;
}
);
}
/**
* @param bool $excludeDrafts
*
* @return Collection<ProformaInvoice>
*/
public function getInvoicesOfTypeProformaInvoice(bool $excludeDrafts = false): Collection
{
return $this->invoices->filter(
function ($invoice) use ($excludeDrafts) {
/* @var AbstractInvoice $invoice */
if ($excludeDrafts && $invoice->isDraft()) {
return false;
}
return $invoice instanceof ProformaInvoice;
}
);
}
/**
* @return CreditInvoice
*/
public function getNewCreditInvoice(): CreditInvoice
{
return new CreditInvoice($this);
}
/**
* @return DownPaymentInvoice
*/
public function getNewDownPaymentInvoice(): DownPaymentInvoice
{
return new DownPaymentInvoice($this);
}
/**
* @return Invoice
*/
public function getNewInvoice(): Invoice
{
return new Invoice($this);
}
/**
* @return ProformaInvoice
*/
public function getNewProformaInvoice(): ProformaInvoice
{
return new ProformaInvoice($this);
}
/**
* @return PackingList
*/
public function getNewPackingList(): PackingList
{
return new PackingList($this);
}
/**
* @return PurchaseOrder
*/
public function getNewPurchaseOrder(): PurchaseOrder
{
return new PurchaseOrder($this, new Supplier(''));
}
/**
* @return Notification|null
*/
public function getNextNotification(): ?Notification
{
foreach ($this->getNotifications() as $notification) {
if ($notification->getTime()->getTimestamp() > time()) {
return $notification;
}
}
return null;
}
/**
* @return Collection<Notification>
*/
public function getNotifications(): Collection
{
return $this->notifications;
}
/**
* @return string
*/
public function getNumber(): string
{
return self::NUMBER_PREFIX . str_pad($this->id, 6, '0', STR_PAD_LEFT);
}
/**
* @return Offer|null
*/
public function getOffer(): ?Offer
{
return $this->offer;
}
/**
* @return DateTime
*/
public function getOpened(): DateTime
{
return $this->opened;
}
/**
* @return OrderConfirmation|null
*/
public function getOrderConfirmation(): ?OrderConfirmation
{
return $this->orderConfirmation;
}
/**
* @return Collection<PackingList>
*/
public function getPackingLists(): Collection
{
return $this->packingLists;
}
/**
* @return Collection<PurchaseOrder>
*/
public function getPurchaseOrders(): Collection
{
return $this->purchaseOrders;
}
/**
* @param bool $defaultCurrency
*
* @return int
*
* @throws NoOrderConfirmationException
*/
public function getRemainingInvoiceValue(bool $defaultCurrency = false): int
{
if ($this->orderConfirmation === null) {
throw new NoOrderConfirmationException('The sales case has no order confirmation');
}
$totalInvoiced = 0;
foreach ($this->getInvoicesOfTypeInvoice() as $invoice) {
if (!$invoice->isDraft()) {
$totalInvoiced += $invoice->getValueNet($defaultCurrency);
}
}
foreach ($this->getInvoicesOfTypeCreditInvoice() as $invoice) {
if (!$invoice->isDraft()) {
$totalInvoiced -= $invoice->getValueNet($defaultCurrency);
}
}
return $this->orderConfirmation->getValueNet($defaultCurrency) - $totalInvoiced;
}
/**
* @return string
*/
public function getStatus(): string
{
if ($this->type == self::TYPE_OFFER) {
if ($this->offer === null) {
return 'draft';
} elseif ($this->orderConfirmation === null) {
return 'offer_' . $this->offer->getStatus();
} else {
return 'oc_' . $this->orderConfirmation->getStatus();
}
} elseif ($this->type == self::TYPE_ORDER_CONFIRMATION) {
if ($this->orderConfirmation === null) {
return 'draft';
} else {
return 'oc_' . $this->orderConfirmation->getStatus();
}
} elseif ($this->type == self::TYPE_PURCHASE_ORDER) {
if (count($this->purchaseOrders) == 0) {
return 'draft';
} else {
return 'po';
}
}
return '';
}
/**
* @return string[]
*/
public static function getStatusChoices(): array
{
return [
'draft',
'offer_draft',
'offer_sent',
'offer_rejected',
'oc_draft',
'oc_not_confirmed',
'oc_preliminary_confirmed',
'oc_confirmed',
'oc_partially_delivered',
'oc_equipment_delivered',
'oc_delivered',
'oc_completed',
'oc_risk_order',
'oc_rejected',
'po',
];
}
/**
* @return string
*/
public function getType(): string
{
return $this->type;
}
/**
* @return string[]
*/
public static function getTypeChoices(): array
{
return [
self::TYPE_OFFER,
self::TYPE_ORDER_CONFIRMATION,
self::TYPE_PURCHASE_ORDER,
];
}
/**
* @return bool
*/
public function isCompleted(): bool
{
if ($this->type == self::TYPE_PURCHASE_ORDER) {
/**
* A PO only case is considered completed if it has POs and none of them have Draft status
*/
$hasDrafts = false;
$purchaseOrders = $this->getPurchaseOrders();
foreach ($purchaseOrders as $purchaseOrder) {
if ($purchaseOrder->isDraft()) {
$hasDrafts = true;
break;
}
}
if (count($purchaseOrders) > 0 && !$hasDrafts) {
return true;
}
} elseif (null !== $this->orderConfirmation) {
if ($this->orderConfirmation->isCompleted()) {
return true;
}
if (!$this->orderConfirmation->isRejected() && !$this->orderConfirmation->isDraft()) {
if (count($this->orderConfirmation->getSalesItems()) > 0 && count($this->orderConfirmation->getUninvoicedSalesItems(false)) == 0) {
return true;
}
}
}
return false;
}
/**
* @return bool
*/
public function isDraft(): bool
{
return $this->getStatus() == 'draft';
}
/**
* @ORM\PostLoad
*/
public function postLoad()
{
$this->currencyOriginal = $this->currency;
}
public function removeAttachment(SalesCaseAttachment $attachment): void
{
$this->attachments->removeElement($attachment);
}
/**
* @param Notification $notification
*/
public function removeNotification(Notification $notification): void
{
$this->notifications->removeElement($notification);
}
/**
* @param Company $company
*/
public function setCompany(Company $company): void
{
$this->company = $company;
$this->currency = $company->getCurrency();
}
/**
* @param string $currency
*/
public function setCurrency(string $currency): void
{
$this->currency = $currency;
}
/**
* @param string|null $description
*/
public function setDescription(?string $description): void
{
$this->description = $description;
}
/**
* @param Offer $offer
*/
public function setOffer(Offer $offer): void
{
$this->offer = $offer;
}
/**
* @param DateTime $opened
*/
public function setOpened(DateTime $opened)
{
$this->opened = $opened;
}
/**
* @param OrderConfirmation $orderConfirmation
*/
public function setOrderConfirmation(OrderConfirmation $orderConfirmation)
{
$this->orderConfirmation = $orderConfirmation;
}
/**
* @param string $type
*/
public function setType(string $type): void
{
$this->type = $type;
}
/**
* @Assert\Callback
*
* @param ExecutionContextInterface $context
*/
public function validate(ExecutionContextInterface $context): void
{
if ($this->company->getCurrency() != $this->currency) {
$context
->buildViolation(
'validation.currency.mismatch',
[
'%company_currency%' => $this->company->getCurrency(),
'%currency%' => $this->currency,
]
)
->setTranslationDomain('SalesCase')
->atPath('company')
->addViolation();
$context
->buildViolation(
'validation.currency.mismatch',
[
'%company_currency%' => $this->company->getCurrency(),
'%currency%' => $this->currency,
]
)
->setTranslationDomain('SalesCase')
->atPath('currency')
->addViolation();
}
// Check if currency id being changed
// The currency can only be changed if the sales case does not yet have an Offer or and OC document
if ($this->currencyOriginal != $this->currency) {
if ($this->offer !== null || $this->orderConfirmation !== null) {
$context
->buildViolation('validation.currency.readonly')
->setTranslationDomain('SalesCase')
->atPath('currency')
->addViolation();
}
}
}
}