src/Controller/UTools/Store/SalesAjaxController.php line 112

Open in your IDE?
  1. <?php
  2. namespace FMS\eLibreria\CmsBundle\Controller\UTools\Store;
  3. use FMS\eLibreria\CmsBundle\Controller\CmsController;
  4. use FMS\eLibreria\CmsBundle\Exception\TaxNotFoundException;
  5. use FMS\eLibreria\CmsBundle\Utils\SearchSaleAjaxFactory\SearchSaleAjaxConstructor;
  6. use FMS\eLibreria\CmsBundle\Verifactu\PosBuyerReader;
  7. use FMS\eLibreria\CmsBundle\Verifactu\PosDocumentTypePresenter;
  8. use FMS\eLibreria\CmsBundle\Verifactu\QuotePresenter;
  9. use FMS\eLibreria\CmsBundle\Verifactu\QuoteRequestReader;
  10. use FMS\eLibreria\CoreBundle\Merchandise\Stock\StockQuery;
  11. use FMS\eLibreria\CoreBundle\Merchandise\Till\TillOffers;
  12. use FMS\eLibreria\CoreBundle\Merchandise\Till\TillPricing;
  13. use FMS\eLibreria\CoreBundle\Merchandise\Till\ValueObject\PackOffer;
  14. use FMS\eLibreria\CoreBundle\Model\Entity\Bill;
  15. use FMS\eLibreria\CoreBundle\Model\Repository\BillRepository;
  16. use FMS\eLibreria\CoreBundle\Verifactu\Breakdown\AdvancePaymentLedger;
  17. use FMS\eLibreria\CoreBundle\Verifactu\ClientAccount\ClientAccountLedger;
  18. use FMS\eLibreria\CoreBundle\Verifactu\ClientAccount\Exception\ClientAccountRefused;
  19. use FMS\eLibreria\CoreBundle\Verifactu\Breakdown\DocumentCalculator;
  20. use FMS\eLibreria\CoreBundle\Verifactu\Breakdown\ServerAmounts;
  21. use FMS\eLibreria\CoreBundle\Verifactu\Breakdown\ValueObject\AmountBasis;
  22. use FMS\eLibreria\CoreBundle\Verifactu\Document\ValueObject\InvoiceType;
  23. use FMS\eLibreria\CmsBundle\Utils\SearchSaleAjaxFactory\SearchSaleAjaxFactory;
  24. use FMS\eLibreria\CoreBundle\Model\Entity\Article;
  25. use FMS\eLibreria\CoreBundle\Model\Entity\Book;
  26. use FMS\eLibreria\CoreBundle\Model\Entity\Client;
  27. use FMS\eLibreria\CoreBundle\Model\Entity\Employee;
  28. use FMS\eLibreria\CoreBundle\Model\Entity\Facility;
  29. use FMS\eLibreria\CoreBundle\Model\Entity\Item;
  30. use FMS\eLibreria\CoreBundle\Model\Entity\Location;
  31. use FMS\eLibreria\CoreBundle\Model\Entity\PayMethod;
  32. use FMS\eLibreria\CoreBundle\Model\Entity\PromoCode;
  33. use FMS\eLibreria\CoreBundle\Model\Entity\RapidCode;
  34. use FMS\eLibreria\CoreBundle\Model\Entity\Sale;
  35. use FMS\eLibreria\CoreBundle\Model\Entity\SaleItem;
  36. use FMS\eLibreria\CoreBundle\Model\Entity\SaleMode;
  37. use FMS\eLibreria\CoreBundle\Model\Entity\Tax;
  38. use FMS\eLibreria\CoreBundle\Model\Repository\ArticleRepository;
  39. use FMS\eLibreria\CoreBundle\Model\Repository\FacilityRepository;
  40. use FMS\eLibreria\CoreBundle\Model\Repository\ItemRepository;
  41. use FMS\eLibreria\CoreBundle\Model\Repository\LocationRepository;
  42. use FMS\eLibreria\CoreBundle\Model\Repository\PromoCodeRepository;
  43. use FMS\eLibreria\CoreBundle\Model\Repository\RapidCodeRepository;
  44. use FMS\eLibreria\CoreBundle\Model\Repository\SaleItemRepository;
  45. use FMS\eLibreria\CoreBundle\Model\Repository\SaleItemReturnedRepository;
  46. use FMS\eLibreria\CoreBundle\Model\Repository\SaleRepository;
  47. use FMS\eLibreria\CoreBundle\Model\Repository\TaxRepository;
  48. use FMS\eLibreria\CoreBundle\Utils\JsonUtils;
  49. use FMS\eLibreria\CoreBundle\Verifactu\Breakdown\Exception\VoucherRefused;
  50. use FMS\eLibreria\CoreBundle\Verifactu\Document\Exception\DocumentCannotBeDelivered;
  51. use FMS\eLibreria\CoreBundle\Verifactu\Document\Exception\IssuingRefused;
  52. use FMS\eLibreria\CoreBundle\Verifactu\Pos\Exception\PaymentRefused;
  53. use FMS\eLibreria\CoreBundle\Verifactu\Pos\Exception\PosChargeRefused;
  54. use FMS\eLibreria\CoreBundle\Verifactu\Pos\PosChargeService;
  55. use FMS\eLibreria\CoreBundle\Verifactu\Time\Exception\MissingFiscalTimezone;
  56. use FMS\eLibreria\CoreBundle\Verifactu\Pos\ValueObject\PaymentMethod;
  57. use FMS\eLibreria\CoreBundle\Verifactu\Pos\ValueObject\PosAdvanceChargeRequest;
  58. use FMS\eLibreria\CoreBundle\Verifactu\Pos\ValueObject\PosChargeLine;
  59. use FMS\eLibreria\CoreBundle\Verifactu\Pos\ValueObject\PosChargeRequest;
  60. use FMS\eLibreria\CoreBundle\Verifactu\Pos\ValueObject\PosPayment;
  61. use FMS\eLibreria\CoreBundle\Verifactu\Transaction\Exception\FiscalOperationRetryableException;
  62. use Psr\Log\LoggerInterface;
  63. use Symfony\Component\HttpFoundation\JsonResponse;
  64. use Symfony\Component\HttpFoundation\Request;
  65. use Symfony\Component\HttpFoundation\Response;
  66. use Symfony\Contracts\Translation\TranslatorInterface;
  67. class SalesAjaxController extends CmsController {
  68. private const TRANSLATION_DOMAIN = "cms";
  69. /**
  70. * What a cashier reads when the database asked the operation to be tried again (change verifactu-02,
  71. * design decision 18).
  72. */
  73. private const MESSAGE_TRY_AGAIN = "verifactu.transaction.try_again";
  74. private const MESSAGE_FISCAL_TERRITORY_MISSING = "sales.editor.charge.refused.fiscal_territory_missing";
  75. /**
  76. * What a cashier reads when something asks to change a sale that was already charged (change
  77. * verifactu-05, design decision 6).
  78. */
  79. private const MESSAGE_SALE_IS_IMMUTABLE = "sales.editor.charge.refused.sale_is_immutable";
  80. /**
  81. * What the document on the screen would say if it were charged now: lines, groups, the discount of the
  82. * document, totals and the reasons it cannot be added up (change verifactu-03, design decision 15).
  83. *
  84. * The till renders this and computes nothing of its own. Nothing here is saved.
  85. */
  86. public function quote(
  87. Request $request,
  88. QuoteRequestReader $reader,
  89. QuotePresenter $presenter,
  90. DocumentCalculator $calculator,
  91. PosBuyerReader $buyers,
  92. PosDocumentTypePresenter $documentTypes
  93. ){
  94. if(!$request->isXmlHttpRequest()){
  95. return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
  96. }
  97. $data = json_decode((string) $request->getContent(), true);
  98. if(!is_array($data)){
  99. return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
  100. }
  101. // The price on the shelf is what the customer pays, so the base comes out of it (decision 2).
  102. $quoteRequest = $reader->read($data, AmountBasis::Gross, InvoiceType::F2);
  103. $quote = $calculator->quote($quoteRequest);
  104. $answer = $presenter->present($quote);
  105. /** @var Client|null $recipient */
  106. $recipient = array_key_exists("client", $data) && !is_null($data["client"])
  107. ? $this->getEntityManager()->getRepository(Client::class)->findByHash($data["client"])
  108. : null;
  109. // Which document this sale would be, decided by the amount of the goods and by who is buying
  110. // (change verifactu-05, design decision 3). The till renders it and decides nothing.
  111. $answer["document"] = $documentTypes->present(
  112. $quote->isRefused() ? "0.00" : $quote->totals()->getImporteTotal(),
  113. $buyers->read($data),
  114. !is_null($recipient),
  115. is_null($recipient) ? null : (string) $recipient->getIdentificationNumber()
  116. );
  117. return new JsonResponse($answer);
  118. }
  119. /**
  120. * Charging a sale: the one entry point of the till, which issues the document of the sale through
  121. * `PosChargeService` (change verifactu-05, design decision 4; D-09).
  122. *
  123. * What arrives is what the sale **is** — which units, which concepts, who is buying and how they pay —
  124. * and never what it is worth: the amounts, the store, the document type and the series are the
  125. * server's. Who may issue is the firewall's answer before this method runs: issuing is the daily work
  126. * of any employee (change verifactu-02, design decision 10).
  127. */
  128. public function save(
  129. Request $request,
  130. PosChargeService $charges,
  131. PosBuyerReader $buyers,
  132. TranslatorInterface $translator
  133. ){
  134. if(!$request->isXmlHttpRequest()){
  135. return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
  136. }
  137. $data = json_decode((string) $request->getContent(), true);
  138. if(!is_array($data)){
  139. return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
  140. }
  141. // A sale is written once, when it is charged, and never again (change verifactu-05, design
  142. // decision 6; D-09). A request that names one is asking to change an invoice that was declared, so
  143. // nothing is read of it and nothing is touched: it is a conflict with what is already stored.
  144. $named = $data["sale"] ?? $data["hash"] ?? null;
  145. if(is_string($named) && $named !== ""){
  146. return new JsonResponse(array(
  147. JsonUtils::JSON_KEY_SUCCESS => false,
  148. JsonUtils::JSON_KEY_ERRORS => $translator->trans(
  149. self::MESSAGE_SALE_IS_IMMUTABLE,
  150. array(),
  151. self::TRANSLATION_DOMAIN
  152. ),
  153. ), Response::HTTP_CONFLICT);
  154. }
  155. $employee = $this->getUser();
  156. if(!$employee instanceof Employee){
  157. throw $this->createAccessDeniedException();
  158. }
  159. try {
  160. $sale = $charges->charge(new PosChargeRequest(
  161. $employee,
  162. (string) ($data["store"] ?? ""),
  163. $this->linesOf($data),
  164. $this->paymentsOf($data),
  165. $buyers->read($data),
  166. $data["client"] ?? null,
  167. $data["promoCode"] ?? null,
  168. // What the customer already paid on account, which comes off what they pay now
  169. // (change verifactu-05, design decision 15).
  170. $this->advancesOf($data),
  171. // The button pressed: how the customer pays what the payments leave (merch-09, M-94).
  172. differenceMethod: $this->restMethodOf($data)
  173. ));
  174. } catch (PosChargeRefused $refused){
  175. return $this->refusal($refused->messageIn($translator, self::TRANSLATION_DOMAIN));
  176. } catch (IssuingRefused $refused){
  177. return $this->refusal($translator->trans(
  178. $refused->refusal()->messageKey(),
  179. $refused->refusal()->parameters(),
  180. self::TRANSLATION_DOMAIN
  181. ));
  182. } catch (VoucherRefused $refused){
  183. return $this->refusal($translator->trans($refused->messageKey(), $refused->parameters(), self::TRANSLATION_DOMAIN));
  184. } catch (PaymentRefused|ClientAccountRefused $refused){
  185. return $this->refusal($translator->trans($refused->messageKey(), $refused->parameters(), self::TRANSLATION_DOMAIN));
  186. } catch (FiscalOperationRetryableException $tryAgain){
  187. return $this->refusal($translator->trans(self::MESSAGE_TRY_AGAIN, array(), self::TRANSLATION_DOMAIN));
  188. } catch (MissingFiscalTimezone $missing){
  189. // An entity upgraded from before the program has no territory until an administrator chooses it: the till
  190. // says so and where, instead of «No se ha podido cobrar» (change merch-09, task 3.2; F-51).
  191. return $this->refusal($translator->trans(self::MESSAGE_FISCAL_TERRITORY_MISSING, array('%entity%' => $missing->entityName()), self::TRANSLATION_DOMAIN));
  192. }
  193. return new JsonResponse(array(
  194. JsonUtils::JSON_KEY_SUCCESS => true,
  195. JsonUtils::JSON_KEY_SALE => $sale,
  196. JsonUtils::JSON_KEY_TARGET => $this->generateUrl(
  197. "cms_utools_store_sales_view",
  198. array("hash" => $sale->getHash())
  199. ),
  200. ));
  201. }
  202. /**
  203. * Charges an advance payment: the customer reserves what is on the screen and leaves money on account
  204. * (change verifactu-05, design decision 15; D-63).
  205. *
  206. * The lines of the screen are the **reserved goods** and nothing else: no unit of stock is locked and none
  207. * is sold, because the goods stay on the shelf until the customer comes for them. What is issued is a
  208. * ticket or an invoice of the amount left.
  209. */
  210. public function saveAdvance(
  211. Request $request,
  212. PosChargeService $charges,
  213. PosBuyerReader $buyers,
  214. TranslatorInterface $translator
  215. ){
  216. if(!$request->isXmlHttpRequest()){
  217. return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
  218. }
  219. $data = json_decode((string) $request->getContent(), true);
  220. if(!is_array($data)){
  221. return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
  222. }
  223. $employee = $this->getUser();
  224. if(!$employee instanceof Employee){
  225. throw $this->createAccessDeniedException();
  226. }
  227. try {
  228. $sale = $charges->chargeAdvance(new PosAdvanceChargeRequest(
  229. $employee,
  230. (string) ($data["store"] ?? ""),
  231. $this->linesOf($data),
  232. (string) ($data["advance"] ?? "0"),
  233. $this->paymentsOf($data),
  234. $buyers->read($data),
  235. $data["client"] ?? null,
  236. $this->restMethodOf($data)
  237. ));
  238. } catch (PosChargeRefused $refused){
  239. return $this->refusal($refused->messageIn($translator, self::TRANSLATION_DOMAIN));
  240. } catch (IssuingRefused $refused){
  241. return $this->refusal($translator->trans(
  242. $refused->refusal()->messageKey(),
  243. $refused->refusal()->parameters(),
  244. self::TRANSLATION_DOMAIN
  245. ));
  246. } catch (VoucherRefused $refused){
  247. return $this->refusal($translator->trans($refused->messageKey(), $refused->parameters(), self::TRANSLATION_DOMAIN));
  248. } catch (PaymentRefused|ClientAccountRefused $refused){
  249. return $this->refusal($translator->trans($refused->messageKey(), $refused->parameters(), self::TRANSLATION_DOMAIN));
  250. } catch (FiscalOperationRetryableException $tryAgain){
  251. return $this->refusal($translator->trans(self::MESSAGE_TRY_AGAIN, array(), self::TRANSLATION_DOMAIN));
  252. } catch (MissingFiscalTimezone $missing){
  253. // An entity upgraded from before the program has no territory until an administrator chooses it: the till
  254. // says so and where, instead of «No se ha podido cobrar» (change merch-09, task 3.2; F-51).
  255. return $this->refusal($translator->trans(self::MESSAGE_FISCAL_TERRITORY_MISSING, array('%entity%' => $missing->entityName()), self::TRANSLATION_DOMAIN));
  256. }
  257. return new JsonResponse(array(
  258. JsonUtils::JSON_KEY_SUCCESS => true,
  259. JsonUtils::JSON_KEY_SALE => $sale,
  260. JsonUtils::JSON_KEY_TARGET => $this->generateUrl(
  261. "cms_utools_store_sales_view",
  262. array("hash" => $sale->getHash())
  263. ),
  264. ));
  265. }
  266. /**
  267. * What is left of an advance the cashier typed the number of, so the till can show it and send it with the
  268. * charge (design decision 15).
  269. */
  270. public function advance(Request $request, AdvancePaymentLedger $ledger, TranslatorInterface $translator){
  271. if(!$request->isXmlHttpRequest()){
  272. return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
  273. }
  274. $data = json_decode((string) $request->getContent(), true);
  275. $number = trim((string) (is_array($data) ? ($data["number"] ?? "") : ""));
  276. /** @var BillRepository $bills */
  277. $bills = $this->getEntityManager()->getRepository(Bill::class);
  278. $advance = $bills->findByBillIdentificationNumber($number);
  279. if(!$advance instanceof Bill || !$advance->isAdvancePayment() || !$advance->isIssued()){
  280. return $this->refusal($translator->trans(
  281. PosChargeRefused::ADVANCE_CANNOT_BE_DEDUCTED,
  282. array("%document%" => $number),
  283. self::TRANSLATION_DOMAIN
  284. ));
  285. }
  286. $remaining = $ledger->remainingOf($advance)->total();
  287. return new JsonResponse(array(
  288. JsonUtils::JSON_KEY_SUCCESS => true,
  289. "advance" => array(
  290. "hash" => $advance->getHash(),
  291. "number" => $advance->getBillIdentificationNumber(),
  292. "remaining" => $remaining,
  293. // The reservation itself, so the till can lead to «Anular reserva» when the customer is not
  294. // taking the goods after all (change verifactu-06, design decision 9; D-63).
  295. "sale" => $advance->getSale()?->getHash(),
  296. ),
  297. ));
  298. }
  299. /**
  300. * @param array<string, mixed> $data
  301. *
  302. * @return list<string>
  303. */
  304. private function advancesOf(array $data): array {
  305. $advances = array();
  306. foreach(is_array($data["advances"] ?? null) ? $data["advances"] : array() as $advance){
  307. $hash = is_array($advance) ? ($advance["hash"] ?? null) : $advance;
  308. if(is_string($hash) && $hash !== ""){
  309. $advances[] = $hash;
  310. }
  311. }
  312. return $advances;
  313. }
  314. /**
  315. * What the till says a refusal is: the words, and nothing charged. It is not an error of the request —
  316. * the cashier can fix it — so it answers 200 with the reason (§B7).
  317. */
  318. /**
  319. * The cashier types the code printed on the return, not the voucher's internal hash: the code is resolved here,
  320. * and what matches no voucher goes on as typed, so the service refuses it by name (change merch-09, task 0.1;
  321. * F-25).
  322. */
  323. private function voucherHashOf(string $typed): string {
  324. $promoCodes = $this->getEntityManager()->getRepository(PromoCode::class);
  325. \assert($promoCodes instanceof PromoCodeRepository);
  326. $voucher = $promoCodes->findOneByPrintedCode($typed);
  327. return $voucher instanceof PromoCode ? (string) $voucher->getHash() : trim($typed);
  328. }
  329. private function refusal(string $reason): JsonResponse {
  330. return new JsonResponse(array(
  331. JsonUtils::JSON_KEY_SUCCESS => false,
  332. JsonUtils::JSON_KEY_ERRORS => $reason,
  333. ));
  334. }
  335. /**
  336. * The lines of the charge: a line of stock names the till line that holds it — what it holds is read from the
  337. * commitments, never from the browser —, and anything else carries its concept, its price and its tax (change
  338. * verifactu-05, design decision 4; change merch-04, design decision 5).
  339. *
  340. * @param array<string, mixed> $data
  341. *
  342. * @return list<PosChargeLine>
  343. */
  344. private function linesOf(array $data): array {
  345. $lines = array();
  346. foreach(is_array($data["lines"] ?? null) ? $data["lines"] : array() as $line){
  347. if(!is_array($line)){
  348. continue;
  349. }
  350. $heldLine = $line["line"] ?? null;
  351. if(is_string($heldLine) && "" !== $heldLine){
  352. $lines[] = PosChargeLine::ofHeld(
  353. $heldLine,
  354. (string) ($line["units"] ?? "1"),
  355. (string) ($line["discount"] ?? "0"),
  356. (string) ($line["concept"] ?? "")
  357. );
  358. continue;
  359. }
  360. $lines[] = PosChargeLine::ofConcept(
  361. (string) ($line["concept"] ?? ""),
  362. (string) ($line["units"] ?? "1"),
  363. (string) ($line["price"] ?? "0"),
  364. $line["tax"] ?? null,
  365. (string) ($line["discount"] ?? "0")
  366. );
  367. }
  368. return $lines;
  369. }
  370. /**
  371. * How the sale was paid. A method the till does not know is refused by the service, which is where the
  372. * rules are (design decision 8).
  373. *
  374. * @param array<string, mixed> $data
  375. *
  376. * @return list<PosPayment>
  377. */
  378. private function paymentsOf(array $data): array {
  379. $payments = array();
  380. foreach(is_array($data["payments"] ?? null) ? $data["payments"] : array() as $payment){
  381. if(!is_array($payment)){
  382. continue;
  383. }
  384. $method = PaymentMethod::tryFrom((string) ($payment["method"] ?? ""));
  385. if(null === $method){
  386. continue;
  387. }
  388. $amount = (string) ($payment["amount"] ?? "0");
  389. if(PaymentMethod::Voucher === $method){
  390. $payments[] = PosPayment::voucher($amount, $this->voucherHashOf((string) ($payment["voucher"] ?? "")));
  391. continue;
  392. }
  393. if(PaymentMethod::ThirdPartyCheque === $method){
  394. $payments[] = PosPayment::cheque(
  395. $amount,
  396. (string) ($payment["chequeIssuer"] ?? ""),
  397. $payment["chequeReference"] ?? null
  398. );
  399. continue;
  400. }
  401. $payments[] = PosPayment::of($method, $amount);
  402. }
  403. return $payments;
  404. }
  405. /**
  406. * The method of the button pressed, which pays what the payments leave; none when the payments cover it all
  407. * (change merch-09, task 0.9; M-94). The service says how much.
  408. *
  409. * @param array<string, mixed> $data
  410. */
  411. private function restMethodOf(array $data): ?PaymentMethod {
  412. return PaymentMethod::tryFrom((string) ($data["rest"] ?? ""));
  413. }
  414. /**
  415. * A ticket that is about to be printed is a delivery, and the till says so before it prints.
  416. *
  417. * The point of sale prints from the browser, so the server never sees the print: this notice is what it
  418. * sees. It writes the delivery of the fiscal document the sale belongs to — today the invoice the sale
  419. * was billed in, and from change 05 the simplified invoice of the ticket itself — and refuses the print
  420. * of a document that can still change (change verifactu-02, decision 16; L-14, K-17).
  421. *
  422. * A sale that belongs to no document yet is not refused: there is nothing to keep unaltered and nothing
  423. * to record.
  424. */
  425. public function printNotice(Request $request){
  426. if(!$request->isXmlHttpRequest()){
  427. return new JsonResponse(null, Response::HTTP_BAD_REQUEST);
  428. }
  429. $data = JsonUtils::loadJSONFromRequest($request);
  430. $hash = JsonUtils::loadParameter(JsonUtils::JSON_KEY_HASH, $data);
  431. /** @var SaleRepository $saleRepository */
  432. $saleRepository = $this->getEntityManager()->getRepository(Sale::class);
  433. /** @var Sale $sale */
  434. $sale = $saleRepository->findByHash($hash);
  435. if(!$sale){
  436. return new JsonResponse(null, Response::HTTP_NOT_FOUND);
  437. }
  438. $document = $sale->getBillLinked();
  439. if(is_null($document)){
  440. return new JsonResponse(array(self::JSON_KEY_RECORDED => false));
  441. }
  442. try {
  443. $this->getFiscalDocumentService()->recordDelivery($document);
  444. } catch (DocumentCannotBeDelivered $refused) {
  445. return new JsonResponse(
  446. array(JsonUtils::JSON_KEY_ERRORS => array(DocumentCannotBeDelivered::CODE)),
  447. Response::HTTP_CONFLICT
  448. );
  449. }
  450. return new JsonResponse(array(self::JSON_KEY_RECORDED => true));
  451. }
  452. const JSON_KEY_RECORDED = "recorded";
  453. /** How many articles the till's search offers: a cashier types until the book is among them. */
  454. private const SEARCH_LIMIT = 15;
  455. // What the till's search offers besides loose copies (change merch-05, design decisions 3 and 6).
  456. private const OFFER_PACK = "pack";
  457. private const OFFER_KIT = "kit";
  458. private const JSON_KEY_PRESENTATION = "presentation";
  459. private const JSON_KEY_AVAILABLE_TEXT = "available_text";
  460. private const PACK_TITLE = "sales.editor.till.pack";
  461. private const PACKS_AVAILABLE = "sales.editor.till.packs_available";
  462. private const KIT_TITLE = "sales.editor.till.kit";
  463. private const DOMAIN = "cms";
  464. /**
  465. * What the till's search offers for a pack's code: the article in packs, with the whole packs every location has
  466. * and the loose copies left (change merch-05, design decision 3). The line it holds is priced by the server.
  467. *
  468. * @return array<string, mixed>
  469. */
  470. private function packOffered(PackOffer $offer, LocationRepository $locations, TranslatorInterface $translator): array {
  471. $presentation = $offer->presentation;
  472. $article = $presentation->getArticle();
  473. $offered = SearchSaleAjaxFactory::getSearchSaleAjaxConstructor($article, array())->getSearchSaleAjaxObject()->jsonSerialize();
  474. $offered[JsonUtils::JSON_KEY_AVAILABLE_LOCATIONS] = array();
  475. foreach ($offer->byLocation as $locationId => $packs) {
  476. $location = $locations->find($locationId);
  477. if ($location instanceof Location) {
  478. $offered[JsonUtils::JSON_KEY_AVAILABLE_LOCATIONS][] = $location->jsonSerialize() + array(
  479. JsonUtils::JSON_KEY_AVAILABLE => $packs['packs'],
  480. self::JSON_KEY_AVAILABLE_TEXT => $translator->trans(self::PACKS_AVAILABLE, array('%packs%' => $packs['packs'], '%loose%' => $packs['loose']), self::DOMAIN),
  481. );
  482. }
  483. }
  484. $offered[JsonUtils::JSON_KEY_TYPE] = self::OFFER_PACK;
  485. $offered[JsonUtils::JSON_KEY_TYPE_TITLE] = $translator->trans(self::PACK_TITLE, array('%factor%' => $presentation->getFactor()), self::DOMAIN);
  486. $offered[JsonUtils::JSON_KEY_TITLE] = sprintf(TillPricing::PACK_CONCEPT, (string) $article->getTitle(), $presentation->getFactor());
  487. $offered[JsonUtils::JSON_KEY_ITEM][JsonUtils::JSON_KEY_EAN] = $presentation->getEan();
  488. $offered[JsonUtils::JSON_KEY_ITEM][self::JSON_KEY_PRESENTATION] = $presentation->getHash();
  489. $offered[JsonUtils::JSON_KEY_ITEM][JsonUtils::JSON_KEY_SALE_PRICE] = $presentation->getSalePrice();
  490. return $offered;
  491. }
  492. /**
  493. * What the till's search offers for a kit sold without stock of its own: the kit, and how many every location can
  494. * make from its components (change merch-05, design decision 6).
  495. *
  496. * @param array<int, int> $byLocation
  497. *
  498. * @return array<string, mixed>
  499. */
  500. private function kitOffered(Article $kit, array $byLocation, LocationRepository $locations, TranslatorInterface $translator): array {
  501. $offered = SearchSaleAjaxFactory::getSearchSaleAjaxConstructor($kit, $this->locationsOffered($byLocation, $locations))->getSearchSaleAjaxObject()->jsonSerialize();
  502. $offered[JsonUtils::JSON_KEY_TYPE] = self::OFFER_KIT;
  503. $offered[JsonUtils::JSON_KEY_TYPE_TITLE] = $translator->trans(self::KIT_TITLE, array(), self::DOMAIN);
  504. return $offered;
  505. }
  506. /**
  507. * The locations the till's modal offers for an article, each with what it has available there.
  508. *
  509. * @param array<int, int> $availableByLocation
  510. *
  511. * @return list<array<string, mixed>>
  512. */
  513. private function locationsOffered(array $availableByLocation, LocationRepository $locations): array {
  514. $offered = array();
  515. foreach ($availableByLocation as $locationId => $available) {
  516. $location = $locations->find($locationId);
  517. if ($location instanceof Location) {
  518. $offered[] = $location->jsonSerialize() + array(JsonUtils::JSON_KEY_AVAILABLE => $available);
  519. }
  520. }
  521. return $offered;
  522. }
  523. /**
  524. * What the till's search offers: rapid codes, and the articles a till can take a copy of, each with the locations
  525. * it can be taken from and what each has available, read from the ledger (change merch-04, design decision 11).
  526. */
  527. public function autocomplete(Request $request, LoggerInterface $logger, StockQuery $stock, TillOffers $offers, TranslatorInterface $translator){
  528. $res = array();
  529. if($request->isXmlHttpRequest()) {
  530. $data = JsonUtils::loadJSONFromRequest($request);
  531. $search = JsonUtils::loadParameter(JsonUtils::JSON_KEY_SEARCH, $data);
  532. $addReservations = JsonUtils::loadParameter(JsonUtils::JSON_KEY_ADD_RESERVATIONS, $data, false);
  533. // $facilityHash = JsonUtils::loadParameter(JsonUtils::JSON_KEY_FACILITY, $data);
  534. $maxItems = intval(JsonUtils::loadParameter(JsonUtils::JSON_KEY_ITEMS, $data, false, false));
  535. if(!is_null($search) && $search != ""){
  536. $logger->debug("Search: " . $search);
  537. /** @var RapidCodeRepository $rapidCodeRepository */
  538. $rapidCodeRepository = $this->getEntityManager()->getRepository(RapidCode::class);
  539. /** @var LocationRepository $locationRepository */
  540. $locationRepository = $this->getEntityManager()->getRepository(Location::class);
  541. /** @var ArticleRepository $articleRepository */
  542. $articleRepository = $this->getEntityManager()->getRepository(Article::class);
  543. // /** @var FacilityRepository $facilityRepository */
  544. // $facilityRepository = $this->getEntityManager()->getRepository(Facility::class);
  545. // /** @var Facility $facility */
  546. // $facility = $facilityRepository->findByHash($facilityHash);
  547. // if(!$facility){
  548. // throw new \InvalidArgumentException("Tienda no encontrada");
  549. // }
  550. $data = array();
  551. $items = array();
  552. $items = array_merge($items, $rapidCodeRepository->findRapidCodesByTitleOrCode($search));
  553. $logger->debug(var_export(array_keys($items), true));
  554. $available = $stock->selectableByArticle((string) $search, true === $addReservations);
  555. $articles = array() === $available ? array() : $articleRepository->findBy(array("id" => array_keys($available)));
  556. usort($articles, static fn (Article $one, Article $other): int => strcmp((string) $one->getTitle(), (string) $other->getTitle()));
  557. $items = array_merge($items, array_slice($articles, 0, self::SEARCH_LIMIT));
  558. foreach ($items as $obj) {
  559. $availableLocations = array();
  560. if($obj instanceof Article) {
  561. $availableLocations = $this->locationsOffered($available[(int) $obj->getId()] ?? array(), $locationRepository);
  562. }
  563. /** @var SearchSaleAjaxConstructor $searchSaleAjaxConstructor */
  564. $searchSaleAjaxConstructor = SearchSaleAjaxFactory::getSearchSaleAjaxConstructor($obj, $availableLocations);
  565. $data[] = $searchSaleAjaxConstructor->getSearchSaleAjaxObject();
  566. }
  567. // A pack's code is the pack, first, so a scan holds it; a kit sold without stock of its own is offered
  568. // with what its components can make (change merch-05, design decisions 3 and 6).
  569. $pack = $offers->packOf((string) $search);
  570. if (null !== $pack) {
  571. array_unshift($data, $this->packOffered($pack, $locationRepository, $translator));
  572. }
  573. foreach ($offers->phantomKitsMatching((string) $search) as $kitId => $byLocation) {
  574. $kit = $articleRepository->find($kitId);
  575. if ($kit instanceof Article) {
  576. $data[] = $this->kitOffered($kit, $byLocation, $locationRepository, $translator);
  577. }
  578. }
  579. $res = $data;
  580. if(!is_null($maxItems) && $maxItems > 0){
  581. $logger->debug("MaxItems: " . $maxItems);
  582. $res = array_splice($data, 0, $maxItems);
  583. }
  584. }
  585. }
  586. return new JsonResponse($res);
  587. }
  588. public function promoCode(Request $request, LoggerInterface $logger){
  589. $res = null;
  590. if($request->isXmlHttpRequest()) {
  591. $data = JsonUtils::loadJSONFromRequest($request);
  592. $promoCodeSearch = JsonUtils::loadParameter(JsonUtils::JSON_KEY_PROMO_CODE, $data);
  593. $saleHash = JsonUtils::loadParameter(JsonUtils::JSON_KEY_SALE_HASH, $data, false);
  594. $logger->debug("Search Promo code: " . $promoCodeSearch);
  595. /** @var PromoCodeRepository $promoCodeRepository */
  596. $promoCodeRepository = $this->getEntityManager()->getRepository(PromoCode::class);
  597. /** @var PromoCode $promoCode */
  598. $promoCode = $promoCodeRepository->findPromoCodeByCode($promoCodeSearch);
  599. if($promoCode) {
  600. $limitTotal = $promoCode->getLimitTotal();
  601. if(!is_null($limitTotal)){
  602. $promoCode->setLimit($limitTotal - $promoCode->getActualConsumed());
  603. }
  604. $logger->debug("Adding promo code: " . $promoCode->getHash() . " Limit: " . $promoCode->getAbsoluteLimit());
  605. $res = $promoCode;
  606. }
  607. if(!is_null($saleHash) && $saleHash != "") {
  608. /** @var SaleRepository $saleRepository */
  609. $saleRepository = $this->getEntityManager()->getRepository(Sale::class);
  610. /** @var Sale $sale */
  611. $sale = $saleRepository->findByHash($saleHash);
  612. if($sale){
  613. $promoCodeInSale = $sale->getPromoCodeApplied();
  614. if(!is_null($promoCodeInSale)){
  615. $logger->debug("Promo code applied on sale: " . $promoCodeInSale->getCode());
  616. if($promoCodeInSale->getCode() == $promoCodeSearch) {
  617. $promoCodeInSale->setLimit($sale->getTotalDiscounted());
  618. $logger->debug("Forcing To Old Promo code. Limit: " . $promoCodeInSale->getAbsoluteLimit());
  619. $res = $promoCodeInSale;
  620. }
  621. }
  622. }
  623. }
  624. }
  625. $response = new JsonResponse();
  626. if(is_null($res)){
  627. $response->setStatusCode(400);
  628. } else {
  629. $res = array(
  630. JsonUtils::JSON_KEY_SUCCESS => true,
  631. JsonUtils::JSON_KEY_PROMO_CODE => $res
  632. );
  633. $response->setContent(json_encode($res));
  634. }
  635. return $response;
  636. }
  637. /**
  638. * The balance of the account of a client, for the till to offer it as a payment (change verifactu-14, task
  639. * 4.6). It only reads: the charge locks the account and spends it, and refuses what the balance does not
  640. * cover whatever the till showed.
  641. */
  642. public function clientBalance(Request $request, ClientAccountLedger $accounts): JsonResponse {
  643. $data = json_decode((string) $request->getContent(), true);
  644. $hash = is_array($data) && isset($data["client"]) ? (string) $data["client"] : "";
  645. $client = "" === $hash ? null : $this->getEntityManager()->getRepository(Client::class)->findByHash($hash);
  646. if (!$client instanceof Client) {
  647. return new JsonResponse(array("balance" => null), 404);
  648. }
  649. return new JsonResponse(array("balance" => $accounts->balanceOf($client)));
  650. }
  651. }