| @@ -6,265 +6,247 @@ | ||
| 6 | 6 | use FluentCart\App\Models\AttributeTerm; |
| 7 | 7 | |
| 8 | 8 | class AttributeSeeder |
| 9 | 9 | { |
| 10 | + /** | |
| 11 | + * JSON encoding flags must match AttributeGroup / AttributeTerm | |
| 12 | + * setSettingsAttribute mutators. Bulk insert() bypasses mutators, so any | |
| 13 | + * encoding-flag drift between seed-time and runtime writes would store | |
| 14 | + * the same logical value with two different byte representations | |
| 15 | + * (UTF-8 unicode + forward slashes are the practical divergences). | |
| 16 | + */ | |
| 17 | + private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; | |
| 18 | + | |
| 10 | 19 | public static function seed() |
| 11 | 20 | { |
| 12 | - if (AttributeGroup::count() > 2) { | |
| 21 | + // settings.type controls how the editor renders a group's term picker: | |
| 22 | + // 'options' (default, text chips), 'color' (swatch + hex), 'image' (thumbnail). | |
| 23 | + // Read by AdvancedVariationConfig.vue::getGroupType(). | |
| 24 | + // | |
| 25 | + // is_system = 1 marks these as protected store-level templates — the UI hides | |
| 26 | + // destructive actions (edit/delete the group itself) on system rows. Merchant- | |
| 27 | + // created groups stay is_system = 0 and are fully editable. | |
| 28 | + // Every row must declare the same keys — WPFluent's bulk insert() builds the | |
| 29 | + // column list from the first row and breaks if later rows omit a key. | |
| 30 | + $now = gmdate('Y-m-d H:i:s'); | |
| 31 | + | |
| 32 | + $groups = [ | |
| 33 | + self::groupRow('Color', 'color', json_encode(['type' => 'color'], self::JSON_FLAGS), $now), | |
| 34 | + self::groupRow('Size', 'size', null, $now), | |
| 35 | + self::groupRow('Material', 'material', null, $now), | |
| 36 | + self::groupRow('Storage', 'storage', null, $now), | |
| 37 | + self::groupRow('Memory (RAM)', 'memory', null, $now), | |
| 38 | + self::groupRow('Weight', 'weight', null, $now), | |
| 39 | + self::groupRow('Style', 'style', null, $now), | |
| 40 | + self::groupRow('Pattern', 'pattern', null, $now), | |
| 41 | + self::groupRow('Age Group', 'age-group', null, $now), | |
| 42 | + self::groupRow('Target Gender', 'target-gender', null, $now), | |
| 43 | + ]; | |
| 44 | + | |
| 45 | + $systemSlugs = array_column($groups, 'slug'); | |
| 46 | + | |
| 47 | + // Slug-based idempotency, NOT table-emptiness. A merchant on an | |
| 48 | + // older Pro could have created their own groups before this seeder | |
| 49 | + // existed; the seeder must still backfill any of the 10 canonical | |
| 50 | + // system templates that are missing. We detect which of OUR slugs | |
| 51 | + // are already present and skip only those — never touching merchant | |
| 52 | + // rows, never duplicating system rows. | |
| 53 | + $existingSystemSlugs = AttributeGroup::query() | |
| 54 | + ->whereIn('slug', $systemSlugs) | |
| 55 | + ->pluck('slug') | |
| 56 | + ->toArray(); | |
| 57 | + | |
| 58 | + $missingSlugs = array_values(array_diff($systemSlugs, $existingSystemSlugs)); | |
| 59 | + | |
| 60 | + if (empty($missingSlugs)) { | |
| 13 | 61 | return; |
| 14 | 62 | } |
| 15 | 63 | |
| 16 | - $groups = [ | |
| 17 | - [ | |
| 18 | - 'title' => 'Digital Product Licensing', | |
| 19 | - 'slug' => 'plugin-license', | |
| 20 | - 'description' => 'Digital product license types.', | |
| 64 | + // Term sets keyed by group slug. Plain strings = title only. Arrays let us | |
| 65 | + // attach per-term settings (e.g. hex color for the swatch renderer). | |
| 66 | + $terms = [ | |
| 67 | + 'color' => [ | |
| 68 | + ['title' => 'Red', 'color' => '#ef4444'], | |
| 69 | + ['title' => 'Blue', 'color' => '#3b82f6'], | |
| 70 | + ['title' => 'Green', 'color' => '#22c55e'], | |
| 71 | + ['title' => 'Yellow', 'color' => '#eab308'], | |
| 72 | + ['title' => 'Black', 'color' => '#000000'], | |
| 73 | + ['title' => 'White', 'color' => '#ffffff'], | |
| 74 | + ['title' => 'Grey', 'color' => '#6b7280'], | |
| 75 | + ['title' => 'Gold', 'color' => '#d4af37'], | |
| 76 | + ['title' => 'Silver', 'color' => '#c0c0c0'], | |
| 77 | + ['title' => 'Beige', 'color' => '#f5f5dc'], | |
| 21 | 78 | ], |
| 22 | - [ | |
| 23 | - 'title' => 'License Period', | |
| 24 | - 'slug' => 'plugin-license-period', | |
| 25 | - 'description' => 'Plugin license period i.e. - annual, lifetime.', | |
| 26 | - ], | |
| 27 | - [ | |
| 28 | - 'title' => 'Cloth sizes', | |
| 29 | - 'slug' => 'cloth-size', | |
| 30 | - 'description' => 'Clothes sizes', | |
| 31 | - ], | |
| 32 | - [ | |
| 33 | - 'title' => 'Cloth color', | |
| 34 | - 'slug' => 'cloth-color', | |
| 35 | - 'description' => 'Clothes color', | |
| 36 | - ], | |
| 37 | - [ | |
| 38 | - 'title' => 'Cloth attributes', | |
| 39 | - 'slug' => 'cloth-attributes', | |
| 40 | - 'description' => 'Clothes logo, collar, long sleeve', | |
| 41 | - ], | |
| 79 | + 'size' => ['XS', 'S', 'M', 'L', 'XL', 'XXL'], | |
| 80 | + 'material' => ['Cotton', 'Polyester', 'Silk', 'Wool', 'Leather', 'Linen', 'Nylon', 'Wood', 'Metal', 'Plastic'], | |
| 81 | + 'storage' => ['64 GB', '128 GB', '256 GB', '512 GB', '1 TB', '2 TB'], | |
| 82 | + 'memory' => ['4 GB', '8 GB', '16 GB', '32 GB', '64 GB'], | |
| 83 | + 'weight' => ['100 g', '250 g', '500 g', '1 kg', '2 kg', '5 kg'], | |
| 84 | + 'style' => ['Classic', 'Modern', 'Vintage', 'Minimalist'], | |
| 85 | + 'pattern' => ['Solid', 'Striped', 'Checked', 'Floral', 'Geometric'], | |
| 86 | + 'age-group' => ['Newborn', 'Infant', 'Toddler', 'Kids', 'Teen', 'Adult', 'All Ages'], | |
| 87 | + 'target-gender' => ['Female', 'Male', 'Unisex'], | |
| 42 | 88 | ]; |
| 43 | 89 | |
| 44 | - $attr = [ | |
| 45 | - 'plugin-license' => [ | |
| 46 | - [ | |
| 47 | - 'serial' => 1, | |
| 48 | - 'group_id' => 1, | |
| 49 | - 'title' => 'Single License', | |
| 50 | - 'slug' => 'single-license', | |
| 51 | - ], | |
| 52 | - [ | |
| 53 | - 'serial' => 2, | |
| 54 | - 'group_id' => 1, | |
| 55 | - 'title' => '1 Site License', | |
| 56 | - 'slug' => '1-site-license', | |
| 57 | - ], | |
| 58 | - [ | |
| 59 | - 'serial' => 3, | |
| 60 | - 'group_id' => 1, | |
| 61 | - 'title' => '5 Sites License', | |
| 62 | - 'slug' => '5-site-license', | |
| 63 | - ], | |
| 64 | - [ | |
| 65 | - 'serial' => 4, | |
| 66 | - 'group_id' => 1, | |
| 67 | - 'title' => '50 Sites License', | |
| 68 | - 'slug' => '50-site-license', | |
| 69 | - ], | |
| 70 | - [ | |
| 71 | - 'serial' => 5, | |
| 72 | - 'group_id' => 1, | |
| 73 | - 'title' => 'Agency License', | |
| 74 | - 'slug' => 'agency-license', | |
| 75 | - ], | |
| 76 | - [ | |
| 77 | - 'serial' => 6, | |
| 78 | - 'group_id' => 1, | |
| 79 | - 'title' => 'Unlimited License', | |
| 80 | - 'slug' => 'unlimited-license', | |
| 81 | - ], | |
| 82 | - ], | |
| 83 | - 'plugin-license-period' => [ | |
| 84 | - [ | |
| 85 | - 'serial' => 1, | |
| 86 | - 'group_id' => 1, | |
| 87 | - 'title' => 'Monthly', | |
| 88 | - 'slug' => 'monthly', | |
| 89 | - ], | |
| 90 | - [ | |
| 91 | - 'serial' => 3, | |
| 92 | - 'group_id' => 1, | |
| 93 | - 'title' => 'Annual', | |
| 94 | - 'slug' => 'annual', | |
| 95 | - ], | |
| 96 | - [ | |
| 97 | - 'serial' => 4, | |
| 98 | - 'group_id' => 1, | |
| 99 | - 'title' => 'Lifetime', | |
| 100 | - 'slug' => 'lifetime', | |
| 101 | - ], | |
| 102 | - ], | |
| 103 | - 'cloth-size' => [ | |
| 104 | - [ | |
| 105 | - 'serial' => 1, | |
| 106 | - 'group_id' => 1, | |
| 107 | - 'title' => 'XS', | |
| 108 | - 'slug' => 'extra-small', | |
| 109 | - ], | |
| 110 | - [ | |
| 111 | - 'serial' => 2, | |
| 112 | - 'group_id' => 1, | |
| 113 | - 'title' => 'S', | |
| 114 | - 'slug' => 'small', | |
| 115 | - ], | |
| 116 | - [ | |
| 117 | - 'serial' => 3, | |
| 118 | - 'group_id' => 1, | |
| 119 | - 'title' => 'M', | |
| 120 | - 'slug' => 'medium', | |
| 121 | - ], | |
| 122 | - [ | |
| 123 | - 'serial' => 4, | |
| 124 | - 'group_id' => 1, | |
| 125 | - 'title' => 'L', | |
| 126 | - 'slug' => 'large', | |
| 127 | - ], | |
| 128 | - [ | |
| 129 | - 'serial' => 5, | |
| 130 | - 'group_id' => 1, | |
| 131 | - 'title' => 'XL', | |
| 132 | - 'slug' => 'extra-large', | |
| 133 | - ], | |
| 134 | - [ | |
| 135 | - 'serial' => 6, | |
| 136 | - 'group_id' => 1, | |
| 137 | - 'title' => 'XXL', | |
| 138 | - 'slug' => 'extra-extra-large', | |
| 139 | - ], | |
| 140 | - [ | |
| 141 | - 'serial' => 7, | |
| 142 | - 'group_id' => 1, | |
| 143 | - 'title' => '3XL', | |
| 144 | - 'slug' => 'extra-3-large', | |
| 145 | - ], | |
| 146 | - ], | |
| 147 | - 'cloth-color' => [ | |
| 148 | - [ | |
| 149 | - 'serial' => 1, | |
| 150 | - 'group_id' => 1, | |
| 151 | - 'title' => 'Red', | |
| 152 | - 'slug' => 'red', | |
| 153 | - ], | |
| 154 | - [ | |
| 155 | - 'serial' => 2, | |
| 156 | - 'group_id' => 1, | |
| 157 | - 'title' => 'Green', | |
| 158 | - 'slug' => 'green', | |
| 159 | - ], | |
| 160 | - [ | |
| 161 | - 'serial' => 3, | |
| 162 | - 'group_id' => 1, | |
| 163 | - 'title' => 'Yellow', | |
| 164 | - 'slug' => 'yellow', | |
| 165 | - ], | |
| 166 | - [ | |
| 167 | - 'serial' => 4, | |
| 168 | - 'group_id' => 1, | |
| 169 | - 'title' => 'Grey', | |
| 170 | - 'slug' => 'grey', | |
| 171 | - ], | |
| 172 | - [ | |
| 173 | - 'serial' => 5, | |
| 174 | - 'group_id' => 1, | |
| 175 | - 'title' => 'Black', | |
| 176 | - 'slug' => 'black', | |
| 177 | - ], | |
| 178 | - [ | |
| 179 | - 'serial' => 6, | |
| 180 | - 'group_id' => 1, | |
| 181 | - 'title' => 'White', | |
| 182 | - 'slug' => 'white', | |
| 183 | - ], | |
| 184 | - [ | |
| 185 | - 'serial' => 7, | |
| 186 | - 'group_id' => 1, | |
| 187 | - 'title' => 'Gold', | |
| 188 | - 'slug' => 'gold', | |
| 189 | - ], | |
| 190 | - ], | |
| 191 | - 'cloth-attributes' => [ | |
| 192 | - [ | |
| 193 | - 'serial' => 1, | |
| 194 | - 'group_id' => 1, | |
| 195 | - 'title' => 'Logo', | |
| 196 | - 'slug' => 'with-logo', | |
| 197 | - ], | |
| 198 | - [ | |
| 199 | - 'serial' => 2, | |
| 200 | - 'group_id' => 1, | |
| 201 | - 'title' => 'Without Logo', | |
| 202 | - 'slug' => 'without-logo', | |
| 203 | - ], | |
| 204 | - [ | |
| 205 | - 'serial' => 3, | |
| 206 | - 'group_id' => 1, | |
| 207 | - 'title' => 'Collar', | |
| 208 | - 'slug' => 'with-collar', | |
| 209 | - ], | |
| 210 | - [ | |
| 211 | - 'serial' => 4, | |
| 212 | - 'group_id' => 1, | |
| 213 | - 'title' => 'Long sleeve', | |
| 214 | - 'slug' => 'long-sleeve', | |
| 215 | - ], | |
| 216 | - [ | |
| 217 | - 'serial' => 5, | |
| 218 | - 'group_id' => 1, | |
| 219 | - 'title' => 'Sleeveless', | |
| 220 | - 'slug' => 'sleeveless', | |
| 221 | - ], | |
| 222 | - [ | |
| 223 | - 'serial' => 6, | |
| 224 | - 'group_id' => 1, | |
| 225 | - 'title' => 'Short sleeve', | |
| 226 | - 'slug' => 'short-sleeve', | |
| 227 | - ], | |
| 228 | - [ | |
| 229 | - 'serial' => 7, | |
| 230 | - 'group_id' => 1, | |
| 231 | - 'title' => 'High collar', | |
| 232 | - 'slug' => 'high-collar', | |
| 233 | - ], | |
| 234 | - ], | |
| 235 | - ]; | |
| 90 | + // Wrap the bulk-insert chain in a single transaction. Without this, a | |
| 91 | + // partial failure during the per-group term insert leaves the store | |
| 92 | + // with some groups seeded and others empty — and because the | |
| 93 | + // idempotency guard above is slug-based, the next activation would | |
| 94 | + // see the partially-seeded slugs as "already present" and skip | |
| 95 | + // them, leaving their terms permanently missing. | |
| 96 | + $db = AttributeGroup::query()->getConnection(); | |
| 97 | + $db->beginTransaction(); | |
| 98 | + $progress = null; | |
| 236 | 99 | |
| 100 | + try { | |
| 101 | + // Authoritative re-check INSIDE the transaction. Closes the | |
| 102 | + // race window between the pre-flight check above and | |
| 103 | + // beginTransaction(): if a parallel process inserted any of | |
| 104 | + // our missing system slugs while we were preparing, recompute | |
| 105 | + // the set so the slug UNIQUE constraint can't trip us. | |
| 106 | + $existingSystemSlugs = AttributeGroup::query() | |
| 107 | + ->whereIn('slug', $systemSlugs) | |
| 108 | + ->pluck('slug') | |
| 109 | + ->toArray(); | |
| 110 | + $missingSlugs = array_values(array_diff($systemSlugs, $existingSystemSlugs)); | |
| 237 | 111 | |
| 238 | - AttributeGroup::insert($groups); | |
| 112 | + if (empty($missingSlugs)) { | |
| 113 | + $db->rollBack(); | |
| 114 | + return; | |
| 115 | + } | |
| 239 | 116 | |
| 240 | - $grs = AttributeGroup::get(); | |
| 117 | + // Filter the row set to only the missing slugs so we never | |
| 118 | + // try to re-insert a system group the merchant (or a parallel | |
| 119 | + // activation) already has. | |
| 120 | + $missingSlugSet = array_flip($missingSlugs); | |
| 121 | + $rowsToInsert = array_values(array_filter( | |
| 122 | + $groups, | |
| 123 | + static function ($row) use ($missingSlugSet) { | |
| 124 | + return isset($missingSlugSet[$row['slug']]); | |
| 125 | + } | |
| 126 | + )); | |
| 241 | 127 | |
| 242 | - if (defined('WP_CLI') && WP_CLI) { | |
| 243 | - $progress = \WP_CLI\Utils\make_progress_bar('%CSeeding Attributed', count($grs)); | |
| 244 | - } | |
| 128 | + // Give each seeded template a real serial so it joins the manual | |
| 129 | + // drag-order with a position instead of the column default (0). | |
| 130 | + // Continue after the current max so templates seeded later (e.g. on | |
| 131 | + // an upgrade where the merchant already has groups) append to the end | |
| 132 | + // rather than colliding at 0. On a fresh install max is 0, so the | |
| 133 | + // templates take 1..N in the curated order above (Color, Size, …). | |
| 134 | + $nextSerial = (int) AttributeGroup::query()->max('serial'); | |
| 135 | + foreach ($rowsToInsert as &$rowToInsert) { | |
| 136 | + $rowToInsert['serial'] = ++$nextSerial; | |
| 137 | + } | |
| 138 | + unset($rowToInsert); | |
| 245 | 139 | |
| 246 | - foreach ($grs as $gr) { | |
| 247 | - if (isset($attr[$gr->slug])) { | |
| 140 | + if (!AttributeGroup::insert($rowsToInsert)) { | |
| 141 | + throw new \RuntimeException('AttributeGroup::insert returned false during seeding.'); | |
| 142 | + } | |
| 248 | 143 | |
| 249 | - $tmp = []; | |
| 144 | + // Fetch back ONLY the rows we just inserted so the term-seed | |
| 145 | + // loop below doesn't accidentally re-seed terms for system | |
| 146 | + // groups the merchant already had (or modified). | |
| 147 | + $savedGroups = AttributeGroup::query()->whereIn('slug', $missingSlugs)->get(); | |
| 250 | 148 | |
| 251 | - foreach ($attr[$gr->slug] as $item) { | |
| 149 | + if (defined('WP_CLI') && WP_CLI) { | |
| 150 | + $progress = \WP_CLI\Utils\make_progress_bar( | |
| 151 | + __('Seeding attributes', 'fluent-cart'), | |
| 152 | + count($savedGroups) | |
| 153 | + ); | |
| 154 | + } | |
| 252 | 155 | |
| 253 | - $item['group_id'] = $gr->id; | |
| 254 | - $tmp[] = $item; | |
| 156 | + foreach ($savedGroups as $group) { | |
| 157 | + if (!isset($terms[$group->slug])) { | |
| 158 | + continue; | |
| 255 | 159 | } |
| 256 | 160 | |
| 257 | - AttributeTerm::insert($tmp); | |
| 258 | - if (defined('WP_CLI') && WP_CLI) { | |
| 161 | + $rows = []; | |
| 162 | + $serial = 1; | |
| 163 | + | |
| 164 | + foreach ($terms[$group->slug] as $term) { | |
| 165 | + // Terms can be plain strings or ['title' => ..., 'color' => ...] arrays. | |
| 166 | + // Color hex is stored as JSON in term.settings.color so the swatch | |
| 167 | + // renderer in AdvancedVariationConfig.vue can read it. | |
| 168 | + if (is_array($term)) { | |
| 169 | + $title = $term['title'] ?? null; | |
| 170 | + if ($title === null) { | |
| 171 | + // Defensive: a future maintainer who adds a term entry | |
| 172 | + // without a 'title' key shouldn't crash the activation. | |
| 173 | + continue; | |
| 174 | + } | |
| 175 | + $settings = isset($term['color']) | |
| 176 | + ? json_encode(['color' => $term['color']], self::JSON_FLAGS) | |
| 177 | + : null; | |
| 178 | + } else { | |
| 179 | + $title = $term; | |
| 180 | + $settings = null; | |
| 181 | + } | |
| 182 | + | |
| 183 | + $rows[] = [ | |
| 184 | + 'serial' => $serial++, | |
| 185 | + 'group_id' => $group->id, | |
| 186 | + 'title' => $title, | |
| 187 | + 'slug' => sanitize_title($title), | |
| 188 | + 'settings' => $settings, | |
| 189 | + 'created_at' => $now, | |
| 190 | + 'updated_at' => $now, | |
| 191 | + ]; | |
| 192 | + } | |
| 193 | + | |
| 194 | + if (empty($rows)) { | |
| 195 | + continue; | |
| 196 | + } | |
| 197 | + | |
| 198 | + if (!AttributeTerm::insert($rows)) { | |
| 199 | + throw new \RuntimeException( | |
| 200 | + sprintf('AttributeTerm::insert returned false for group "%s".', $group->slug) | |
| 201 | + ); | |
| 202 | + } | |
| 203 | + | |
| 204 | + if ($progress) { | |
| 259 | 205 | $progress->tick(); |
| 260 | 206 | } |
| 261 | 207 | } |
| 208 | + | |
| 209 | + $db->commit(); | |
| 210 | + } catch (\Throwable $e) { | |
| 211 | + $db->rollBack(); | |
| 212 | + if ($progress) { | |
| 213 | + // Make sure the CLI progress bar doesn't leave a stuck cursor | |
| 214 | + // when the seeder bails out partway through. | |
| 215 | + $progress->finish(); | |
| 216 | + } | |
| 217 | + // Rethrow so the caller (the fluent_cart/after_migrate hook fired | |
| 218 | + // from DBMigrator::migrate() during plugin activation/migrate) can | |
| 219 | + // surface a visible activation failure instead of silently | |
| 220 | + // committing a half-seeded state. | |
| 221 | + throw $e; | |
| 262 | 222 | } |
| 263 | - if (defined('WP_CLI') && WP_CLI) { | |
| 264 | - $progress->tick(); | |
| 223 | + | |
| 224 | + if ($progress) { | |
| 265 | 225 | $progress->finish(); |
| 266 | 226 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 267 | 227 | echo \WP_CLI::colorize('%n'); |
| 268 | 228 | } |
| 229 | + } | |
| 230 | + | |
| 231 | + /** | |
| 232 | + * Builds a group row with the shared column set. Centralised so all 10 | |
| 233 | + * group rows declare the same keys (WPFluent's bulk insert() builds the | |
| 234 | + * column list from the first row and silently drops keys missing from | |
| 235 | + * later rows). | |
| 236 | + */ | |
| 237 | + private static function groupRow(string $title, string $slug, $settings, string $now): array | |
| 238 | + { | |
| 239 | + return [ | |
| 240 | + 'title' => $title, | |
| 241 | + 'slug' => $slug, | |
| 242 | + 'settings' => $settings, | |
| 243 | + // Placeholder — overwritten with a real position just before insert | |
| 244 | + // (continuing after the current max serial). Declared here so every | |
| 245 | + // row carries the same key set for WPFluent's bulk insert(). | |
| 246 | + 'serial' => 0, | |
| 247 | + 'is_system' => 1, | |
| 248 | + 'created_at' => $now, | |
| 249 | + 'updated_at' => $now, | |
| 250 | + ]; | |
| 269 | 251 | } |
| 270 | 252 | } |