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 */ 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 $lines * * @return array */ 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 $lines * @param array $totals суммы строк в копейках * @param array $adjustable индексы строк, которые можно менять * * @return array */ 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 */ 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 */ 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 $lines * * @return array|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); } }