<?php
namespace FMS\eLibreria\CmsBundle\Controller\UTools\Store;
use FMS\eLibreria\CmsBundle\Controller\AbstractCrudEntity;
use FMS\eLibreria\CmsBundle\Utils\BreadcrumbItem;
use FMS\eLibreria\CmsBundle\Verifactu\PosTicketView;
use FMS\eLibreria\CoreBundle\Model\Entity\Employee;
use FMS\eLibreria\CoreBundle\Model\Entity\Facility;
use FMS\eLibreria\CoreBundle\Model\Entity\Item;
use FMS\eLibreria\CoreBundle\Model\Entity\Location;
use FMS\eLibreria\CoreBundle\Model\Entity\Provider;
use FMS\eLibreria\CoreBundle\Model\Entity\Role;
use FMS\eLibreria\CoreBundle\Model\Entity\Sale;
use FMS\eLibreria\CoreBundle\Model\Entity\SaleItem;
use FMS\eLibreria\CoreBundle\Model\Entity\SalePayment;
use FMS\eLibreria\CoreBundle\Model\Entity\Bill;
use FMS\eLibreria\CoreBundle\Model\Entity\SimpleBillMessage;
use FMS\eLibreria\CoreBundle\Model\Entity\SimpleBillMessageType;
use FMS\eLibreria\CoreBundle\Model\Entity\Tax;
use FMS\eLibreria\CoreBundle\Model\Object\FilterElement;
use FMS\eLibreria\CoreBundle\Model\Repository\FacilityRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\LocationRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\ProviderRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\SaleRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\SimpleBillMessageRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\TaxRepository;
use FMS\eLibreria\CoreBundle\Verifactu\Breakdown\AdvancePaymentLedger;
use FMS\eLibreria\CoreBundle\Verifactu\ClientAccount\ClientAccountLedger;
use FMS\eLibreria\CoreBundle\Verifactu\Correction\TicketReturnService;
use FMS\eLibreria\CoreBundle\Verifactu\Document\ValueObject\DocumentAction;
use FMS\eLibreria\CoreBundle\Verifactu\Document\ValueObject\InvoiceType;
use FMS\eLibreria\CoreBundle\Verifactu\Pos\ValueObject\PaymentMethod;
use FMS\eLibreria\CoreBundle\Verifactu\Pos\ValueObject\SaleStatus;
use FMS\eLibreria\CoreBundle\Verifactu\Status\RecordPanelReader;
use FMS\eLibreria\CoreBundle\Verifactu\Status\ValueObject\DocumentRecordStatus;
use Symfony\Component\HttpFoundation\Request;
class SalesController extends AbstractCrudEntity {
public static function getSubscribedServices(): array {
return array_merge(parent::getSubscribedServices(), array(
ClientAccountLedger::class => ClientAccountLedger::class,
));
}
/**
* How a return or a cancelled reservation of this document may give the money back: cash, card and the
* bearer voucher always, and the account of a client only when the document names that client (change
* verifactu-14, task 4.4). The service refuses the account without a client all the same.
*
* @return list<PaymentMethod>
*/
public static function refundMethodsOf(?Bill $document): array {
$hasAClient = !is_null($document) && !is_null($document->getClient());
return array_values(array_filter(
PaymentMethod::cases(),
static fn (PaymentMethod $method): bool => $method->isRefundable() && ($hasAClient || !$method->needsAClient())
));
}
protected function getEntityClass() {
return Sale::class;
}
protected function getEntityName() {
return "Sales";
}
protected function getRoutePrefix() {
return "cms_utools_store_sales";
}
protected function getDefaultActions() {
return array();
}
protected function getExtraActions() {
return array();
}
protected function getSearchFilters() {
// What a sale is, which is decided by the document issued when it was charged: a ticket, an invoice of
// the till, one whose document was annulled, or a sale charged before this version (change
// verifactu-05, design decisions 2 and 12; D-47).
$statuses = array();
foreach(SaleStatus::cases() as $status){
$statuses[$status->value] = "sales.status." . $status->value;
}
// A sale shows the status of its ticket, because a sale is the document that was issued when it was
// charged (change verifactu-10, design decision 1; change verifactu-05, design decision 2).
$recordStatuses = array();
foreach(DocumentRecordStatus::cases() as $recordStatus){
$recordStatuses[$recordStatus->value] = $recordStatus->labelKey();
}
$filters = array(
// The identifier of a sale is the identifier of its document since this change (decision 2).
"idNum" => new FilterElement("idNum", "identificationNumber", "id_num"),
"status" => new FilterElement("status", "status", "status", FilterElement::FILTER_TYPE_SELECT, $statuses),
"recordStatus" => new FilterElement("recordStatus", "billLinked.recordStatus", "record_status", FilterElement::FILTER_TYPE_SELECT, $recordStatuses),
"facility" => new FilterElement("facility", "facility.name", "facility"),
"createdAt" => new FilterElement("createdAt", "createdAt", "created_at", FilterElement::FILTER_TYPE_DATE),
"total" => new FilterElement("total", "total", "total"),
"employee" => new FilterElement("employee", array("employee.firstname", "employee.lastname"), "created_by"),
);
// An installation that does not issue in VERI*FACTU has no status with the AEAT to filter by (change
// verifactu-15, design decision 2; D-76).
if (!$this->issuesInVerifactu()) {
unset($filters["recordStatus"]);
}
return $filters;
}
protected function getHeaderList() {
return array_values(array_filter(array(
"idNum",
"status",
$this->issuesInVerifactu() ? "recordStatus" : null,
"facility",
"createdAt",
"total",
"employee",
)));
}
protected function isExportEnabled() {
return true;
}
/**
* @param FilterElement $value
*/
public function translateArray($value){
$translator = $this->getTranslator();
return $translator->trans(("sales.list." . $value->getFormLabel()), array(), "cms");
}
protected function exportTitleLine() {
$headerList = $this->getHeaderList();
$searchFilters = $this->getSearchFilters();
$headers = array();
foreach($headerList as $header){
$headers[] = $searchFilters[$header];
}
return implode(";", array_map(array($this, "translateArray"), $headers)) . PHP_EOL;
}
/**
* @param array $row
*/
protected function exportRowToLine($row) {
/** @var Sale $sale */
$sale = $row[0];
$translator = $this->getTranslator();
// A sale charged before this version has no document of this version, so it has no AEAT status to
// export either (change verifactu-10, design decision 1).
$document = $sale->getBillLinked();
$columns = array(
sprintf("=\"%s\"", $sale->getIdentificationNumber()),
$translator->trans("sales.status." . $sale->getStatus()->value, array(), "cms"),
is_null($document) || !$document->getRecordStatus()->isApplicable()
? ""
: $translator->trans($document->getRecordStatus()->labelKey(), array(), "cms"),
$sale->getFacility(),
$sale->getCreatedAt(true),
str_replace(".",",",$sale->getTotal()),
$sale->getEmployee(),
);
// The same columns as the header, which leaves the AEAT out while nothing is activated (change 15).
if (!$this->issuesInVerifactu()) {
unset($columns[2]);
}
return implode(";", $columns) . PHP_EOL;
}
protected function getEntityForms($entity, $editMode) {
return array();
}
/**
* The ticket view is the last argument because the base class fixes the two before it: Symfony injects
* it as a service and nothing ever passes it by hand.
*/
public function formEntity(
Request $request,
$hash = null,
?PosTicketView $tickets = null,
?TicketReturnService $returns = null,
?AdvancePaymentLedger $advances = null,
?RecordPanelReader $records = null
) {
\assert($tickets instanceof PosTicketView);
\assert($returns instanceof TicketReturnService);
\assert($advances instanceof AdvancePaymentLedger);
\assert($records instanceof RecordPanelReader);
$options = $this->getBasicOptions();
$editMode = !is_null($hash);
$brRoot = $this->getBreadcrumbRootItem($options);
$brRoot->addChildren(new BreadcrumbItem("sales.header." . ($editMode ? "edit" : "create")));
/** @var Employee $user */
$user = $this->getUser();
$entity = null;
$routes = $this->getEntityRoutes();
/** @var SaleRepository $saleRepository */
$saleRepository = $this->getEntityManager()->getRepository(Sale::class);
/** @var TaxRepository $taxRepository */
$taxRepository = $this->getEntityManager()->getRepository(Tax::class);
/** @var FacilityRepository $facilityRepository */
$facilityRepository = $this->getEntityManager()->getRepository(Facility::class);
/** @var ProviderRepository $providerRepository */
$providerRepository = $this->getEntityManager()->getRepository(Provider::class);
/** @var SimpleBillMessageRepository $sBMRepository */
$sBMRepository = $this->getEntityManager()->getRepository(SimpleBillMessage::class);
if(!$editMode){
$entity = new Sale($user);
$maxIdYear = $saleRepository->getNumberSaleOnThisYear();
$entity->setIdYear($maxIdYear + 1);
} else {
/** @var Sale $entity */
$entity = $saleRepository->findByHash($hash);
if(!$entity){
$this->addFlash(self::FLASH_ERROR, self::ERROR_MSG_ID_NOT_FOUND);
return $this->redirectToRoute($routes[self::ROUTE_KEY_INDEX]);
}
$promoCodeInSale = $entity->getPromoCodeApplied();
if(!is_null($promoCodeInSale)){
$promoCodeInSale->setLimit($entity->getTotalDiscounted());
}
}
$options[self::TWIG_OPTIONS_ENTITY] = $entity;
$options["simple_bill_messages"] = array(
SimpleBillMessageType::HEADER => $sBMRepository->getByType(SimpleBillMessageType::HEADER),
SimpleBillMessageType::BOTTOM => $sBMRepository->getByType(SimpleBillMessageType::BOTTOM),
);
// What the ticket prints: the copy the document was issued with, or the live messages when it has
// none — a sale not billed yet, or a ticket issued before this version (change verifactu-02,
// decision 22).
$document = is_null($entity) ? null : $entity->getBillLinked();
$keepsItsOwn = !is_null($document) && $document->hasPrintedMessages();
$options["printed_header_messages"] = $keepsItsOwn
? Bill::printedMessageLines($document->getHeaderMessages())
: self::messageLinesOf($options["simple_bill_messages"][SimpleBillMessageType::HEADER]);
$options["printed_footer_messages"] = $keepsItsOwn
? Bill::printedMessageLines($document->getFooterMessages())
: self::messageLinesOf($options["simple_bill_messages"][SimpleBillMessageType::BOTTOM]);
// The paper itself, read off the document (change verifactu-05, design decision 7; R-27). A sale
// being made has no document yet, and prints nothing.
$options["ticket"] = is_null($document) ? null : $tickets->of($document);
// Where the ticket of this sale stands with the AEAT, and every record behind that answer. It is
// shown on the screen and never on the paper: what the customer was handed says what it said the day
// it was handed over (change verifactu-10, design decisions 1 and 2).
$options["record_status"] = is_null($document) ? null : $document->getRecordStatus();
$options["record_panel"] = is_null($document)
? null
: $records->of($document, $this->isGranted(Role::ROLE_ADMIN));
// Whether this ticket can still be exchanged for an invoice with the customer's data, and which
// exchange documents it when it cannot (design decision 9; D-11).
/** @var \FMS\eLibreria\CoreBundle\Model\Repository\BillRepository $billRepository */
$billRepository = $this->getEntityManager()->getRepository(Bill::class);
$options["exchanged_in"] = is_null($document) ? null : $billRepository->exchangeInForceOf($document);
$options["exchangeable"] = !is_null($document)
&& is_null($options["exchanged_in"])
&& \FMS\eLibreria\CoreBundle\Verifactu\Document\ValueObject\InvoiceType::F2 === $document->getInvoiceType()
&& !$document->isSimplifiedArt7273()
&& !$document->isPreVerifactu()
&& $document->isIssued();
if (!is_null($document) && !$keepsItsOwn) {
// A ticket issued before this version kept no copy of the messages, so it prints the live ones
// exactly as it did before the upgrade (change 02, decision 22).
$options["ticket"]["headerMessages"] = $options["printed_header_messages"];
$options["ticket"]["footerMessages"] = $options["printed_footer_messages"];
}
// How the sale was paid: every payment with its amount, which is what the till wrote, never the one method
// `sales.pay_method` keeps (change merch-09, task 0.9; M-94).
$options["sale_payments"] = is_null($entity) || is_null($entity->getId())
? array()
: $this->getEntityManager()->getRepository(SalePayment::class)->findBy(array("sale" => $entity), array("id" => "ASC"));
// «Devolución»: what of this sale can still come back, what came back already, and whether this user may
// take it back at all (change verifactu-06, design decision 7; D-18).
$options["returnable_units"] = is_null($entity) || is_null($entity->getId())
? array()
: $returns->returnableUnitsOf($entity);
$options["sale_returns"] = is_null($entity) ? array() : $entity->getSaleReturns();
$options["can_be_returned"] = !is_null($document)
&& $document->isIssued()
&& !$document->isAnnulled()
&& !$document->isAdvancePayment()
&& array_sum($options["returnable_units"]) > 0
&& $this->isGranted(DocumentAction::Return_->value, $document);
$options["return_token"] = TicketReturnController::RETURN_TOKEN;
$options["refund_methods"] = self::refundMethodsOf($document);
// The account of the client the document names: what the shop owes them, which only they spend
// (change verifactu-14, task 4.6).
$client = is_null($document) ? null : $document->getClient();
$options["client_account"] = is_null($client)
? null
: array(
"client" => $client,
"balance" => $this->accounts()->balanceOf($client),
);
// «Cambiar por otro libro»: whatever may come back may be exchanged for other goods, and the difference is
// paid with a method that needs nothing but its amount (change verifactu-14, design decisions 5 and 6).
$options["can_exchange_goods"] = $options["can_be_returned"];
$options["goods_exchange_token"] = GoodsExchangeController::EXCHANGE_TOKEN;
$options["difference_methods"] = array_values(array_filter(
PaymentMethod::cases(),
static fn (PaymentMethod $method): bool => $method->isPaidWithAnAmountAlone()
));
// «Anular reserva»: an advance document is the reservation, and what is left of it is what the ledger
// says — never a column (change verifactu-06, design decision 9; change 03, decision 23; D-63).
$options["advance_remaining"] = is_null($document) || !$document->isAdvancePayment() || !$document->isIssued()
? null
: $advances->remainingOf($document)->total();
$options["can_cancel_reservation"] = !is_null($options["advance_remaining"])
&& !is_null($document)
&& !$document->isAnnulled()
&& bccomp($options["advance_remaining"], "0.00", 2) > 0
&& $this->isGranted(
(InvoiceType::F2 === $document->getInvoiceType()
? DocumentAction::Return_
: DocumentAction::Correct)->value,
$document
);
$options["cancel_reservation_token"] = TicketReturnController::CANCEL_TOKEN;
$options["taxes"] = $taxRepository->findAllTaxesOrdered();
// Never a system or a retired location (change merch-01, design decision 4).
$options["locations"] = $this->getLocationChoices()->forFacility();
$options["facilities"] = $facilityRepository->findAllFacilitiesOrdered();
$options["providers"] = $providerRepository->findAll();
$options["ajax_routes"] = array(
"save" => $this->generateUrl("cms_utools_store_sales_ajax_save"),
"autocomplete" => $this->generateUrl("cms_utools_store_sales_ajax_autocomplete"),
"promo_code" => $this->generateUrl("cms_utools_store_sales_ajax_promo_code"),
// What a line of the till holds while it is open (change merch-04, design decision 1; M-12).
"till_hold" => $this->generateUrl("cms_utools_store_sales_ajax_till_hold"),
"till_units" => $this->generateUrl("cms_utools_store_sales_ajax_till_units"),
"till_renew" => $this->generateUrl("cms_utools_store_sales_ajax_till_renew"),
"till_release" => $this->generateUrl("cms_utools_store_sales_ajax_till_release"),
"till_release_all" => $this->generateUrl("cms_utools_store_sales_ajax_till_release_all"),
// The till tells the server before it prints, so the delivery is written down and a document
// that can still change is not handed over (change verifactu-02, decision 16).
"print_notice" => $this->generateUrl("cms_utools_store_sales_ajax_print_notice"),
// What the ticket would be worth if it were charged now. The till renders this and adds
// nothing up of its own (change verifactu-03, design decision 15).
"quote" => $this->generateUrl("cms_utools_store_sales_ajax_quote"),
// Who the invoice is issued to, when the buyer asks for their data or the rules require it
// (change verifactu-05, design decision 3). It is the search the bill editor already uses:
// one place decides what a client is (§C5, change verifactu-04).
"search_client" => $this->generateUrl("cms_utools_accounting_bills_ajax_search_fiscal_entity"),
// The balance of the chosen client, which only that client spends (change verifactu-14, task 4.6).
"client_balance" => $this->generateUrl("cms_utools_store_sales_ajax_client_balance"),
// Money on account: charging an advance for what is on the screen, and reading what is left of
// one the customer left earlier (change verifactu-05, design decision 15; D-63).
"save_advance" => $this->generateUrl("cms_utools_store_sales_ajax_save_advance"),
"advance" => $this->generateUrl("cms_utools_store_sales_ajax_advance"),
);
return $this->render("utools/store/sales/editor.html.twig", $options);
}
// The quick sale screen is gone with change verifactu-05 (design decision 13; D-48, X-21): it rendered
// an editor that never created a sale, so nothing it showed was ever charged, printed or recorded.
/**
* The live messages of the installation as lines, in the order they are printed in.
*
* @param SimpleBillMessage[] $messages
*
* @return string[]
*/
private static function messageLinesOf(array $messages) {
$lines = array();
foreach($messages as $message){
$lines[] = (string) $message->getMessage();
}
return $lines;
}
/**
* A sale is not deleted (change verifactu-05, design decision 6; D-09).
*
* The action that did it removed the sale, returned its units to stock and refused only when the sale
* was linked to an invoice. Every sale is an invoice now, and one that was issued was declared to the
* AEAT the moment it was: what is wrong with it is corrected by issuing another document (change 06),
* never by taking the first one out of the database.
*/
protected function isDeleteEnabled(){
return false;
}
private function accounts(): ClientAccountLedger {
/** @var ClientAccountLedger $accounts */
$accounts = $this->container->get(ClientAccountLedger::class);
return $accounts;
}
}