<?php
namespace FMS\eLibreria\CmsBundle\Controller\UTools\Store;
use FMS\eLibreria\CmsBundle\Controller\CmsController;
use FMS\eLibreria\CmsBundle\Exception\TaxNotFoundException;
use FMS\eLibreria\CmsBundle\Utils\SearchSaleAjaxFactory\SearchSaleAjaxConstructor;
use FMS\eLibreria\CmsBundle\Verifactu\PosBuyerReader;
use FMS\eLibreria\CmsBundle\Verifactu\PosDocumentTypePresenter;
use FMS\eLibreria\CmsBundle\Verifactu\QuotePresenter;
use FMS\eLibreria\CmsBundle\Verifactu\QuoteRequestReader;
use FMS\eLibreria\CoreBundle\Merchandise\Stock\StockQuery;
use FMS\eLibreria\CoreBundle\Merchandise\Till\TillOffers;
use FMS\eLibreria\CoreBundle\Merchandise\Till\TillPricing;
use FMS\eLibreria\CoreBundle\Merchandise\Till\ValueObject\PackOffer;
use FMS\eLibreria\CoreBundle\Model\Entity\Bill;
use FMS\eLibreria\CoreBundle\Model\Repository\BillRepository;
use FMS\eLibreria\CoreBundle\Verifactu\Breakdown\AdvancePaymentLedger;
use FMS\eLibreria\CoreBundle\Verifactu\ClientAccount\ClientAccountLedger;
use FMS\eLibreria\CoreBundle\Verifactu\ClientAccount\Exception\ClientAccountRefused;
use FMS\eLibreria\CoreBundle\Verifactu\Breakdown\DocumentCalculator;
use FMS\eLibreria\CoreBundle\Verifactu\Breakdown\ServerAmounts;
use FMS\eLibreria\CoreBundle\Verifactu\Breakdown\ValueObject\AmountBasis;
use FMS\eLibreria\CoreBundle\Verifactu\Document\ValueObject\InvoiceType;
use FMS\eLibreria\CmsBundle\Utils\SearchSaleAjaxFactory\SearchSaleAjaxFactory;
use FMS\eLibreria\CoreBundle\Model\Entity\Article;
use FMS\eLibreria\CoreBundle\Model\Entity\Book;
use FMS\eLibreria\CoreBundle\Model\Entity\Client;
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\PayMethod;
use FMS\eLibreria\CoreBundle\Model\Entity\PromoCode;
use FMS\eLibreria\CoreBundle\Model\Entity\RapidCode;
use FMS\eLibreria\CoreBundle\Model\Entity\Sale;
use FMS\eLibreria\CoreBundle\Model\Entity\SaleItem;
use FMS\eLibreria\CoreBundle\Model\Entity\SaleMode;
use FMS\eLibreria\CoreBundle\Model\Entity\Tax;
use FMS\eLibreria\CoreBundle\Model\Repository\ArticleRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\FacilityRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\ItemRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\LocationRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\PromoCodeRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\RapidCodeRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\SaleItemRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\SaleItemReturnedRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\SaleRepository;
use FMS\eLibreria\CoreBundle\Model\Repository\TaxRepository;
use FMS\eLibreria\CoreBundle\Utils\JsonUtils;
use FMS\eLibreria\CoreBundle\Verifactu\Breakdown\Exception\VoucherRefused;
use FMS\eLibreria\CoreBundle\Verifactu\Document\Exception\DocumentCannotBeDelivered;
use FMS\eLibreria\CoreBundle\Verifactu\Document\Exception\IssuingRefused;
use FMS\eLibreria\CoreBundle\Verifactu\Pos\Exception\PaymentRefused;
use FMS\eLibreria\CoreBundle\Verifactu\Pos\Exception\PosChargeRefused;
use FMS\eLibreria\CoreBundle\Verifactu\Pos\PosChargeService;
use FMS\eLibreria\CoreBundle\Verifactu\Time\Exception\MissingFiscalTimezone;
use FMS\eLibreria\CoreBundle\Verifactu\Pos\ValueObject\PaymentMethod;
use FMS\eLibreria\CoreBundle\Verifactu\Pos\ValueObject\PosAdvanceChargeRequest;
use FMS\eLibreria\CoreBundle\Verifactu\Pos\ValueObject\PosChargeLine;
use FMS\eLibreria\CoreBundle\Verifactu\Pos\ValueObject\PosChargeRequest;
use FMS\eLibreria\CoreBundle\Verifactu\Pos\ValueObject\PosPayment;
use FMS\eLibreria\CoreBundle\Verifactu\Transaction\Exception\FiscalOperationRetryableException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
class SalesAjaxController extends CmsController {
private const TRANSLATION_DOMAIN = "cms";
/**
* What a cashier reads when the database asked the operation to be tried again (change verifactu-02,
* design decision 18).
*/
private const MESSAGE_TRY_AGAIN = "verifactu.transaction.try_again";
private const MESSAGE_FISCAL_TERRITORY_MISSING = "sales.editor.charge.refused.fiscal_territory_missing";
/**
* What a cashier reads when something asks to change a sale that was already charged (change
* verifactu-05, design decision 6).
*/
private const MESSAGE_SALE_IS_IMMUTABLE = "sales.editor.charge.refused.sale_is_immutable";
/**
* What the document on the screen would say if it were charged now: lines, groups, the discount of the
* document, totals and the reasons it cannot be added up (change verifactu-03, design decision 15).
*
* The till renders this and computes nothing of its own. Nothing here is saved.
*/
public function quote(
Request $request,
QuoteRequestReader $reader,
QuotePresenter $presenter,
DocumentCalculator $calculator,
PosBuyerReader $buyers,
PosDocumentTypePresenter $documentTypes
){
if(!$request->isXmlHttpRequest()){
return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
}
$data = json_decode((string) $request->getContent(), true);
if(!is_array($data)){
return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
}
// The price on the shelf is what the customer pays, so the base comes out of it (decision 2).
$quoteRequest = $reader->read($data, AmountBasis::Gross, InvoiceType::F2);
$quote = $calculator->quote($quoteRequest);
$answer = $presenter->present($quote);
/** @var Client|null $recipient */
$recipient = array_key_exists("client", $data) && !is_null($data["client"])
? $this->getEntityManager()->getRepository(Client::class)->findByHash($data["client"])
: null;
// Which document this sale would be, decided by the amount of the goods and by who is buying
// (change verifactu-05, design decision 3). The till renders it and decides nothing.
$answer["document"] = $documentTypes->present(
$quote->isRefused() ? "0.00" : $quote->totals()->getImporteTotal(),
$buyers->read($data),
!is_null($recipient),
is_null($recipient) ? null : (string) $recipient->getIdentificationNumber()
);
return new JsonResponse($answer);
}
/**
* Charging a sale: the one entry point of the till, which issues the document of the sale through
* `PosChargeService` (change verifactu-05, design decision 4; D-09).
*
* What arrives is what the sale **is** — which units, which concepts, who is buying and how they pay —
* and never what it is worth: the amounts, the store, the document type and the series are the
* server's. Who may issue is the firewall's answer before this method runs: issuing is the daily work
* of any employee (change verifactu-02, design decision 10).
*/
public function save(
Request $request,
PosChargeService $charges,
PosBuyerReader $buyers,
TranslatorInterface $translator
){
if(!$request->isXmlHttpRequest()){
return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
}
$data = json_decode((string) $request->getContent(), true);
if(!is_array($data)){
return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
}
// A sale is written once, when it is charged, and never again (change verifactu-05, design
// decision 6; D-09). A request that names one is asking to change an invoice that was declared, so
// nothing is read of it and nothing is touched: it is a conflict with what is already stored.
$named = $data["sale"] ?? $data["hash"] ?? null;
if(is_string($named) && $named !== ""){
return new JsonResponse(array(
JsonUtils::JSON_KEY_SUCCESS => false,
JsonUtils::JSON_KEY_ERRORS => $translator->trans(
self::MESSAGE_SALE_IS_IMMUTABLE,
array(),
self::TRANSLATION_DOMAIN
),
), Response::HTTP_CONFLICT);
}
$employee = $this->getUser();
if(!$employee instanceof Employee){
throw $this->createAccessDeniedException();
}
try {
$sale = $charges->charge(new PosChargeRequest(
$employee,
(string) ($data["store"] ?? ""),
$this->linesOf($data),
$this->paymentsOf($data),
$buyers->read($data),
$data["client"] ?? null,
$data["promoCode"] ?? null,
// What the customer already paid on account, which comes off what they pay now
// (change verifactu-05, design decision 15).
$this->advancesOf($data),
// The button pressed: how the customer pays what the payments leave (merch-09, M-94).
differenceMethod: $this->restMethodOf($data)
));
} catch (PosChargeRefused $refused){
return $this->refusal($refused->messageIn($translator, self::TRANSLATION_DOMAIN));
} catch (IssuingRefused $refused){
return $this->refusal($translator->trans(
$refused->refusal()->messageKey(),
$refused->refusal()->parameters(),
self::TRANSLATION_DOMAIN
));
} catch (VoucherRefused $refused){
return $this->refusal($translator->trans($refused->messageKey(), $refused->parameters(), self::TRANSLATION_DOMAIN));
} catch (PaymentRefused|ClientAccountRefused $refused){
return $this->refusal($translator->trans($refused->messageKey(), $refused->parameters(), self::TRANSLATION_DOMAIN));
} catch (FiscalOperationRetryableException $tryAgain){
return $this->refusal($translator->trans(self::MESSAGE_TRY_AGAIN, array(), self::TRANSLATION_DOMAIN));
} catch (MissingFiscalTimezone $missing){
// An entity upgraded from before the program has no territory until an administrator chooses it: the till
// says so and where, instead of «No se ha podido cobrar» (change merch-09, task 3.2; F-51).
return $this->refusal($translator->trans(self::MESSAGE_FISCAL_TERRITORY_MISSING, array('%entity%' => $missing->entityName()), self::TRANSLATION_DOMAIN));
}
return new JsonResponse(array(
JsonUtils::JSON_KEY_SUCCESS => true,
JsonUtils::JSON_KEY_SALE => $sale,
JsonUtils::JSON_KEY_TARGET => $this->generateUrl(
"cms_utools_store_sales_view",
array("hash" => $sale->getHash())
),
));
}
/**
* Charges an advance payment: the customer reserves what is on the screen and leaves money on account
* (change verifactu-05, design decision 15; D-63).
*
* The lines of the screen are the **reserved goods** and nothing else: no unit of stock is locked and none
* is sold, because the goods stay on the shelf until the customer comes for them. What is issued is a
* ticket or an invoice of the amount left.
*/
public function saveAdvance(
Request $request,
PosChargeService $charges,
PosBuyerReader $buyers,
TranslatorInterface $translator
){
if(!$request->isXmlHttpRequest()){
return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
}
$data = json_decode((string) $request->getContent(), true);
if(!is_array($data)){
return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
}
$employee = $this->getUser();
if(!$employee instanceof Employee){
throw $this->createAccessDeniedException();
}
try {
$sale = $charges->chargeAdvance(new PosAdvanceChargeRequest(
$employee,
(string) ($data["store"] ?? ""),
$this->linesOf($data),
(string) ($data["advance"] ?? "0"),
$this->paymentsOf($data),
$buyers->read($data),
$data["client"] ?? null,
$this->restMethodOf($data)
));
} catch (PosChargeRefused $refused){
return $this->refusal($refused->messageIn($translator, self::TRANSLATION_DOMAIN));
} catch (IssuingRefused $refused){
return $this->refusal($translator->trans(
$refused->refusal()->messageKey(),
$refused->refusal()->parameters(),
self::TRANSLATION_DOMAIN
));
} catch (VoucherRefused $refused){
return $this->refusal($translator->trans($refused->messageKey(), $refused->parameters(), self::TRANSLATION_DOMAIN));
} catch (PaymentRefused|ClientAccountRefused $refused){
return $this->refusal($translator->trans($refused->messageKey(), $refused->parameters(), self::TRANSLATION_DOMAIN));
} catch (FiscalOperationRetryableException $tryAgain){
return $this->refusal($translator->trans(self::MESSAGE_TRY_AGAIN, array(), self::TRANSLATION_DOMAIN));
} catch (MissingFiscalTimezone $missing){
// An entity upgraded from before the program has no territory until an administrator chooses it: the till
// says so and where, instead of «No se ha podido cobrar» (change merch-09, task 3.2; F-51).
return $this->refusal($translator->trans(self::MESSAGE_FISCAL_TERRITORY_MISSING, array('%entity%' => $missing->entityName()), self::TRANSLATION_DOMAIN));
}
return new JsonResponse(array(
JsonUtils::JSON_KEY_SUCCESS => true,
JsonUtils::JSON_KEY_SALE => $sale,
JsonUtils::JSON_KEY_TARGET => $this->generateUrl(
"cms_utools_store_sales_view",
array("hash" => $sale->getHash())
),
));
}
/**
* What is left of an advance the cashier typed the number of, so the till can show it and send it with the
* charge (design decision 15).
*/
public function advance(Request $request, AdvancePaymentLedger $ledger, TranslatorInterface $translator){
if(!$request->isXmlHttpRequest()){
return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
}
$data = json_decode((string) $request->getContent(), true);
$number = trim((string) (is_array($data) ? ($data["number"] ?? "") : ""));
/** @var BillRepository $bills */
$bills = $this->getEntityManager()->getRepository(Bill::class);
$advance = $bills->findByBillIdentificationNumber($number);
if(!$advance instanceof Bill || !$advance->isAdvancePayment() || !$advance->isIssued()){
return $this->refusal($translator->trans(
PosChargeRefused::ADVANCE_CANNOT_BE_DEDUCTED,
array("%document%" => $number),
self::TRANSLATION_DOMAIN
));
}
$remaining = $ledger->remainingOf($advance)->total();
return new JsonResponse(array(
JsonUtils::JSON_KEY_SUCCESS => true,
"advance" => array(
"hash" => $advance->getHash(),
"number" => $advance->getBillIdentificationNumber(),
"remaining" => $remaining,
// The reservation itself, so the till can lead to «Anular reserva» when the customer is not
// taking the goods after all (change verifactu-06, design decision 9; D-63).
"sale" => $advance->getSale()?->getHash(),
),
));
}
/**
* @param array<string, mixed> $data
*
* @return list<string>
*/
private function advancesOf(array $data): array {
$advances = array();
foreach(is_array($data["advances"] ?? null) ? $data["advances"] : array() as $advance){
$hash = is_array($advance) ? ($advance["hash"] ?? null) : $advance;
if(is_string($hash) && $hash !== ""){
$advances[] = $hash;
}
}
return $advances;
}
/**
* What the till says a refusal is: the words, and nothing charged. It is not an error of the request —
* the cashier can fix it — so it answers 200 with the reason (§B7).
*/
/**
* The cashier types the code printed on the return, not the voucher's internal hash: the code is resolved here,
* and what matches no voucher goes on as typed, so the service refuses it by name (change merch-09, task 0.1;
* F-25).
*/
private function voucherHashOf(string $typed): string {
$promoCodes = $this->getEntityManager()->getRepository(PromoCode::class);
\assert($promoCodes instanceof PromoCodeRepository);
$voucher = $promoCodes->findOneByPrintedCode($typed);
return $voucher instanceof PromoCode ? (string) $voucher->getHash() : trim($typed);
}
private function refusal(string $reason): JsonResponse {
return new JsonResponse(array(
JsonUtils::JSON_KEY_SUCCESS => false,
JsonUtils::JSON_KEY_ERRORS => $reason,
));
}
/**
* The lines of the charge: a line of stock names the till line that holds it — what it holds is read from the
* commitments, never from the browser —, and anything else carries its concept, its price and its tax (change
* verifactu-05, design decision 4; change merch-04, design decision 5).
*
* @param array<string, mixed> $data
*
* @return list<PosChargeLine>
*/
private function linesOf(array $data): array {
$lines = array();
foreach(is_array($data["lines"] ?? null) ? $data["lines"] : array() as $line){
if(!is_array($line)){
continue;
}
$heldLine = $line["line"] ?? null;
if(is_string($heldLine) && "" !== $heldLine){
$lines[] = PosChargeLine::ofHeld(
$heldLine,
(string) ($line["units"] ?? "1"),
(string) ($line["discount"] ?? "0"),
(string) ($line["concept"] ?? "")
);
continue;
}
$lines[] = PosChargeLine::ofConcept(
(string) ($line["concept"] ?? ""),
(string) ($line["units"] ?? "1"),
(string) ($line["price"] ?? "0"),
$line["tax"] ?? null,
(string) ($line["discount"] ?? "0")
);
}
return $lines;
}
/**
* How the sale was paid. A method the till does not know is refused by the service, which is where the
* rules are (design decision 8).
*
* @param array<string, mixed> $data
*
* @return list<PosPayment>
*/
private function paymentsOf(array $data): array {
$payments = array();
foreach(is_array($data["payments"] ?? null) ? $data["payments"] : array() as $payment){
if(!is_array($payment)){
continue;
}
$method = PaymentMethod::tryFrom((string) ($payment["method"] ?? ""));
if(null === $method){
continue;
}
$amount = (string) ($payment["amount"] ?? "0");
if(PaymentMethod::Voucher === $method){
$payments[] = PosPayment::voucher($amount, $this->voucherHashOf((string) ($payment["voucher"] ?? "")));
continue;
}
if(PaymentMethod::ThirdPartyCheque === $method){
$payments[] = PosPayment::cheque(
$amount,
(string) ($payment["chequeIssuer"] ?? ""),
$payment["chequeReference"] ?? null
);
continue;
}
$payments[] = PosPayment::of($method, $amount);
}
return $payments;
}
/**
* The method of the button pressed, which pays what the payments leave; none when the payments cover it all
* (change merch-09, task 0.9; M-94). The service says how much.
*
* @param array<string, mixed> $data
*/
private function restMethodOf(array $data): ?PaymentMethod {
return PaymentMethod::tryFrom((string) ($data["rest"] ?? ""));
}
/**
* A ticket that is about to be printed is a delivery, and the till says so before it prints.
*
* The point of sale prints from the browser, so the server never sees the print: this notice is what it
* sees. It writes the delivery of the fiscal document the sale belongs to — today the invoice the sale
* was billed in, and from change 05 the simplified invoice of the ticket itself — and refuses the print
* of a document that can still change (change verifactu-02, decision 16; L-14, K-17).
*
* A sale that belongs to no document yet is not refused: there is nothing to keep unaltered and nothing
* to record.
*/
public function printNotice(Request $request){
if(!$request->isXmlHttpRequest()){
return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
}
$data = JsonUtils::loadJSONFromRequest($request);
$hash = JsonUtils::loadParameter(JsonUtils::JSON_KEY_HASH, $data);
/** @var SaleRepository $saleRepository */
$saleRepository = $this->getEntityManager()->getRepository(Sale::class);
/** @var Sale $sale */
$sale = $saleRepository->findByHash($hash);
if(!$sale){
return new JsonResponse(null, Response::HTTP_NOT_FOUND);
}
$document = $sale->getBillLinked();
if(is_null($document)){
return new JsonResponse(array(self::JSON_KEY_RECORDED => false));
}
try {
$this->getFiscalDocumentService()->recordDelivery($document);
} catch (DocumentCannotBeDelivered $refused) {
return new JsonResponse(
array(JsonUtils::JSON_KEY_ERRORS => array(DocumentCannotBeDelivered::CODE)),
Response::HTTP_CONFLICT
);
}
return new JsonResponse(array(self::JSON_KEY_RECORDED => true));
}
const JSON_KEY_RECORDED = "recorded";
/** How many articles the till's search offers: a cashier types until the book is among them. */
private const SEARCH_LIMIT = 15;
// What the till's search offers besides loose copies (change merch-05, design decisions 3 and 6).
private const OFFER_PACK = "pack";
private const OFFER_KIT = "kit";
private const JSON_KEY_PRESENTATION = "presentation";
private const JSON_KEY_AVAILABLE_TEXT = "available_text";
private const PACK_TITLE = "sales.editor.till.pack";
private const PACKS_AVAILABLE = "sales.editor.till.packs_available";
private const KIT_TITLE = "sales.editor.till.kit";
private const DOMAIN = "cms";
/**
* What the till's search offers for a pack's code: the article in packs, with the whole packs every location has
* and the loose copies left (change merch-05, design decision 3). The line it holds is priced by the server.
*
* @return array<string, mixed>
*/
private function packOffered(PackOffer $offer, LocationRepository $locations, TranslatorInterface $translator): array {
$presentation = $offer->presentation;
$article = $presentation->getArticle();
$offered = SearchSaleAjaxFactory::getSearchSaleAjaxConstructor($article, array())->getSearchSaleAjaxObject()->jsonSerialize();
$offered[JsonUtils::JSON_KEY_AVAILABLE_LOCATIONS] = array();
foreach ($offer->byLocation as $locationId => $packs) {
$location = $locations->find($locationId);
if ($location instanceof Location) {
$offered[JsonUtils::JSON_KEY_AVAILABLE_LOCATIONS][] = $location->jsonSerialize() + array(
JsonUtils::JSON_KEY_AVAILABLE => $packs['packs'],
self::JSON_KEY_AVAILABLE_TEXT => $translator->trans(self::PACKS_AVAILABLE, array('%packs%' => $packs['packs'], '%loose%' => $packs['loose']), self::DOMAIN),
);
}
}
$offered[JsonUtils::JSON_KEY_TYPE] = self::OFFER_PACK;
$offered[JsonUtils::JSON_KEY_TYPE_TITLE] = $translator->trans(self::PACK_TITLE, array('%factor%' => $presentation->getFactor()), self::DOMAIN);
$offered[JsonUtils::JSON_KEY_TITLE] = sprintf(TillPricing::PACK_CONCEPT, (string) $article->getTitle(), $presentation->getFactor());
$offered[JsonUtils::JSON_KEY_ITEM][JsonUtils::JSON_KEY_EAN] = $presentation->getEan();
$offered[JsonUtils::JSON_KEY_ITEM][self::JSON_KEY_PRESENTATION] = $presentation->getHash();
$offered[JsonUtils::JSON_KEY_ITEM][JsonUtils::JSON_KEY_SALE_PRICE] = $presentation->getSalePrice();
return $offered;
}
/**
* What the till's search offers for a kit sold without stock of its own: the kit, and how many every location can
* make from its components (change merch-05, design decision 6).
*
* @param array<int, int> $byLocation
*
* @return array<string, mixed>
*/
private function kitOffered(Article $kit, array $byLocation, LocationRepository $locations, TranslatorInterface $translator): array {
$offered = SearchSaleAjaxFactory::getSearchSaleAjaxConstructor($kit, $this->locationsOffered($byLocation, $locations))->getSearchSaleAjaxObject()->jsonSerialize();
$offered[JsonUtils::JSON_KEY_TYPE] = self::OFFER_KIT;
$offered[JsonUtils::JSON_KEY_TYPE_TITLE] = $translator->trans(self::KIT_TITLE, array(), self::DOMAIN);
return $offered;
}
/**
* The locations the till's modal offers for an article, each with what it has available there.
*
* @param array<int, int> $availableByLocation
*
* @return list<array<string, mixed>>
*/
private function locationsOffered(array $availableByLocation, LocationRepository $locations): array {
$offered = array();
foreach ($availableByLocation as $locationId => $available) {
$location = $locations->find($locationId);
if ($location instanceof Location) {
$offered[] = $location->jsonSerialize() + array(JsonUtils::JSON_KEY_AVAILABLE => $available);
}
}
return $offered;
}
/**
* What the till's search offers: rapid codes, and the articles a till can take a copy of, each with the locations
* it can be taken from and what each has available, read from the ledger (change merch-04, design decision 11).
*/
public function autocomplete(Request $request, LoggerInterface $logger, StockQuery $stock, TillOffers $offers, TranslatorInterface $translator){
$res = array();
if($request->isXmlHttpRequest()) {
$data = JsonUtils::loadJSONFromRequest($request);
$search = JsonUtils::loadParameter(JsonUtils::JSON_KEY_SEARCH, $data);
$addReservations = JsonUtils::loadParameter(JsonUtils::JSON_KEY_ADD_RESERVATIONS, $data, false);
// $facilityHash = JsonUtils::loadParameter(JsonUtils::JSON_KEY_FACILITY, $data);
$maxItems = intval(JsonUtils::loadParameter(JsonUtils::JSON_KEY_ITEMS, $data, false, false));
if(!is_null($search) && $search != ""){
$logger->debug("Search: " . $search);
/** @var RapidCodeRepository $rapidCodeRepository */
$rapidCodeRepository = $this->getEntityManager()->getRepository(RapidCode::class);
/** @var LocationRepository $locationRepository */
$locationRepository = $this->getEntityManager()->getRepository(Location::class);
/** @var ArticleRepository $articleRepository */
$articleRepository = $this->getEntityManager()->getRepository(Article::class);
// /** @var FacilityRepository $facilityRepository */
// $facilityRepository = $this->getEntityManager()->getRepository(Facility::class);
// /** @var Facility $facility */
// $facility = $facilityRepository->findByHash($facilityHash);
// if(!$facility){
// throw new \InvalidArgumentException("Tienda no encontrada");
// }
$data = array();
$items = array();
$items = array_merge($items, $rapidCodeRepository->findRapidCodesByTitleOrCode($search));
$logger->debug(var_export(array_keys($items), true));
$available = $stock->selectableByArticle((string) $search, true === $addReservations);
$articles = array() === $available ? array() : $articleRepository->findBy(array("id" => array_keys($available)));
usort($articles, static fn (Article $one, Article $other): int => strcmp((string) $one->getTitle(), (string) $other->getTitle()));
$items = array_merge($items, array_slice($articles, 0, self::SEARCH_LIMIT));
foreach ($items as $obj) {
$availableLocations = array();
if($obj instanceof Article) {
$availableLocations = $this->locationsOffered($available[(int) $obj->getId()] ?? array(), $locationRepository);
}
/** @var SearchSaleAjaxConstructor $searchSaleAjaxConstructor */
$searchSaleAjaxConstructor = SearchSaleAjaxFactory::getSearchSaleAjaxConstructor($obj, $availableLocations);
$data[] = $searchSaleAjaxConstructor->getSearchSaleAjaxObject();
}
// A pack's code is the pack, first, so a scan holds it; a kit sold without stock of its own is offered
// with what its components can make (change merch-05, design decisions 3 and 6).
$pack = $offers->packOf((string) $search);
if (null !== $pack) {
array_unshift($data, $this->packOffered($pack, $locationRepository, $translator));
}
foreach ($offers->phantomKitsMatching((string) $search) as $kitId => $byLocation) {
$kit = $articleRepository->find($kitId);
if ($kit instanceof Article) {
$data[] = $this->kitOffered($kit, $byLocation, $locationRepository, $translator);
}
}
$res = $data;
if(!is_null($maxItems) && $maxItems > 0){
$logger->debug("MaxItems: " . $maxItems);
$res = array_splice($data, 0, $maxItems);
}
}
}
return new JsonResponse($res);
}
public function promoCode(Request $request, LoggerInterface $logger){
$res = null;
if($request->isXmlHttpRequest()) {
$data = JsonUtils::loadJSONFromRequest($request);
$promoCodeSearch = JsonUtils::loadParameter(JsonUtils::JSON_KEY_PROMO_CODE, $data);
$saleHash = JsonUtils::loadParameter(JsonUtils::JSON_KEY_SALE_HASH, $data, false);
$logger->debug("Search Promo code: " . $promoCodeSearch);
/** @var PromoCodeRepository $promoCodeRepository */
$promoCodeRepository = $this->getEntityManager()->getRepository(PromoCode::class);
/** @var PromoCode $promoCode */
$promoCode = $promoCodeRepository->findPromoCodeByCode($promoCodeSearch);
if($promoCode) {
$limitTotal = $promoCode->getLimitTotal();
if(!is_null($limitTotal)){
$promoCode->setLimit($limitTotal - $promoCode->getActualConsumed());
}
$logger->debug("Adding promo code: " . $promoCode->getHash() . " Limit: " . $promoCode->getAbsoluteLimit());
$res = $promoCode;
}
if(!is_null($saleHash) && $saleHash != "") {
/** @var SaleRepository $saleRepository */
$saleRepository = $this->getEntityManager()->getRepository(Sale::class);
/** @var Sale $sale */
$sale = $saleRepository->findByHash($saleHash);
if($sale){
$promoCodeInSale = $sale->getPromoCodeApplied();
if(!is_null($promoCodeInSale)){
$logger->debug("Promo code applied on sale: " . $promoCodeInSale->getCode());
if($promoCodeInSale->getCode() == $promoCodeSearch) {
$promoCodeInSale->setLimit($sale->getTotalDiscounted());
$logger->debug("Forcing To Old Promo code. Limit: " . $promoCodeInSale->getAbsoluteLimit());
$res = $promoCodeInSale;
}
}
}
}
}
$response = new JsonResponse();
if(is_null($res)){
$response->setStatusCode(400);
} else {
$res = array(
JsonUtils::JSON_KEY_SUCCESS => true,
JsonUtils::JSON_KEY_PROMO_CODE => $res
);
$response->setContent(json_encode($res));
}
return $response;
}
/**
* The balance of the account of a client, for the till to offer it as a payment (change verifactu-14, task
* 4.6). It only reads: the charge locks the account and spends it, and refuses what the balance does not
* cover whatever the till showed.
*/
public function clientBalance(Request $request, ClientAccountLedger $accounts): JsonResponse {
$data = json_decode((string) $request->getContent(), true);
$hash = is_array($data) && isset($data["client"]) ? (string) $data["client"] : "";
$client = "" === $hash ? null : $this->getEntityManager()->getRepository(Client::class)->findByHash($hash);
if (!$client instanceof Client) {
return new JsonResponse(array("balance" => null), 404);
}
return new JsonResponse(array("balance" => $accounts->balanceOf($client)));
}
}