src/ApplicationBundle/Modules/Inventory/Controller/CentralProductControlController.php line 105

Open in your IDE?
  1. <?php
  2. namespace ApplicationBundle\Modules\Inventory\Controller;
  3. use ApplicationBundle\Controller\GenericController;
  4. use ApplicationBundle\Interfaces\SessionCheckInterface;
  5. use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
  6. use ApplicationBundle\Modules\Inventory\Service\CentralProductControlService;
  7. use Symfony\Component\HttpFoundation\JsonResponse;
  8. use Symfony\Component\HttpFoundation\Request;
  9. /**
  10.  * Central Product Control — the reconciliation hub UI on the _CENTRAL_ server (also reachable from the
  11.  * super-admin dashboard). P1: list every global product, edit attributes, and push edits down to the ERPs.
  12.  *
  13.  * The list renders anywhere (queries inv_products), but all MUTATIONS are gated to _CENTRAL_.
  14.  * Design: CENTRAL_PRODUCT_CONTROL_PLAN.md.
  15.  */
  16. class CentralProductControlController extends GenericController implements SessionCheckInterface
  17. {
  18.     private function svc(): CentralProductControlService
  19.     {
  20.         return new CentralProductControlService($this->getDoctrine()->getManager());
  21.     }
  22.     private function isCentral(): bool
  23.     {
  24.         $sys $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
  25.         return $sys === '_CENTRAL_';
  26.     }
  27.     public function listAction(Request $request)
  28.     {
  29.         $search trim((string) $request->query->get('q'''));
  30.         $products = [];
  31.         try {
  32.             $products $this->svc()->listProducts($search1500);
  33.         } catch (\Throwable $e) { /* lean/missing schema → empty list */ }
  34.         return $this->render('@Inventory/pages/central/central_product_control.html.twig', [
  35.             'page_title'     => 'Central Product Control',
  36.             'products'       => $products,
  37.             'search'         => $search,
  38.             'is_central'     => $this->isCentral(),
  39.             'sidebar_partial' => '@Inventory/pages/central/_central_catalog_sidebar.html.twig',
  40.             'cp_active'      => 'products',
  41.         ]);
  42.     }
  43.     /** Save edited attributes to the central product (name/description/alias/specifications). */
  44.     public function saveAction(Request $request): JsonResponse
  45.     {
  46.         if (!$this->isCentral()) {
  47.             return new JsonResponse(['success' => false'message' => 'Editing the global catalog is only available on the central server.'], 403);
  48.         }
  49.         $globalId = (int) $request->request->get('globalId'0);
  50.         if ($globalId <= 0) {
  51.             return new JsonResponse(['success' => false'message' => 'globalId is required.'], 400);
  52.         }
  53.         $fields = [];
  54.         foreach (['name''description''alias''specifications'] as $f) {
  55.             if ($request->request->has($f)) {
  56.                 $fields[$f] = $request->request->get($f);
  57.             }
  58.         }
  59.         try {
  60.             $written $this->svc()->updateProductAttributes($globalId$fields);
  61.         } catch (\Throwable $e) {
  62.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  63.         }
  64.         return new JsonResponse(['success' => true'updated' => $written]);
  65.     }
  66.     /** Return a single product's editable attributes (for the edit modal). */
  67.     public function getAction(Request $request$globalId): JsonResponse
  68.     {
  69.         $p $this->svc()->getProductByGlobalId((int) $globalId);
  70.         if (!$p) {
  71.             return new JsonResponse(['success' => false'message' => 'Product not found.'], 404);
  72.         }
  73.         return new JsonResponse(['success' => true'product' => $p]);
  74.     }
  75.     /** JSON product search (for the in-modal "find & merge duplicates" box). */
  76.     public function searchAction(Request $request): JsonResponse
  77.     {
  78.         $q trim((string) $request->query->get('q'''));
  79.         $items = [];
  80.         if ($q !== '') {
  81.             try {
  82.                 foreach ($this->svc()->listProducts($q250) as $p) {
  83.                     $items[] = [
  84.                         'globalId' => (int) $p['globalId'],
  85.                         'name'     => $p['name'],
  86.                         'modelNo'  => $p['modelNo'] ?? '',
  87.                         'merged'   => (int) ($p['merged'] ?? 0),
  88.                     ];
  89.                 }
  90.             } catch (\Throwable $e) { /* lean schema → empty */ }
  91.         }
  92.         return new JsonResponse(['success' => true'items' => $items]);
  93.     }
  94.     /** Duplicate-cluster detection page → the merge wizard. */
  95.     public function duplicatesAction(Request $request)
  96.     {
  97.         $clusters = [];
  98.         try {
  99.             $clusters $this->svc()->findDuplicateClusters(60);
  100.         } catch (\Throwable $e) { /* lean schema → none */ }
  101.         return $this->render('@Inventory/pages/central/central_product_duplicates.html.twig', [
  102.             'page_title'     => 'Duplicate Products',
  103.             'clusters'       => $clusters,
  104.             'is_central'     => $this->isCentral(),
  105.             'sidebar_partial' => '@Inventory/pages/central/_central_catalog_sidebar.html.twig',
  106.             'cp_active'      => 'duplicates',
  107.         ]);
  108.     }
  109.     /**
  110.      * Merge duplicate products into a canonical one, then push the bulk global-ID remap to every ERP.
  111.      * Never deletes — duplicates become aliases of the canonical.
  112.      */
  113.     public function mergeAction(Request $request): JsonResponse
  114.     {
  115.         if (!$this->isCentral()) {
  116.             return new JsonResponse(['success' => false'message' => 'Merge is only available on the central server.'], 403);
  117.         }
  118.         $loginId   = (int) $request->getSession()->get(UserConstants::USER_LOGIN_ID);
  119.         $canonical = (int) $request->request->get('canonicalGlobalId'0);
  120.         $dups      $request->request->get('dupGlobalIds', []);
  121.         if (!is_array($dups)) {
  122.             $dups explode(',', (string) $dups);
  123.         }
  124.         $dups array_values(array_filter(array_map('intval'$dups)));
  125.         if ($canonical <= || empty($dups)) {
  126.             return new JsonResponse(['success' => false'message' => 'canonicalGlobalId and dupGlobalIds are required.'], 400);
  127.         }
  128.         $svc $this->svc();
  129.         try {
  130.             // Optional: caller-supplied winning field values for the canonical (the conflict picker, P3).
  131.             $fields $request->request->get('canonicalFields', []);
  132.             if (is_array($fields) && !empty($fields)) {
  133.                 $svc->updateProductAttributes($canonical$fields);
  134.             }
  135.             $map $svc->recordMerge($canonical$dups$loginId'central merge');
  136.         } catch (\Throwable $e) {
  137.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  138.         }
  139.         $push $this->pushRemapToErps($map);
  140.         return new JsonResponse(['success' => true'canonical' => $canonical'remap' => $map'push' => $push]);
  141.     }
  142.     /** Bulk datasheet import + review queue page (P4). */
  143.     public function importFormAction(Request $request)
  144.     {
  145.         $queue = [];
  146.         try {
  147.             $queue $this->svc()->importQueue('pending'200);
  148.         } catch (\Throwable $e) { /* lean schema → empty */ }
  149.         return $this->render('@Inventory/pages/central/central_product_import.html.twig', [
  150.             'page_title'      => 'Bulk Datasheet Import',
  151.             'queue'           => $queue,
  152.             'is_central'      => $this->isCentral(),
  153.             'sidebar_partial' => '@Inventory/pages/central/_central_catalog_sidebar.html.twig',
  154.             'cp_active'       => 'import',
  155.         ]);
  156.     }
  157.     /** Parse a pasted blob of datasheets and stage them for review. */
  158.     public function importStageAction(Request $request): JsonResponse
  159.     {
  160.         if (!$this->isCentral()) {
  161.             return new JsonResponse(['success' => false'message' => 'Import runs on the central server.'], 403);
  162.         }
  163.         $blob trim((string) $request->request->get('blob'''));
  164.         if ($blob === '') {
  165.             return new JsonResponse(['success' => false'message' => 'Nothing to import.'], 400);
  166.         }
  167.         $loginId = (int) $request->getSession()->get(UserConstants::USER_LOGIN_ID);
  168.         try {
  169.             $res $this->svc()->stageImportBatch($blob$loginId);
  170.         } catch (\Throwable $e) {
  171.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  172.         }
  173.         return new JsonResponse(array_merge(['success' => true], $res));
  174.     }
  175.     /** Apply one staged import row to its matched global product. */
  176.     public function importApplyAction(Request $request): JsonResponse
  177.     {
  178.         if (!$this->isCentral()) {
  179.             return new JsonResponse(['success' => false'message' => 'Import runs on the central server.'], 403);
  180.         }
  181.         $id = (int) $request->request->get('id'0);
  182.         try {
  183.             $res $this->svc()->applyImport($id);
  184.         } catch (\Throwable $e) {
  185.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 400);
  186.         }
  187.         return new JsonResponse(array_merge(['success' => true], $res));
  188.     }
  189.     public function importRejectAction(Request $request): JsonResponse
  190.     {
  191.         if (!$this->isCentral()) {
  192.             return new JsonResponse(['success' => false'message' => 'Import runs on the central server.'], 403);
  193.         }
  194.         $id = (int) $request->request->get('id'0);
  195.         try {
  196.             $this->svc()->rejectImport($id);
  197.         } catch (\Throwable $e) {
  198.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 400);
  199.         }
  200.         return new JsonResponse(['success' => true]);
  201.     }
  202.     /** Merge + datasheet-import history (audit trail). */
  203.     public function auditAction(Request $request)
  204.     {
  205.         $merges = []; $imports = [];
  206.         try { $merges  $this->svc()->mergeHistory(200); } catch (\Throwable $e) {}
  207.         try { $imports $this->svc()->importHistory(200); } catch (\Throwable $e) {}
  208.         return $this->render('@Inventory/pages/central/central_product_audit.html.twig', [
  209.             'page_title'      => 'Catalog History',
  210.             'merges'          => $merges,
  211.             'imports'         => $imports,
  212.             'is_central'      => $this->isCentral(),
  213.             'sidebar_partial' => '@Inventory/pages/central/_central_catalog_sidebar.html.twig',
  214.             'cp_active'       => 'audit',
  215.         ]);
  216.     }
  217.     /** Bulk-push a {oldGid: newGid} remap to every active ERP server (shared-secret guarded). */
  218.     private function pushRemapToErps(array $map): array
  219.     {
  220.         $secret $this->container->hasParameter('central_sync_secret') ? (string) $this->container->getParameter('central_sync_secret') : '';
  221.         try {
  222.             $cgManager $this->getDoctrine()->getManager('company_group');
  223.         } catch (\Throwable $e) {
  224.             return ['pushed' => 0'results' => ['error' => $e->getMessage()]];
  225.         }
  226.         return CentralProductControlService::pushRemapMap($cgManager$secret$map);
  227.     }
  228. }