feat: moneta 2.0, MONETA.Assistant payment module for PHPShop 6.7+

This commit is contained in:
2026-10-27 11:45:22 +03:00
parent 78b7405637
commit f8c12ce44a
82 changed files with 7387 additions and 58 deletions
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
use Closure;
/**
* Åäèíèöà èçìåðåíèÿ ïîçèöèè ÷åêà (`measure`, ïåðå÷åíü `kassaspecification.pdf`) ïî åäèíèöå òîâàðà
* PHPShop (`ed_izm` — ñâîáîäíûé òåêñò: «øò.», «êã», «ì2»). Âàðèàíòû íàïèñàíèÿ — òå, ÷òî êàññà
* ðàñïîçíà¸ò ñàìà («àâòîîïðåäåëåíèå» â ñïåöèôèêàöèè), ïëþñ òî÷êà íà êîíöå è ïðîáåëû, êàê ïèøóò
* â PHPShop. Íå ðàñïîçíàíî èëè ïóñòî — `unit` (øòóêà), çíà÷åíèå êàññû ïî óìîë÷àíèþ.
*
* Ñðàâíåíèå áåç ó÷¸òà ðåãèñòðà ÷åðåç `/iu`: `mbstring` ÿäðî PHPShop íå òðåáóåò. Âàðèàíòû — ëèòåðàëû
* ìîäóëÿ: ïîñëå ñáîðêè îíè â cp1251, à øàáëîí ñ `/u` îáÿçàí áûòü UTF-8, ïîýòîìó ïåðåä ñðàâíåíèåì èõ
* ïåðåêîäèðóåò òîò æå `$toUtf8`, ÷òî è ïðî÷èå ëèòåðàëû ÷åêà ({@see ReceiptBuilder}).
*/
final class Measure
{
public const UNIT = 'unit';
/** Êîä êàññû -> âàðèàíòû íàïèñàíèÿ (áåç ïðîáåëîâ, â íèæíåì ðåãèñòðå). */
private const VARIANTS = [
self::UNIT => ['øòóêà', 'øòóêè', 'øòóê', 'øò', 'pc', 'pce', 'pcs', 'nmb'],
'gram' => ['ãðàìì', 'ãðàììû', 'ã', 'ãð', 'g', 'grm'],
'kilogram' => ['êèëîãðàìì', 'êèëîãðàììû', 'êã', 'kg', 'kgm'],
'ton' => ['òîííà', 'òîííû', 'ò', 't', 'tne'],
'centimeter' => ['ñàíòèìåòð', 'ñì', 'cm', 'cmt'],
'decimeter' => ['äåöèìåòð', 'äì', 'dm', 'dmt'],
'meter' => ['ìåòð', 'ìåòðû', 'ì', 'm', 'mtr', 'ïì', 'ïîã.ì'],
'square_centimeter' => ['êâàäðàòíûéñàíòèìåòð', 'êâ.ñì', 'ñì2', 'cm2', 'cmk'],
'square_decimeter' => ['êâàäðàòíûéäåöèìåòð', 'êâ.äì', 'äì2', 'dm2', 'dmk'],
'square_meter' => ['êâàäðàòíûéìåòð', 'êâ.ì', 'ì2', 'm2', 'mtk'],
'milliliter' => ['ìèëëèëèòð', 'ìë', 'ml', 'mlt'],
'liter' => ['ëèòð', 'ëèòðû', 'ë', 'l', 'ltr'],
'cubic_meter' => ['êóáè÷åñêèéìåòð', 'êóá.ì', 'ì3', 'm3', 'mtq'],
'kilowatt_hour' => ['êèëîâàòò÷àñ', 'êâò*÷', 'êâò-÷', 'êâò÷', 'kwh'],
'gigacalorie' => ['ãèãàêàëîðèÿ', 'ãêàë', 'ãèãàêàë'],
'day' => ['ñóòêè', 'ñóò', 'äåíü', 'äí', 'd', 'day'],
'hour' => ['÷àñ', '÷', 'h', 'hur'],
'minute' => ['ìèíóòà', 'ìèí', 'min'],
'second' => ['ñåêóíäà', 'ñåê', 'ñ', 's', 'sec'],
'kilobyte' => ['êèëîáàéò', 'êáàéò', 'êá', 'kib', 'êèá', 'kb'],
'megabyte' => ['ìåãàáàéò', 'ìáàéò', 'ìá', 'mib', 'ìèá', 'mb'],
'gigabyte' => ['ãèãàáàéò', 'ãáàéò', 'ãá', 'gib', 'ãèá', 'gb'],
'terabyte' => ['òåðàáàéò', 'òáàéò', 'òá', 'tib', 'òèá', 'tb'],
];
private function __construct() {}
/**
* @param string $unit åäèíèöà òîâàðà â UTF-8
* @param Closure(string): string $toUtf8 ëèòåðàëû ìîäóëÿ -> UTF-8
*/
public static function fromUnit(string $unit, Closure $toUtf8): string
{
// Ïðîáåëû íå çíà÷èìû («êâ. ì»), òî÷êà íà êîíöå — ñîêðàùåíèå («øò.»).
$unit = rtrim((string) preg_replace('/\s+/u', '', $unit), '.');
if ($unit === '') {
return self::UNIT;
}
foreach (self::VARIANTS as $measure => $variants) {
foreach ($variants as $variant) {
if (preg_match('/\A' . preg_quote($toUtf8($variant), '/') . '\z/iu', $unit) === 1) {
return $measure;
}
}
}
return self::UNIT;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
/**
* Ïðèçíàê ñïîñîáà ðàñ÷¸òà (`pm` â `INVENTORY`, `paymentMethod` â JSON Check URL): âåñü ïåðå÷åíü
* `kassaspecification.pdf`. Äåéñòâóåò íà âñå ïîçèöèè ÷åêà, âêëþ÷àÿ äîñòàâêó.
* Ðàñ÷¸òíûå ñòàâêè ÍÄÑ ïðè àâàíñå è ïðåäîïëàòå ìîäóëü ñàì íå âûáèðàåò.
* Çíà÷åíèÿ õðàíÿòñÿ â íàñòðîéêàõ ìîäóëÿ — íå ïåðåèìåíîâûâàòü.
*/
final class PaymentMethod
{
public const FULL_PREPAYMENT = 'full_prepayment';
public const PREPAYMENT = 'prepayment';
public const ADVANCE = 'advance';
public const FULL_PAYMENT = 'full_payment';
public const PARTIAL_PAYMENT = 'partial_payment';
public const CREDIT = 'credit';
public const CREDIT_PAYMENT = 'credit_payment';
/** Çíà÷åíèå -> ïîäïèñü â íàñòðîéêàõ, ïîðÿäîê — êàê â `kassaspecification.pdf`. */
public const LABELS = [
self::FULL_PREPAYMENT => 'Ïîëíàÿ ïðåäîïëàòà',
self::PREPAYMENT => '×àñòè÷íàÿ ïðåäîïëàòà',
self::ADVANCE => 'Àâàíñ',
self::FULL_PAYMENT => 'Ïîëíûé ðàñ÷¸ò',
self::PARTIAL_PAYMENT => '×àñòè÷íûé ðàñ÷¸ò è êðåäèò',
self::CREDIT => 'Êðåäèò',
self::CREDIT_PAYMENT => 'Âûïëàòà ïî êðåäèòó',
];
/** @var string */
private $value;
private function __construct(string $value)
{
$this->value = $value;
}
/**
* Çíà÷åíèå èç íàñòðîåê; íåèçâåñòíîå èëè ïóñòîå — ïîëíûé ðàñ÷¸ò (äåôîëò êàññû).
*/
public static function fromSetting(?string $value): self
{
return new self(isset(self::LABELS[(string) $value]) ? (string) $value : self::FULL_PAYMENT);
}
public static function fullPayment(): self
{
return new self(self::FULL_PAYMENT);
}
public function getValue(): string
{
return $this->value;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
/**
* Ïðèçíàê ïðåäìåòà ðàñ÷¸òà (`po` â `INVENTORY`, `paymentObject` â JSON Check URL): ïåðå÷åíü
* `kassaspecification.pdf` ïëþñ `composite`.
* Íàñòðîéêîé âûáèðàåòñÿ òîëüêî äëÿ òîâàðîâ; äîñòàâêà âñåãäà {@see self::SERVICE}.
* Çíà÷åíèÿ õðàíÿòñÿ â íàñòðîéêàõ ìîäóëÿ — íå ïåðåèìåíîâûâàòü.
*/
final class PaymentObject
{
public const COMMODITY = 'commodity';
public const SERVICE = 'service';
/** Çíà÷åíèå -> ïîäïèñü â íàñòðîéêàõ. */
public const LABELS = [
self::COMMODITY => 'Òîâàð',
'excise' => 'Ïîäàêöèçíûé òîâàð',
'job' => 'Ðàáîòà',
self::SERVICE => 'Óñëóãà',
'gambling_bet' => 'Ñòàâêà àçàðòíîé èãðû',
'gambling_prize' => 'Âûèãðûø àçàðòíîé èãðû',
'lottery' => 'Ëîòåðåéíûé áèëåò',
'lottery_prize' => 'Âûèãðûø ëîòåðåè',
'intellectual_activity' => 'Ïðåäîñòàâëåíèå ðåçóëüòàòîâ èíòåëëåêòóàëüíîé äåÿòåëüíîñòè',
'payment' => 'Ïëàò¸æ',
'agent_commission' => 'Àãåíòñêîå âîçíàãðàæäåíèå',
'award' => 'Âûïëàòà (âçíîñ, ïåíè, øòðàô, âîçíàãðàæäåíèå, áîíóñ)',
'composite' => 'Ñîñòàâíîé ïðåäìåò ðàñ÷¸òà',
'another' => 'Äðóãîå',
'property_right' => 'Èìóùåñòâåííîå ïðàâî',
'non_operating' => 'Âíåðåàëèçàöèîííûé äîõîä',
'insurance_premium' => 'Ñòðàõîâîé ñáîð',
'sales_tax' => 'Òîðãîâûé ñáîð',
'resort_fee' => 'Êóðîðòíûé ñáîð',
];
/** @var string */
private $value;
private function __construct(string $value)
{
$this->value = $value;
}
/**
* Çíà÷åíèå èç íàñòðîåê; íåèçâåñòíîå èëè ïóñòîå — òîâàð (äåôîëò êàññû).
*/
public static function fromSetting(?string $value): self
{
return new self(isset(self::LABELS[(string) $value]) ? (string) $value : self::COMMODITY);
}
public static function commodity(): self
{
return new self(self::COMMODITY);
}
public static function service(): self
{
return new self(self::SERVICE);
}
public function getValue(): string
{
return $this->value;
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
use InvalidArgumentException;
use MonetaPhpShop\Money;
/**
* Êîëè÷åñòâî ïîçèöèè ÷åêà ñ òî÷íîñòüþ êàññû — òðè çíàêà ïîñëå òî÷êè (`kassaspecification.pdf`,
* â ñõåìå Check URL `multipleOf 0.001`). Õðàíèòñÿ öåëûì ÷èñëîì òûñÿ÷íûõ, êàê êîïåéêè â {@see Money}:
* Êîðçèíà ÿäðà õðàíèò êîëè÷åñòâî êàê åñòü.
*/
final class Quantity
{
private const SCALE = 1000;
/** Ïîòîëîê êàññû: 99999.999. */
public const MAX_THOUSANDTHS = 99999999;
/** @var int */
private $thousandths;
private function __construct(int $thousandths)
{
if (($thousandths < 1) || ($thousandths > self::MAX_THOUSANDTHS)) {
throw new InvalidArgumentException('Êîëè÷åñòâî ïîçèöèè ÷åêà âíå ãðàíèö êàññû.');
}
$this->thousandths = $thousandths;
}
public static function pieces(int $pieces): self
{
return new self($pieces * self::SCALE);
}
public static function fromThousandths(int $thousandths): self
{
return new self($thousandths);
}
/**
* Êîëè÷åñòâî èç êîðçèíû çàêàçà (`num`): ÷èñëî èëè ÷èñëîâàÿ ñòðîêà, îêðóãëÿåòñÿ äî òûñÿ÷íûõ.
* Íîëü, îòðèöàòåëüíîå, íå ÷èñëî èëè áîëüøå ïîòîëêà êàññû — null.
*
* @param mixed $raw
*/
public static function fromCart($raw): ?self
{
if (is_string($raw)) {
$raw = trim($raw);
}
if (!is_int($raw) && !is_float($raw) && !(is_string($raw) && is_numeric($raw))) {
return null;
}
$thousandths = (int) round((float) $raw * self::SCALE);
if (($thousandths < 1) || ($thousandths > self::MAX_THOUSANDTHS)) {
return null;
}
return new self($thousandths);
}
public function getThousandths(): int
{
return $this->thousandths;
}
/**
* Öåëîå ëè êîëè÷åñòâî: òîëüêî òàêóþ ñòðîêó ìîæíî ðåçàòü íà äâå ñ ðàçíèöåé öåí â êîïåéêó.
*/
public function isWhole(): bool
{
return $this->thousandths % self::SCALE === 0;
}
/**
* Öåëîå ÷èñëî øòóê; òîëüêî äëÿ {@see self::isWhole()}.
*/
public function getPieces(): int
{
return intdiv($this->thousandths, self::SCALE);
}
/**
* Äåñÿòè÷íàÿ çàïèñü áåç ëèøíèõ íóëåé: `2`, `2.5`, `0.125`.
*/
public function toDecimal(): string
{
if ($this->isWhole()) {
return (string) $this->getPieces();
}
return rtrim(
sprintf('%d.%03d', intdiv($this->thousandths, self::SCALE), $this->thousandths % self::SCALE),
'0',
);
}
/**
* Ñóììà ïîçèöèè: öåíà x êîëè÷åñòâî, îêðóãëåíèå äî êîïåéêè (ïîëîâèíà — ââåðõ).
*/
public function totalOf(Money $price): Money
{
return Money::fromMinorUnits(
intdiv(($price->toMinorUnits() * $this->thousandths) + intdiv(self::SCALE, 2), self::SCALE),
);
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
use InvalidArgumentException;
use MonetaPhpShop\Money;
use MonetaPhpShop\protocol\JsonNumber;
/**
* ×åê 54-ÔÇ çàêàçà: ïîêóïàòåëü è ïîçèöèè, ñóììà ïîçèöèé ðàâíà ñóììå îïëàòû äî êîïåéêè.
* Îòäà¸òñÿ â äâóõ ôîðìàõ: `MNT_ATTRIBUTES` XML-îòâåòîâ è îáúåêò `receipt` JSON-îòâåòà Check URL.
*/
final class Receipt
{
/** @var ReceiptClient|null */
private $client;
/** @var array<int, ReceiptItem> */
private $items;
/**
* @param array<int, ReceiptItem> $items
*/
public function __construct(?ReceiptClient $client, array $items)
{
if ($items === []) {
throw new InvalidArgumentException('×åê áåç ïîçèöèé.');
}
$this->client = $client;
$this->items = array_values($items);
}
public function getClient(): ?ReceiptClient
{
return $this->client;
}
/**
* @return array<int, ReceiptItem>
*/
public function getItems(): array
{
return $this->items;
}
public function total(): Money
{
$total = Money::fromMinorUnits(0);
foreach ($this->items as $item) {
$total = $total->add($item->total());
}
return $total;
}
/**
* `MNT_ATTRIBUTES`: `INVENTORY` âñåãäà; `CLIENT`, `CUSTOMER` (email), `PHONE` (áåç `+`) —
* åñëè åñòü êîíòàêò; `SNO` — ïðè ôèñêàëèçàöèè, åñëè ñèñòåìà íàëîãîîáëîæåíèÿ âûáðàíà.
* `DELIVERY` íå ïåðåäà¸òñÿ: äîñòàâêà — ïîçèöèåé `INVENTORY`.
* Áåç ôèñêàëèçàöèè ïîçèöèè óðåçàíû ({@see ReceiptItem::toInventoryPosition()}).
*
* @return array<string, string>
*/
public function toAttributes(bool $fiscalization = true, ?TaxSystem $taxSystem = null): array
{
$attributes = [
'INVENTORY' => JsonNumber::encode(
array_map(static function (ReceiptItem $item) use ($fiscalization): array {
return $item->toInventoryPosition($fiscalization);
}, $this->items),
),
];
$client = ($this->client !== null) ? $this->client->toArray(ReceiptClient::KASSA_PHONE_MAX_LENGTH) : [];
if ($client !== []) {
$attributes['CLIENT'] = JsonNumber::encode([$client]);
if (isset($client['email'])) {
$attributes['CUSTOMER'] = $client['email'];
}
if (isset($client['phone'])) {
$attributes['PHONE'] = ltrim($client['phone'], '+');
}
}
if ($fiscalization && ($taxSystem !== null) && $taxSystem->isPassed()) {
$attributes['SNO'] = $taxSystem->getValue();
}
return $attributes;
}
/**
* Îáúåêò `receipt` JSON-îòâåòà Check URL. Áåç êîíòàêòà — ïóñòîé `client` (`{}`).
*
* @return array<string, mixed>
*/
public function toJsonReceipt(): array
{
$client = $this->client !== null ? $this->client->toArray(ReceiptClient::JSON_PHONE_MAX_LENGTH) : [];
return [
'client' => $client === [] ? new \stdClass() : $client,
'items' => array_map(static function (ReceiptItem $item): array {
return $item->toCheckItem();
}, $this->items),
];
}
}
+360
View File
@@ -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);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
use MonetaPhpShop\PhoneNumber;
/**
* Ïîêóïàòåëü â ÷åêå — îáúåêò `client` (`kassaspecification.pdf`): íóæåí õîòÿ áû îäèí èç
* `email`/`phone`, ÔÈÎ — òîëüêî âìåñòå ñ íèìè. Ñòðîêè â UTF-8.
*/
final class ReceiptClient
{
public const NAME_MAX_LENGTH = 256;
public const EMAIL_MAX_LENGTH = 64;
/** `phone_number` JSON-ñõåìû Check URL (`cmsspecification.pdf`). */
public const JSON_PHONE_MAX_LENGTH = 13;
/** `kassaspecification.pdf` — äëÿ `CLIENT` â `MNT_ATTRIBUTES`. */
public const KASSA_PHONE_MAX_LENGTH = 19;
/** @var string */
private $name;
/** @var string */
private $email;
/** @var string */
private $phone;
private function __construct(string $name, string $email, string $phone)
{
$this->name = $name;
$this->email = $email;
$this->phone = $phone;
}
/**
* Ïîêóïàòåëü èç äàííûõ çàêàçà èëè null, åñëè ÷åêó íåêóäà óéòè. «Email äëÿ ÷åêîâ» ìåð÷àíòà
* ïîäñòàâëÿåòñÿ, òîëüêî êîãäà ó ïîêóïàòåëÿ íåò íè email, íè òåëåôîíà; ÔÈÎ îñòà¸òñÿ.
*/
public static function fromContacts(string $email, string $phone, string $name, string $fallbackEmail): ?self
{
$phone = PhoneNumber::normalize($phone) ?? '';
if (strlen($phone) > self::KASSA_PHONE_MAX_LENGTH) {
$phone = '';
}
$email = self::email($email);
if (($email === '') && ($phone === '')) {
$email = self::email($fallbackEmail);
}
if (($email === '') && ($phone === '')) {
return null;
}
return new self(ReceiptText::clean($name, self::NAME_MAX_LENGTH), $email, $phone);
}
public function getEmail(): string
{
return $this->email;
}
public function getPhone(): string
{
return $this->phone;
}
/**
* Îáúåêò `client`; ïóñòûå êëþ÷è íå ïåðåäàþòñÿ.
*
* @param int $phoneMaxLength {@see self::JSON_PHONE_MAX_LENGTH} èëè {@see self::KASSA_PHONE_MAX_LENGTH}
*
* @return array<string, string>
*/
public function toArray(int $phoneMaxLength): array
{
$phone = strlen($this->phone) <= $phoneMaxLength ? $this->phone : '';
if (($this->email === '') && ($phone === '')) {
// Òåëåôîí äëèííåå ñõåìû, à email íåò — áåç êîíòàêòà ÔÈÎ êàññà íå ïðèìåò.
return [];
}
return array_filter(
['name' => $this->name, 'email' => $this->email, 'phone' => $phone],
static function (string $value): bool {
return ($value !== '');
},
);
}
private static function email(string $email): string
{
$email = trim($email);
if (
strlen($email) > self::EMAIL_MAX_LENGTH
|| filter_var($email, FILTER_VALIDATE_EMAIL) === false
) {
return '';
}
return $email;
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
use InvalidArgumentException;
use MonetaPhpShop\Money;
use MonetaPhpShop\protocol\JsonNumber;
/**
* Ïîçèöèÿ ÷åêà: `inventPositions` (`kassaspecification.pdf`) è ýëåìåíò `items` JSON Check URL
* (`cmsspecification.pdf`). Êîëè÷åñòâî — äî òð¸õ çíàêîâ ïîñëå òî÷êè ({@see Quantity});
* åäèíèöà èçìåðåíèÿ (`measure`) — èç ïåðå÷íÿ êàññû ({@see Measure}).
* Ïðèçíàê ïðåäìåòà ðàñ÷¸òà (`po` / `paymentObject`) — ÿâíî: ó òîâàðîâ èç íàñòðîéêè, ó äîñòàâêè
* `service` (áåç íåãî êàññà ñòàâèò `commodity` âñåì, è «Äîñòàâêà» óõîäèëà áû òîâàðîì). Ñïîñîá
* ðàñ÷¸òà (`pm` / `paymentMethod`) — ÿâíî: `kassaspecification.pdf` òðåáóåò ïîëå.
* Çíà÷åíèÿ — íàñòðîéêè «Ïðèçíàê ïðåäìåòà ðàñ÷¸òà» ({@see PaymentObject}) è
* «Ïðèçíàê ñïîñîáà ðàñ÷¸òà» ({@see PaymentMethod}). Îáà ïðèçíàêà — òîëüêî â ÷åêå äëÿ êàññû:
* áåç ôèñêàëèçàöèè `INVENTORY` èõ íå íåñ¸ò ({@see self::toInventoryPosition()}).
*/
final class ReceiptItem
{
public const NAME_MAX_LENGTH = 128;
/** Ïîòîëîê êàññû äëÿ öåíû è äëÿ `price * quantity`. */
public const PRICE_MAX = '42949672.95';
/** @var string */
private $name;
/** @var Money */
private $price;
/** @var Quantity */
private $quantity;
/** @var string */
private $measure;
/** @var Vat */
private $vat;
/** @var PaymentObject */
private $paymentObject;
/** @var PaymentMethod */
private $paymentMethod;
/**
* @param string $name â UTF-8; ïóñòîå ïîñëå ÷èñòêè êàññà íå ïðèìåò — çàïàñíîå íàçâàíèå
* ïîäñòàâëÿåò {@see ReceiptBuilder} (ëèòåðàëû ìîäóëÿ ïîñëå ñáîðêè â cp1251)
* @param PaymentObject|null $paymentObject null — òîâàð
* @param PaymentMethod|null $paymentMethod null — ïîëíûé ðàñ÷¸ò
* @param string $measure êîä åäèíèöû èçìåðåíèÿ êàññû ({@see Measure})
*/
public function __construct(
string $name,
Money $price,
Quantity $quantity,
Vat $vat,
?PaymentObject $paymentObject = null,
?PaymentMethod $paymentMethod = null,
string $measure = Measure::UNIT
) {
$this->name = ReceiptText::clean($name, self::NAME_MAX_LENGTH);
if ($this->name === '') {
throw new InvalidArgumentException('Ïóñòîå íàçâàíèå ïîçèöèè ÷åêà.');
}
$this->price = $price;
$this->quantity = $quantity;
$this->measure = $measure;
$this->vat = $vat;
$this->paymentObject = $paymentObject ?? PaymentObject::commodity();
$this->paymentMethod = $paymentMethod ?? PaymentMethod::fullPayment();
$max = Money::fromDecimal(self::PRICE_MAX)->toMinorUnits();
if (($price->toMinorUnits() > $max) || ($this->total()->toMinorUnits() > $max)) {
throw new InvalidArgumentException('Ñóììà ïîçèöèè ÷åêà âûøå ïîòîëêà êàññû.');
}
}
public function getName(): string
{
return $this->name;
}
public function getPrice(): Money
{
return $this->price;
}
public function getQuantity(): Quantity
{
return $this->quantity;
}
public function getMeasure(): string
{
return $this->measure;
}
public function getVat(): Vat
{
return $this->vat;
}
public function getPaymentObject(): PaymentObject
{
return $this->paymentObject;
}
public function getPaymentMethod(): PaymentMethod
{
return $this->paymentMethod;
}
/**
* Ñóììà ïîçèöèè òàê, êàê å¸ ïîñ÷èòàåò êàññà: öåíà x êîëè÷åñòâî äî êîïåéêè.
*/
public function total(): Money
{
return $this->quantity->totalOf($this->price);
}
/**
* Ýëåìåíò `items` JSON-îòâåòà Check URL; öåíà — ìåòêîé {@see JsonNumber}.
*
* @return array<string, mixed>
*/
public function toCheckItem(): array
{
return [
'name' => $this->name,
'price' => JsonNumber::placeholder($this->price),
'quantity' => JsonNumber::quantity($this->quantity),
'measure' => $this->measure,
'paymentMethod' => $this->paymentMethod->getValue(),
'paymentObject' => $this->paymentObject->getValue(),
'vat' => $this->vat->getCode(),
];
}
/**
* Îáúåêò `inventPositions` äëÿ àòðèáóòà `INVENTORY`. Áåç ôèñêàëèçàöèè — òîëüêî òî, ÷òî
* MONETA.RU ñîõðàíèò â îïåðàöèè: áåç `vatTag`, `pm`, `po`.
*
* @return array<string, mixed>
*/
public function toInventoryPosition(bool $fiscalization = true): array
{
$position = [
'name' => $this->name,
'price' => JsonNumber::placeholder($this->price),
'quantity' => JsonNumber::quantity($this->quantity),
'measure' => $this->measure,
];
if (!$fiscalization) {
return $position;
}
return $position + [
'vatTag' => $this->vat->getTag(),
'pm' => $this->paymentMethod->getValue(),
'po' => $this->paymentObject->getValue(),
];
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
/**
* Òåêñòîâûå ïîëÿ ÷åêà (íàçâàíèÿ ïîçèöèé, ÔÈÎ), ñòðîêè â UTF-8: «çíà÷åíèÿ íå äîëæíû ñîäåðæàòü
* êàâû÷åê, çíàêîâ &, $, #, îáðàòíûõ è ïðÿìûõ ñëýøåé» (`cmsspecification.pdf`); êîíòðàêò ìîäóëåé
* äîáàâëÿåò `%` è `;`. Îñòàâëÿåì áåëûé ñïèñîê: áóêâû, öèôðû, ïðîáåë è `.,()_¹+-`.
*
* Òîëüêî ASCII â øàáëîíàõ (`¹` — `\x{2116}`): ïîñëå ñáîðêè ôàéë â cp1251, à øàáëîí ñ `/u`
* îáÿçàí áûòü UTF-8. Áåç `mbstring` — ÿäðî PHPShop åãî íå òðåáóåò.
*/
final class ReceiptText
{
private const ELLIPSIS = '...';
private function __construct() {}
public static function clean(string $value, int $maxLength): string
{
$value = self::sanitize($value);
if ((int) preg_match_all('/./su', $value) <= $maxLength) {
return $value;
}
preg_match('/\A.{0,' . ($maxLength - strlen(self::ELLIPSIS)) . '}/su', $value, $matches);
return rtrim($matches[0] ?? '') . self::ELLIPSIS;
}
private static function sanitize(string $value): string
{
$decoded = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$allowed = preg_replace('/[^\p{L}\p{N}\s.,()_\x{2116}+\-]/u', ' ', $decoded) ?? '';
return trim(preg_replace('/\s+/u', ' ', $allowed) ?? '');
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
/**
* Система налогообложения — атрибут `SNO` ответа Pay URL (`kassaspecification.pdf`, необязательный).
* Передаётся только при фискализации и только если мерчант её выбрал; по умолчанию
* не передаётся — касса берёт систему налогообложения из своих настроек. В JSON Check URL поля нет
* (схема `cmsspecification.pdf`). Значения хранятся в настройках модуля — не переименовывать.
*/
final class TaxSystem
{
/** Не передавать: система налогообложения из настроек кассы. */
public const KASSA = '';
/** Значение -> подпись в настройках, коды — по `kassaspecification.pdf`. */
public const LABELS = [
self::KASSA => 'Не передавать (из настроек кассы)',
'1' => 'Общая (ОСНО)',
'2' => 'Упрощённая (УСН) доход',
'3' => 'Упрощённая (УСН) доход минус расход',
'4' => 'Единый налог на вменённый доход (ЕНВД)',
'5' => 'Единый сельскохозяйственный налог (ЕСХН)',
'6' => 'Патентная (ПСН)',
];
/** @var string */
private $value;
private function __construct(string $value)
{
$this->value = $value;
}
/**
* Значение из настроек; неизвестное или пустое — не передавать.
*/
public static function fromSetting(?string $value): self
{
$value = (string) $value;
return new self(isset(self::LABELS[$value]) ? $value : self::KASSA);
}
public static function kassa(): self
{
return new self(self::KASSA);
}
public function getValue(): string
{
return $this->value;
}
public function isPassed(): bool
{
return ($this->value !== self::KASSA);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
/**
* Ñòàâêà ÍÄÑ ïîçèöèè ÷åêà: êîä `vat` äëÿ JSON Check URL (`cmsspecification.pdf`)
* è òåã `vatTag` äëÿ `INVENTORY` (`kassaspecification.pdf`).
*
* Èç íàñòðîåê PHPShop ìîäóëü áåð¸ò òîëüêî ïðÿìûå ñòàâêè ïî ïðîöåíòó. Ðàñ÷¸òíûå (`vat120`, `1106`…)
* ïðèâÿçàíû ê àâàíñàì è ïðåäîïëàòå (`pm` `advance`/`prepayment`) — èõ ìîäóëü ñàì íå âûáèðàåò,
* òîëüêî ìåð÷àíò íàñòðîéêîé ({@see VatSetting}).
* Value-îáúåêò âìåñòî enum — PHP 7.4.
*/
final class Vat
{
public const NONE = 'none';
/** Ïðîöåíò ñòàâêè -> êîä `vat`. */
private const BY_PERCENT = [
0 => 'vat0',
5 => 'vat5',
7 => 'vat7',
10 => 'vat10',
20 => 'vat20',
22 => 'vat22',
];
/**
* Êîä `vat` (`cmsspecification.pdf`) -> `vatTag` (`kassaspecification.pdf`).
*/
private const TAGS = [
self::NONE => '1105',
'vat0' => '1104',
'vat5' => '1108',
'vat7' => '1109',
'vat10' => '1103',
'vat20' => '1102',
'vat22' => '1113',
'vat105' => '1110',
'vat107' => '1111',
'vat110' => '1107',
'vat120' => '1106',
'vat122' => '1114',
];
/** @var string */
private $code;
private function __construct(string $code)
{
$this->code = $code;
}
public static function none(): self
{
return new self(self::NONE);
}
/**
* Ñòàâêà ïî êîäó `vat`; íåèçâåñòíûé êîä — null.
*/
public static function fromCode(string $code): ?self
{
return isset(self::TAGS[$code]) ? new self($code) : null;
}
/**
* Ñòàâêà èç íàñòðîåê PHPShop — ñâîáîäíîå ÷èñëî ïðîöåíòîâ (`nds`, `ofd_nds` äîñòàâêè).
* Ïóñòî èëè ñòàâêè íåò â ïåðå÷íå êàññû — «áåç ÍÄÑ»: íåèçâåñòíóþ ñòàâêó íå óãàäûâàåì.
*/
public static function fromPercent(string $percent): self
{
return self::tryFromPercent($percent) ?? self::none();
}
/**
* Êàê {@see self::fromPercent()}, íî ïóñòîå èëè íåèçâåñòíîå çíà÷åíèå — null: âûçûâàþùèé
* ïðåäóïðåæäàåò ìåð÷àíòà, ÷òî ïîçèöèÿ óéä¸ò â ÷åê «áåç ÍÄÑ».
*/
public static function tryFromPercent(string $percent): ?self
{
$percent = trim($percent);
if (preg_match('/\A[0-9]{1,3}(?:[.,]0+)?\z/', $percent) !== 1) {
return null;
}
$value = (int) $percent;
return isset(self::BY_PERCENT[$value]) ? new self(self::BY_PERCENT[$value]) : null;
}
public function getCode(): string
{
return $this->code;
}
public function getTag(): string
{
return self::TAGS[$this->code];
}
public function equals(self $other): bool
{
return ($this->code === $other->code);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
/**
* Ñòàâêè ÍÄÑ èç íàñòðîåê PHPShop, êîòîðûõ íåò â ïåðå÷íå êàññû: ïîçèöèè ñ íèìè óõîäÿò â ÷åê
* «áåç ÍÄÑ» ({@see Vat::fromPercent()}) — ìîäóëü ñòàâêó íå óãàäûâàåò, íî ìåð÷àíò äîëæåí îá ýòîì
* çíàòü. Èñòî÷íèê ïðåäóïðåæäåíèÿ â íàñòðîéêàõ ìîäóëÿ.
*
* Ïðîâåðÿåòñÿ òîëüêî òî, ÷òî ìîäóëü äåéñòâèòåëüíî áåð¸ò èç PHPShop: ïðè ôèñêàëèçàöèè,
* âêëþ÷¸ííîì ÍÄÑ ìàãàçèíà è íàñòðîéêå «èç PHPShop» ó òîâàðîâ èëè äîñòàâêè.
*/
final class VatRateWarnings
{
private function __construct() {}
/**
* @param string $systemPercent ñèñòåìíàÿ ñòàâêà PHPShop (`nds`)
* @param array<int, array{0: string, 1: string}> $deliveries ñïîñîáû äîñòàâêè: [íàçâàíèå, «Çíà÷åíèå ÍÄÑ»
* (`ofd_nds`, ïóñòî — ñèñòåìíàÿ ñòàâêà)]
*
* @return array<int, string> íåðàñïîçíàííûå ñòàâêè: `ñèñòåìíàÿ ñòàâêà PHPShop (òîâàðû): 23`
*/
public static function find(
bool $fiscalization,
bool $vatEnabled,
VatSetting $goodsVat,
VatSetting $deliveryVat,
string $systemPercent,
array $deliveries
): array {
if (!$fiscalization || !$vatEnabled) {
return [];
}
$warnings = [];
if ($goodsVat->getFixed() === null && Vat::tryFromPercent($systemPercent) === null) {
$warnings[] = 'ñèñòåìíàÿ ñòàâêà PHPShop (òîâàðû): ' . self::shown($systemPercent);
}
if ($deliveryVat->getFixed() === null) {
foreach ($deliveries as [$name, $percent]) {
$percent = (trim($percent) !== '') ? $percent : $systemPercent;
if (Vat::tryFromPercent($percent) === null) {
$warnings[] = 'äîñòàâêà «' . $name . '»: ' . self::shown($percent);
}
}
}
return $warnings;
}
/**
* Çíà÷åíèå äëÿ ñîîáùåíèÿ: ïóñòîå — ñëîâîì, èíà÷å êàê ââ¸ë ìåð÷àíò.
*/
public static function shown(string $percent): string
{
$percent = trim($percent);
return ($percent === '') ? 'íå çàäàíà' : $percent . '%';
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace MonetaPhpShop\receipt;
/**
* Íàñòðîéêè «ÍÄÑ íà òîâàðû» è «ÍÄÑ íà äîñòàâêó»: ñòàâêà èç PHPShop
* (ïî óìîë÷àíèþ) èëè îäíà ôèêñèðîâàííàÿ ñòàâêà íà âñå ïîçèöèè, âêëþ÷àÿ ðàñ÷¸òíûå —
* èõ ïðè àâàíñå è ïðåäîïëàòå âûáèðàåò ìåð÷àíò, ìîäóëü ñàì íå ïîäñòàâëÿåò.
* Çíà÷åíèÿ õðàíÿòñÿ â íàñòðîéêàõ ìîäóëÿ — íå ïåðåèìåíîâûâàòü.
*/
final class VatSetting
{
/** Ñòàâêà èç PHPShop: ñèñòåìíàÿ ó òîâàðîâ, ñïîñîáà äîñòàâêè ó äîñòàâêè. */
public const PLATFORM = 'platform';
/** Ôèêñèðîâàííûå ñòàâêè: êîä `vat` -> ïîäïèñü â íàñòðîéêàõ. */
public const FIXED_LABELS = [
Vat::NONE => 'Áåç ÍÄÑ',
'vat0' => 'ÍÄÑ 0%',
'vat5' => 'ÍÄÑ 5%',
'vat7' => 'ÍÄÑ 7%',
'vat10' => 'ÍÄÑ 10%',
'vat20' => 'ÍÄÑ 20%',
'vat22' => 'ÍÄÑ 22%',
'vat105' => 'ÍÄÑ 5% (ðàñ÷¸òíàÿ ñòàâêà 5/105)',
'vat107' => 'ÍÄÑ 7% (ðàñ÷¸òíàÿ ñòàâêà 7/107)',
'vat110' => 'ÍÄÑ 10% (ðàñ÷¸òíàÿ ñòàâêà 10/110)',
'vat120' => 'ÍÄÑ 20% (ðàñ÷¸òíàÿ ñòàâêà 20/120)',
'vat122' => 'ÍÄÑ 22% (ðàñ÷¸òíàÿ ñòàâêà 22/122)',
];
/** @var Vat|null */
private $fixed;
private function __construct(?Vat $fixed)
{
$this->fixed = $fixed;
}
/**
* Çíà÷åíèå èç íàñòðîåê; ïóñòîå, {@see self::PLATFORM} èëè íåèçâåñòíîå — ñòàâêà èç PHPShop.
*/
public static function fromSetting(?string $value): self
{
$value = (string) $value;
return new self(isset(self::FIXED_LABELS[$value]) ? Vat::fromCode($value) : null);
}
public static function platform(): self
{
return new self(null);
}
/**
* Ôèêñèðîâàííàÿ ñòàâêà èëè null — áðàòü èç PHPShop.
*/
public function getFixed(): ?Vat
{
return $this->fixed;
}
public function getValue(): string
{
return ($this->fixed !== null) ? $this->fixed->getCode() : self::PLATFORM;
}
/**
* Ïîäïèñè äëÿ âûïàäàþùåãî ñïèñêà: ïåðâûì — ñòàâêà èç PHPShop ñ ïîäïèñüþ `$platformLabel`.
*
* @return array<string, string>
*/
public static function labels(string $platformLabel): array
{
return [self::PLATFORM => $platformLabel] + self::FIXED_LABELS;
}
}