Skip to content

Bulk Product Import

Flow ID: SY-17 | Module(s): job, eshop | Complexity: High Last Updated: 2026-06-04

Business Overview

The bulk product import system allows merchants to create and update products in batch via Excel spreadsheets (XLSX/XLS). Three distinct jobs handle different scenarios: inserting new products, updating existing products by barcode match, and updating existing products by SKU (product code) match. All three are triggered from the admin panel and run asynchronously as background jobs, with task notifications on completion.

Three import modes:

  1. Insert new products -- creates products that do not yet exist (matched by barcode), set as inactive by default
  2. Update by barcode -- updates existing products matched by their barcode value
  3. Update by product code (SKU) -- updates existing products matched by their SKU in the product_codes table

What the user experiences:

  • Upload an XLSX/XLS file via the admin product import interface
  • File is persisted via storage('private') under IMPORT_FILES_PATH (local-disk root or S3, depending on PRIVATE_STORAGE_DISK)
  • Job runs asynchronously in the background
  • On completion, an admin task notification appears with results (e.g., "25 products imported")

Architecture

Admin Upload -> storage('private')->putStream(IMPORT_FILES_PATH . fileName)
  |
  +--> AdvInsertProductsFromFile          (new products)
  |      OR
  +--> AdvUpdateProductsFromFileByBarcode  (update by barcode)
  |      OR
  +--> AdvUpdateProductsFromFileByProductCode (update by SKU)
       |
       +--> LocalTempFile::withFile(...)  Materialise remote object as local temp
       +--> loadFile($localPath)          PhpSpreadsheet XLSX/XLS reader
       +--> processSheetData($data)       Parse rows, match/create products (in DB transaction)
       +--> tasks_model::addTask()        Notify admin of results
       +--> storage('private')->delete()  Remove source from storage (only on success)

Job Classes

ClassFilePurpose
AdvInsertProductsFromFileecommercen/job/libraries/AdvInsertProductsFromFile.phpInsert new products
AdvUpdateProductsFromFileByBarcodeecommercen/job/libraries/AdvUpdateProductsFromFileByBarcode.phpUpdate by barcode
AdvUpdateProductsFromFileByProductCodeecommercen/job/libraries/AdvUpdateProductsFromFileByProductCode.phpUpdate by SKU
InsertProductsFromFileapplication/modules/job/libraries/InsertProductsFromFile.phpClient override (insert)
UpdateProductsFromFileByBarcodeapplication/modules/job/libraries/UpdateProductsFromFileByBarcode.phpClient override (barcode)
UpdateProductsFromFileByProductCodeapplication/modules/job/libraries/UpdateProductsFromFileByProductCode.phpClient override (SKU)

Dependencies

ClassFileRole
Product_modelapplication/modules/eshop/models/Product_model.phpProduct lookup and slug creation
Vendors_modelapplication/modules/eshop/models/Vendors_model.phpVendor lookup/creation
Product_category_modelapplication/modules/eshop/models/Product_category_model.phpCategory lookup/creation
Vats_modelapplication/modules/eshop/models/Vats_model.phpVAT rate lookup/creation
Product_media_modelapplication/modules/eshop/models/Product_media_model.phpImage association
Tasks_modelapplication/modules/auth/models/Tasks_model.phpAdmin notification

Code Flow

Common: File Loading

All three jobs share the same file loading strategy:

  1. Build remote path: IMPORT_FILES_PATH . $fileName (storage-relative — used via storage('private'))
  2. Materialise the remote object as a local temp file via LocalTempFile::withFile(storage('private'), $remotePath, fn ($localPath) => …) — PhpSpreadsheet requires a real filesystem path so the bytes are downloaded into sys_get_temp_dir() for the read, then unlinked in finally regardless of outcome
  3. Try XLSX reader (PhpSpreadsheet\Reader\Xlsx) on the local path
  4. On failure, try XLS reader (PhpSpreadsheet\Reader\Xls) on the local path
  5. On failure, set error message title and return empty array
  6. Sheet data is converted to an associative array keyed by column letters (A-Q)
  7. After the callback returns, the source file on storage('private') is deleted only if processing succeeded — failed extractions leave the source for operator inspection

Spreadsheet Column Mapping

ColumnFieldRequired (Insert)Description
ASKUYesProduct code / SKU
BProduct NameYesProduct name
CVendorNoVendor name (matched or "No Vendor" fallback)
DBarcodeNoProduct barcode (match key for barcode updates)
EVAT %YesVAT percentage (matched or created)
FPriceNoRetail price including VAT
GDiscount PriceNoFinal/discounted price (used to calculate discount %)
HStockNoStock quantity
IWeightNoProduct weight
JDescriptionNoFull product description
KShort DescriptionNoShort/summary description
LUsageNoUsage instructions
MIngredientsNoProduct ingredients
NImage 1NoPrimary image filename/URI
OImage 2NoSecondary image filename/URI
PImage 3NoTertiary image filename/URI
QImage 4NoQuaternary image filename/URI

SY-17a: Insert New Products (AdvInsertProductsFromFile)

  1. Skip header row (row 1 is column headers)
  2. Extract barcodes from column D of all rows
  3. Find existing products via product_model->getProductIdsByBarcode()
  4. Filter new products -- only rows whose barcode is NOT in the existing set
  5. Validate required fields: columns A (SKU), B (Name), E (VAT%) must be non-empty
  6. For each new product: a. Get or create VAT rate b. Get or create vendor (falls back to "No Vendor") c. Calculate net price: price / (1 + vat/100) d. Calculate discount percentage if discount price provided e. Insert into shop_product with active=0 f. Assign to "Uncategorized" category (created if missing) via Adv_product_category_model::linkProductCategory() (ecommercen/job/libraries/AdvInsertProductsFromFile.php:175-179); the call is idempotent — re-running the import or overlapping rows cannot create duplicate junction rows because the table carries a UNIQUE INDEX on (product_id, category_id) (see AD-05 Category Management) g. Insert shop_product_mui for all site languages (name + slug) h. Insert shop_product_barcodes if barcode provided i. Insert product_codes with SKU and stock j. Insert product images (up to 4, first is main)
  7. Notify admin via tasks_model->addTask() with count of inserted products
  8. Delete file after processing

SY-17b: Update by Barcode (AdvUpdateProductsFromFileByBarcode)

  1. Skip header row
  2. Extract barcodes from column D
  3. Find existing products via product_model->getProductIdsByBarcode()
  4. If no matches found, set error notification and exit
  5. For each matching row: a. Update shop_product: VAT, vendor, weight, price, discount (only non-empty fields) b. Update shop_product_mui for all languages: name, description, short_description, usage, ingredients c. Update product_codes stock (if provided)
  6. Notify admin with updated/total counts

SY-17c: Update by Product Code (AdvUpdateProductsFromFileByProductCode)

  1. Skip header row
  2. Extract SKUs from column A
  3. Find existing products via product_model->getProductIdsByProductCode()
  4. If no matches found, set error notification and exit
  5. For each matching row: a. Update shop_product: VAT, vendor, weight, price, discount b. Update shop_product_mui for all languages c. Update shop_product_barcodes (if barcode provided) d. Update product_codes stock (if provided)
  6. Notify admin with updated/total counts

Data Model

Primary Tables

TableRole
shop_productMain product record (price, weight, VAT, vendor, active status)
shop_product_muiProduct name, slug, descriptions per language
product_codesSKU and stock per product variant
shop_product_barcodesBarcode associations
product_mediaProduct images
shop_product_category_lpProduct-to-category assignments; duplicate-safe (UNIQUE INDEX on (product_id, category_id) — see AD-05 Category Management)

Auto-Created Reference Data

The import jobs automatically create missing reference data:

EntityConditionDefault Values
VAT rateNo matching value in shop_vatCreated with specified percentage
VendorNo matching vendor nameFalls back to "No Vendor" (created if missing)
Category"Uncategorized" not foundCreated as unpublished root category

Configuration

Job Scheduling

These jobs are NOT cron-scheduled. They are dispatched on-demand from the admin panel when a file is uploaded:

php
// Triggered via admin interface, not via jobs.php
// CLI: php cli.php job/run/InsertProductsFromFile --customerId=1 --fileName=import_20240101.xlsx

Job Options

All three jobs share the same options:

OptionTypeRequiredDescription
customerIdintYesAdmin user ID for task notification
fileNamestringYesUploaded file name within the private storage type under IMPORT_FILES_PATH

Constants

ConstantValueDefined In
IMPORT_FILES_PATH'import/' (storage-relative — used via storage('private'))application/config/constants.php

Import Defaults (Insert Job)

SettingValuePurpose
active0New products are created as inactive
pointFactor10Default loyalty points factor
uncategorizedCategory'Uncategorized'Default category for new products

Client Extension Points

  1. Override the job classes: Create subclasses in application/modules/job/libraries/ to customize import logic (e.g., add custom columns, change matching strategy, add validation).

  2. Override spreadsheet parsing: Override getProductData() in a subclass to add or remap columns (e.g., add custom attributes in columns R+).

  3. Override product creation: Override insertProduct() or updateProduct() to add client-specific fields or post-processing (e.g., auto-categorization, ERP sync).

  4. Override file loading: Override loadFile() to support additional formats (e.g., CSV).

  5. Override notification: Override the task notification section in executeCommand() to customize the completion message or add email notification.


Business Rules

RuleDescription
Inactive by defaultNew products from insert are created with active=0 -- admin must review and activate
TransactionalAll database operations run within a transaction; failure rolls back all changes
Barcode deduplicationInsert job skips rows whose barcode already exists in the database
Required fields (insert)SKU (A), Name (B), and VAT (E) are required for insertion
Partial updatesUpdate jobs only modify non-empty fields; blank cells are skipped
Net price calculationPrices in the file are VAT-inclusive; the system stores net prices: price / (1 + vat%)
Discount calculationWhen a discount price is provided: discount% = ((price - finalPrice) / price) * 100
Multi-language MUIProduct names and descriptions are inserted/updated for ALL configured site languages
File cleanupThe uploaded file is deleted after processing regardless of outcome
Admin notificationA task is always created for the admin user, even on error