<?php
namespace ApplicationBundle\Modules\Inventory\Controller;
use ApplicationBundle\Controller\GenericController;
use ApplicationBundle\Interfaces\SessionCheckInterface;
use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
use ApplicationBundle\Modules\Inventory\Service\CentralProductControlService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* Central Product Control — the reconciliation hub UI on the _CENTRAL_ server (also reachable from the
* super-admin dashboard). P1: list every global product, edit attributes, and push edits down to the ERPs.
*
* The list renders anywhere (queries inv_products), but all MUTATIONS are gated to _CENTRAL_.
* Design: CENTRAL_PRODUCT_CONTROL_PLAN.md.
*/
class CentralProductControlController extends GenericController implements SessionCheckInterface
{
private function svc(): CentralProductControlService
{
return new CentralProductControlService($this->getDoctrine()->getManager());
}
private function isCentral(): bool
{
$sys = $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
return $sys === '_CENTRAL_';
}
public function listAction(Request $request)
{
$search = trim((string) $request->query->get('q', ''));
$products = [];
try {
$products = $this->svc()->listProducts($search, 150, 0);
} catch (\Throwable $e) { /* lean/missing schema → empty list */ }
return $this->render('@Inventory/pages/central/central_product_control.html.twig', [
'page_title' => 'Central Product Control',
'products' => $products,
'search' => $search,
'is_central' => $this->isCentral(),
'sidebar_partial' => '@Inventory/pages/central/_central_catalog_sidebar.html.twig',
'cp_active' => 'products',
]);
}
/** Save edited attributes to the central product (name/description/alias/specifications). */
public function saveAction(Request $request): JsonResponse
{
if (!$this->isCentral()) {
return new JsonResponse(['success' => false, 'message' => 'Editing the global catalog is only available on the central server.'], 403);
}
$globalId = (int) $request->request->get('globalId', 0);
if ($globalId <= 0) {
return new JsonResponse(['success' => false, 'message' => 'globalId is required.'], 400);
}
$fields = [];
foreach (['name', 'description', 'alias', 'specifications'] as $f) {
if ($request->request->has($f)) {
$fields[$f] = $request->request->get($f);
}
}
try {
$written = $this->svc()->updateProductAttributes($globalId, $fields);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => true, 'updated' => $written]);
}
/** Return a single product's editable attributes (for the edit modal). */
public function getAction(Request $request, $globalId): JsonResponse
{
$p = $this->svc()->getProductByGlobalId((int) $globalId);
if (!$p) {
return new JsonResponse(['success' => false, 'message' => 'Product not found.'], 404);
}
return new JsonResponse(['success' => true, 'product' => $p]);
}
/** JSON product search (for the in-modal "find & merge duplicates" box). */
public function searchAction(Request $request): JsonResponse
{
$q = trim((string) $request->query->get('q', ''));
$items = [];
if ($q !== '') {
try {
foreach ($this->svc()->listProducts($q, 25, 0) as $p) {
$items[] = [
'globalId' => (int) $p['globalId'],
'name' => $p['name'],
'modelNo' => $p['modelNo'] ?? '',
'merged' => (int) ($p['merged'] ?? 0),
];
}
} catch (\Throwable $e) { /* lean schema → empty */ }
}
return new JsonResponse(['success' => true, 'items' => $items]);
}
/** Duplicate-cluster detection page → the merge wizard. */
public function duplicatesAction(Request $request)
{
$clusters = [];
try {
$clusters = $this->svc()->findDuplicateClusters(60);
} catch (\Throwable $e) { /* lean schema → none */ }
return $this->render('@Inventory/pages/central/central_product_duplicates.html.twig', [
'page_title' => 'Duplicate Products',
'clusters' => $clusters,
'is_central' => $this->isCentral(),
'sidebar_partial' => '@Inventory/pages/central/_central_catalog_sidebar.html.twig',
'cp_active' => 'duplicates',
]);
}
/**
* Merge duplicate products into a canonical one, then push the bulk global-ID remap to every ERP.
* Never deletes — duplicates become aliases of the canonical.
*/
public function mergeAction(Request $request): JsonResponse
{
if (!$this->isCentral()) {
return new JsonResponse(['success' => false, 'message' => 'Merge is only available on the central server.'], 403);
}
$loginId = (int) $request->getSession()->get(UserConstants::USER_LOGIN_ID);
$canonical = (int) $request->request->get('canonicalGlobalId', 0);
$dups = $request->request->get('dupGlobalIds', []);
if (!is_array($dups)) {
$dups = explode(',', (string) $dups);
}
$dups = array_values(array_filter(array_map('intval', $dups)));
if ($canonical <= 0 || empty($dups)) {
return new JsonResponse(['success' => false, 'message' => 'canonicalGlobalId and dupGlobalIds are required.'], 400);
}
$svc = $this->svc();
try {
// Optional: caller-supplied winning field values for the canonical (the conflict picker, P3).
$fields = $request->request->get('canonicalFields', []);
if (is_array($fields) && !empty($fields)) {
$svc->updateProductAttributes($canonical, $fields);
}
$map = $svc->recordMerge($canonical, $dups, $loginId, 'central merge');
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
$push = $this->pushRemapToErps($map);
return new JsonResponse(['success' => true, 'canonical' => $canonical, 'remap' => $map, 'push' => $push]);
}
/** Bulk datasheet import + review queue page (P4). */
public function importFormAction(Request $request)
{
$queue = [];
try {
$queue = $this->svc()->importQueue('pending', 200);
} catch (\Throwable $e) { /* lean schema → empty */ }
return $this->render('@Inventory/pages/central/central_product_import.html.twig', [
'page_title' => 'Bulk Datasheet Import',
'queue' => $queue,
'is_central' => $this->isCentral(),
'sidebar_partial' => '@Inventory/pages/central/_central_catalog_sidebar.html.twig',
'cp_active' => 'import',
]);
}
/** Parse a pasted blob of datasheets and stage them for review. */
public function importStageAction(Request $request): JsonResponse
{
if (!$this->isCentral()) {
return new JsonResponse(['success' => false, 'message' => 'Import runs on the central server.'], 403);
}
$blob = trim((string) $request->request->get('blob', ''));
if ($blob === '') {
return new JsonResponse(['success' => false, 'message' => 'Nothing to import.'], 400);
}
$loginId = (int) $request->getSession()->get(UserConstants::USER_LOGIN_ID);
try {
$res = $this->svc()->stageImportBatch($blob, $loginId);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(array_merge(['success' => true], $res));
}
/** Apply one staged import row to its matched global product. */
public function importApplyAction(Request $request): JsonResponse
{
if (!$this->isCentral()) {
return new JsonResponse(['success' => false, 'message' => 'Import runs on the central server.'], 403);
}
$id = (int) $request->request->get('id', 0);
try {
$res = $this->svc()->applyImport($id);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 400);
}
return new JsonResponse(array_merge(['success' => true], $res));
}
public function importRejectAction(Request $request): JsonResponse
{
if (!$this->isCentral()) {
return new JsonResponse(['success' => false, 'message' => 'Import runs on the central server.'], 403);
}
$id = (int) $request->request->get('id', 0);
try {
$this->svc()->rejectImport($id);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 400);
}
return new JsonResponse(['success' => true]);
}
/** Merge + datasheet-import history (audit trail). */
public function auditAction(Request $request)
{
$merges = []; $imports = [];
try { $merges = $this->svc()->mergeHistory(200); } catch (\Throwable $e) {}
try { $imports = $this->svc()->importHistory(200); } catch (\Throwable $e) {}
return $this->render('@Inventory/pages/central/central_product_audit.html.twig', [
'page_title' => 'Catalog History',
'merges' => $merges,
'imports' => $imports,
'is_central' => $this->isCentral(),
'sidebar_partial' => '@Inventory/pages/central/_central_catalog_sidebar.html.twig',
'cp_active' => 'audit',
]);
}
/** Bulk-push a {oldGid: newGid} remap to every active ERP server (shared-secret guarded). */
private function pushRemapToErps(array $map): array
{
$secret = $this->container->hasParameter('central_sync_secret') ? (string) $this->container->getParameter('central_sync_secret') : '';
try {
$cgManager = $this->getDoctrine()->getManager('company_group');
} catch (\Throwable $e) {
return ['pushed' => 0, 'results' => ['error' => $e->getMessage()]];
}
return CentralProductControlService::pushRemapMap($cgManager, $secret, $map);
}
}