src/CompanyGroupBundle/Controller/AdminQuoteController.php line 31

Open in your IDE?
  1. <?php
  2. namespace CompanyGroupBundle\Controller;
  3. use ApplicationBundle\Constants\GeneralConstant;
  4. use ApplicationBundle\Controller\GenericController;
  5. use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
  6. use CompanyGroupBundle\Entity\SubscriptionQuote;
  7. use CompanyGroupBundle\Modules\Api\Service\LegacySubscriptionBillingService;
  8. use Symfony\Component\HttpFoundation\JsonResponse;
  9. use Symfony\Component\HttpFoundation\Request;
  10. use Symfony\Component\Routing\Generator\UrlGenerator;
  11. /**
  12.  * Admin quote management.
  13.  * All views extend central_header.html.twig.
  14.  * Entity manager: company_group
  15.  */
  16. class AdminQuoteController extends GenericController
  17. {
  18.     const QUOTES_PER_PAGE 20;
  19.     // =========================================================================
  20.     // ACCESS CHECK
  21.     // =========================================================================
  22.     private function requireAdminAccess(Request $request): bool
  23.     {
  24.         $session    $request->getSession();
  25.         $userId     = (int)$session->get(UserConstants::USER_ID0);
  26.         $isBuddybee = (int)$session->get(UserConstants::IS_BUDDYBEE_ADMIN0);
  27.         $allAccess  = (int)$session->get(UserConstants::ALL_MODULE_ACCESS_FLAG0);
  28.         return $userId && ($isBuddybee === || $allAccess === 1);
  29.     }
  30.     // =========================================================================
  31.     // LIST QUOTES
  32.     // =========================================================================
  33.     /**
  34.      * GET /admin/quotes
  35.      */
  36.     public function ListQuotesAction(Request $request)
  37.     {
  38.         if (!$this->requireAdminAccess($request)) {
  39.             return $this->redirectToRoute('dashboard');
  40.         }
  41.         $service $this->get('app.quote_service');
  42.         $page    max(1, (int)$request->query->get('page'1));
  43.         $offset  = ($page 1) * self::QUOTES_PER_PAGE;
  44.         $filters = [
  45.             'status'    => $request->query->get('status',    ''),
  46.             'plan_type' => $request->query->get('plan_type'''),
  47.             'search'    => trim($request->query->get('q',    '')),
  48.         ];
  49.         $result     $service->listQuotes($filtersself::QUOTES_PER_PAGE$offset);
  50.         $totalPages = (int)ceil($result['total'] / self::QUOTES_PER_PAGE);
  51.         // Status summary counts
  52.         $em   $this->getDoctrine()->getManager('company_group');
  53.         $conn $em->getConnection();
  54.         $summary = [
  55.             'all'       => (int)$conn->fetchOne("SELECT COUNT(*) FROM subscription_quotes"),
  56.             'requested' => (int)$conn->fetchOne("SELECT COUNT(*) FROM subscription_quotes WHERE status = 'requested'"),
  57.             'modified'  => (int)$conn->fetchOne("SELECT COUNT(*) FROM subscription_quotes WHERE status = 'modified'"),
  58.             'sent'      => (int)$conn->fetchOne("SELECT COUNT(*) FROM subscription_quotes WHERE status = 'sent'"),
  59.             'accepted'  => (int)$conn->fetchOne("SELECT COUNT(*) FROM subscription_quotes WHERE status = 'accepted'"),
  60.             'rejected'  => (int)$conn->fetchOne("SELECT COUNT(*) FROM subscription_quotes WHERE status = 'rejected'"),
  61.             'draft'     => (int)$conn->fetchOne("SELECT COUNT(*) FROM subscription_quotes WHERE status = 'draft'"),
  62.         ];
  63.         return $this->render('@CompanyGroup/pages/admin/quotes/list_quotes.html.twig', [
  64.             'page_title'  => 'Quotes',
  65.             'quotes'      => $result['items'],
  66.             'total'       => $result['total'],
  67.             'currentPage' => $page,
  68.             'totalPages'  => $totalPages,
  69.             'filters'     => $filters,
  70.             'summary'     => $summary,
  71.         ]);
  72.     }
  73.     // =========================================================================
  74.     // VIEW / EDIT QUOTE
  75.     // =========================================================================
  76.     /**
  77.      * GET /admin/quotes/{id}
  78.      */
  79.     public function ViewQuoteAction(Request $request$id)
  80.     {
  81.         if (!$this->requireAdminAccess($request)) {
  82.             return $this->redirectToRoute('dashboard');
  83.         }
  84.         $em    $this->getDoctrine()->getManager('company_group');
  85.         $quote $em->getRepository('CompanyGroupBundle\Entity\SubscriptionQuote')->find($id);
  86.         if (!$quote) {
  87.             throw $this->createNotFoundException('Quote #' $id ' not found.');
  88.         }
  89.         $service  $this->get('app.quote_service');
  90.         $pricing  $this->get('app.pricing_service');
  91.         $history  $service->getHistory((int)$quote->getId());
  92.         // Build breakdown for both Team and Enterprise (Enterprise uses same per-user rates when no flat override)
  93.         $breakdown $pricing->getPriceBreakdown(
  94.             (int)$quote->getNormalUserCount(),
  95.             (int)$quote->getAdminUserCount(),
  96.             (int)$quote->getMlUserCount(),
  97.             $quote->getBillingCycle() ?? 'monthly',
  98.             $quote->getPlanType()
  99.         );
  100.         /** @var LegacySubscriptionBillingService $billing */
  101.         $billing $this->get('app.legacy_subscription_billing_service');
  102.         $legacyInvoice $billing->findQuoteInvoice($quote);
  103.         $invoice null;
  104.         if ($legacyInvoice) {
  105.             $invoice = [
  106.                 'id' => (int)$legacyInvoice->getId(),
  107.                 'invoiceNumber' => (string)($legacyInvoice->getDocumentHash() ?: ('EI-' str_pad((string)$legacyInvoice->getId(), 8'0'STR_PAD_LEFT))),
  108.             ];
  109.         }
  110.         return $this->render('@CompanyGroup/pages/admin/quotes/edit_quote.html.twig', [
  111.             'page_title' => 'Quote #' $id,
  112.             'quote'      => $quote,
  113.             'history'    => $history,
  114.             'breakdown'  => $breakdown,
  115.             'invoice'    => $invoice,
  116.             'view_only'  => true,
  117.         ]);
  118.     }
  119.     /**
  120.      * GET  /admin/quotes/{id}/edit  — show edit form
  121.      * POST /admin/quotes/{id}/edit  — save changes
  122.      */
  123.     public function EditQuoteAction(Request $request$id)
  124.     {
  125.         if (!$this->requireAdminAccess($request)) {
  126.             return $this->redirectToRoute('dashboard');
  127.         }
  128.         $em    $this->getDoctrine()->getManager('company_group');
  129.         $quote $em->getRepository('CompanyGroupBundle\Entity\SubscriptionQuote')->find($id);
  130.         if (!$quote) {
  131.             throw $this->createNotFoundException('Quote #' $id ' not found.');
  132.         }
  133.         if ($request->isMethod('POST')) {
  134.             $post    $request->request;
  135.             $service $this->get('app.quote_service');
  136.             $data = [
  137.                 'plan_type'         => $post->get('plan_type',         $quote->getPlanType()),
  138.                 'billing_cycle'     => $post->get('billing_cycle',     $quote->getBillingCycle()),
  139.                 'payment_type'      => $post->get('payment_type',      $quote->getPaymentType()),
  140.                 'normal_user_count' => max(0, (int)$post->get('normal_user_count'0)),
  141.                 'admin_user_count'  => max(0, (int)$post->get('admin_user_count',  0)),
  142.                 'ml_user_count'     => max(0, (int)$post->get('ml_user_count',     0)),
  143.                 'discount_amount'   => max(0, (float)$post->get('discount_amount'0)),
  144.                 'discount_percent'  => max(0, (float)$post->get('discount_percent'0)),
  145.                 'promo_code_id'     => $post->get('promo_code_id'null),
  146.                 'base_amount'       => max(0, (float)$post->get('base_amount',     0)),
  147.                 'customer_name'     => trim($post->get('customer_name',  '')),
  148.                 'customer_phone'    => trim($post->get('customer_phone''')),
  149.                 'company_name'      => trim($post->get('company_name',   '')),
  150.                 'admin_notes'       => trim($post->get('admin_notes',    '')),
  151.                 'company_address'       => trim($post->get('company_address',    '')),
  152.                 'company_vat_no'       => trim($post->get('company_vat_no',    '')),
  153.                 'customer_designation'       => trim($post->get('customer_designation',    '')),
  154.                 'expires_at'        => $post->get('expires_at'''),
  155.             ];
  156.             $service->adminModifyQuote($quote$data$this->loggedUserId($request));
  157.             $this->addFlash('success''Quote updated successfully.');
  158.             return $this->redirectToRoute('admin_quote_view', ['id' => $id]);
  159.         }
  160.         $pricing        $this->get('app.pricing_service');
  161.         $maxDiscount    null;
  162.         // Build breakdown for both Team and Enterprise
  163.         $breakdown $pricing->getPriceBreakdown(
  164.             (int)$quote->getNormalUserCount(),
  165.             (int)$quote->getAdminUserCount(),
  166.             (int)$quote->getMlUserCount(),
  167.             $quote->getBillingCycle() ?? 'monthly',
  168.             $quote->getPlanType()
  169.         );
  170.         $adminUserId $this->loggedUserId($request);
  171.         if ($adminUserId) {
  172.             $adminDetails $em->getRepository('CompanyGroupBundle\Entity\EntityApplicantDetails')->find($adminUserId);
  173.             if ($adminDetails && $adminDetails->getMaxDiscountAllowed() !== null) {
  174.                 $maxDiscount = (float)$adminDetails->getMaxDiscountAllowed();
  175.             }
  176.         }
  177.         return $this->render('@CompanyGroup/pages/admin/quotes/edit_quote.html.twig', [
  178.             'page_title'   => 'Edit Quote #' $id,
  179.             'quote'        => $quote,
  180.             'breakdown'    => $breakdown,
  181.             'view_only'    => false,
  182.             'max_discount' => $maxDiscount,
  183.         ]);
  184.     }
  185.     // =========================================================================
  186.     // SEND QUOTE TO CUSTOMER
  187.     // =========================================================================
  188.     /**
  189.      * POST /admin/quotes/{id}/send
  190.      */
  191.     public function SendQuoteAction(Request $request$id)
  192.     {
  193.         if (!$this->requireAdminAccess($request)) {
  194.             return new JsonResponse(['success' => false'message' => 'Unauthorized'], 403);
  195.         }
  196.         $em    $this->getDoctrine()->getManager('company_group');
  197.         $quote $em->getRepository('CompanyGroupBundle\Entity\SubscriptionQuote')->find($id);
  198.         if (!$quote) {
  199.             return new JsonResponse(['success' => false'message' => 'Quote not found'], 404);
  200.         }
  201.         $service $this->get('app.quote_service');
  202.         $service->markSent($quote$this->loggedUserId($request));
  203.         // Quote public URL for the customer
  204.         $quoteUrl $this->generateUrl('quote_view_customer', ['token' => $quote->getQuoteToken()], UrlGenerator::ABSOLUTE_URL);
  205.         // ── Send email notification to customer ──────────────────────────────
  206.         $customerEmail $quote->getCustomerEmail();
  207.         $emailSent     false;
  208.         if ($customerEmail && GeneralConstant::EMAIL_ENABLED == 1) {
  209.             try {
  210.                 $customerName $quote->getCustomerName() ?: $customerEmail;
  211.                 $subject      'Your HoneyBee ERP Quote #' $quote->getId() . ' is ready';
  212.                 $body $this->renderView('@CompanyGroup/email/quote_sent.html.twig', [
  213.                     'quote'    => $quote,
  214.                     'quoteUrl' => $quoteUrl,
  215.                 ]);
  216.                 $message = (new \Swift_Message($subject))
  217.                     ->setFrom(['contact@ourhoneybee.eu' => 'HoneyBee ERP'])
  218.                     ->setTo([$customerEmail => $customerName])
  219.                     ->setReplyTo('contact@ourhoneybee.eu')
  220.                     ->setBody($body'text/html');
  221.                 $this->get('swiftmailer.mailer.quotes')->send($message);
  222.                 $emailSent true;
  223.             } catch (\Exception $e) {
  224.                 // Log but don't block the admin flow — quote is already marked sent
  225.                 $this->addFlash('warning''Quote marked as sent, but the notification email could not be delivered: ' $e->getMessage());
  226.             }
  227.         }
  228.         if ($emailSent) {
  229.             $this->addFlash('success''Quote marked as sent and email delivered to ' $customerEmail '. Customer link: ' $quoteUrl);
  230.         } elseif (!$emailSent && !$customerEmail) {
  231.             $this->addFlash('success''Quote marked as sent (no customer email on record). Customer link: ' $quoteUrl);
  232.         }
  233.         return $this->redirectToRoute('admin_quote_view', ['id' => $id]);
  234.     }
  235.     // =========================================================================
  236.     // CREATE QUOTE (admin-initiated)
  237.     // =========================================================================
  238.     /**
  239.      * GET  /admin/quotes/create  — show create form
  240.      * POST /admin/quotes/create  — save new quote
  241.      */
  242.     public function CreateQuoteAction(Request $request)
  243.     {
  244.         if (!$this->requireAdminAccess($request)) {
  245.             return $this->redirectToRoute('dashboard');
  246.         }
  247.         if ($request->isMethod('POST')) {
  248.             $post    $request->request;
  249.             $service $this->get('app.quote_service');
  250.             $data = [
  251.                 'plan_type'         => $post->get('plan_type',         SubscriptionQuote::PLAN_TEAM),
  252.                 'billing_cycle'     => $post->get('billing_cycle',     'monthly'),
  253.                 'payment_type'      => $post->get('payment_type',      'manual'),
  254.                 'normal_user_count' => max(0, (int)$post->get('normal_user_count'0)),
  255.                 'admin_user_count'  => max(0, (int)$post->get('admin_user_count',  0)),
  256.                 'ml_user_count'     => max(0, (int)$post->get('ml_user_count',     0)),
  257.                 'discount_amount'   => max(0, (float)$post->get('discount_amount'0)),
  258.                 'discount_percent'  => max(0, (float)$post->get('discount_percent'0)),
  259.                 'promo_code_id'     => $post->get('promo_code_id'null),
  260.                 'base_amount'       => max(0, (float)$post->get('base_amount',     0)),
  261.                 'customer_email'    => trim($post->get('customer_email''')),
  262.                 'customer_name'     => trim($post->get('customer_name',  '')),
  263.                 'customer_phone'    => trim($post->get('customer_phone''')),
  264.                 'company_name'      => trim($post->get('company_name',   '')),
  265.                 'company_address'   => trim($post->get('company_address',    '')),
  266.                 'company_vat_no'    => trim($post->get('company_vat_no',    '')),
  267.                 'customer_designation' => trim($post->get('customer_designation',    '')),
  268.                 'admin_notes'       => trim($post->get('admin_notes',    '')),
  269.                 'currency'          => $post->get('currency''EUR'),
  270.             ];
  271.             if (empty($data['customer_email'])) {
  272.                 $this->addFlash('error''Customer email is required.');
  273.                 return $this->redirectToRoute('admin_quote_create');
  274.             }
  275.             $quote $service->createAdminQuote($data$this->loggedUserId($request));
  276.             $this->addFlash('success''Quote created. Token: ' $quote->getQuoteToken());
  277.             return $this->redirectToRoute('admin_quote_view', ['id' => $quote->getId()]);
  278.         }
  279.         $pricing $this->get('app.pricing_service');
  280.         return $this->render('@CompanyGroup/pages/admin/quotes/create_quote.html.twig', [
  281.             'page_title'  => 'Create Quote',
  282.             'price_rates' => [
  283.                 'normal' => \CompanyGroupBundle\Modules\Api\Service\PricingService::PRICE_NORMAL_USER_MONTHLY,
  284.                 'admin'  => \CompanyGroupBundle\Modules\Api\Service\PricingService::PRICE_ADMIN_USER_MONTHLY,
  285.                 'ml'     => \CompanyGroupBundle\Modules\Api\Service\PricingService::PRICE_ML_USER_MONTHLY,
  286.             ],
  287.         ]);
  288.     }
  289.     // =========================================================================
  290.     // DUPLICATE QUOTE
  291.     // =========================================================================
  292.     // =========================================================================
  293.     // PROMO CODE LOOKUP
  294.     // =========================================================================
  295.     /**
  296.      * GET /admin/quotes/promo-lookup?code=XXX
  297.      */
  298.     public function PromoCodeLookupAction(Request $request)
  299.     {
  300.         if (!$this->requireAdminAccess($request)) {
  301.             return new JsonResponse(['success' => false'message' => 'Unauthorized'], 403);
  302.         }
  303.         $code trim($request->query->get('code'''));
  304.         if (!$code) {
  305.             return new JsonResponse(['success' => false'message' => 'No code provided']);
  306.         }
  307.         $em    $this->getDoctrine()->getManager('company_group');
  308.         $promo $em->getRepository('CompanyGroupBundle\Entity\PromoCode')->findOneBy(['code' => $code]);
  309.         if (!$promo) {
  310.             return new JsonResponse(['success' => false'message' => 'Promo code not found']);
  311.         }
  312.         $now time();
  313.         if ($promo->getExpiresAtTs() && $promo->getExpiresAtTs() < $now) {
  314.             return new JsonResponse(['success' => false'message' => 'Promo code has expired']);
  315.         }
  316.         if ($promo->getStartsAtTs() && $promo->getStartsAtTs() > $now) {
  317.             return new JsonResponse(['success' => false'message' => 'Promo code is not yet active']);
  318.         }
  319.         if ($promo->getMaxUseCount() && $promo->getUseCountBalance() !== null && $promo->getUseCountBalance() <= 0) {
  320.             return new JsonResponse(['success' => false'message' => 'Promo code usage limit reached']);
  321.         }
  322.         return new JsonResponse([
  323.             'success'           => true,
  324.             'id'                => $promo->getId(),
  325.             'code'              => $promo->getCode(),
  326.             'promo_type'        => (int)$promo->getPromoType(),
  327.             'promo_value'       => (float)$promo->getPromoValue(),
  328.             'max_discount'      => $promo->getMaxDiscountAmount() ? (float)$promo->getMaxDiscountAmount() : null,
  329.             'min_amount'        => $promo->getMinAmountForApplication() ? (float)$promo->getMinAmountForApplication() : null,
  330.             'label'             => (int)$promo->getPromoType() === 1
  331.                 '−€' number_format((float)$promo->getPromoValue(), 2) . ' off'
  332.                 : (float)$promo->getPromoValue() . '% off',
  333.         ]);
  334.     }
  335.     // =========================================================================
  336.     // DUPLICATE QUOTE
  337.     // =========================================================================
  338.     /**
  339.      * POST /admin/quotes/{id}/duplicate
  340.      */
  341.     public function DuplicateQuoteAction(Request $request$id)
  342.     {
  343.         if (!$this->requireAdminAccess($request)) {
  344.             return $this->redirectToRoute('dashboard');
  345.         }
  346.         $em    $this->getDoctrine()->getManager('company_group');
  347.         $quote $em->getRepository('CompanyGroupBundle\Entity\SubscriptionQuote')->find($id);
  348.         if (!$quote) {
  349.             throw $this->createNotFoundException('Quote #' $id ' not found.');
  350.         }
  351.         $service $this->get('app.quote_service');
  352.         $newQuote $service->createAdminQuote([
  353.             'plan_type'         => $quote->getPlanType(),
  354.             'billing_cycle'     => $quote->getBillingCycle(),
  355.             'payment_type'      => $quote->getPaymentType(),
  356.             'normal_user_count' => $quote->getNormalUserCount(),
  357.             'admin_user_count'  => $quote->getAdminUserCount(),
  358.             'ml_user_count'     => $quote->getMlUserCount(),
  359.             'base_amount'       => $quote->getBaseAmount(),
  360.             'discount_amount'   => $quote->getDiscountAmount(),
  361.             'customer_email'    => $quote->getCustomerEmail(),
  362.             'customer_name'     => $quote->getCustomerName(),
  363.             'customer_phone'    => $quote->getCustomerPhone(),
  364.             'company_name'      => $quote->getCompanyName(),
  365.             'admin_notes'       => '[Duplicated from #' $id '] ' $quote->getAdminNotes(),
  366.             'currency'          => $quote->getCurrency() ?? 'EUR',
  367.         ], $this->loggedUserId($request));
  368.         $this->addFlash('success''Quote duplicated as #' $newQuote->getId());
  369.         return $this->redirectToRoute('admin_quote_edit', ['id' => $newQuote->getId()]);
  370.     }
  371. }