Files

361 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
use Closure;
use InvalidArgumentException;
use MonetaPhpShop\Money;
use MonetaPhpShop\order\Order;
/**
* ×åê çàêàçà PHPShop: ñòðîêè êîðçèíû, äîñòàâêà îòäåëüíîé ïîçèöèåé, ïîêóïàòåëü.
*
* Ñóììà ïîçèöèé îáÿçàíà ñîâïàñòü ñ ñóììîé çàêàçà äî êîïåéêè (`cmsspecification.pdf`), à èòîã
* PHPShop (`phpshop_orders.sum`) — ýòî òîâàðû ñî ñêèäêîé (êðîìå ïðîìî-öåí), ìèíóñ áîíóñû, ïëþñ
* äîñòàâêà öåëûì ÷èñëîì, ñ îêðóãëåíèåì ÿäðà. Ïîýòîìó öåíû ñòðîê ñíà÷àëà ñ÷èòàþòñÿ êàê ó ÿäðà,
* à ðàñõîæäåíèå (áîíóñû, îêðóãëåíèÿ, ðó÷íàÿ ïðàâêà çàêàçà) ðàçíîñèòñÿ ïî òîâàðíûì ñòðîêàì
* ïðîïîðöèîíàëüíî èõ ñóììå; øòó÷íàÿ ñòðîêà, êîòîðàÿ íå äåëèòñÿ íà êîëè÷åñòâî, ðåæåòñÿ íà äâå
* ñ ðàçíèöåé öåí â êîïåéêó, ó âåñîâîé (äðîáíîå êîëè÷åñòâî) ïîäáèðàåòñÿ öåíà çà åäèíèöó. Íå ïîëó÷èëîñü —
* îäíà ïîçèöèÿ «Îïëàòà çàêàçà» íà âñþ ñóììó.
*/
final class ReceiptBuilder
{
private const DELIVERY_NAME = 'Äîñòàâêà';
private const ITEM_FALLBACK_NAME = 'Òîâàð';
private const PAYMENT_NAME = 'Îïëàòà çàêàçà ¹';
/** @var Closure(string): string */
private $toUtf8;
/** @var PaymentMethod */
private $paymentMethod;
/** @var PaymentObject */
private $goodsObject;
/** @var Vat */
private $paymentVat;
/**
* @param Closure(string): string $toUtf8 ñòðîêè çàêàçà è ëèòåðàëû ìîäóëÿ (cp1251 ïîñëå ñáîðêè) -> UTF-8
* @param PaymentMethod $paymentMethod ñïîñîá ðàñ÷¸òà âñåõ ïîçèöèé — íàñòðîéêà ìîäóëÿ
* @param PaymentObject|null $goodsObject ïðåäìåò ðàñ÷¸òà òîâàðîâ — íàñòðîéêà ìîäóëÿ; null — òîâàð.
* Ó äîñòàâêè âñåãäà óñëóãà.
* @param Vat|null $paymentVat ñòàâêà çàïàñíîé ïîçèöèè «Îïëàòà çàêàçà»: ôèêñèðîâàííàÿ «ÍÄÑ íà òîâàðû»;
* null — áåç ÍÄÑ
*/
public function __construct(
Closure $toUtf8,
PaymentMethod $paymentMethod,
?PaymentObject $goodsObject = null,
?Vat $paymentVat = null
) {
$this->toUtf8 = $toUtf8;
$this->paymentMethod = $paymentMethod;
$this->goodsObject = $goodsObject ?? PaymentObject::commodity();
$this->paymentVat = $paymentVat ?? Vat::none();
}
/**
* @param Vat $goodsVat ñèñòåìíàÿ ñòàâêà PHPShop äëÿ òîâàðîâ
* @param Vat $deliveryVat ñòàâêà ñïîñîáà äîñòàâêè
* @param string $fallbackEmail íàñòðîéêà «Email äëÿ ÷åêîâ», ìîæåò áûòü ïóñòîé
*/
public function build(Order $order, Vat $goodsVat, Vat $deliveryVat, string $fallbackEmail): Receipt
{
$client = ReceiptClient::fromContacts(
$order->getEmail(),
$order->getPhone(),
$this->utf8($order->getCustomerName()),
$fallbackEmail,
);
$amount = $order->getAmount()->toMinorUnits();
$items = $this->items($this->balance($this->lines($order, $goodsVat, $deliveryVat), $amount));
if ($items === null) {
$items = [
new ReceiptItem(
$this->utf8(self::PAYMENT_NAME . $order->getUid()),
$order->getAmount(),
Quantity::pieces(1),
$this->paymentVat,
$this->goodsObject,
$this->paymentMethod,
),
];
}
return new Receipt($client, $items);
}
/**
* Ñòðîêè ÷åêà ïî ïðàâèëàì ÿäðà (`PHPShopDone::send_to_order()`): ñêèäêà ïîêóïàòåëÿ — êî âñåì
* òîâàðàì áåç ïðîìî-öåíû, äîñòàâêà èç êîðçèíû çàêàçà. Êîëè÷åñòâî — êàê â êîðçèíå, â òîì ÷èñëå
* äðîáíîå (âåñîâîé òîâàð), åäèíèöà èçìåðåíèÿ — èç `ed_izm` òîâàðà.
*
* @return array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}>
*/
private function lines(Order $order, Vat $goodsVat, Vat $deliveryVat): array
{
$cart = $order->getCart();
$lines = [];
$products = isset($cart['cart']) && is_array($cart['cart']) ? $cart['cart'] : [];
$discount = $order->getDiscountPercent();
foreach ($products as $product) {
if (!is_array($product)) {
continue;
}
$price = (float) ($product['price'] ?? 0);
if (($discount > 0) && empty($product['promo_price'])) {
$price -= $price * $discount / 100;
}
$quantity = Quantity::fromCart($product['num'] ?? null);
if (($quantity === null) || ($price <= 0)) {
continue;
}
$lines[] = [
'name' => $this->name((string) ($product['name'] ?? '')),
'unit' => Money::fromFloat($price)->toMinorUnits(),
'quantity' => $quantity,
'measure' => Measure::fromUnit($this->utf8((string) ($product['ed_izm'] ?? '')), $this->toUtf8),
'vat' => $goodsVat,
'delivery' => false,
];
}
$delivery = (float) ($cart['dostavka'] ?? 0);
if ($delivery > 0) {
$lines[] = [
'name' => $this->utf8(self::DELIVERY_NAME),
'unit' => Money::fromFloat($delivery)->toMinorUnits(),
'quantity' => Quantity::pieces(1),
'measure' => Measure::UNIT,
'vat' => $deliveryVat,
'delivery' => true,
];
}
return $lines;
}
/**
* Ïîäãîíêà ñóììû ñòðîê ïîä ñóììó çàêàçà â êîïåéêàõ. Ñíà÷àëà ðàñõîæäåíèå áåðóò øòó÷íûå òîâàðû;
* åñëè íå óêëàäûâàåòñÿ — âñå øòó÷íûå ñòðîêè âìåñòå ñ äîñòàâêîé; åñëè øòó÷íûõ ñòðîê íåò —
* è âåñîâûå, ïîäáîðîì öåíû çà åäèíèöó. Ñòðîêó âåñîâîãî òîâàðà íà äâå íå ðàçðåçàòü, ïîýòîìó
* å¸ òðîãàþò ïîñëåäíåé. Ïóñòîé ðåçóëüòàò — íå âûøëî.
*
* @param array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}> $lines
*
* @return array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}>
*/
private function balance(array $lines, int $target): array
{
if (($target <= 0) || ($lines === [])) {
return [];
}
$totals = [];
foreach ($lines as $index => $line) {
$totals[$index] = self::lineTotal($line);
}
if (array_sum($totals) === $target) {
return $lines;
}
$stages = [
static function (array $line): bool {
return !$line['delivery'] && $line['quantity']->isWhole();
},
static function (array $line): bool {
return $line['quantity']->isWhole();
},
static function (array $line): bool {
return true;
},
];
foreach ($stages as $isAdjustable) {
$adjustable = [];
$fixed = 0;
foreach ($lines as $index => $line) {
if ($isAdjustable($line)) {
$adjustable[] = $index;
} else {
$fixed += $totals[$index];
}
}
if (($adjustable === []) || ($target - $fixed <= 0)) {
continue;
}
return $this->distribute($lines, $totals, $adjustable, $target - $fixed);
}
return [];
}
/**
* Ðàçíîñ ñóììû `$goal` ïî ñòðîêàì `$adjustable` ïðîïîðöèîíàëüíî èõ ñóììå.
*
* @param array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}> $lines
* @param array<int, int> $totals ñóììû ñòðîê â êîïåéêàõ
* @param array<int, int> $adjustable èíäåêñû ñòðîê, êîòîðûå ìîæíî ìåíÿòü
*
* @return array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}>
*/
private function distribute(array $lines, array $totals, array $adjustable, int $goal): array
{
$base = 0;
foreach ($adjustable as $index) {
$base += $totals[$index];
}
if ($base <= 0) {
return [];
}
// Íàèáîëüøèå îñòàòêè: äîëè îêðóãëÿþòñÿ âíèç, íåäîñòàþùèå êîïåéêè — ñòðîêàì ñ áîëüøèì îñòàòêîì.
$shares = [];
$remainders = [];
foreach ($adjustable as $index) {
if ($totals[$index] > intdiv(PHP_INT_MAX, $goal)) {
return [];
}
$product = $totals[$index] * $goal;
$shares[$index] = intdiv($product, $base);
$remainders[$index] = $product % $base;
}
arsort($remainders);
$left = $goal - array_sum($shares);
foreach (array_keys($remainders) as $index) {
if ($left <= 0) {
break;
}
$shares[$index]++;
$left--;
}
$balanced = [];
foreach ($lines as $index => $line) {
if (!isset($shares[$index])) {
$balanced[] = $line;
continue;
}
$parts = $line['quantity']->isWhole()
? $this->split($line, $shares[$index])
: $this->reprice($line, $shares[$index]);
if ($parts === []) {
return [];
}
foreach ($parts as $part) {
$balanced[] = $part;
}
}
return $balanced;
}
/**
* Øòó÷íàÿ ñòðîêà ñ íîâîé ñóììîé: öåëàÿ öåíà çà øòóêó, îñòàòîê êîïååê — âòîðîé ñòðîêîé òîé æå
* ïîçèöèè ñ öåíîé íà êîïåéêó âûøå. Íóëåâûå ÷àñòè îòáðàñûâàþòñÿ.
*
* @param array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool} $line
*
* @return array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}>
*/
private function split(array $line, int $total): array
{
$pieces = $line['quantity']->getPieces();
$unit = intdiv($total, $pieces);
$rest = $total - ($unit * $pieces);
$parts = [];
if ($rest > 0) {
$parts[] = ['unit' => $unit + 1, 'quantity' => Quantity::pieces($rest)] + $line;
}
if (($unit > 0) && ($pieces - $rest > 0)) {
$parts[] = ['unit' => $unit, 'quantity' => Quantity::pieces($pieces - $rest)] + $line;
}
return $parts;
}
/**
* Âåñîâàÿ ñòðîêà ñ íîâîé ñóììîé: öåíà çà åäèíèöó, ïðè êîòîðîé öåíà x êîëè÷åñòâî äî êîïåéêè
* äà¸ò ðîâíî `$total`. Òàêîé öåíû ìîæåò íå áûòü (íà 2.5 êã øàã ñóììû — 2–3 êîïåéêè) — òîãäà ïóñòî.
*
* @param array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool} $line
*
* @return array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}>
*/
private function reprice(array $line, int $total): array
{
$thousandths = $line['quantity']->getThousandths();
$estimate = intdiv(($total * 1000) + intdiv($thousandths, 2), $thousandths);
foreach ([$estimate, $estimate - 1, $estimate + 1] as $unit) {
if ($unit > 0 && self::lineTotal(['unit' => $unit] + $line) === $total) {
return [['unit' => $unit] + $line];
}
}
return [];
}
/**
* @param array{unit: int, quantity: Quantity} $line
*/
private static function lineTotal(array $line): int
{
return $line['quantity']->totalOf(Money::fromMinorUnits($line['unit']))->toMinorUnits();
}
/**
* @param array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}> $lines
*
* @return array<int, ReceiptItem>|null null — ñòðîê íåò èëè ñòðîêà íå ïðîõîäèò îãðàíè÷åíèÿ êàññû
*/
private function items(array $lines): ?array
{
if ($lines === []) {
return null;
}
$items = [];
try {
foreach ($lines as $line) {
$items[] = new ReceiptItem(
$line['name'],
Money::fromMinorUnits($line['unit']),
$line['quantity'],
$line['vat'],
$line['delivery'] ? PaymentObject::service() : $this->goodsObject,
$this->paymentMethod,
$line['measure'],
);
}
} catch (InvalidArgumentException $exception) {
return null;
}
return $items;
}
private function name(string $raw): string
{
$name = ReceiptText::clean($this->utf8($raw), ReceiptItem::NAME_MAX_LENGTH);
return ($name !== '') ? $name : $this->utf8(self::ITEM_FALLBACK_NAME);
}
private function utf8(string $value): string
{
return ($this->toUtf8)($value);
}
}