app/Plugin/PiaHistory/EventSubscriber/ProductEditEventSubscriber.php line 217

Open in your IDE?
  1. <?php
  2. namespace Plugin\PiaHistory\EventSubscriber;
  3. use Eccube\Entity\Product;
  4. use Eccube\Repository\ProductRepository;
  5. use Eccube\Repository\MemberRepository;
  6. use Doctrine\ORM\EntityManagerInterface;
  7. use Plugin\PiaHistory\Entity\PiaHistory;
  8. use Plugin\PiaHistory\Entity\PiaHisProduct;
  9. use Plugin\PiaHistory\Entity\PiaHisProductClass;
  10. use Plugin\PiaHistory\Entity\PiaHisProductCategory;
  11. use Plugin\PiaHistory\Entity\PiaHisProductImage;
  12. use Plugin\PiaHistory\Entity\PiaHisProductStock;
  13. use Plugin\PiaHistory\Entity\PiaHisProductTag;
  14. use Plugin\PiaHistory\Entity\PiaHisProductAdd;
  15. use Plugin\PiaHistory\Entity\PiaHisRankPrice;
  16. use Plugin\PiaHistory\Entity\PiaHisCustomerClass;
  17. use Plugin\PiaHistory\Entity\PiaHisPoint;
  18. use Plugin\PiaHistory\Entity\PiaHisMeyasu;
  19. use Plugin\PiaHistory\Repository\PiaHistoryRepository;
  20. use Plugin\PiaHistory\Repository\PiaHisProductRepository;
  21. use Plugin\PiaHistory\Repository\PiaHisProductClassRepository;
  22. use Plugin\PiaHistory\Repository\PiaHisProductCategoryRepository;
  23. use Plugin\PiaHistory\Repository\PiaHisProductImageRepository;
  24. use Plugin\PiaHistory\Repository\PiaHisProductStockRepository;
  25. use Plugin\PiaHistory\Repository\PiaHisProductTagRepository;
  26. use Plugin\PiaHistory\Repository\PiaHisProductAddRepository;
  27. use Plugin\PiaHistory\Repository\PiaHisRankPriceRepository;
  28. use Plugin\PiaHistory\Repository\PiaHisCustomerClassRepository;
  29. use Plugin\PiaHistory\Repository\PiaHisPointRepository;
  30. use Plugin\PiaHistory\Repository\PiaHisMeyasuRepository;
  31. use Eccube\Event\EccubeEvents;
  32. use Eccube\Event\EventArgs;
  33. use Symfony\Component\HttpFoundation\RequestStack;
  34. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  35. use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
  36. use Symfony\Component\HttpKernel\Event\RequestEvent;
  37. class ProductEditEventSubscriber implements EventSubscriberInterface
  38. {
  39.     private $piaHistoryRepository;
  40.     private $piaHisProductRepository;
  41.     private $piaHisProductClassRepository;
  42.     private $piaHisProductCategoryRepository;
  43.     private $piaHisProductImageRepository;
  44.     private $piaHisProductStockRepository;
  45.     private $piaHisProductTagRepository;
  46.     private $piaHisProductAddRepository;
  47.     private $piaHisRankPriceRepository;
  48.     private $piaHisCustomerClassRepository;
  49.     private $piaHisPointRepository;
  50.     private $piaHisMeyasuRepository;
  51.     private $productRepository;
  52.     private $memberRepository;
  53.     private $tokenStorage;
  54.     private $entityManager;
  55.     private $requestStack;
  56.     // 商品基本情報のフィールドラベル(updateNote生成用)
  57.     private static $basicFieldLabels = [
  58.         'name'               => '商品名',
  59.         'note'               => 'ショップ用メモ欄',
  60.         'description_list'   => '商品一覧コメント',
  61.         'description_detail' => '商品説明',
  62.         'search_word'        => '検索ワード',
  63.         'free_area'          => 'フリーエリア',
  64.         'status'             => '商品ステータス',
  65.         'datetime_start'     => '表示設定日時(開始)',
  66.         'datetime_end'       => '表示設定日時(終了)',
  67.     ];
  68.     // 商品規格フィールドのラベル(updateNote生成用)
  69.     private static $classFieldLabels = [
  70.         'price02' => '販売価格',
  71.         'price01' => '通常価格',
  72.         'stock'   => '在庫',
  73.     ];
  74.     public function __construct(
  75.         PiaHistoryRepository $piaHistoryRepository,
  76.         PiaHisProductRepository $piaHisProductRepository,
  77.         PiaHisProductClassRepository $piaHisProductClassRepository,
  78.         PiaHisProductCategoryRepository $piaHisProductCategoryRepository,
  79.         PiaHisProductImageRepository $piaHisProductImageRepository,
  80.         PiaHisProductStockRepository $piaHisProductStockRepository,
  81.         PiaHisProductTagRepository $piaHisProductTagRepository,
  82.         PiaHisProductAddRepository $piaHisProductAddRepository,
  83.         PiaHisRankPriceRepository $piaHisRankPriceRepository,
  84.         PiaHisCustomerClassRepository $piaHisCustomerClassRepository,
  85.         PiaHisPointRepository $piaHisPointRepository,
  86.         PiaHisMeyasuRepository $piaHisMeyasuRepository,
  87.         ProductRepository $productRepository,
  88.         MemberRepository $memberRepository,
  89.         TokenStorageInterface $tokenStorage,
  90.         EntityManagerInterface $entityManager,
  91.         RequestStack $requestStack
  92.     ) {
  93.         $this->piaHistoryRepository $piaHistoryRepository;
  94.         $this->piaHisProductRepository $piaHisProductRepository;
  95.         $this->piaHisProductClassRepository $piaHisProductClassRepository;
  96.         $this->piaHisProductCategoryRepository $piaHisProductCategoryRepository;
  97.         $this->piaHisProductImageRepository $piaHisProductImageRepository;
  98.         $this->piaHisProductStockRepository $piaHisProductStockRepository;
  99.         $this->piaHisProductTagRepository $piaHisProductTagRepository;
  100.         $this->piaHisProductAddRepository $piaHisProductAddRepository;
  101.         $this->piaHisRankPriceRepository $piaHisRankPriceRepository;
  102.         $this->piaHisCustomerClassRepository $piaHisCustomerClassRepository;
  103.         $this->piaHisPointRepository $piaHisPointRepository;
  104.         $this->piaHisMeyasuRepository $piaHisMeyasuRepository;
  105.         $this->productRepository $productRepository;
  106.         $this->memberRepository $memberRepository;
  107.         $this->tokenStorage $tokenStorage;
  108.         $this->entityManager $entityManager;
  109.         $this->requestStack $requestStack;
  110.     }
  111.     public static function getSubscribedEvents()
  112.     {
  113.         return [
  114.             'kernel.request' => ['onKernelRequest'0],
  115.             EccubeEvents::ADMIN_PRODUCT_EDIT_COMPLETE => ['onProductEditComplete', -1000],
  116.         ];
  117.     }
  118.     /**
  119.      * 商品編集画面のPOSTリクエストを検知して変更前の値を保存
  120.      */
  121.     public function onKernelRequest(RequestEvent $event)
  122.     {
  123.         $request $event->getRequest();
  124.         if ($request->isMethod('POST') &&
  125.             $request->attributes->get('_route') === 'admin_product_product_edit') {
  126.             $productId $request->get('id');
  127.             error_log('PiaHistory: onKernelRequest - Product ID: ' $productId);
  128.             if ($productId) {
  129.                 $this->saveProductBeforeData($productId);
  130.             }
  131.         }
  132.     }
  133.     public function onProductEditComplete(EventArgs $event)
  134.     {
  135.         error_log('PiaHistory: onProductEditComplete called');
  136.         $Product $event->getArgument('Product');
  137.         if ($Product && $Product->getId()) {
  138.             $this->saveProductHistory($Product->getId());
  139.         }
  140.     }
  141.     // -------------------------------------------------------------------------
  142.     // 変更前データをセッションに保存
  143.     // -------------------------------------------------------------------------
  144.     private function saveProductBeforeData($productId)
  145.     {
  146.         try {
  147.             $product $this->productRepository->find($productId);
  148.             if (!$product) {
  149.                 return;
  150.             }
  151.             $request $this->requestStack->getCurrentRequest();
  152.             if (!$request) {
  153.                 return;
  154.             }
  155.             $session $request->getSession();
  156.             $beforeData = [
  157.                 'product'            => $this->buildProductSnapshot($product),
  158.                 'product_classes'    => $this->buildProductClassesSnapshot($product),
  159.                 'product_categories' => $this->buildProductCategoriesSnapshot($product),
  160.                 'product_images'     => $this->buildProductImagesSnapshot($product),
  161.                 'product_tags'       => $this->buildProductTagsSnapshot($product),
  162.                 'rank_prices'        => $this->buildRankPricesSnapshot($product),
  163.                 'product_add'        => $this->buildProductAddSnapshot($product),
  164.             ];
  165.             $session->set('pia_history_before_data_' $productId$beforeData);
  166.         } catch (\Exception $e) {
  167.             error_log('PiaHistory: 変更前データ保存エラー - ' $e->getMessage());
  168.         }
  169.     }
  170.     /** 商品基本情報スナップショット */
  171.     private function buildProductSnapshot(Product $product): array
  172.     {
  173.         return [
  174.             'id'                 => $product->getId(),
  175.             'name'               => $product->getName(),
  176.             'note'               => $product->getNote(),
  177.             'description_list'   => $product->getDescriptionList(),
  178.             'description_detail' => $product->getDescriptionDetail(),
  179.             'search_word'        => $product->getSearchWord(),
  180.             'free_area'          => $product->getFreeArea(),
  181.             'status'             => $product->getStatus() ? $product->getStatus()->getId() : null,
  182.             'datetime_start'     => $this->getDisplayStartDate($product),
  183.             'datetime_end'       => $this->getDisplayEndDate($product),
  184.         ];
  185.     }
  186.     /**
  187.      * 商品規格スナップショット(変更検知で比較するフィールドのみ)
  188.      *
  189.      * price01/price02 は会員別価格(buildRankPricesSnapshot)と同じ理由で、
  190.      * DBから読み込んだ直後と同一リクエスト内でセットされた直後とで小数点以下の表記が
  191.      * 食い違うことがあるため、normalizePriceString() で正規化してから比較する(2026-08-28)。
  192.      */
  193.     private function buildProductClassesSnapshot(Product $product): array
  194.     {
  195.         $data = [];
  196.         foreach ($product->getProductClasses() as $pc) {
  197.             $data[] = [
  198.                 'id'       => $pc->getId(),
  199.                 'code'     => $pc->getCode(),
  200.                 'price01'  => $this->normalizePriceString($pc->getPrice01()),
  201.                 'price02'  => $this->normalizePriceString($pc->getPrice02()),
  202.                 'stock'    => $pc->isStockUnlimited() ? null : (string)$pc->getStock(),
  203.             ];
  204.         }
  205.         return $data;
  206.     }
  207.     /**
  208.      * 商品カテゴリースナップショット(比較用)
  209.      *
  210.      * EC-CUBE4の ProductCategory には並び順を保持するカラムが無く
  211.      * getProductCategories() の取得順序は保存の度に入れ替わりうる。
  212.      * 取得順インデックスを比較に使うと、実際は同じカテゴリー構成でも
  213.      * 「変更あり」に誤検知するため、category_id をソートした集合として比較する
  214.      * (2026-08-28修正)。
  215.      */
  216.     private function buildProductCategoriesSnapshot(Product $product): array
  217.     {
  218.         $ids = [];
  219.         foreach ($product->getProductCategories() as $pc) {
  220.             $ids[] = $pc->getCategory()->getId();
  221.         }
  222.         sort($ids);
  223.         return $ids;
  224.     }
  225.     /** 商品画像スナップショット */
  226.     private function buildProductImagesSnapshot(Product $product): array
  227.     {
  228.         $data = [];
  229.         foreach ($product->getProductImage() as $i => $img) {
  230.             $data[] = [
  231.                 'file_name' => $img->getFileName(),
  232.                 'rank'      => $img->getSortNo() ?: ($i 1),
  233.             ];
  234.         }
  235.         return $data;
  236.     }
  237.     /** 商品タグスナップショット */
  238.     private function buildProductTagsSnapshot(Product $product): array
  239.     {
  240.         $data = [];
  241.         foreach ($product->getProductTag() as $pt) {
  242.             $data[] = ['tag_id' => $pt->getTag()->getId()];
  243.         }
  244.         return $data;
  245.     }
  246.     /** 会員別価格スナップショット(CustomerRank42) */
  247.     private function buildRankPricesSnapshot(Product $product): array
  248.     {
  249.         $data = [];
  250.         if (!class_exists('Plugin\CustomerRank42\Entity\CustomerPrice')) {
  251.             return $data;
  252.         }
  253.         try {
  254.             $repo $this->entityManager->getRepository('Plugin\CustomerRank42\Entity\CustomerPrice');
  255.             foreach ($product->getProductClasses() as $pc) {
  256.                 $prices $repo->findBy(['ProductClass' => $pc]);
  257.                 foreach ($prices as $cp) {
  258.                     $data[] = [
  259.                         'product_class_id' => $pc->getId(),
  260.                         'product_code'     => $pc->getCode(),
  261.                         'rank_id'          => $cp->getCustomerRank()->getId(),
  262.                         'rank_name'        => $cp->getCustomerRank()->getName(),
  263.                         // DBから読み込んだ直後は "17523.00"、同一リクエスト内でフォーム経由で
  264.                         // セットされた直後は "17523" のように小数点以下の表記が食い違うことがあり、
  265.                         // 単純な文字列比較では実際は無変更なのに「変更あり」と誤検知していた。
  266.                         // 数値として正規化してから文字列化することで表記を統一する(2026-08-28)。
  267.                         'price'            => $this->normalizePriceString($cp->getPrice()),
  268.                     ];
  269.                 }
  270.             }
  271.         } catch (\Exception $e) {
  272.             error_log('PiaHistory: 会員別価格スナップショットエラー - ' $e->getMessage());
  273.         }
  274.         return $data;
  275.     }
  276.     /**
  277.      * 金額の比較用正規化。
  278.      *
  279.      * Doctrineの decimal 型は、DBから読み込んだ直後(例: "17523.00")と、
  280.      * 同一リクエスト内でフォーム処理等により新たにセットされた直後(例: "17523")とで
  281.      * 小数点以下の桁数表記が食い違うことがある。数値としては同じでも文字列としては
  282.      * 別物になり、厳密比較で誤検知する原因になっていた(2026-08-28)。
  283.      */
  284.     private function normalizePriceString($value): ?string
  285.     {
  286.         if ($value === null || $value === '') {
  287.             return null;
  288.         }
  289.         return number_format((float)$value2'.''');
  290.     }
  291.     /** 商品追加項目スナップショット(ProductPlus42) */
  292.     private function buildProductAddSnapshot(Product $product): array
  293.     {
  294.         $data = [];
  295.         if (!class_exists('Plugin\ProductPlus42\Entity\ProductData')) {
  296.             return $data;
  297.         }
  298.         try {
  299.             $repo $this->entityManager->getRepository('Plugin\ProductPlus42\Entity\ProductData');
  300.             $productDatas $repo->findBy(['Product' => $product]);
  301.             foreach ($productDatas as $pd) {
  302.                 $item $pd->getProductItem();
  303.                 if (!$item) {
  304.                     continue;
  305.                 }
  306.                 $data[] = [
  307.                     'item_id'   => $item->getId(),
  308.                     'item_name' => $item->getName(),
  309.                     'value'     => $pd->getDataValue(),
  310.                 ];
  311.             }
  312.         } catch (\Exception $e) {
  313.             error_log('PiaHistory: 商品追加項目スナップショットエラー - ' $e->getMessage());
  314.         }
  315.         return $data;
  316.     }
  317.     // -------------------------------------------------------------------------
  318.     // 履歴保存メイン
  319.     // -------------------------------------------------------------------------
  320.     private function saveProductHistory($productId)
  321.     {
  322.         try {
  323.             error_log('PiaHistory: saveProductHistory called for Product ID: ' $productId);
  324.             if (!$this->entityManager->isOpen()) {
  325.                 error_log('PiaHistory: EntityManager is closed, skipping history save');
  326.                 return;
  327.             }
  328.             $product $this->productRepository->find($productId);
  329.             if (!$product) {
  330.                 return;
  331.             }
  332.             $request $this->requestStack->getCurrentRequest();
  333.             if (!$request) {
  334.                 return;
  335.             }
  336.             $session  $request->getSession();
  337.             $beforeData $session->get('pia_history_before_data_' $productId);
  338.             if (!$beforeData) {
  339.                 return;
  340.             }
  341.             // 変更検知
  342.             $changes $this->detectDetailedChanges($product$beforeData);
  343.             error_log('PiaHistory: Changes detected: ' . (empty($changes) ? 'NO' 'YES'));
  344.             if (empty($changes)) {
  345.                 $session->remove('pia_history_before_data_' $productId);
  346.                 return;
  347.             }
  348.             // updateNote / updateNum を生成
  349.             ['num' => $num'note' => $note] = $this->generateUpdateNote($changes);
  350.             // 管理者情報を取得
  351.             [$adminId$adminName$adminDepartment] = $this->getAdminInfo();
  352.             $this->entityManager->beginTransaction();
  353.             // 履歴メイン登録
  354.             $history = new PiaHistory();
  355.             $history->setProductId($productId);
  356.             $history->setUpdateId($adminId);
  357.             $history->setUpdateName($adminName);
  358.             $history->setUpdateDepartment($adminDepartment);
  359.             $history->setUpdateNum($num);
  360.             $history->setUpdateNote($note);
  361.             $history->setCreateDate(new \DateTime());
  362.             $history->setUpdateDate(new \DateTime());
  363.             $history->setDelFlg(0);
  364.             $this->entityManager->persist($history);
  365.             $this->entityManager->flush();
  366.             $historyId $history->getId();
  367.             // 各詳細テーブルへの保存
  368.             // 商品基本情報+カテゴリー変更(カテゴリーの前後は詳細画面表示用にJSONへ同梱する。
  369.             // 2026-08-28: カテゴリーは1つ前の履歴レコードとの突き合わせが不確実なため、
  370.             // 商品基本情報と同じ「保存時点でbefore/afterを直接保存」方式に統一した)
  371.             $basicChangedFields $changes['product'] ?? [];
  372.             if (isset($changes['product_categories'])) {
  373.                 $basicChangedFields['__categories'] = [
  374.                     'before' => $changes['product_categories']['before'],
  375.                     'after'  => $changes['product_categories']['after'],
  376.                 ];
  377.             }
  378.             if (!empty($basicChangedFields)) {
  379.                 $this->saveProductBasicHistory($historyId$product$basicChangedFields);
  380.             }
  381.             if (isset($changes['product_classes'])) {
  382.                 $this->saveProductClassHistory($historyId$product);
  383.             }
  384.             if (isset($changes['product_categories'])) {
  385.                 $this->saveProductCategoryHistory($historyId$product);
  386.             }
  387.             if (isset($changes['product_images'])) {
  388.                 $this->saveProductImageHistory($historyId$product);
  389.             }
  390.             if (isset($changes['product_tags'])) {
  391.                 $this->saveProductTagHistory($historyId$product);
  392.             }
  393.             // 在庫は常に現状を保存
  394.             $this->saveProductStockHistory($historyId$product);
  395.             // 会員別価格(変更があった場合のみ。判定・updateNote生成と同じ$currentRankPricesを使う)
  396.             if (isset($changes['rank_prices'])) {
  397.                 $this->saveRankPriceHistory($historyId$beforeData['rank_prices'] ?? [], $changes['rank_prices']['after']);
  398.             }
  399.             // 商品追加項目(変更があった場合のみ)
  400.             if (isset($changes['product_add'])) {
  401.                 $this->saveProductAddHistory($historyId$product);
  402.             }
  403.             // 目安価格
  404.             $this->saveMeyasuHistory($historyId$product);
  405.             $this->entityManager->flush();
  406.             $this->entityManager->commit();
  407.             $session->remove('pia_history_before_data_' $productId);
  408.         } catch (\Throwable $e) {
  409.             if ($this->entityManager->getConnection()->isTransactionActive()) {
  410.                 $this->entityManager->rollback();
  411.             }
  412.             error_log('PiaHistory: 商品履歴保存エラー - ' $e->getMessage());
  413.         }
  414.     }
  415.     // -------------------------------------------------------------------------
  416.     // 変更検知
  417.     // -------------------------------------------------------------------------
  418.     private function detectDetailedChanges(Product $product, array $beforeData): array
  419.     {
  420.         $changes = [];
  421.         // 基本情報
  422.         // null と空文字、改行コードの違いだけの「実質的に変更なし」を誤検知しないよう、
  423.         // 比較は正規化した値で行う(status/日時は元の値のまま比較)(2026-08-28修正)
  424.         $currentProduct $this->buildProductSnapshot($product);
  425.         $diffFields = [];
  426.         foreach (array_keys(self::$basicFieldLabels) as $field) {
  427.             $beforeVal $beforeData['product'][$field] ?? null;
  428.             $afterVal  $currentProduct[$field] ?? null;
  429.             $beforeCmp in_array($field, ['status''datetime_start''datetime_end'], true)
  430.                 ? $beforeVal
  431.                 $this->normalizeTextForCompare($beforeVal);
  432.             $afterCmp in_array($field, ['status''datetime_start''datetime_end'], true)
  433.                 ? $afterVal
  434.                 $this->normalizeTextForCompare($afterVal);
  435.             if ($beforeCmp !== $afterCmp) {
  436.                 $diffFields[$field] = [
  437.                     'before' => $beforeVal,
  438.                     'after'  => $afterVal,
  439.                 ];
  440.             }
  441.         }
  442.         if (!empty($diffFields)) {
  443.             $changes['product'] = $diffFields;
  444.         }
  445.         // 商品規格(同一フィールド構造で比較)
  446.         $currentClasses $this->buildProductClassesSnapshot($product);
  447.         if ($currentClasses !== ($beforeData['product_classes'] ?? [])) {
  448.             $changes['product_classes'] = [
  449.                 'before' => $beforeData['product_classes'] ?? [],
  450.                 'after'  => $currentClasses,
  451.             ];
  452.         }
  453.         // カテゴリー
  454.         $currentCategories $this->buildProductCategoriesSnapshot($product);
  455.         if ($currentCategories !== ($beforeData['product_categories'] ?? [])) {
  456.             $changes['product_categories'] = [
  457.                 'before' => $beforeData['product_categories'] ?? [],
  458.                 'after'  => $currentCategories,
  459.             ];
  460.         }
  461.         // 画像
  462.         $currentImages $this->buildProductImagesSnapshot($product);
  463.         if ($currentImages !== ($beforeData['product_images'] ?? [])) {
  464.             $changes['product_images'] = [
  465.                 'before' => $beforeData['product_images'] ?? [],
  466.                 'after'  => $currentImages,
  467.             ];
  468.         }
  469.         // タグ
  470.         $currentTags $this->buildProductTagsSnapshot($product);
  471.         if ($currentTags !== ($beforeData['product_tags'] ?? [])) {
  472.             $changes['product_tags'] = [
  473.                 'before' => $beforeData['product_tags'] ?? [],
  474.                 'after'  => $currentTags,
  475.             ];
  476.         }
  477.         // 会員別価格(CustomerRank42)
  478.         // 「現在の会員別価格」は判定・updateNote生成・保存の3箇所で使うため、ここで1回だけ取得して
  479.         // 使い回す(2026-08-28)。
  480.         $currentRankPrices $this->buildRankPricesSnapshot($product);
  481.         $changedRankPrices $this->detectRankPriceChanges($beforeData['rank_prices'] ?? [], $currentRankPrices);
  482.         if (!empty($changedRankPrices)) {
  483.             $changes['rank_prices'] = [
  484.                 'before'  => $beforeData['rank_prices'] ?? [],
  485.                 'after'   => $currentRankPrices,
  486.                 'changed' => $changedRankPrices,
  487.             ];
  488.         }
  489.         // 商品追加項目(ProductPlus42)
  490.         $changedProductAdd $this->detectProductAddChanges($product$beforeData['product_add'] ?? []);
  491.         if (!empty($changedProductAdd)) {
  492.             $changes['product_add'] = [
  493.                 'before'  => $beforeData['product_add'] ?? [],
  494.                 'after'   => $this->buildProductAddSnapshot($product),
  495.                 'changed' => $changedProductAdd,
  496.             ];
  497.         }
  498.         return $changes;
  499.     }
  500.     /**
  501.      * テキスト系フィールドの比較用正規化
  502.      *
  503.      * null と空文字は同じ「未入力」として扱い、改行コード(\r\n / \r)は \n に統一する。
  504.      * これを行わずに厳密比較すると、フォーム送信時に null が空文字として保存される等の
  505.      * 型の違いだけで「変更あり」と誤検知してしまう(2026-08-28、商品説明・ショップ用メモ欄等で確認)。
  506.      */
  507.     private function normalizeTextForCompare($value): string
  508.     {
  509.         if ($value === null) {
  510.             return '';
  511.         }
  512.         $value str_replace(["\r\n""\r"], "\n", (string)$value);
  513.         return trim($value);
  514.     }
  515.     /**
  516.      * 会員別価格の変更検知
  517.      *
  518.      * $currentRankPrices は呼び出し側(detectDetailedChanges)で1回だけ取得したものを渡す
  519.      * (保存処理・updateNote生成と同じ値を必ず使うため。2026-08-28)。
  520.      */
  521.     private function detectRankPriceChanges(array $beforeRankPrices, array $currentRankPrices): array
  522.     {
  523.         // [rank_id_productClassId] => price のマップで比較
  524.         $beforeMap = [];
  525.         foreach ($beforeRankPrices as $rp) {
  526.             $key $rp['rank_id'] . '_' $rp['product_class_id'];
  527.             $beforeMap[$key] = $rp;
  528.         }
  529.         $afterMap = [];
  530.         foreach ($currentRankPrices as $rp) {
  531.             $key $rp['rank_id'] . '_' $rp['product_class_id'];
  532.             $afterMap[$key] = $rp;
  533.         }
  534.         $changed = [];
  535.         foreach ($afterMap as $key => $after) {
  536.             $before $beforeMap[$key] ?? null;
  537.             if (!$before || $before['price'] !== $after['price']) {
  538.                 $changed[] = [
  539.                     'rank_name'    => $after['rank_name'],
  540.                     'product_code' => $after['product_code'],
  541.                     'before_price' => $before $before['price'] : null,
  542.                     'after_price'  => $after['price'],
  543.                 ];
  544.             }
  545.         }
  546.         foreach ($beforeMap as $key => $before) {
  547.             if (!isset($afterMap[$key])) {
  548.                 $changed[] = [
  549.                     'rank_name'    => $before['rank_name'],
  550.                     'product_code' => $before['product_code'],
  551.                     'before_price' => $before['price'],
  552.                     'after_price'  => null,
  553.                 ];
  554.             }
  555.         }
  556.         return $changed;
  557.     }
  558.     /** 商品追加項目の変更検知 */
  559.     private function detectProductAddChanges(Product $product, array $beforeProductAdd): array
  560.     {
  561.         if (!class_exists('Plugin\ProductPlus42\Entity\ProductData')) {
  562.             return [];
  563.         }
  564.         $currentProductAdd $this->buildProductAddSnapshot($product);
  565.         $beforeMap = [];
  566.         foreach ($beforeProductAdd as $pa) {
  567.             $beforeMap[$pa['item_id']] = $pa;
  568.         }
  569.         $afterMap = [];
  570.         foreach ($currentProductAdd as $pa) {
  571.             $afterMap[$pa['item_id']] = $pa;
  572.         }
  573.         $changed = [];
  574.         // 「beforeにデータ行が無い=未入力」と「値が空文字/null」を同じ扱いで比較する。
  575.         // 行の有無だけで判定すると、フォーム送信時に空値のProductDataが新規作成されただけの
  576.         // ケースまで「変更あり」と誤検知してしまう(2026-08-28修正)。
  577.         foreach ($afterMap as $itemId => $after) {
  578.             $before $beforeMap[$itemId] ?? null;
  579.             $beforeVal $before $before['value'] : null;
  580.             if ($this->normalizeTextForCompare($beforeVal) !== $this->normalizeTextForCompare($after['value'])) {
  581.                 $changed[] = [
  582.                     'item_name'    => $after['item_name'],
  583.                     'before_value' => $beforeVal,
  584.                     'after_value'  => $after['value'],
  585.                 ];
  586.             }
  587.         }
  588.         return $changed;
  589.     }
  590.     // -------------------------------------------------------------------------
  591.     // updateNote / updateNum の生成(EC-CUBE3準拠形式)
  592.     // -------------------------------------------------------------------------
  593.     private function generateUpdateNote(array $changes): array
  594.     {
  595.         $logs = [];
  596.         $num  0;
  597.         // 商品基本情報
  598.         if (isset($changes['product'])) {
  599.             foreach (array_keys($changes['product']) as $field) {
  600.                 $label self::$basicFieldLabels[$field] ?? $field;
  601.                 $logs[] = "「{$label}」";
  602.                 $num++;
  603.             }
  604.         }
  605.         // 商品規格(価格・在庫)フィールド単位でログ
  606.         if (isset($changes['product_classes'])) {
  607.             $beforeMap = [];
  608.             foreach ($changes['product_classes']['before'] as $bc) {
  609.                 if (isset($bc['code'])) {
  610.                     $beforeMap[$bc['code']] = $bc;
  611.                 }
  612.             }
  613.             foreach ($changes['product_classes']['after'] as $ac) {
  614.                 $code $ac['code'] ?? '';
  615.                 $bc   $beforeMap[$code] ?? null;
  616.                 if (!$bc) {
  617.                     continue;
  618.                 }
  619.                 foreach (self::$classFieldLabels as $field => $label) {
  620.                     if (array_key_exists($field$ac) && array_key_exists($field$bc) &&
  621.                         $ac[$field] !== $bc[$field]) {
  622.                         $logs[] = "「{$code}{$label}」";
  623.                         $num++;
  624.                     }
  625.                 }
  626.             }
  627.         }
  628.         // 会員別価格
  629.         if (isset($changes['rank_prices']['changed'])) {
  630.             foreach ($changes['rank_prices']['changed'] as $rp) {
  631.                 $code $rp['product_code'] ?? '';
  632.                 $label $code "{$code}の会員価格:{$rp['rank_name']}"会員価格:{$rp['rank_name']}";
  633.                 $logs[] = "「{$label}」";
  634.                 $num++;
  635.             }
  636.         }
  637.         // 商品追加項目
  638.         if (isset($changes['product_add']['changed'])) {
  639.             foreach ($changes['product_add']['changed'] as $pa) {
  640.                 $logs[] = "「拡張項目:{$pa['item_name']}」";
  641.                 $num++;
  642.             }
  643.         }
  644.         // カテゴリー
  645.         if (isset($changes['product_categories'])) {
  646.             $logs[] = '「カテゴリー」';
  647.             $num++;
  648.         }
  649.         // 画像
  650.         if (isset($changes['product_images'])) {
  651.             $logs[] = '「商品画像」';
  652.             $num++;
  653.         }
  654.         // タグ
  655.         if (isset($changes['product_tags'])) {
  656.             $logs[] = '「商品タグ」';
  657.             $num++;
  658.         }
  659.         $note = !empty($logs)
  660.             ? implode('、'$logs) . 'を更新しました。'
  661.             '変更箇所無し';
  662.         return ['num' => $num'note' => $note];
  663.     }
  664.     // -------------------------------------------------------------------------
  665.     // 各詳細テーブルへの保存
  666.     // -------------------------------------------------------------------------
  667.     private function saveProductBasicHistory(int $historyIdProduct $product, array $changedFields)
  668.     {
  669.         $productHistory = new PiaHisProduct();
  670.         $productHistory->setHisId($historyId);
  671.         $productHistory->setProductId($product->getId());
  672.         $productHistory->setStatus($product->getStatus() ? $product->getStatus()->getId() : 0);
  673.         $productHistory->setName($product->getName());
  674.         $productHistory->setNote($product->getNote());
  675.         $productHistory->setDescriptionList($product->getDescriptionList());
  676.         $productHistory->setDescriptionDetail($product->getDescriptionDetail());
  677.         $productHistory->setSearchWord($product->getSearchWord());
  678.         $productHistory->setFreeArea($product->getFreeArea());
  679.         $productHistory->setDatetimeStart($this->getDisplayStartDate($product));
  680.         $productHistory->setDatetimeEnd($this->getDisplayEndDate($product));
  681.         $productHistory->setDelFlg(0);
  682.         $productHistory->setChangeData(json_encode($changedFieldsJSON_UNESCAPED_UNICODE));
  683.         $this->entityManager->persist($productHistory);
  684.     }
  685.     private function saveProductClassHistory(int $historyIdProduct $product)
  686.     {
  687.         foreach ($product->getProductClasses() as $pc) {
  688.             $classHistory = new PiaHisProductClass();
  689.             $classHistory->setHisId($historyId);
  690.             $classHistory->setProductId($product->getId());
  691.             $classHistory->setProductTypeId(null);
  692.             $classHistory->setClassCategoryId1($pc->getClassCategory1() ? $pc->getClassCategory1()->getId() : null);
  693.             $classHistory->setClassCategoryId2($pc->getClassCategory2() ? $pc->getClassCategory2()->getId() : null);
  694.             $classHistory->setDeliveryDateId($pc->getDeliveryDuration() ? $pc->getDeliveryDuration()->getId() : null);
  695.             $classHistory->setProductCode($pc->getCode());
  696.             $classHistory->setStock($pc->getStock());
  697.             $classHistory->setStockUnlimited($pc->isStockUnlimited());
  698.             $classHistory->setSaleLimit($pc->getSaleLimit());
  699.             $classHistory->setPrice01($pc->getPrice01());
  700.             $classHistory->setPrice02($pc->getPrice02());
  701.             $classHistory->setDeliveryFee($pc->getDeliveryFee());
  702.             $this->entityManager->persist($classHistory);
  703.         }
  704.     }
  705.     private function saveProductCategoryHistory(int $historyIdProduct $product)
  706.     {
  707.         foreach ($product->getProductCategories() as $i => $productCategory) {
  708.             $categoryHistory = new PiaHisProductCategory();
  709.             $categoryHistory->setHisId($historyId);
  710.             $categoryHistory->setProductId($product->getId());
  711.             $categoryHistory->setCategoryId($productCategory->getCategory()->getId());
  712.             $categoryHistory->setRank($i 1);
  713.             $this->entityManager->persist($categoryHistory);
  714.         }
  715.     }
  716.     private function saveProductImageHistory(int $historyIdProduct $product)
  717.     {
  718.         foreach ($product->getProductImage() as $i => $productImage) {
  719.             $imageHistory = new PiaHisProductImage();
  720.             $imageHistory->setHisId($historyId);
  721.             $imageHistory->setProductId($product->getId());
  722.             $imageHistory->setFileName($productImage->getFileName());
  723.             $imageHistory->setRank($productImage->getSortNo() ?: ($i 1));
  724.             $this->entityManager->persist($imageHistory);
  725.         }
  726.     }
  727.     private function saveProductStockHistory(int $historyIdProduct $product)
  728.     {
  729.         foreach ($product->getProductClasses() as $pc) {
  730.             $stockHistory = new PiaHisProductStock();
  731.             $stockHistory->setHisId($historyId);
  732.             $stockHistory->setProductClassId($pc->getId());
  733.             $stockHistory->setStock($pc->isStockUnlimited() ? null $pc->getStock());
  734.             $this->entityManager->persist($stockHistory);
  735.         }
  736.     }
  737.     private function saveProductTagHistory(int $historyIdProduct $product)
  738.     {
  739.         foreach ($product->getProductTag() as $productTag) {
  740.             $tagHistory = new PiaHisProductTag();
  741.             $tagHistory->setHisId($historyId);
  742.             $tagHistory->setProductId($product->getId());
  743.             $tagHistory->setTag($productTag->getTag()->getId());
  744.             $this->entityManager->persist($tagHistory);
  745.         }
  746.     }
  747.     /**
  748.      * 会員別価格履歴を保存(CustomerRank42)
  749.      *
  750.      * 詳細画面で「1つ前の履歴レコード」を探して突き合わせる方式は、
  751.      * 直前の履歴にそのランクの価格が保存されているとは限らず食い違いの原因になっていたため、
  752.      * 保存時点で確定している変更前価格($beforeRankPrices)をこの履歴の行に直接持たせる
  753.      * 方式に変更した(2026-08-28)。
  754.      *
  755.      * $currentRankPrices は独自にDBへ再クエリせず、判定・updateNote生成で使ったのと
  756.      * 同じ配列(buildRankPricesSnapshot()の戻り値、$changes['rank_prices']['after'])を
  757.      * 呼び出し側から受け取る。別々に再取得すると、取得タイミングのズレで判定結果と
  758.      * 保存内容が食い違う不具合があったため(2026-08-28、R-TRUST社員様/ご紹介会員で発覚)。
  759.      * $beforeRankPrices・$currentRankPrices とも同じ形式の配列
  760.      * (rank_id, product_class_id, rank_name, product_code, price を持つ連想配列のリスト)。
  761.      */
  762.     private function saveRankPriceHistory(int $historyId, array $beforeRankPrices, array $currentRankPrices)
  763.     {
  764.         $beforeMap = [];
  765.         foreach ($beforeRankPrices as $rp) {
  766.             $key $rp['rank_id'] . '_' $rp['product_class_id'];
  767.             $beforeMap[$key] = $rp['price'];
  768.         }
  769.         foreach ($currentRankPrices as $rp) {
  770.             $key $rp['rank_id'] . '_' $rp['product_class_id'];
  771.             $rankHistory = new PiaHisRankPrice();
  772.             $rankHistory->setHisId($historyId);
  773.             $rankHistory->setCustomerRankId($rp['rank_id']);
  774.             $rankHistory->setProductClassId($rp['product_class_id']);
  775.             $rankHistory->setPrice($rp['price']);
  776.             $rankHistory->setBeforePrice($beforeMap[$key] ?? null);
  777.             $this->entityManager->persist($rankHistory);
  778.         }
  779.     }
  780.     /** 商品追加項目履歴を保存(ProductPlus42) */
  781.     private function saveProductAddHistory(int $historyIdProduct $product)
  782.     {
  783.         if (!class_exists('Plugin\ProductPlus42\Entity\ProductData')) {
  784.             return;
  785.         }
  786.         try {
  787.             $repo $this->entityManager->getRepository('Plugin\ProductPlus42\Entity\ProductData');
  788.             $productDatas $repo->findBy(['Product' => $product]);
  789.             foreach ($productDatas as $pd) {
  790.                 $item $pd->getProductItem();
  791.                 if (!$item) {
  792.                     continue;
  793.                 }
  794.                 $addHistory = new PiaHisProductAdd();
  795.                 $addHistory->setHisId($historyId);
  796.                 $addHistory->setProductId($product->getId());
  797.                 $addHistory->setColumnId($item->getId());
  798.                 $addHistory->setValue($pd->getDataValue());
  799.                 $this->entityManager->persist($addHistory);
  800.             }
  801.         } catch (\Exception $e) {
  802.             error_log('PiaHistory: 商品追加項目履歴保存エラー - ' $e->getMessage());
  803.         }
  804.     }
  805.     /** 目安価格履歴を保存(PiaMeyasu) */
  806.     private function saveMeyasuHistory(int $historyIdProduct $product)
  807.     {
  808.         try {
  809.             if (!class_exists('Plugin\PiaMeyasu\Entity\PiaMeyasu')) {
  810.                 return;
  811.             }
  812.             $meyasuRepo $this->entityManager->getRepository('Plugin\PiaMeyasu\Entity\PiaMeyasu');
  813.             $meyasuData $meyasuRepo->findOneBy(['product_id' => $product->getId()]);
  814.             if ($meyasuData) {
  815.                 $meyasuHistory = new PiaHisMeyasu();
  816.                 $meyasuHistory->setHisId($historyId);
  817.                 $meyasuHistory->setProductId($product->getId());
  818.                 $meyasuHistory->setCoinCost($meyasuData->getCoinCost());
  819.                 $meyasuHistory->setExTypeId($meyasuData->getExTypeId());
  820.                 $meyasuHistory->setCommission($meyasuData->getCommission());
  821.                 $meyasuHistory->setEsDispPrice($meyasuData->getEsDispPrice());
  822.                 $this->entityManager->persist($meyasuHistory);
  823.             }
  824.         } catch (\Exception $e) {
  825.             error_log('PiaHistory: 目安価格履歴保存エラー - ' $e->getMessage());
  826.         }
  827.     }
  828.     // -------------------------------------------------------------------------
  829.     // ユーティリティ
  830.     // -------------------------------------------------------------------------
  831.     /** ログイン管理者情報を取得 */
  832.     private function getAdminInfo(): array
  833.     {
  834.         $adminId         1;
  835.         $adminName       '管理者';
  836.         $adminDepartment '';
  837.         if ($this->tokenStorage->getToken() && $this->tokenStorage->getToken()->getUser()) {
  838.             $admin   $this->tokenStorage->getToken()->getUser();
  839.             $adminId $admin->getId();
  840.             $member $this->memberRepository->find($adminId);
  841.             if ($member) {
  842.                 $adminName $member->getName();
  843.                 if (method_exists($member'getDepartment')) {
  844.                     $adminDepartment $member->getDepartment() ?: '';
  845.                 }
  846.             }
  847.         }
  848.         return [$adminId$adminName$adminDepartment];
  849.     }
  850.     /** PiaProductDispプラグインから表示開始日を取得 */
  851.     private function getDisplayStartDate(Product $product): ?string
  852.     {
  853.         try {
  854.             if (class_exists('Plugin\PiaProductDisp\Entity\ProductDisplayPeriod')) {
  855.                 $repo $this->entityManager->getRepository('Plugin\PiaProductDisp\Entity\ProductDisplayPeriod');
  856.                 $dp   $repo->findOneBy(['Product' => $product]);
  857.                 if ($dp && $dp->getDisplayStartDate()) {
  858.                     return $dp->getDisplayStartDate()->format('Y-m-d H:i:s');
  859.                 }
  860.             }
  861.         } catch (\Exception $e) {
  862.             error_log('PiaHistory: 表示開始日取得エラー - ' $e->getMessage());
  863.         }
  864.         return null;
  865.     }
  866.     /** PiaProductDispプラグインから表示終了日を取得 */
  867.     private function getDisplayEndDate(Product $product): ?string
  868.     {
  869.         try {
  870.             if (class_exists('Plugin\PiaProductDisp\Entity\ProductDisplayPeriod')) {
  871.                 $repo $this->entityManager->getRepository('Plugin\PiaProductDisp\Entity\ProductDisplayPeriod');
  872.                 $dp   $repo->findOneBy(['Product' => $product]);
  873.                 if ($dp && $dp->getDisplayEndDate()) {
  874.                     return $dp->getDisplayEndDate()->format('Y-m-d H:i:s');
  875.                 }
  876.             }
  877.         } catch (\Exception $e) {
  878.             error_log('PiaHistory: 表示終了日取得エラー - ' $e->getMessage());
  879.         }
  880.         return null;
  881.     }
  882. }