diff --git a/Controller/Admin/ConfigController.php b/Controller/Admin/ConfigController.php
index 33f2872..6181c62 100644
--- a/Controller/Admin/ConfigController.php
+++ b/Controller/Admin/ConfigController.php
@@ -159,8 +159,11 @@ public function index(Request $request, Connection $em)
// 全データ移行
$this->saveCustomer($em, $csvDir);
+ $this->releaseCustomerPhaseMemory();
$this->saveProduct($em, $csvDir);
+ $this->releaseProductPhaseMemory();
$this->saveOrder($em, $csvDir);
+ $this->releaseOrderPhaseMemory();
}
// plg_customerplusの移行処理を作る
@@ -1056,8 +1059,14 @@ protected function upsertAuthorityAndMember($em, $dir)
]);
}
if ($hasMember) {
- // 既存メンバーを一旦非稼働化
- $em->exec('UPDATE dtb_member SET work_id = 0');
+ // 2.x系の場合はDELETE+INSERTで全置換(MySQL saveToC と同等)
+ $is2xData = !$this->dataMigrationService->isVersion('4.0/4.1');
+ if ($is2xData) {
+ $this->dataMigrationService->resetTable($em, 'dtb_member');
+ } else {
+ // 4.0/4.1系:既存メンバーを一旦非稼働化してUPSERT
+ $em->exec('UPDATE dtb_member SET work_id = 0');
+ }
// CSV を読み取り UPSERT
$file = $memberCsv;
if (($handle = fopen($file, 'r')) === false) {
@@ -1085,7 +1094,25 @@ protected function upsertAuthorityAndMember($em, $dir)
$insertValues[$col] = $data[$col] ?? 'member';
continue;
}
- // 4.0系のカラム名マッピング
+ // 2.x系: member_id → id
+ if ($col === 'id' && !array_key_exists('id', $data) && array_key_exists('member_id', $data)) {
+ $insertValues[$col] = $data['member_id'];
+ continue;
+ }
+ // 2.x系: authority → authority_id, work → work_id
+ if ($col === 'authority_id' && !array_key_exists($col, $data) && array_key_exists('authority', $data)) {
+ $insertValues[$col] = $data['authority'];
+ continue;
+ }
+ if ($col === 'work_id' && !array_key_exists($col, $data) && array_key_exists('work', $data)) {
+ $insertValues[$col] = ($data['del_flg'] ?? '0') === '1' ? 0 : $data['work'];
+ continue;
+ }
+ if ($col === 'sort_no' && !array_key_exists($col, $data) && array_key_exists('rank', $data)) {
+ $insertValues[$col] = $data['rank'];
+ continue;
+ }
+ // 4.0系: work_id → work, authority_id → authority
if ($col === 'work' && !array_key_exists($col, $data) && array_key_exists('work_id', $data)) {
$insertValues[$col] = $data['work_id'];
continue;
@@ -1119,8 +1146,14 @@ protected function upsertAuthorityAndMember($em, $dir)
$insertValues['two_factor_auth_enabled'] = 0;
}
if (array_key_exists('two_factor_auth_key', $insertValues) && $insertValues['two_factor_auth_key'] === null) {
- $insertValues['two_factor_auth_key'] = null; // NULL許可(この行は冗長だが明示的に残す)
+ $insertValues['two_factor_auth_key'] = null;
+ }
+ // saveToC と同等: creator_id のデフォルト値
+ if (array_key_exists('creator_id', $insertValues) && empty($insertValues['creator_id'])) {
+ $insertValues['creator_id'] = 1;
}
+ // PostgreSQL用の型変換
+ $insertValues = $this->dataMigrationService->convertDataTypesForPostgreSQL($em, 'dtb_member', $insertValues);
$colsSql = implode(',', array_map(fn($c) => '"' . $c . '"', array_keys($insertValues)));
$placeholders = implode(',', array_fill(0, count($insertValues), '?'));
$updateSql = implode(', ', array_map(fn($c) => '"' . $c . '" = EXCLUDED."' . $c . '"', $updateCols));
@@ -1128,6 +1161,8 @@ protected function upsertAuthorityAndMember($em, $dir)
$em->prepare($sql)->executeStatement(array_values($insertValues));
}
fclose($handle);
+ // PostgreSQL: シーケンスを最大ID+1にリセット
+ $this->dataMigrationService->setIdSeq($em, 'dtb_member');
}
// メンバーIDキャッシュ
try {
@@ -1832,8 +1867,10 @@ private function saveToO($em, $tmpDir, $csvName, $tableName = null, $allow_zero
}
}
- // shippingに紐付けるデータを保持
- $this->shipping_order[$data['id']] = $data;
+ // shippingに紐付けるデータを保持(必要なフィールドのみ)
+ $this->shipping_order[$data['id']] = [
+ 'commit_date' => $data['commit_date'] ?? null,
+ ];
break;
@@ -2343,4 +2380,30 @@ private function fix4x($em, $tmpDir, $csvName)
return $i; // indexを返す
}
}
+
+ private function releaseCustomerPhaseMemory(): void
+ {
+ $this->customer_point = [];
+ gc_collect_cycles();
+ }
+
+ private function releaseProductPhaseMemory(): void
+ {
+ $this->stock = [];
+ $this->product_images = [];
+ $this->dtb_class_combination = [];
+ $this->delivery_id = [];
+ $this->product_class_id = [];
+ gc_collect_cycles();
+ }
+
+ private function releaseOrderPhaseMemory(): void
+ {
+ $this->order_item = [];
+ $this->shipping_id = [];
+ $this->shipping_order = [];
+ $this->tax_rule = [];
+ $this->delivery_time = [];
+ gc_collect_cycles();
+ }
}
diff --git a/Form/Type/Admin/ConfigType.php b/Form/Type/Admin/ConfigType.php
index f9b0e31..b3d34f9 100644
--- a/Form/Type/Admin/ConfigType.php
+++ b/Form/Type/Admin/ConfigType.php
@@ -7,8 +7,10 @@
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\File;
use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\Regex;
class ConfigType extends AbstractType
{
@@ -36,11 +38,24 @@ public function buildForm(FormBuilderInterface $builder, array $options)
->add('auth_magic', TextType::class, [
'label' => 'AUTH_MAGIC',
'required' => true,
- //'placeholder' => '',
+ 'constraints' => [
+ new NotBlank(),
+ new Regex([
+ 'pattern' => '/^[a-zA-Z0-9_\-\.]+$/',
+ 'message' => 'AUTH_MAGICは英数字・アンダースコア・ハイフン・ドットのみ使用できます。',
+ ]),
+ ],
'attr' => [
'placeholder' => "旧サイトのAUTH_MAGICを入力してください。",
],
])
;
}
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'csrf_token_id' => 'data_migration43_config',
+ ]);
+ }
}
diff --git a/Service/DataMigrationService.php b/Service/DataMigrationService.php
index 8493e03..7ac8948 100644
--- a/Service/DataMigrationService.php
+++ b/Service/DataMigrationService.php
@@ -44,6 +44,12 @@ class DataMigrationService
*/
private $mappingCache = [];
+ /**
+ * テーブルカラム情報のキャッシュ(テーブル名 => カラム配列)
+ * @var array
+ */
+ private $tableColumnsCache = [];
+
/**
* Customer item option data for migration
* @var array
@@ -120,6 +126,11 @@ public function isVersion($version)
public function updateEnv($newMagicValue)
{
+ $newMagicValue = preg_replace('/[\r\n]/', '', $newMagicValue);
+ if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $newMagicValue)) {
+ throw new \InvalidArgumentException('AUTH_MAGIC contains invalid characters.');
+ }
+
$projectDir = $this->params->get('kernel.project_dir');
$envFile = $projectDir . '/.env';
@@ -178,7 +189,10 @@ public function convertDataTypesForPostgreSQL($em, $tableName, $data)
try {
- $columns = $em->getSchemaManager()->listTableColumns($tableName);
+ if (!isset($this->tableColumnsCache[$tableName])) {
+ $this->tableColumnsCache[$tableName] = $em->getSchemaManager()->listTableColumns($tableName);
+ }
+ $columns = $this->tableColumnsCache[$tableName];
$hasConversion = false;
foreach ($data as $key => &$value) {
@@ -203,7 +217,6 @@ public function convertDataTypesForPostgreSQL($em, $tableName, $data)
}
} catch (\Exception $e) {
error_log("Error in convertDataTypesForPostgreSQL for table '$tableName': " . $e->getMessage());
- error_log("Data being processed: " . json_encode($data));
// エラーが発生した場合は元のデータをそのまま返す
}
diff --git a/Tests/Fixtures/member_test.tar.gz b/Tests/Fixtures/member_test.tar.gz
index b2bbbd3..4c899b0 100644
Binary files a/Tests/Fixtures/member_test.tar.gz and b/Tests/Fixtures/member_test.tar.gz differ
diff --git a/Tests/Web/Admin/ConfigControllerTest.php b/Tests/Web/Admin/ConfigControllerTest.php
index b8fe2f4..f3c3dcd 100644
--- a/Tests/Web/Admin/ConfigControllerTest.php
+++ b/Tests/Web/Admin/ConfigControllerTest.php
@@ -107,6 +107,307 @@ public function testバックアップファイルをアップロードできる
}
}
+ /**
+ * 移行を実行するヘルパー。フィクスチャ名を指定して POST → DBAL Connection を返す。
+ */
+ private function performMigration(string $fixture, array $extraPost = []): \Doctrine\DBAL\Connection
+ {
+ $container = self::getContainer();
+ $project_dir = $container->getParameter('kernel.project_dir');
+
+ $file = $project_dir . '/app/Plugin/DataMigration43/Tests/Fixtures/' . $fixture . '.tar.gz';
+ $testFile = $project_dir . '/app/Plugin/DataMigration43/Tests/Fixtures/test.tar.gz';
+
+ $fs = new Filesystem();
+ $fs->copy($file, $testFile);
+
+ $file = new UploadedFile($testFile, 'test.tar.gz', 'application/x-tar', null, true);
+
+ $post = [
+ 'config' => array_merge([
+ Constant::TOKEN_NAME => 'dummy',
+ 'import_file' => $file,
+ 'auth_magic' => 'dummy',
+ ], $extraPost),
+ ];
+
+ $this->client->request(
+ 'POST',
+ $this->generateUrl('data_migration43_admin_config'),
+ $post,
+ ['config' => ['import_file' => $file]]
+ );
+
+ $response = $this->client->getResponse();
+ $statusCode = $response->getStatusCode();
+ if ($statusCode === Response::HTTP_INTERNAL_SERVER_ERROR) {
+ // 500エラー時はレスポンスボディからエラー情報を抽出
+ $body = $response->getContent();
+ // HTMLからエラーメッセージを抽出
+ $errorMsg = '';
+ if (preg_match('/
]*class="exception-message[^"]*"[^>]*>(.*?)<\/h1>/s', $body, $m)) {
+ $errorMsg = strip_tags($m[1]);
+ } elseif (preg_match('/(.*?)<\/title>/s', $body, $m)) {
+ $errorMsg = strip_tags($m[1]);
+ }
+ self::fail("移行リクエストが500エラーを返しました: " . $errorMsg);
+ }
+ self::assertTrue(
+ $statusCode === Response::HTTP_FOUND || $statusCode === Response::HTTP_OK,
+ "移行リクエストが予期しないステータス {$statusCode} を返しました"
+ );
+
+ // EntityManagerのIDマップをクリアし、最新のDB状態を取得可能にする
+ $this->entityManager->clear();
+
+ return $this->entityManager->getConnection();
+ }
+
+ public function test2系のデータ移行内容が正しいこと()
+ {
+ try {
+ $conn = $this->performMigration('2_13_5');
+
+ // --- 会員 ---
+ $customer = $conn->fetchAssociative('SELECT * FROM dtb_customer WHERE id = ?', [1]);
+ self::assertNotFalse($customer, '会員id=1が存在すること');
+ self::assertSame('てすと', $customer['name01']);
+ self::assertSame('たろう', $customer['name02']);
+ self::assertSame('テスト', $customer['kana01']);
+ self::assertSame('タロウ', $customer['kana02']);
+ self::assertSame('hoge@example.com', $customer['email']);
+ self::assertSame('7772222', $customer['postal_code']);
+ self::assertEquals(2, (int) $customer['sex_id']);
+ self::assertEquals(12, (int) $customer['job_id']);
+ self::assertEquals(6, (int) $customer['pref_id']);
+ // status=2, del_flg=0 → customer_status_id=2
+ self::assertEquals(2, (int) $customer['customer_status_id']);
+ self::assertSame('customer', $customer['discriminator_type']);
+
+ // --- 商品 ---
+ $products = $conn->fetchAllAssociative('SELECT * FROM dtb_product ORDER BY id');
+ self::assertCount(3, $products);
+ self::assertSame('アイスクリーム', $products[0]['name']);
+ self::assertSame('おなべ', $products[1]['name']);
+ self::assertSame('おなべレシピ', $products[2]['name']);
+
+ // product_status_id: status=1, del_flg=0 → 1
+ foreach ($products as $p) {
+ self::assertEquals(1, (int) $p['product_status_id'], $p['name'] . 'のproduct_status_idが1であること');
+ }
+
+ // product_class: 各商品にvisible=trueのレコードが存在
+ foreach ([1, 2, 3] as $pid) {
+ $visibleCount = (int) $conn->fetchOne(
+ 'SELECT COUNT(*) FROM dtb_product_class WHERE product_id = ? AND visible = true',
+ [$pid]
+ );
+ self::assertGreaterThan(0, $visibleCount, "product_id={$pid}にvisible=trueのproduct_classが存在すること");
+ }
+
+ // product_stock: 在庫レコードが存在
+ $stockCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_product_stock');
+ self::assertGreaterThan(0, $stockCount, '在庫レコードが存在すること');
+
+ // product_image: 画像レコードが存在(2.xはインラインカラムから変換)
+ $imageCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_product_image');
+ self::assertGreaterThan(0, $imageCount, '商品画像レコードが存在すること');
+
+ // --- 受注 ---
+ $orders = $conn->fetchAllAssociative('SELECT * FROM dtb_order ORDER BY id');
+ self::assertCount(2, $orders);
+ // order_id=1: 会員注文
+ self::assertEquals(1, (int) $orders[0]['customer_id'], '受注1はcustomer_id=1であること');
+ // order_id=2: ゲスト (customer_id=0→NULL)
+ self::assertNull($orders[1]['customer_id'], '受注2はゲスト注文のためcustomer_id=NULLであること');
+
+ foreach ($orders as $order) {
+ self::assertSame('JPY', $order['currency_code'], 'currency_codeがJPYであること');
+ self::assertNotNull($order['order_date'], 'order_dateが設定されていること');
+ }
+
+ // order_item: 商品明細(type=1)が存在
+ $productItems = (int) $conn->fetchOne(
+ 'SELECT COUNT(*) FROM dtb_order_item WHERE order_item_type_id = 1'
+ );
+ self::assertGreaterThan(0, $productItems, '商品明細(type=1)が存在すること');
+
+ // order_item: 送料(type=2)が生成されること
+ $delivFeeItems = (int) $conn->fetchOne(
+ 'SELECT COUNT(*) FROM dtb_order_item WHERE order_item_type_id = 2'
+ );
+ self::assertGreaterThan(0, $delivFeeItems, '送料(type=2)のorder_itemが生成されること');
+
+ // tax計算が行われていること (tax > 0)
+ $taxItems = (int) $conn->fetchOne(
+ 'SELECT COUNT(*) FROM dtb_order_item WHERE tax > 0'
+ );
+ self::assertGreaterThan(0, $taxItems, 'taxが計算されていること');
+
+ // --- 配送 ---
+ $shippings = $conn->fetchAllAssociative('SELECT * FROM dtb_shipping ORDER BY id');
+ self::assertCount(2, $shippings);
+ // delivery_idがorderのdeliv_idから正しく設定されていること
+ foreach ($shippings as $shipping) {
+ self::assertNotNull($shipping['delivery_id'], 'delivery_idが設定されていること');
+ }
+
+ // --- カテゴリ ---
+ $categoryCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_category');
+ self::assertGreaterThanOrEqual(6, $categoryCount, 'カテゴリが6件以上存在すること');
+
+ // --- 税率 ---
+ $taxRate = $conn->fetchOne(
+ 'SELECT tax_rate FROM dtb_tax_rule WHERE product_id IS NULL AND product_class_id IS NULL LIMIT 1'
+ );
+ self::assertEquals(8, (int) $taxRate, '税率が8%であること');
+
+ } catch (\Exception $e) {
+ if ($this->entityManager->getConnection()->isTransactionActive()) {
+ $this->entityManager->getConnection()->rollBack();
+ $this->entityManager->getConnection()->beginTransaction();
+ }
+ throw $e;
+ }
+ }
+
+ public function test3系のデータ移行内容が正しいこと()
+ {
+ try {
+ $conn = $this->performMigration('3_0_18');
+
+ // --- 会員 ---
+ $customer = $conn->fetchAssociative('SELECT * FROM dtb_customer WHERE id = ?', [1]);
+ self::assertNotFalse($customer, '会員id=1が存在すること');
+ self::assertSame('足立', $customer['name01']);
+ self::assertSame('智広', $customer['name02']);
+ self::assertSame('chihiro_adachi@ec-cube.co.jp', $customer['email']);
+ self::assertEquals(1, (int) $customer['pref_id']);
+
+ // --- 商品 ---
+ $products = $conn->fetchAllAssociative('SELECT * FROM dtb_product ORDER BY id');
+ self::assertCount(2, $products);
+ self::assertSame('ディナーフォーク', $products[0]['name']);
+ self::assertSame('パーコレーター', $products[1]['name']);
+
+ // product_classが存在
+ foreach ([1, 2] as $pid) {
+ $pcCount = (int) $conn->fetchOne(
+ 'SELECT COUNT(*) FROM dtb_product_class WHERE product_id = ?',
+ [$pid]
+ );
+ self::assertGreaterThan(0, $pcCount, "product_id={$pid}にproduct_classが存在すること");
+ }
+
+ // --- 受注 ---
+ $orderCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_order');
+ self::assertEquals(4, $orderCount, '受注が4件であること');
+
+ // order_item: 商品明細が存在
+ $productItems = (int) $conn->fetchOne(
+ 'SELECT COUNT(*) FROM dtb_order_item WHERE order_item_type_id = 1'
+ );
+ self::assertGreaterThan(0, $productItems, '商品明細(type=1)が存在すること');
+
+ // --- 配送 ---
+ $shippingCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_shipping');
+ self::assertGreaterThanOrEqual(5, $shippingCount, '配送が5件以上であること(order 4がマルチ配送)');
+
+ // order 4のshippingが2件であること
+ $order4ShippingCount = (int) $conn->fetchOne(
+ 'SELECT COUNT(*) FROM dtb_shipping WHERE order_id = ?',
+ [4]
+ );
+ self::assertEquals(2, $order4ShippingCount, 'order 4のshippingが2件であること(マルチ配送)');
+
+ // delivery_idが設定されていること
+ $nullDeliveryCount = (int) $conn->fetchOne(
+ 'SELECT COUNT(*) FROM dtb_shipping WHERE delivery_id IS NULL'
+ );
+ self::assertEquals(0, $nullDeliveryCount, '全配送にdelivery_idが設定されていること');
+
+ // --- 税率 ---
+ $taxRate = $conn->fetchOne(
+ 'SELECT tax_rate FROM dtb_tax_rule WHERE product_id IS NULL AND product_class_id IS NULL LIMIT 1'
+ );
+ self::assertEquals(8, (int) $taxRate, '税率が8%であること');
+
+ } catch (\Exception $e) {
+ if ($this->entityManager->getConnection()->isTransactionActive()) {
+ $this->entityManager->getConnection()->rollBack();
+ $this->entityManager->getConnection()->beginTransaction();
+ }
+ throw $e;
+ }
+ }
+
+ public function test4系のデータ移行内容が正しいこと()
+ {
+ try {
+ $conn = $this->performMigration('4_1_2');
+
+ // --- 会員 ---
+ $customer = $conn->fetchAssociative('SELECT * FROM dtb_customer WHERE id = ?', [1]);
+ self::assertNotFalse($customer, '会員id=1が存在すること');
+ self::assertSame('大垣', $customer['name01']);
+ self::assertSame('翼', $customer['name02']);
+ self::assertEquals(55784, (int) $customer['point'], 'pointが55784であること');
+
+ // --- 商品 ---
+ $productCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_product');
+ self::assertEquals(12, $productCount, '商品が12件であること');
+
+ // --- 受注 ---
+ $orderCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_order');
+ self::assertEquals(20, $orderCount, '受注が20件であること');
+
+ // --- 受注明細: 各種order_item_type_idが存在 ---
+ foreach ([1, 2, 3, 4] as $typeId) {
+ $count = (int) $conn->fetchOne(
+ 'SELECT COUNT(*) FROM dtb_order_item WHERE order_item_type_id = ?',
+ [$typeId]
+ );
+ self::assertGreaterThan(0, $count, "order_item_type_id={$typeId}のレコードが存在すること");
+ }
+
+ } catch (\Exception $e) {
+ if ($this->entityManager->getConnection()->isTransactionActive()) {
+ $this->entityManager->getConnection()->rollBack();
+ $this->entityManager->getConnection()->beginTransaction();
+ }
+ throw $e;
+ }
+ }
+
+ public function testメンバーのデータ移行内容が正しいこと()
+ {
+ try {
+ $conn = $this->performMigration('member_test');
+
+ // 全メンバーを確認
+ $allMembers = $conn->fetchAllAssociative('SELECT id, name, login_id, authority_id FROM dtb_member ORDER BY id');
+ self::assertGreaterThanOrEqual(2, count($allMembers), 'メンバーが2件以上存在すること (実際: ' . count($allMembers) . '件, IDs: ' . implode(',', array_column($allMembers, 'id')) . ')');
+
+ // login_idでメンバーを検索(IDはDB環境依存の可能性があるため)
+ $admin = $conn->fetchAssociative('SELECT * FROM dtb_member WHERE login_id = ?', ['testadmin']);
+ self::assertNotFalse($admin, 'login_id=testadminのメンバーが存在すること (全メンバーIDs: ' . implode(',', array_column($allMembers, 'id')) . ')');
+ self::assertSame('テスト管理者', $admin['name']);
+ self::assertEquals(0, (int) $admin['authority_id'], 'testadminのauthority_idが0(システム管理者)であること');
+
+ $owner = $conn->fetchAssociative('SELECT * FROM dtb_member WHERE login_id = ?', ['testowner']);
+ self::assertNotFalse($owner, 'login_id=testownerのメンバーが存在すること');
+ self::assertSame('テスト店舗オーナー', $owner['name']);
+ self::assertEquals(1, (int) $owner['authority_id'], 'testownerのauthority_idが1(店舗オーナー)であること');
+
+ } catch (\Exception $e) {
+ if ($this->entityManager->getConnection()->isTransactionActive()) {
+ $this->entityManager->getConnection()->rollBack();
+ $this->entityManager->getConnection()->beginTransaction();
+ }
+ throw $e;
+ }
+ }
+
/**
* ECCUBE2Downloadsプラグインがインストール済みの場合、
* ダウンロード商品のsale_type_idが222に書き換わることをテスト