This commit is contained in:
Thomas Peterson 2026-01-13 11:04:34 +01:00
parent 3a0019a1b9
commit d598a9214f
22 changed files with 573 additions and 78 deletions

View File

@ -9,7 +9,7 @@ PSC\Shop\EntityBundle\Entity\Product:
pos: <numberBetween(1, 200)>
notEdit: false
private: false
taxClass: 19
mwert: 19
price: 0
set_config: '{}'
shop: '@shop_1'
@ -23,7 +23,7 @@ PSC\Shop\EntityBundle\Entity\Product:
notEdit: false
pos: <numberBetween(1, 200)>
private: false
taxClass: 19
mwert: 19
price: 0
set_config: '{}'
shop: '@shop_1'
@ -37,7 +37,7 @@ PSC\Shop\EntityBundle\Entity\Product:
notEdit: false
pos: <numberBetween(1, 200)>
private: false
taxClass: 19
mwert: 19
price: 0
set_config: '\[{"article_id": "7996f00d-a5e6-4915-970d-9f7c4287cd92"}, {"article_id": "7996f00d-a5e6-4915-970d-9f7c4287cd91"} ]'
shop: '@shop_1'
@ -51,7 +51,7 @@ PSC\Shop\EntityBundle\Entity\Product:
type: 2
pos: <numberBetween(1, 200)>
private: false
taxClass: 19
mwert: 19
price: 20
set_config: '{}'
shop: '@shop_1'
@ -65,7 +65,7 @@ PSC\Shop\EntityBundle\Entity\Product:
pos: <numberBetween(1, 200)>
notEdit: false
private: false
taxClass: 19
mwert: 19
set_config: '{}'
shop: '@shop_1'
calcXml: >
@ -116,7 +116,7 @@ PSC\Shop\EntityBundle\Entity\Product:
pos: <numberBetween(1, 200)>
notEdit: false
private: false
taxClass: 19
mwert: 19
set_config: '{}'
shop: '@shop_1'
calcXml: >

View File

@ -34,4 +34,39 @@ class VoucherItemRepository extends EntityRepository
->getQuery()->execute();
return $isDeleted;
}
/**
* Count unused codes for a voucher
*
* @param int $voucherUid
* @return int
*/
public function countUnusedCodes(int $voucherUid): int
{
return (int) $this->createQueryBuilder('vi')
->select('COUNT(vi.uid)')
->where('vi.voucher = :voucherId')
->andWhere('vi.used = false')
->setParameter('voucherId', $voucherUid)
->getQuery()
->getSingleScalarResult();
}
/**
* Find unused VoucherItems (for display only, not for locking)
*
* @param int $voucherUid
* @param int $limit
* @return array
*/
public function findUnusedCodes(int $voucherUid, int $limit = 100): array
{
return $this->createQueryBuilder('vi')
->where('vi.voucher = :voucherId')
->andWhere('vi.used = false')
->setParameter('voucherId', $voucherUid)
->setMaxResults($limit)
->getQuery()
->getResult();
}
}

View File

@ -63,9 +63,7 @@ readonly class Calc
$order->addTax($order->getShipping()->getCalcPrice()->tax);
foreach ($order->getPositions() as $position) {
$position->getProduct()->setShopUuid($order->getShop()->getUuid());
if (
$this->productTypeRegistry->getProductType($position->getProduct()->getSpecialProductTypeObject()->getTyp())
) {
if ($this->productTypeRegistry->getProductType($position->getProduct()->getSpecialProductTypeObject()->getTyp())) {
$specialProductTransformer = $this->productTypeRegistry
->getProductType($position->getProduct()->getSpecialProductTypeObject()->getTyp())
->getProducer();
@ -77,7 +75,6 @@ readonly class Calc
$specialProductTransformer->calcPriceForOrderPosition($position);
}
}
$priceNet = $priceNet->plus(Money::ofMinor($position->getPrice()->getAllNet(), 'EUR'));
$priceVat = $priceVat->plus(Money::ofMinor($position->getPrice()->getAllVat(), 'EUR'));
$priceGross = $priceGross->plus(Money::ofMinor($position->getPrice()->getAllGross(), 'EUR'));

View File

@ -5,37 +5,44 @@ namespace PSC\Shop\OrderBundle\Service;
use PSC\Shop\EntityBundle\Document\Country;
use PSC\Shop\EntityBundle\Repository\CountryRepository;
use PSC\Shop\OrderBundle\Model\Order\Tax;
use PSC\System\SettingsBundle\Service\Shop;
class VatCalc
{
public function __construct(private readonly CountryRepository $countryRepository)
{
}
public function __construct(
private readonly CountryRepository $countryRepository,
private readonly Shop $shopService,
) {}
public function calcVat(\PSC\Shop\OrderBundle\Model\Base $order): void
{
$country = strtoupper($order->getDeliveryAddress()->getCountry());
$ustid = $order->getDeliveryAddress()->getUstid();
if($country == '') {
if ($country == '') {
$country = 'DE';
}
/**
* @var Country $countryDoc
*/
$countryDoc = $this->countryRepository->findOneBy(['code' => $country]);
if ($order->getShop()->getId() == 0) {
$shop = $this->shopService->getShopByUid($order->getShop()->getUuid());
$countryDoc = $this->countryRepository->findOneBy([
'code' => $country,
'shop' => $shop->getId(),
]);
} else {
$countryDoc = $this->countryRepository->findOneBy([
'code' => $country,
'shop' => $order->getShop()->getId(),
]);
}
$useVat = true;
if($ustid != "") {
if ($ustid != '') {
$useVat = $countryDoc->isWithTaxWithUstNr();
}else{
} else {
$useVat = $countryDoc->isWithTaxWithoutUstNr();
}
if(!$useVat) {
if (!$useVat) {
$order->getPayment()->getCalcPrice()->vat = 0;
$order->getPayment()->getCalcPrice()->gross = $order->getPayment()->getCalcPrice()->net;
$order->getPayment()->getCalcPrice()->tax = new Tax();
@ -44,7 +51,7 @@ class VatCalc
$order->getShipping()->getCalcPrice()->gross = $order->getPayment()->getCalcPrice()->net;
$order->getShipping()->getCalcPrice()->tax = new Tax();
foreach($order->getPositions() as $position) {
foreach ($order->getPositions() as $position) {
$position->getPrice()->setVat(0);
$position->getPrice()->setGross(0);
$position->getPrice()->setAllVat($position->getPrice()->getNet());
@ -57,5 +64,4 @@ class VatCalc
$order->setTaxes([]);
}
}
}

View File

@ -0,0 +1,139 @@
<?php
namespace App\Tests\PSC\Shop\Order\Api;
use PSC\Shop\ContactBundle\Repository\ContactRepository;
use PSC\Shop\EntityBundle\Entity\Shop;
use PSC\Shop\EntityBundle\Repository\JobRepository;
use PSC\Shop\EntityBundle\Repository\ShopRepository;
use PSC\Shop\PaymentBundle\Repository\PaymentRepository;
use PSC\Shop\ShippingBundle\Repository\ShippingRepository;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Tests\RefreshDatabaseTrait;
class CreateVatTest extends WebTestCase
{
use RefreshDatabaseTrait;
public function testCreateOrderDefault(): void
{
$client = static::createClient();
$shopRepository = static::getContainer()->get(ShopRepository::class);
/**
* @var Shop $shop
*/
$shop = $shopRepository->findOneBy(['title' => 'Printchampion']);
$shippingRepository = static::getContainer()->get(ShippingRepository::class);
$paymentRepository = static::getContainer()->get(PaymentRepository::class);
$client->jsonRequest(
'POST',
'/api/order/create',
[
'shop' => [
'uuid' => (string) $shop->getUuid(),
],
'type' => 2,
'shipping' => [
'uid' => $shippingRepository->findOneBy(['title' => 'Abholung vor Ort'])->getUid(),
],
'payment' => [
'uid' => $paymentRepository->findOneBy(['title' => 'Bar'])->getUid(),
],
'draft' => false,
'deliveryAddress' => [
'firstname' => 'Thomas',
'lastname' => 'Peterson',
'street' => 'Chausseestr.',
'houseNumber' => '24',
'zip' => '17506',
'city' => 'Gribow',
],
'invoiceAddress' => [
'firstname' => 'Thomas',
'lastname' => 'Peterson',
'street' => 'Chausseestr.',
'houseNumber' => '24',
'zip' => '17400',
'city' => 'Berlin',
],
'positions' => [
[
'count' => 1,
'product' => [
'title' => 'test XML',
'specialProductTypeObject' => [
'typ' => 6,
'taxClass' => 700,
'xml' => '<?xml version="1.0" encoding="utf-8"?>
<kalkulation>
<artikel>
<name>Blocks A5 25blatt geleimt</name>
<kommentar>kein</kommentar>
<option id="auflage" name="Auflage" type="Input" width="3" require="true" default="1">
<auflage>
<grenze formel="(10*5)">1-</grenze>
</auflage>
</option>
</artikel>
</kalkulation>',
],
],
],
[
'count' => 1,
'product' => [
'title' => 'test Manual Position',
'specialProductTypeObject' => [
'typ' => 1,
'cent' => true,
'net' => 14500,
'taxClass' => 700,
],
],
],
],
],
['HTTP_apiKey' => $shop->getApiKey()],
);
self::assertSame(200, $client->getResponse()->getStatusCode());
$data = json_decode($client->getResponse()->getContent(), true);
$client->jsonRequest(
'POST',
'/api/order/getonebyuuid',
[
'uuid' => $data['uuid'],
],
['HTTP_apiKey' => $shop->getApiKey()],
);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertSame(20865, $data['gross']);
self::assertSame('SAN-' . date('Ym') . '-1', $data['alias']);
self::assertSame('Berlin', $data['invoiceAddress']['city']);
self::assertSame('Gribow', $data['deliveryAddress']['city']);
self::assertSame('ShopMusterOrt', $data['senderAddress']['city']);
self::assertSame('ShopMusterIban', $data['senderAddress']['iban']);
self::assertSame(200, $client->getResponse()->getStatusCode());
/**
* @var JobRepository $jobs
*/
$jobs = static::getContainer()->get(JobRepository::class);
self::assertCount(0, $jobs->findBy(['data.order' => $data['uuid']]));
}
}

View File

@ -2,17 +2,17 @@
namespace App\Tests\PSC\Shop\Order\Service;
use PSC\Shop\OrderBundle\Model\Order\Position\Price;
use Tests\RefreshDatabaseTrait;
use PSC\Component\ApiBundle\Model\Shop;
use PSC\Shop\ContactBundle\Model\Address;
use PSC\Shop\OrderBundle\Model\Order\Position;
use PSC\Shop\OrderBundle\Model\Order\Position\Price;
use PSC\Shop\OrderBundle\Service\Calc;
use PSC\Shop\PaymentBundle\Model\Payment;
use PSC\Shop\ProductBundle\Model\Product;
use PSC\Shop\ProductBundle\Model\ProductSpecialObject;
use PSC\Shop\ShippingBundle\Model\Shipping;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Tests\RefreshDatabaseTrait;
class ImportCalcTest extends KernelTestCase
{
@ -26,11 +26,11 @@ class ImportCalcTest extends KernelTestCase
/**
* @var Calc $calcService
*/
*/
$calcService = $container->get(Calc::class);
$shop = new Shop();
$shop->setUuid('shop1');
$shop->setUuid('771a1176-d531-48ed-93b8-eec1fd4b917f');
$order = new \PSC\Shop\OrderBundle\Model\Order();
$order->setShop($shop);
@ -57,7 +57,7 @@ class ImportCalcTest extends KernelTestCase
$specialProductSettingsW1 = new ProductSpecialObject();
$specialProductSettingsW1->setCount(1);
$specialProductSettingsW1->setNet(25.5*100);
$specialProductSettingsW1->setNet(25.5 * 100);
$specialProductSettingsW1->setCent(true);
$productW1->setSpecialProductTypeObject($specialProductSettingsW1);
$price1 = new Price();

View File

@ -3,14 +3,14 @@
namespace Plugins\System\PSC\XmlCalc\Api;
use PSC\Shop\ContactBundle\Model\AccountType;
use PSC\Shop\EntityBundle\Repository\JobRepository;
use Tests\RefreshDatabaseTrait;
use PSC\Shop\ContactBundle\Repository\ContactRepository;
use PSC\Shop\EntityBundle\Entity\Shop;
use PSC\Shop\EntityBundle\Repository\JobRepository;
use PSC\Shop\EntityBundle\Repository\ShopRepository;
use PSC\Shop\PaymentBundle\Repository\PaymentRepository;
use PSC\Shop\ShippingBundle\Repository\ShippingRepository;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Tests\RefreshDatabaseTrait;
class CreateOrderFromExistingProductTest extends WebTestCase
{
@ -33,14 +33,14 @@ class CreateOrderFromExistingProductTest extends WebTestCase
'/api/order/create',
[
'shop' => [
'uuid' => (string)$shop->getUuid()
'uuid' => (string) $shop->getUuid(),
],
'type' => 2,
'shipping' => [
'uid' => $shippingRepository->findOneBy(['title' => 'Abholung vor Ort'])->getUid()
'uid' => $shippingRepository->findOneBy(['title' => 'Abholung vor Ort'])->getUid(),
],
'payment' => [
'uid' => $paymentRepository->findOneBy(['title' => 'Bar bei Abholung'])->getUid()
'uid' => $paymentRepository->findOneBy(['title' => 'Bar bei Abholung'])->getUid(),
],
'draft' => false,
'deliveryAddress' => [
@ -49,7 +49,7 @@ class CreateOrderFromExistingProductTest extends WebTestCase
'street' => 'Chausseestr.',
'houseNumber' => '24',
'zip' => '17506',
'city' => 'Gribow'
'city' => 'Gribow',
],
'invoiceAddress' => [
'firstname' => 'Thomas',
@ -57,7 +57,7 @@ class CreateOrderFromExistingProductTest extends WebTestCase
'street' => 'Chausseestr.',
'houseNumber' => '24',
'zip' => '17400',
'city' => 'Berlin'
'city' => 'Berlin',
],
'positions' => [
[
@ -68,14 +68,14 @@ class CreateOrderFromExistingProductTest extends WebTestCase
'specialProductTypeObject' => [
'typ' => 6,
'params' => [
'auflage' => 100
'auflage' => 100,
],
]
]
]
]
],
['HTTP_apiKey' => $shop->getApiKey()]
],
],
],
],
['HTTP_apiKey' => $shop->getApiKey()],
);
self::assertSame(200, $client->getResponse()->getStatusCode());
@ -88,7 +88,7 @@ class CreateOrderFromExistingProductTest extends WebTestCase
[
'uuid' => $data['uuid'],
],
['HTTP_apiKey' => $shop->getApiKey()]
['HTTP_apiKey' => $shop->getApiKey()],
);
self::assertSame(200, $client->getResponse()->getStatusCode());
@ -117,17 +117,17 @@ class CreateOrderFromExistingProductTest extends WebTestCase
'/api/order/create',
[
'shop' => [
'uuid' => (string)$shop->getUuid()
'uuid' => (string) $shop->getUuid(),
],
'type' => 2,
'shipping' => [
'uid' => $shippingRepository->findOneBy(['title' => 'Abholung vor Ort'])->getUid()
'uid' => $shippingRepository->findOneBy(['title' => 'Abholung vor Ort'])->getUid(),
],
'payment' => [
'uid' => $paymentRepository->findOneBy(['title' => 'Bar bei Abholung'])->getUid()
'uid' => $paymentRepository->findOneBy(['title' => 'Bar bei Abholung'])->getUid(),
],
'contact' => [
'accountType' => AccountType::COMPANY
'accountType' => AccountType::COMPANY,
],
'draft' => false,
'deliveryAddress' => [
@ -136,7 +136,7 @@ class CreateOrderFromExistingProductTest extends WebTestCase
'street' => 'Chausseestr.',
'houseNumber' => '24',
'zip' => '17506',
'city' => 'Gribow'
'city' => 'Gribow',
],
'invoiceAddress' => [
'firstname' => 'Thomas',
@ -144,7 +144,7 @@ class CreateOrderFromExistingProductTest extends WebTestCase
'street' => 'Chausseestr.',
'houseNumber' => '24',
'zip' => '17400',
'city' => 'Berlin'
'city' => 'Berlin',
],
'positions' => [
[
@ -155,14 +155,14 @@ class CreateOrderFromExistingProductTest extends WebTestCase
'specialProductTypeObject' => [
'typ' => 6,
'params' => [
'auflage' => 100
'auflage' => 100,
],
]
]
]
]
],
['HTTP_apiKey' => $shop->getApiKey()]
],
],
],
],
['HTTP_apiKey' => $shop->getApiKey()],
);
self::assertSame(200, $client->getResponse()->getStatusCode());
@ -176,7 +176,7 @@ class CreateOrderFromExistingProductTest extends WebTestCase
[
'uuid' => $data['uuid'],
],
['HTTP_apiKey' => $shop->getApiKey()]
['HTTP_apiKey' => $shop->getApiKey()],
);
self::assertSame(200, $client->getResponse()->getStatusCode());
@ -187,6 +187,4 @@ class CreateOrderFromExistingProductTest extends WebTestCase
$jobs = static::getContainer()->get(JobRepository::class);
self::assertCount(0, $jobs->findBy(['data.order' => $data['uuid']]));
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace Plugin\Custom\PSC\Gutschein\Form\Field;
use Doctrine\ORM\EntityManagerInterface;
use PSC\Shop\EntityBundle\Entity\Product;
use PSC\Shop\EntityBundle\Entity\Voucher;
use PSC\Shop\EntityBundle\Repository\VoucherItemRepository;
use PSC\System\PluginBundle\Form\Interfaces\Field;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
class ProductSettings implements Field
{
public function __construct(
private readonly EntityManagerInterface $entityManager
) {}
public function getTemplate()
{
return '@PluginCustomPSCGutschein/form/field/product_settings.html.twig';
}
public function getModule()
{
return Field::Product;
}
public function formPreSubmit(FormEvent $event)
{
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$product = $options['product'];
// Load all vouchers and build choices with code count
$voucherRepo = $this->entityManager->getRepository(Voucher::class);
/** @var VoucherItemRepository $voucherItemRepo */
$voucherItemRepo = $this->entityManager->getRepository(\PSC\Shop\EntityBundle\Entity\VoucherItem::class);
$vouchers = $voucherRepo->findAll();
$choices = ['-- Zufallscode generieren (Standard) --' => ''];
foreach ($vouchers as $voucher) {
$unusedCount = $voucherItemRepo->countUnusedCodes($voucher->getUid());
$label = sprintf(
'%s (%d verfügbare Codes)',
$voucher->getTitle(),
$unusedCount
);
$choices[$label] = (string)$voucher->getUid();
}
$currentLinkedVoucher = null;
if ($product && isset($product->gutschein['linkedVoucherUid'])) {
$currentLinkedVoucher = (string)$product->gutschein['linkedVoucherUid'];
}
$builder->add('linkedVoucherUid', ChoiceType::class, [
'required' => false,
'label' => 'Verknüpfter Gutschein',
'choices' => $choices,
'data' => $currentLinkedVoucher,
'help' => 'Wählen Sie einen Gutschein aus dem VoucherBundle, um vordefinierte Codes zu verwenden. Wenn kein Gutschein ausgewählt ist, wird ein Zufallscode generiert.',
]);
return $builder;
}
public function getGroup()
{
return \Plugin\Custom\PSC\Gutschein\Form\Group\Calc::GROUP_ID;
}
public function formPostSetData(FormEvent $event)
{
}
public function formPostSubmit(FormEvent $event)
{
/** @var Product $product */
$product = $event->getData();
$form = $event->getForm();
if ($form->has('linkedVoucherUid')) {
$linkedVoucherUid = $form->get('linkedVoucherUid')->getData();
// Get existing gutschein data
$gutscheinData = $product->gutschein ?? [];
// Update or set linkedVoucherUid
if (!empty($linkedVoucherUid)) {
$gutscheinData['linkedVoucherUid'] = (int)$linkedVoucherUid;
} else {
// Remove if empty (use random generation)
unset($gutscheinData['linkedVoucherUid']);
}
// Save back to product
$product->gutschein = $gutscheinData;
}
}
public function formPreSetData(FormEvent $event)
{
}
public function formSubmit(FormEvent $event)
{
}
}

View File

@ -25,6 +25,9 @@ class ProductSpecialObject implements IProductTypeObject
private string $voucherCode = '';
private ?\DateTime $expirationDate = null;
// VoucherBundle integration
private ?int $linkedVoucherUid = null;
public function getName(): string
{
return 'Gutscheinprodukt';
@ -46,6 +49,7 @@ class ProductSpecialObject implements IProductTypeObject
'voucherCode' => $this->voucherCode,
'expirationDate' => $this->expirationDate?->format('Y-m-d H:i:s'),
'validityMonths' => $this->validityMonths,
'linkedVoucherUid' => $this->linkedVoucherUid,
];
}
@ -169,4 +173,14 @@ class ProductSpecialObject implements IProductTypeObject
{
$this->expirationDate = $expirationDate;
}
public function getLinkedVoucherUid(): ?int
{
return $this->linkedVoucherUid;
}
public function setLinkedVoucherUid(?int $linkedVoucherUid): void
{
$this->linkedVoucherUid = $linkedVoucherUid;
}
}

View File

@ -29,6 +29,7 @@ class Producer implements IUiProducer, IProducerHydrateModel
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly CodeGenerator $codeGenerator,
private readonly \Plugin\Custom\PSC\Gutschein\Service\VoucherCodeDistributor $voucherCodeDistributor,
) {}
public function setProduct(Product $product): void
@ -136,11 +137,54 @@ class Producer implements IUiProducer, IProducerHydrateModel
public function calcPriceForOrderPosition(Position $position): void
{
// Generate unique voucher code for this order position
$voucherCode = $this->codeGenerator->generate();
/** @var ProductSpecialObject $specProd */
$specProd = $position->getProduct()->getSpecialProductTypeObject();
$voucherCode = null;
// Try to get code from VoucherBundle if product is linked
if ($specProd->getLinkedVoucherUid()) {
try {
$contact = $position->getOrder()?->getContact();
$voucherItem = $this->voucherCodeDistributor->getNextAvailableCode(
$specProd->getLinkedVoucherUid(),
$contact
);
if ($voucherItem) {
// Successfully got code from VoucherBundle
$voucherCode = $voucherItem->getCode();
error_log(sprintf(
'[Gutschein] Distributed VoucherBundle code %s from Voucher %d for Product %s',
$voucherCode,
$specProd->getLinkedVoucherUid(),
$position->getProduct()->getUuid()
));
} else {
// No codes available - log warning
error_log(sprintf(
'[Gutschein] WARNING: No available codes in VoucherBundle Voucher %d for Product %s, using random generation',
$specProd->getLinkedVoucherUid(),
$position->getProduct()->getUuid()
));
}
} catch (\Exception $e) {
// Error accessing VoucherBundle - log and fall back
error_log(sprintf(
'[Gutschein] ERROR: Failed to access VoucherBundle (Voucher %d): %s, using random generation',
$specProd->getLinkedVoucherUid(),
$e->getMessage()
));
}
}
// Fall back to random generation if no VoucherBundle code obtained
if (!$voucherCode) {
$voucherCode = $this->codeGenerator->generate();
}
// Set voucher details
$specProd->setVoucherCode($voucherCode);
$specProd->setVoucherAmount($this->voucherAmount);
$specProd->setRecipientName($this->recipientName);

View File

@ -17,3 +17,7 @@ services:
Plugin\Custom\PSC\Gutschein\Form\Field\Calc:
tags:
- { name: psc.backend.custom.fields, productType: 9 }
Plugin\Custom\PSC\Gutschein\Form\Field\ProductSettings:
tags:
- { name: psc.backend.custom.fields, productType: 9 }

View File

@ -6,8 +6,8 @@
<div class="row">
<div class="col-md-6">
<div class="form-group">
{{ form_label(form.gutschein.validityMonths) }}
{{ form_widget(form.gutschein.validityMonths) }}
{{ form_label(form.validityMonths) }}
{{ form_widget(form.validityMonths) }}
<small class="form-text text-muted">Gültigkeitsdauer ab Kaufdatum</small>
</div>
</div>
@ -16,22 +16,22 @@
<div class="row">
<div class="col-md-4">
<div class="form-group">
{{ form_label(form.gutschein.minAmount) }}
{{ form_widget(form.gutschein.minAmount) }}
{{ form_label(form.minAmount) }}
{{ form_widget(form.minAmount) }}
<small class="form-text text-muted">Mindestbetrag</small>
</div>
</div>
<div class="col-md-4">
<div class="form-group">
{{ form_label(form.gutschein.defaultAmount) }}
{{ form_widget(form.gutschein.defaultAmount) }}
{{ form_label(form.defaultAmount) }}
{{ form_widget(form.defaultAmount) }}
<small class="form-text text-muted">Vorauswahl</small>
</div>
</div>
<div class="col-md-4">
<div class="form-group">
{{ form_label(form.gutschein.maxAmount) }}
{{ form_widget(form.gutschein.maxAmount) }}
{{ form_label(form.maxAmount) }}
{{ form_widget(form.maxAmount) }}
<small class="form-text text-muted">Maximalbetrag</small>
</div>
</div>

View File

@ -0,0 +1,29 @@
<div class="card mb-3">
<div class="card-header">
<h5>VoucherBundle Integration</h5>
</div>
<div class="card-body">
<div class="form-group">
{{ form_label(form.linkedVoucherUid) }}
{{ form_widget(form.linkedVoucherUid, {'attr': {'class': 'form-control'}}) }}
{{ form_help(form.linkedVoucherUid) }}
</div>
<div class="alert alert-info mt-3">
<strong>Wie funktioniert die Verknüpfung?</strong>
<ul class="mb-0 mt-2">
<li>Wählen Sie einen Gutschein aus dem VoucherBundle aus, um vordefinierte Codes zu verwenden</li>
<li>Bei jedem Verkauf wird automatisch ein Code aus dem Voucher zugewiesen</li>
<li>Der Code wird als "verwendet" markiert und mit dem Kunden verknüpft</li>
<li>Wenn keine Codes mehr verfügbar sind, wird automatisch ein Zufallscode generiert</li>
<li>Ohne Verknüpfung werden weiterhin Zufallscodes generiert (Standard-Verhalten)</li>
</ul>
</div>
{% if form.linkedVoucherUid.vars.value %}
<div class="alert alert-success">
<strong>Aktuell verknüpft:</strong> Dieser Gutschein verwendet Codes aus dem ausgewählten VoucherBundle-Gutschein.
</div>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,103 @@
<?php
/**
* PrintshopCreator Suite
*
* @author Thomas Peterson <info@thomas-peterson.de>
* @copyright 2012-2013 PrintshopCreator GmbH
* @license Private
* @link http://www.printshopcreator.de
*/
namespace Plugin\Custom\PSC\Gutschein\Service;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use PSC\Shop\EntityBundle\Entity\VoucherItem;
use PSC\Shop\EntityBundle\Entity\Contact;
/**
* VoucherCodeDistributor
*
* Service for atomic code distribution from VoucherBundle
* Uses pessimistic locking to prevent race conditions
*/
class VoucherCodeDistributor
{
public function __construct(
private readonly EntityManagerInterface $entityManager
) {}
/**
* Get next available unused code from a Voucher
* Uses pessimistic locking to prevent race conditions
*
* @param int $voucherUid
* @param Contact|null $contact
* @return VoucherItem|null
* @throws \Exception
*/
public function getNextAvailableCode(int $voucherUid, ?Contact $contact = null): ?VoucherItem
{
$this->entityManager->beginTransaction();
try {
// Use pessimistic write lock for atomic assignment
$qb = $this->entityManager->createQueryBuilder();
$voucherItem = $qb->select('vi')
->from(VoucherItem::class, 'vi')
->where('vi.voucher = :voucherId')
->andWhere('vi.used = :used')
->setParameter('voucherId', $voucherUid)
->setParameter('used', false)
->setMaxResults(1)
->getQuery()
->setLockMode(LockMode::PESSIMISTIC_WRITE)
->getOneOrNullResult();
if (!$voucherItem) {
$this->entityManager->rollback();
return null;
}
// Mark as used and link to contact
$voucherItem->setUsed(true);
if ($contact) {
$voucherItem->setContact($contact);
}
$voucherItem->setUpdatedAt(new \DateTime());
$this->entityManager->flush();
$this->entityManager->commit();
return $voucherItem;
} catch (\Exception $e) {
$this->entityManager->rollback();
throw $e;
}
}
/**
* Check if a Voucher has available codes
*
* @param int $voucherUid
* @return bool
*/
public function hasAvailableCodes(int $voucherUid): bool
{
return $this->getRemainingCodeCount($voucherUid) > 0;
}
/**
* Get count of remaining unused codes
*
* @param int $voucherUid
* @return int
*/
public function getRemainingCodeCount(int $voucherUid): int
{
$repo = $this->entityManager->getRepository(VoucherItem::class);
return $repo->countUnusedCodes($voucherUid);
}
}

View File

@ -35,6 +35,10 @@ class Product implements IProductTransformer
if ($productDoc->getCustom4()) {
$prodSpec->setValidityMonths((int)$productDoc->getCustom4());
}
// Load linked VoucherBundle Voucher UID from custom5
if ($productDoc->getCustom5()) {
$prodSpec->setLinkedVoucherUid((int)$productDoc->getCustom5());
}
}
public function toDb(
@ -50,5 +54,9 @@ class Product implements IProductTransformer
$productDoc->setCustom2((string)$prodSpec->getMaxAmount());
$productDoc->setCustom3((string)$prodSpec->getDefaultAmount());
$productDoc->setCustom4((string)$prodSpec->getValidityMonths());
// Save linked VoucherBundle Voucher UID to custom5
$productDoc->setCustom5(
$prodSpec->getLinkedVoucherUid() ? (string)$prodSpec->getLinkedVoucherUid() : ''
);
}
}

View File

@ -32,7 +32,7 @@ class Producer implements IUiProducer, IProducerHydrateModel, ICalcNeedContact
private Product $product;
private Engine $engine;
private EntityManagerInterface $entityManager;
private null|Contact $contact = null;
private ?Contact $contact = null;
public function __construct(
Shop $shopService,
@ -73,7 +73,6 @@ class Producer implements IUiProducer, IProducerHydrateModel, ICalcNeedContact
* @var ProductSpecialObject $specProd
*/
$specProd = $this->product->getSpecialProductTypeObject();
$priceObj = Money::ofMinor($this->engine->getPrice() * 100, 'EUR');
$price = new Price();
@ -152,6 +151,9 @@ class Producer implements IUiProducer, IProducerHydrateModel, ICalcNeedContact
$engine->loadString($product->getCalcXml());
$engine->setFormulas($product->getShop()->getFormel());
$engine->setParameters($product->getShop()->getParameter());
$spec = $this->product->getSpecialProductTypeObject();
$spec->setTaxClass($product->getMwert() * 100);
} elseif ($this->product->getUuid()) {
/**
* @var \PSC\Shop\EntityBundle\Entity\Product $product
@ -169,6 +171,9 @@ class Producer implements IUiProducer, IProducerHydrateModel, ICalcNeedContact
$engine->loadString($product->getCalcXml());
$engine->setFormulas($product->getShop()->getFormel());
$engine->setParameters($product->getShop()->getParameter());
$spec = $this->product->getSpecialProductTypeObject();
$spec->setTaxClass($product->getMwert() * 100);
}
$this->engine = $engine;
@ -248,7 +253,7 @@ class Producer implements IUiProducer, IProducerHydrateModel, ICalcNeedContact
return $temp;
}
public function setContact(null|Contact $contact): void
public function setContact(?Contact $contact): void
{
$this->contact = $contact;
}