feat: moneta 2.0, MONETA.Assistant payment module for PHPShop 6.7+
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user