# Adding New Columns to the Product Importer

This document records every file that must be changed when adding a new importable column to the product importer. All of these were discovered the hard way when adding `track_inventory` — missing even one causes a silent failure where the import reports success but nothing changes in the database.

---

## The Problem We Hit

When `track_inventory` was added, five separate places had to be updated. Missing any one of them caused a different kind of failure:

| Missed file | Symptom |
|---|---|
| `ProcessProductImportJob` `$map` | Column silently ignored — not indexed, never read from the CSV row |
| `ProcessProductImportJob` attribute block | Column indexed but never written into `$attrs`, so `update()` never sees it |
| `Product` `$fillable` | `update(['is_storable' => true])` silently dropped by Laravel mass-assignment protection |
| `Product` `$casts` | Value stored as raw string `"1"` instead of boolean — may cause UI display issues |
| Blade `$knownColumns` | Column shows grey "ignored" in the Import Preview, confusing the user |
| Blade instructions table | Column missing from the "Supported Columns" reference table shown to users |

The `ImportManager::buildAttributes()` and `ImportManager::COLUMN_MAP` also exist but are used by a **separate synchronous code path** — they are NOT called by the queue job. Both must be kept in sync.

---

## All Six Files to Update

### 1. `plugins/webkul/imports/src/Jobs/ProcessProductImportJob.php`

This is the queue job that actually processes the import. It has its **own** column map and its **own** attribute-building block, completely separate from `ImportManager`.

#### Step A — Add to the `$map` array (around line 70)

This registers the CSV header so it gets an index position. Without this the column is never found in the row array.

```php
$map = [
    'sku'             => 'sku',
    'name'            => 'name',
    // ... existing columns ...
    'track_inventory' => 'track_inventory',   // <-- added here
    'uom'             => 'uom',
    // ...
];
```

#### Step B — Add attribute-building logic in the row processing loop (around line 157)

This reads the indexed value and converts it into the actual DB attribute. Add it near the other boolean fields:

```php
// Boolean fields (1/0, true/false, yes/no, on/off)
foreach (['enable_sales', 'enable_purchase', 'is_favorite'] as $f) {
    if (isset($colIndex[$f]) && ($v = $get($f)) !== null) {
        $attrs[$f] = in_array(strtolower($v), ['1', 'true', 'yes', 'on'], true);
    }
}

// track_inventory maps to the DB column is_storable
if (isset($colIndex['track_inventory'])) {
    $v = trim((string) ($row[$colIndex['track_inventory']] ?? ''));
    if ($v !== '') {
        $attrs['is_storable'] = in_array(strtolower($v), ['1', 'true', 'yes', 'on'], true);
    }
}
```

> **Note:** The CSV column name (`track_inventory`) and the DB column name (`is_storable`) are different here. Most columns map 1:1 but this one does not. Always check the actual DB column name in the migration.

---

### 2. `plugins/webkul/imports/src/ImportManager.php`

This class is used by the **synchronous** import path (direct call, not queue). It must mirror everything in the job.

#### Step A — Add to `COLUMN_MAP` constant (around line 26)

```php
protected const COLUMN_MAP = [
    // ... existing ...
    'track_inventory' => 'track_inventory',
];
```

#### Step B — Add to `buildAttributes()` method (around line 356)

```php
if (isset($colIndex['track_inventory'])) {
    $val = $get('track_inventory');
    if ($val !== null) {
        $attrs['is_storable'] = $this->parseBool($val);
    }
}
```

The `parseBool()` helper already exists and accepts `1`, `true`, `yes`, `on` (case-insensitive).

---

### 3. `plugins/webkul/products/src/Models/Product.php`

Laravel's mass-assignment protection silently drops any field not in `$fillable`. The `update()` call in the job returns `true` with no error — it just does nothing. This is the hardest bug to diagnose.

#### Step A — Add to `$fillable`

```php
protected $fillable = [
    // ...
    'enable_sales',
    'enable_purchase',
    'is_storable',      // <-- add here
    'is_favorite',
    // ...
];
```

#### Step B — Add to `$casts` (if the field is a boolean, date, or enum)

```php
protected $casts = [
    'enable_sales'    => 'boolean',
    'enable_purchase' => 'boolean',
    'is_storable'     => 'boolean',   // <-- add here
    'is_favorite'     => 'boolean',
    // ...
];
```

> **After editing the model**, restart the queue worker. It loads the model class into memory at startup — if you edit `$fillable` while the worker is running, the running worker still uses the old class definition. The import will appear to succeed but nothing will be written.
>
> ```bash
> php artisan queue:restart   # signals worker to stop after its current job
> php artisan queue:work      # start a fresh worker with the new code
> ```

---

### 4. `plugins/webkul/imports/resources/views/filament/pages/product-import.blade.php`

#### Step A — Add to `$knownColumns` in the `@php` block (around line 87)

This controls the green tick / grey "ignored" display in the **Import Preview** table. If missing, the column shows as grey even though the import would actually process it — confusing but not breaking.

```php
$knownColumns = array_merge(
    [
        'sku', 'name', 'price', 'cost', 'tax_rate', 'barcode', 'reference',
        'type', 'description', 'description_sale', 'description_purchase',
        'enable_sales', 'enable_purchase',
        'track_inventory',    // <-- add here
        'is_favorite', 'uom', 'weight', 'volume', 'company',
    ],
    $channelPriceCols
);
```

#### Step B — Add to the `requiredColumns` JS logic (around line 108)

If the column is required (only `sku` is currently required for update; `name` is required for create), update the `getRequiredColumns()` helper. For optional columns, no change needed here.

```js
getRequiredColumns() {
    const type = this.$wire.data?.import_type ?? 'create_update';
    return type === 'update' ? ['sku'] : ['sku', 'name'];
},
```

#### Step C — Add to the Supported Columns instructions table (around line 331)

This is the reference table shown below the upload form. Badge options are `required`, `create` (for create-only fields like `name`), or `optional`.

```php
@foreach([
    'sku'             => ['required', 'Unique product identifier — used to match on update'],
    'name'            => ['create',   'Product display name'],
    // ... existing ...
    'track_inventory' => ['optional', '1 or 0 — enable inventory tracking for this product'],
    // ...
] as $col => [$req, $hint])
```

---

## Quick Checklist

Copy this when adding a new column:

```
[ ] ProcessProductImportJob — add to $map array
[ ] ProcessProductImportJob — add attribute-building logic in row loop
[ ] ImportManager::COLUMN_MAP — add column mapping
[ ] ImportManager::buildAttributes() — add attribute logic
[ ] Product::$fillable — add DB column name
[ ] Product::$casts — add type cast if boolean/date/enum
[ ] product-import.blade.php $knownColumns — add for green preview tick
[ ] product-import.blade.php instructions table — add to supported columns list
[ ] Restart queue worker after any model/job changes
```

---

## Naming Gotcha: CSV column vs DB column

Some CSV column names do not match the database column name. Always check both:

| CSV header | DB column | Notes |
|---|---|---|
| `track_inventory` | `is_storable` | Historical naming difference |
| `uom` | `uom_id` (FK) | Resolved by name lookup in `buildAttributes` |
| `company` | `company_id` (FK) | Resolved by name lookup in `buildAttributes` |
| All others | Same name | Direct mapping |

When the names differ, the `$map` in the job uses the CSV name as the key, but the `$attrs` array must use the DB column name as the key before calling `$existing->update($attrs)`.

---

## Why the Queue Worker Must Be Restarted

`php artisan queue:work` loads all PHP classes into memory once at startup. If you then edit a model's `$fillable`, the running worker process still holds the old class in memory. The import will run, `update()` will be called, Laravel will silently discard the unguarded field, and the job will report success.

`php artisan queue:restart` only sends a signal via the cache — the worker checks for this signal after finishing each job. You still need to run `queue:work` again to start a fresh process with the updated code.
