<?php
namespace Plugin\PiaHistory\EventSubscriber;
use Eccube\Entity\Product;
use Eccube\Repository\ProductRepository;
use Eccube\Repository\MemberRepository;
use Doctrine\ORM\EntityManagerInterface;
use Plugin\PiaHistory\Entity\PiaHistory;
use Plugin\PiaHistory\Entity\PiaHisProduct;
use Plugin\PiaHistory\Entity\PiaHisProductClass;
use Plugin\PiaHistory\Entity\PiaHisProductCategory;
use Plugin\PiaHistory\Entity\PiaHisProductImage;
use Plugin\PiaHistory\Entity\PiaHisProductStock;
use Plugin\PiaHistory\Entity\PiaHisProductTag;
use Plugin\PiaHistory\Entity\PiaHisProductAdd;
use Plugin\PiaHistory\Entity\PiaHisRankPrice;
use Plugin\PiaHistory\Entity\PiaHisCustomerClass;
use Plugin\PiaHistory\Entity\PiaHisPoint;
use Plugin\PiaHistory\Entity\PiaHisMeyasu;
use Plugin\PiaHistory\Repository\PiaHistoryRepository;
use Plugin\PiaHistory\Repository\PiaHisProductRepository;
use Plugin\PiaHistory\Repository\PiaHisProductClassRepository;
use Plugin\PiaHistory\Repository\PiaHisProductCategoryRepository;
use Plugin\PiaHistory\Repository\PiaHisProductImageRepository;
use Plugin\PiaHistory\Repository\PiaHisProductStockRepository;
use Plugin\PiaHistory\Repository\PiaHisProductTagRepository;
use Plugin\PiaHistory\Repository\PiaHisProductAddRepository;
use Plugin\PiaHistory\Repository\PiaHisRankPriceRepository;
use Plugin\PiaHistory\Repository\PiaHisCustomerClassRepository;
use Plugin\PiaHistory\Repository\PiaHisPointRepository;
use Plugin\PiaHistory\Repository\PiaHisMeyasuRepository;
use Eccube\Event\EccubeEvents;
use Eccube\Event\EventArgs;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
class ProductEditEventSubscriber implements EventSubscriberInterface
{
private $piaHistoryRepository;
private $piaHisProductRepository;
private $piaHisProductClassRepository;
private $piaHisProductCategoryRepository;
private $piaHisProductImageRepository;
private $piaHisProductStockRepository;
private $piaHisProductTagRepository;
private $piaHisProductAddRepository;
private $piaHisRankPriceRepository;
private $piaHisCustomerClassRepository;
private $piaHisPointRepository;
private $piaHisMeyasuRepository;
private $productRepository;
private $memberRepository;
private $tokenStorage;
private $entityManager;
private $requestStack;
// 商品基本情報のフィールドラベル(updateNote生成用)
private static $basicFieldLabels = [
'name' => '商品名',
'note' => 'ショップ用メモ欄',
'description_list' => '商品一覧コメント',
'description_detail' => '商品説明',
'search_word' => '検索ワード',
'free_area' => 'フリーエリア',
'status' => '商品ステータス',
'datetime_start' => '表示設定日時(開始)',
'datetime_end' => '表示設定日時(終了)',
];
// 商品規格フィールドのラベル(updateNote生成用)
private static $classFieldLabels = [
'price02' => '販売価格',
'price01' => '通常価格',
'stock' => '在庫',
];
public function __construct(
PiaHistoryRepository $piaHistoryRepository,
PiaHisProductRepository $piaHisProductRepository,
PiaHisProductClassRepository $piaHisProductClassRepository,
PiaHisProductCategoryRepository $piaHisProductCategoryRepository,
PiaHisProductImageRepository $piaHisProductImageRepository,
PiaHisProductStockRepository $piaHisProductStockRepository,
PiaHisProductTagRepository $piaHisProductTagRepository,
PiaHisProductAddRepository $piaHisProductAddRepository,
PiaHisRankPriceRepository $piaHisRankPriceRepository,
PiaHisCustomerClassRepository $piaHisCustomerClassRepository,
PiaHisPointRepository $piaHisPointRepository,
PiaHisMeyasuRepository $piaHisMeyasuRepository,
ProductRepository $productRepository,
MemberRepository $memberRepository,
TokenStorageInterface $tokenStorage,
EntityManagerInterface $entityManager,
RequestStack $requestStack
) {
$this->piaHistoryRepository = $piaHistoryRepository;
$this->piaHisProductRepository = $piaHisProductRepository;
$this->piaHisProductClassRepository = $piaHisProductClassRepository;
$this->piaHisProductCategoryRepository = $piaHisProductCategoryRepository;
$this->piaHisProductImageRepository = $piaHisProductImageRepository;
$this->piaHisProductStockRepository = $piaHisProductStockRepository;
$this->piaHisProductTagRepository = $piaHisProductTagRepository;
$this->piaHisProductAddRepository = $piaHisProductAddRepository;
$this->piaHisRankPriceRepository = $piaHisRankPriceRepository;
$this->piaHisCustomerClassRepository = $piaHisCustomerClassRepository;
$this->piaHisPointRepository = $piaHisPointRepository;
$this->piaHisMeyasuRepository = $piaHisMeyasuRepository;
$this->productRepository = $productRepository;
$this->memberRepository = $memberRepository;
$this->tokenStorage = $tokenStorage;
$this->entityManager = $entityManager;
$this->requestStack = $requestStack;
}
public static function getSubscribedEvents()
{
return [
'kernel.request' => ['onKernelRequest', 0],
EccubeEvents::ADMIN_PRODUCT_EDIT_COMPLETE => ['onProductEditComplete', -1000],
];
}
/**
* 商品編集画面のPOSTリクエストを検知して変更前の値を保存
*/
public function onKernelRequest(RequestEvent $event)
{
$request = $event->getRequest();
if ($request->isMethod('POST') &&
$request->attributes->get('_route') === 'admin_product_product_edit') {
$productId = $request->get('id');
error_log('PiaHistory: onKernelRequest - Product ID: ' . $productId);
if ($productId) {
$this->saveProductBeforeData($productId);
}
}
}
public function onProductEditComplete(EventArgs $event)
{
error_log('PiaHistory: onProductEditComplete called');
$Product = $event->getArgument('Product');
if ($Product && $Product->getId()) {
$this->saveProductHistory($Product->getId());
}
}
// -------------------------------------------------------------------------
// 変更前データをセッションに保存
// -------------------------------------------------------------------------
private function saveProductBeforeData($productId)
{
try {
$product = $this->productRepository->find($productId);
if (!$product) {
return;
}
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return;
}
$session = $request->getSession();
$beforeData = [
'product' => $this->buildProductSnapshot($product),
'product_classes' => $this->buildProductClassesSnapshot($product),
'product_categories' => $this->buildProductCategoriesSnapshot($product),
'product_images' => $this->buildProductImagesSnapshot($product),
'product_tags' => $this->buildProductTagsSnapshot($product),
'rank_prices' => $this->buildRankPricesSnapshot($product),
'product_add' => $this->buildProductAddSnapshot($product),
];
$session->set('pia_history_before_data_' . $productId, $beforeData);
} catch (\Exception $e) {
error_log('PiaHistory: 変更前データ保存エラー - ' . $e->getMessage());
}
}
/** 商品基本情報スナップショット */
private function buildProductSnapshot(Product $product): array
{
return [
'id' => $product->getId(),
'name' => $product->getName(),
'note' => $product->getNote(),
'description_list' => $product->getDescriptionList(),
'description_detail' => $product->getDescriptionDetail(),
'search_word' => $product->getSearchWord(),
'free_area' => $product->getFreeArea(),
'status' => $product->getStatus() ? $product->getStatus()->getId() : null,
'datetime_start' => $this->getDisplayStartDate($product),
'datetime_end' => $this->getDisplayEndDate($product),
];
}
/**
* 商品規格スナップショット(変更検知で比較するフィールドのみ)
*
* price01/price02 は会員別価格(buildRankPricesSnapshot)と同じ理由で、
* DBから読み込んだ直後と同一リクエスト内でセットされた直後とで小数点以下の表記が
* 食い違うことがあるため、normalizePriceString() で正規化してから比較する(2026-08-28)。
*/
private function buildProductClassesSnapshot(Product $product): array
{
$data = [];
foreach ($product->getProductClasses() as $pc) {
$data[] = [
'id' => $pc->getId(),
'code' => $pc->getCode(),
'price01' => $this->normalizePriceString($pc->getPrice01()),
'price02' => $this->normalizePriceString($pc->getPrice02()),
'stock' => $pc->isStockUnlimited() ? null : (string)$pc->getStock(),
];
}
return $data;
}
/**
* 商品カテゴリースナップショット(比較用)
*
* EC-CUBE4の ProductCategory には並び順を保持するカラムが無く
* getProductCategories() の取得順序は保存の度に入れ替わりうる。
* 取得順インデックスを比較に使うと、実際は同じカテゴリー構成でも
* 「変更あり」に誤検知するため、category_id をソートした集合として比較する
* (2026-08-28修正)。
*/
private function buildProductCategoriesSnapshot(Product $product): array
{
$ids = [];
foreach ($product->getProductCategories() as $pc) {
$ids[] = $pc->getCategory()->getId();
}
sort($ids);
return $ids;
}
/** 商品画像スナップショット */
private function buildProductImagesSnapshot(Product $product): array
{
$data = [];
foreach ($product->getProductImage() as $i => $img) {
$data[] = [
'file_name' => $img->getFileName(),
'rank' => $img->getSortNo() ?: ($i + 1),
];
}
return $data;
}
/** 商品タグスナップショット */
private function buildProductTagsSnapshot(Product $product): array
{
$data = [];
foreach ($product->getProductTag() as $pt) {
$data[] = ['tag_id' => $pt->getTag()->getId()];
}
return $data;
}
/** 会員別価格スナップショット(CustomerRank42) */
private function buildRankPricesSnapshot(Product $product): array
{
$data = [];
if (!class_exists('Plugin\CustomerRank42\Entity\CustomerPrice')) {
return $data;
}
try {
$repo = $this->entityManager->getRepository('Plugin\CustomerRank42\Entity\CustomerPrice');
foreach ($product->getProductClasses() as $pc) {
$prices = $repo->findBy(['ProductClass' => $pc]);
foreach ($prices as $cp) {
$data[] = [
'product_class_id' => $pc->getId(),
'product_code' => $pc->getCode(),
'rank_id' => $cp->getCustomerRank()->getId(),
'rank_name' => $cp->getCustomerRank()->getName(),
// DBから読み込んだ直後は "17523.00"、同一リクエスト内でフォーム経由で
// セットされた直後は "17523" のように小数点以下の表記が食い違うことがあり、
// 単純な文字列比較では実際は無変更なのに「変更あり」と誤検知していた。
// 数値として正規化してから文字列化することで表記を統一する(2026-08-28)。
'price' => $this->normalizePriceString($cp->getPrice()),
];
}
}
} catch (\Exception $e) {
error_log('PiaHistory: 会員別価格スナップショットエラー - ' . $e->getMessage());
}
return $data;
}
/**
* 金額の比較用正規化。
*
* Doctrineの decimal 型は、DBから読み込んだ直後(例: "17523.00")と、
* 同一リクエスト内でフォーム処理等により新たにセットされた直後(例: "17523")とで
* 小数点以下の桁数表記が食い違うことがある。数値としては同じでも文字列としては
* 別物になり、厳密比較で誤検知する原因になっていた(2026-08-28)。
*/
private function normalizePriceString($value): ?string
{
if ($value === null || $value === '') {
return null;
}
return number_format((float)$value, 2, '.', '');
}
/** 商品追加項目スナップショット(ProductPlus42) */
private function buildProductAddSnapshot(Product $product): array
{
$data = [];
if (!class_exists('Plugin\ProductPlus42\Entity\ProductData')) {
return $data;
}
try {
$repo = $this->entityManager->getRepository('Plugin\ProductPlus42\Entity\ProductData');
$productDatas = $repo->findBy(['Product' => $product]);
foreach ($productDatas as $pd) {
$item = $pd->getProductItem();
if (!$item) {
continue;
}
$data[] = [
'item_id' => $item->getId(),
'item_name' => $item->getName(),
'value' => $pd->getDataValue(),
];
}
} catch (\Exception $e) {
error_log('PiaHistory: 商品追加項目スナップショットエラー - ' . $e->getMessage());
}
return $data;
}
// -------------------------------------------------------------------------
// 履歴保存メイン
// -------------------------------------------------------------------------
private function saveProductHistory($productId)
{
try {
error_log('PiaHistory: saveProductHistory called for Product ID: ' . $productId);
if (!$this->entityManager->isOpen()) {
error_log('PiaHistory: EntityManager is closed, skipping history save');
return;
}
$product = $this->productRepository->find($productId);
if (!$product) {
return;
}
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return;
}
$session = $request->getSession();
$beforeData = $session->get('pia_history_before_data_' . $productId);
if (!$beforeData) {
return;
}
// 変更検知
$changes = $this->detectDetailedChanges($product, $beforeData);
error_log('PiaHistory: Changes detected: ' . (empty($changes) ? 'NO' : 'YES'));
if (empty($changes)) {
$session->remove('pia_history_before_data_' . $productId);
return;
}
// updateNote / updateNum を生成
['num' => $num, 'note' => $note] = $this->generateUpdateNote($changes);
// 管理者情報を取得
[$adminId, $adminName, $adminDepartment] = $this->getAdminInfo();
$this->entityManager->beginTransaction();
// 履歴メイン登録
$history = new PiaHistory();
$history->setProductId($productId);
$history->setUpdateId($adminId);
$history->setUpdateName($adminName);
$history->setUpdateDepartment($adminDepartment);
$history->setUpdateNum($num);
$history->setUpdateNote($note);
$history->setCreateDate(new \DateTime());
$history->setUpdateDate(new \DateTime());
$history->setDelFlg(0);
$this->entityManager->persist($history);
$this->entityManager->flush();
$historyId = $history->getId();
// 各詳細テーブルへの保存
// 商品基本情報+カテゴリー変更(カテゴリーの前後は詳細画面表示用にJSONへ同梱する。
// 2026-08-28: カテゴリーは1つ前の履歴レコードとの突き合わせが不確実なため、
// 商品基本情報と同じ「保存時点でbefore/afterを直接保存」方式に統一した)
$basicChangedFields = $changes['product'] ?? [];
if (isset($changes['product_categories'])) {
$basicChangedFields['__categories'] = [
'before' => $changes['product_categories']['before'],
'after' => $changes['product_categories']['after'],
];
}
if (!empty($basicChangedFields)) {
$this->saveProductBasicHistory($historyId, $product, $basicChangedFields);
}
if (isset($changes['product_classes'])) {
$this->saveProductClassHistory($historyId, $product);
}
if (isset($changes['product_categories'])) {
$this->saveProductCategoryHistory($historyId, $product);
}
if (isset($changes['product_images'])) {
$this->saveProductImageHistory($historyId, $product);
}
if (isset($changes['product_tags'])) {
$this->saveProductTagHistory($historyId, $product);
}
// 在庫は常に現状を保存
$this->saveProductStockHistory($historyId, $product);
// 会員別価格(変更があった場合のみ。判定・updateNote生成と同じ$currentRankPricesを使う)
if (isset($changes['rank_prices'])) {
$this->saveRankPriceHistory($historyId, $beforeData['rank_prices'] ?? [], $changes['rank_prices']['after']);
}
// 商品追加項目(変更があった場合のみ)
if (isset($changes['product_add'])) {
$this->saveProductAddHistory($historyId, $product);
}
// 目安価格
$this->saveMeyasuHistory($historyId, $product);
$this->entityManager->flush();
$this->entityManager->commit();
$session->remove('pia_history_before_data_' . $productId);
} catch (\Throwable $e) {
if ($this->entityManager->getConnection()->isTransactionActive()) {
$this->entityManager->rollback();
}
error_log('PiaHistory: 商品履歴保存エラー - ' . $e->getMessage());
}
}
// -------------------------------------------------------------------------
// 変更検知
// -------------------------------------------------------------------------
private function detectDetailedChanges(Product $product, array $beforeData): array
{
$changes = [];
// 基本情報
// null と空文字、改行コードの違いだけの「実質的に変更なし」を誤検知しないよう、
// 比較は正規化した値で行う(status/日時は元の値のまま比較)(2026-08-28修正)
$currentProduct = $this->buildProductSnapshot($product);
$diffFields = [];
foreach (array_keys(self::$basicFieldLabels) as $field) {
$beforeVal = $beforeData['product'][$field] ?? null;
$afterVal = $currentProduct[$field] ?? null;
$beforeCmp = in_array($field, ['status', 'datetime_start', 'datetime_end'], true)
? $beforeVal
: $this->normalizeTextForCompare($beforeVal);
$afterCmp = in_array($field, ['status', 'datetime_start', 'datetime_end'], true)
? $afterVal
: $this->normalizeTextForCompare($afterVal);
if ($beforeCmp !== $afterCmp) {
$diffFields[$field] = [
'before' => $beforeVal,
'after' => $afterVal,
];
}
}
if (!empty($diffFields)) {
$changes['product'] = $diffFields;
}
// 商品規格(同一フィールド構造で比較)
$currentClasses = $this->buildProductClassesSnapshot($product);
if ($currentClasses !== ($beforeData['product_classes'] ?? [])) {
$changes['product_classes'] = [
'before' => $beforeData['product_classes'] ?? [],
'after' => $currentClasses,
];
}
// カテゴリー
$currentCategories = $this->buildProductCategoriesSnapshot($product);
if ($currentCategories !== ($beforeData['product_categories'] ?? [])) {
$changes['product_categories'] = [
'before' => $beforeData['product_categories'] ?? [],
'after' => $currentCategories,
];
}
// 画像
$currentImages = $this->buildProductImagesSnapshot($product);
if ($currentImages !== ($beforeData['product_images'] ?? [])) {
$changes['product_images'] = [
'before' => $beforeData['product_images'] ?? [],
'after' => $currentImages,
];
}
// タグ
$currentTags = $this->buildProductTagsSnapshot($product);
if ($currentTags !== ($beforeData['product_tags'] ?? [])) {
$changes['product_tags'] = [
'before' => $beforeData['product_tags'] ?? [],
'after' => $currentTags,
];
}
// 会員別価格(CustomerRank42)
// 「現在の会員別価格」は判定・updateNote生成・保存の3箇所で使うため、ここで1回だけ取得して
// 使い回す(2026-08-28)。
$currentRankPrices = $this->buildRankPricesSnapshot($product);
$changedRankPrices = $this->detectRankPriceChanges($beforeData['rank_prices'] ?? [], $currentRankPrices);
if (!empty($changedRankPrices)) {
$changes['rank_prices'] = [
'before' => $beforeData['rank_prices'] ?? [],
'after' => $currentRankPrices,
'changed' => $changedRankPrices,
];
}
// 商品追加項目(ProductPlus42)
$changedProductAdd = $this->detectProductAddChanges($product, $beforeData['product_add'] ?? []);
if (!empty($changedProductAdd)) {
$changes['product_add'] = [
'before' => $beforeData['product_add'] ?? [],
'after' => $this->buildProductAddSnapshot($product),
'changed' => $changedProductAdd,
];
}
return $changes;
}
/**
* テキスト系フィールドの比較用正規化
*
* null と空文字は同じ「未入力」として扱い、改行コード(\r\n / \r)は \n に統一する。
* これを行わずに厳密比較すると、フォーム送信時に null が空文字として保存される等の
* 型の違いだけで「変更あり」と誤検知してしまう(2026-08-28、商品説明・ショップ用メモ欄等で確認)。
*/
private function normalizeTextForCompare($value): string
{
if ($value === null) {
return '';
}
$value = str_replace(["\r\n", "\r"], "\n", (string)$value);
return trim($value);
}
/**
* 会員別価格の変更検知
*
* $currentRankPrices は呼び出し側(detectDetailedChanges)で1回だけ取得したものを渡す
* (保存処理・updateNote生成と同じ値を必ず使うため。2026-08-28)。
*/
private function detectRankPriceChanges(array $beforeRankPrices, array $currentRankPrices): array
{
// [rank_id_productClassId] => price のマップで比較
$beforeMap = [];
foreach ($beforeRankPrices as $rp) {
$key = $rp['rank_id'] . '_' . $rp['product_class_id'];
$beforeMap[$key] = $rp;
}
$afterMap = [];
foreach ($currentRankPrices as $rp) {
$key = $rp['rank_id'] . '_' . $rp['product_class_id'];
$afterMap[$key] = $rp;
}
$changed = [];
foreach ($afterMap as $key => $after) {
$before = $beforeMap[$key] ?? null;
if (!$before || $before['price'] !== $after['price']) {
$changed[] = [
'rank_name' => $after['rank_name'],
'product_code' => $after['product_code'],
'before_price' => $before ? $before['price'] : null,
'after_price' => $after['price'],
];
}
}
foreach ($beforeMap as $key => $before) {
if (!isset($afterMap[$key])) {
$changed[] = [
'rank_name' => $before['rank_name'],
'product_code' => $before['product_code'],
'before_price' => $before['price'],
'after_price' => null,
];
}
}
return $changed;
}
/** 商品追加項目の変更検知 */
private function detectProductAddChanges(Product $product, array $beforeProductAdd): array
{
if (!class_exists('Plugin\ProductPlus42\Entity\ProductData')) {
return [];
}
$currentProductAdd = $this->buildProductAddSnapshot($product);
$beforeMap = [];
foreach ($beforeProductAdd as $pa) {
$beforeMap[$pa['item_id']] = $pa;
}
$afterMap = [];
foreach ($currentProductAdd as $pa) {
$afterMap[$pa['item_id']] = $pa;
}
$changed = [];
// 「beforeにデータ行が無い=未入力」と「値が空文字/null」を同じ扱いで比較する。
// 行の有無だけで判定すると、フォーム送信時に空値のProductDataが新規作成されただけの
// ケースまで「変更あり」と誤検知してしまう(2026-08-28修正)。
foreach ($afterMap as $itemId => $after) {
$before = $beforeMap[$itemId] ?? null;
$beforeVal = $before ? $before['value'] : null;
if ($this->normalizeTextForCompare($beforeVal) !== $this->normalizeTextForCompare($after['value'])) {
$changed[] = [
'item_name' => $after['item_name'],
'before_value' => $beforeVal,
'after_value' => $after['value'],
];
}
}
return $changed;
}
// -------------------------------------------------------------------------
// updateNote / updateNum の生成(EC-CUBE3準拠形式)
// -------------------------------------------------------------------------
private function generateUpdateNote(array $changes): array
{
$logs = [];
$num = 0;
// 商品基本情報
if (isset($changes['product'])) {
foreach (array_keys($changes['product']) as $field) {
$label = self::$basicFieldLabels[$field] ?? $field;
$logs[] = "「{$label}」";
$num++;
}
}
// 商品規格(価格・在庫)フィールド単位でログ
if (isset($changes['product_classes'])) {
$beforeMap = [];
foreach ($changes['product_classes']['before'] as $bc) {
if (isset($bc['code'])) {
$beforeMap[$bc['code']] = $bc;
}
}
foreach ($changes['product_classes']['after'] as $ac) {
$code = $ac['code'] ?? '';
$bc = $beforeMap[$code] ?? null;
if (!$bc) {
continue;
}
foreach (self::$classFieldLabels as $field => $label) {
if (array_key_exists($field, $ac) && array_key_exists($field, $bc) &&
$ac[$field] !== $bc[$field]) {
$logs[] = "「{$code}の{$label}」";
$num++;
}
}
}
}
// 会員別価格
if (isset($changes['rank_prices']['changed'])) {
foreach ($changes['rank_prices']['changed'] as $rp) {
$code = $rp['product_code'] ?? '';
$label = $code ? "{$code}の会員価格:{$rp['rank_name']}" : "会員価格:{$rp['rank_name']}";
$logs[] = "「{$label}」";
$num++;
}
}
// 商品追加項目
if (isset($changes['product_add']['changed'])) {
foreach ($changes['product_add']['changed'] as $pa) {
$logs[] = "「拡張項目:{$pa['item_name']}」";
$num++;
}
}
// カテゴリー
if (isset($changes['product_categories'])) {
$logs[] = '「カテゴリー」';
$num++;
}
// 画像
if (isset($changes['product_images'])) {
$logs[] = '「商品画像」';
$num++;
}
// タグ
if (isset($changes['product_tags'])) {
$logs[] = '「商品タグ」';
$num++;
}
$note = !empty($logs)
? implode('、', $logs) . 'を更新しました。'
: '変更箇所無し';
return ['num' => $num, 'note' => $note];
}
// -------------------------------------------------------------------------
// 各詳細テーブルへの保存
// -------------------------------------------------------------------------
private function saveProductBasicHistory(int $historyId, Product $product, array $changedFields)
{
$productHistory = new PiaHisProduct();
$productHistory->setHisId($historyId);
$productHistory->setProductId($product->getId());
$productHistory->setStatus($product->getStatus() ? $product->getStatus()->getId() : 0);
$productHistory->setName($product->getName());
$productHistory->setNote($product->getNote());
$productHistory->setDescriptionList($product->getDescriptionList());
$productHistory->setDescriptionDetail($product->getDescriptionDetail());
$productHistory->setSearchWord($product->getSearchWord());
$productHistory->setFreeArea($product->getFreeArea());
$productHistory->setDatetimeStart($this->getDisplayStartDate($product));
$productHistory->setDatetimeEnd($this->getDisplayEndDate($product));
$productHistory->setDelFlg(0);
$productHistory->setChangeData(json_encode($changedFields, JSON_UNESCAPED_UNICODE));
$this->entityManager->persist($productHistory);
}
private function saveProductClassHistory(int $historyId, Product $product)
{
foreach ($product->getProductClasses() as $pc) {
$classHistory = new PiaHisProductClass();
$classHistory->setHisId($historyId);
$classHistory->setProductId($product->getId());
$classHistory->setProductTypeId(null);
$classHistory->setClassCategoryId1($pc->getClassCategory1() ? $pc->getClassCategory1()->getId() : null);
$classHistory->setClassCategoryId2($pc->getClassCategory2() ? $pc->getClassCategory2()->getId() : null);
$classHistory->setDeliveryDateId($pc->getDeliveryDuration() ? $pc->getDeliveryDuration()->getId() : null);
$classHistory->setProductCode($pc->getCode());
$classHistory->setStock($pc->getStock());
$classHistory->setStockUnlimited($pc->isStockUnlimited());
$classHistory->setSaleLimit($pc->getSaleLimit());
$classHistory->setPrice01($pc->getPrice01());
$classHistory->setPrice02($pc->getPrice02());
$classHistory->setDeliveryFee($pc->getDeliveryFee());
$this->entityManager->persist($classHistory);
}
}
private function saveProductCategoryHistory(int $historyId, Product $product)
{
foreach ($product->getProductCategories() as $i => $productCategory) {
$categoryHistory = new PiaHisProductCategory();
$categoryHistory->setHisId($historyId);
$categoryHistory->setProductId($product->getId());
$categoryHistory->setCategoryId($productCategory->getCategory()->getId());
$categoryHistory->setRank($i + 1);
$this->entityManager->persist($categoryHistory);
}
}
private function saveProductImageHistory(int $historyId, Product $product)
{
foreach ($product->getProductImage() as $i => $productImage) {
$imageHistory = new PiaHisProductImage();
$imageHistory->setHisId($historyId);
$imageHistory->setProductId($product->getId());
$imageHistory->setFileName($productImage->getFileName());
$imageHistory->setRank($productImage->getSortNo() ?: ($i + 1));
$this->entityManager->persist($imageHistory);
}
}
private function saveProductStockHistory(int $historyId, Product $product)
{
foreach ($product->getProductClasses() as $pc) {
$stockHistory = new PiaHisProductStock();
$stockHistory->setHisId($historyId);
$stockHistory->setProductClassId($pc->getId());
$stockHistory->setStock($pc->isStockUnlimited() ? null : $pc->getStock());
$this->entityManager->persist($stockHistory);
}
}
private function saveProductTagHistory(int $historyId, Product $product)
{
foreach ($product->getProductTag() as $productTag) {
$tagHistory = new PiaHisProductTag();
$tagHistory->setHisId($historyId);
$tagHistory->setProductId($product->getId());
$tagHistory->setTag($productTag->getTag()->getId());
$this->entityManager->persist($tagHistory);
}
}
/**
* 会員別価格履歴を保存(CustomerRank42)
*
* 詳細画面で「1つ前の履歴レコード」を探して突き合わせる方式は、
* 直前の履歴にそのランクの価格が保存されているとは限らず食い違いの原因になっていたため、
* 保存時点で確定している変更前価格($beforeRankPrices)をこの履歴の行に直接持たせる
* 方式に変更した(2026-08-28)。
*
* $currentRankPrices は独自にDBへ再クエリせず、判定・updateNote生成で使ったのと
* 同じ配列(buildRankPricesSnapshot()の戻り値、$changes['rank_prices']['after'])を
* 呼び出し側から受け取る。別々に再取得すると、取得タイミングのズレで判定結果と
* 保存内容が食い違う不具合があったため(2026-08-28、R-TRUST社員様/ご紹介会員で発覚)。
* $beforeRankPrices・$currentRankPrices とも同じ形式の配列
* (rank_id, product_class_id, rank_name, product_code, price を持つ連想配列のリスト)。
*/
private function saveRankPriceHistory(int $historyId, array $beforeRankPrices, array $currentRankPrices)
{
$beforeMap = [];
foreach ($beforeRankPrices as $rp) {
$key = $rp['rank_id'] . '_' . $rp['product_class_id'];
$beforeMap[$key] = $rp['price'];
}
foreach ($currentRankPrices as $rp) {
$key = $rp['rank_id'] . '_' . $rp['product_class_id'];
$rankHistory = new PiaHisRankPrice();
$rankHistory->setHisId($historyId);
$rankHistory->setCustomerRankId($rp['rank_id']);
$rankHistory->setProductClassId($rp['product_class_id']);
$rankHistory->setPrice($rp['price']);
$rankHistory->setBeforePrice($beforeMap[$key] ?? null);
$this->entityManager->persist($rankHistory);
}
}
/** 商品追加項目履歴を保存(ProductPlus42) */
private function saveProductAddHistory(int $historyId, Product $product)
{
if (!class_exists('Plugin\ProductPlus42\Entity\ProductData')) {
return;
}
try {
$repo = $this->entityManager->getRepository('Plugin\ProductPlus42\Entity\ProductData');
$productDatas = $repo->findBy(['Product' => $product]);
foreach ($productDatas as $pd) {
$item = $pd->getProductItem();
if (!$item) {
continue;
}
$addHistory = new PiaHisProductAdd();
$addHistory->setHisId($historyId);
$addHistory->setProductId($product->getId());
$addHistory->setColumnId($item->getId());
$addHistory->setValue($pd->getDataValue());
$this->entityManager->persist($addHistory);
}
} catch (\Exception $e) {
error_log('PiaHistory: 商品追加項目履歴保存エラー - ' . $e->getMessage());
}
}
/** 目安価格履歴を保存(PiaMeyasu) */
private function saveMeyasuHistory(int $historyId, Product $product)
{
try {
if (!class_exists('Plugin\PiaMeyasu\Entity\PiaMeyasu')) {
return;
}
$meyasuRepo = $this->entityManager->getRepository('Plugin\PiaMeyasu\Entity\PiaMeyasu');
$meyasuData = $meyasuRepo->findOneBy(['product_id' => $product->getId()]);
if ($meyasuData) {
$meyasuHistory = new PiaHisMeyasu();
$meyasuHistory->setHisId($historyId);
$meyasuHistory->setProductId($product->getId());
$meyasuHistory->setCoinCost($meyasuData->getCoinCost());
$meyasuHistory->setExTypeId($meyasuData->getExTypeId());
$meyasuHistory->setCommission($meyasuData->getCommission());
$meyasuHistory->setEsDispPrice($meyasuData->getEsDispPrice());
$this->entityManager->persist($meyasuHistory);
}
} catch (\Exception $e) {
error_log('PiaHistory: 目安価格履歴保存エラー - ' . $e->getMessage());
}
}
// -------------------------------------------------------------------------
// ユーティリティ
// -------------------------------------------------------------------------
/** ログイン管理者情報を取得 */
private function getAdminInfo(): array
{
$adminId = 1;
$adminName = '管理者';
$adminDepartment = '';
if ($this->tokenStorage->getToken() && $this->tokenStorage->getToken()->getUser()) {
$admin = $this->tokenStorage->getToken()->getUser();
$adminId = $admin->getId();
$member = $this->memberRepository->find($adminId);
if ($member) {
$adminName = $member->getName();
if (method_exists($member, 'getDepartment')) {
$adminDepartment = $member->getDepartment() ?: '';
}
}
}
return [$adminId, $adminName, $adminDepartment];
}
/** PiaProductDispプラグインから表示開始日を取得 */
private function getDisplayStartDate(Product $product): ?string
{
try {
if (class_exists('Plugin\PiaProductDisp\Entity\ProductDisplayPeriod')) {
$repo = $this->entityManager->getRepository('Plugin\PiaProductDisp\Entity\ProductDisplayPeriod');
$dp = $repo->findOneBy(['Product' => $product]);
if ($dp && $dp->getDisplayStartDate()) {
return $dp->getDisplayStartDate()->format('Y-m-d H:i:s');
}
}
} catch (\Exception $e) {
error_log('PiaHistory: 表示開始日取得エラー - ' . $e->getMessage());
}
return null;
}
/** PiaProductDispプラグインから表示終了日を取得 */
private function getDisplayEndDate(Product $product): ?string
{
try {
if (class_exists('Plugin\PiaProductDisp\Entity\ProductDisplayPeriod')) {
$repo = $this->entityManager->getRepository('Plugin\PiaProductDisp\Entity\ProductDisplayPeriod');
$dp = $repo->findOneBy(['Product' => $product]);
if ($dp && $dp->getDisplayEndDate()) {
return $dp->getDisplayEndDate()->format('Y-m-d H:i:s');
}
}
} catch (\Exception $e) {
error_log('PiaHistory: 表示終了日取得エラー - ' . $e->getMessage());
}
return null;
}
}