Skip to content

[5.x] Inventory Import/Export #3930

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 29 commits into
base: 5.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
af734c0
WIP import
lukeholder Sep 18, 2024
8253286
Merge branch '5.2' into feature/import-export-inventory
lukeholder Oct 10, 2024
2b90225
Merge branch '5.3' into feature/import-export-inventory
lukeholder Nov 18, 2024
cff7188
WIP
lukeholder Nov 20, 2024
d343843
Merge branch '5.3' into feature/import-export-inventory
lukeholder Dec 3, 2024
5ff9d14
Merge branch '5.3' into feature/import-export-inventory
lukeholder Dec 11, 2024
b610e23
Start 5.4 release notes
lukeholder Jan 31, 2025
0becd5f
Merge branch '5.x' into feature/import-export-inventory
lukeholder Jan 31, 2025
dea8dd0
Merge branch '5.x' into feature/import-export-inventory
lukeholder Feb 3, 2025
4d262e6
Merge branch '5.x' into 5.4
lukeholder Feb 6, 2025
84df8fe
Merge branch '5.x' into 5.4
nfourtythree Feb 10, 2025
8d4188b
Update workflow
nfourtythree Feb 10, 2025
168bc61
Merge branch '5.x' into 5.4
nfourtythree Mar 3, 2025
a5a83e3
Merge branch '5.x' into feature/import-export-inventory
lukeholder Mar 10, 2025
9c33226
WIP
lukeholder Mar 12, 2025
3fdd00a
Merge branch '5.x' into feature/import-export-inventory
lukeholder Mar 12, 2025
2516572
Cleanup
lukeholder Mar 12, 2025
181964b
Import export working
lukeholder Mar 12, 2025
312ada7
WIP
lukeholder Mar 12, 2025
9ce09b2
Cleanup
lukeholder Mar 12, 2025
01b43d0
Fix translations
lukeholder Mar 13, 2025
6a076f2
Merge branch '5.x' into 5.4
lukeholder Mar 13, 2025
7ee203f
Merge branch '5.x' into 5.4
lukeholder Mar 14, 2025
d5e6df5
Merge branch '5.4' into feature/import-export-inventory
lukeholder Mar 19, 2025
bdf86ed
WIP
lukeholder Mar 19, 2025
90543aa
Merge branch '5.x' into feature/import-export-inventory
lukeholder Mar 26, 2025
45fa667
WIP upload file
lukeholder Mar 26, 2025
33f39b5
WIP
lukeholder Mar 26, 2025
4f13c12
WIP
lukeholder Mar 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- '5.x'
- '5.4'
pull_request:
permissions:
contents: read
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Release Notes for Craft Commerce 5.4 (WIP)
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"dompdf/dompdf": "^2.0.2",
"ibericode/vat": "^1.2.2",
"iio/libmergepdf": "^4.0",
"league/csv": "^9.22",
"moneyphp/money": "^4.2.0"
},
"require-dev": {
Expand Down
93 changes: 92 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 31 additions & 1 deletion src/controllers/InventoryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use craft\helpers\ArrayHelper;
use craft\helpers\Cp;
use craft\helpers\Html;
use craft\helpers\UrlHelper;
use craft\web\assets\htmx\HtmxAsset;
use craft\web\Controller;
use craft\web\CpScreenResponseBehavior;
Expand Down Expand Up @@ -197,12 +198,41 @@ public function actionEditLocationLevels(?string $inventoryLocationHandle = null
];
}

$importBtnId = sprintf("import-%s", mt_rand());
$items['import'] = [
'type' => MenuItemType::Button,
'id' => $importBtnId,
'icon' => 'arrow-down',
'label' => Craft::t('commerce', 'Import Inventory'),
];

// make import button open a slideout
$view->registerJsWithVars(fn($id, $settings) => <<<JS
$('#' + $id).on('click', (e) => {
e.preventDefault();
const slideout = new Craft.CpScreenSlideout('commerce/inventory-importexport', $settings);
});
JS, [
$view->namespaceInputId($importBtnId),
[],
]);

$exportBtnId = sprintf("export-%s", mt_rand());
$items['export'] = [
'type' => MenuItemType::Link,
'id' => $exportBtnId,
'url' => UrlHelper::actionUrl('commerce/inventory-importexport/export', ['inventoryLocationId' => $currentLocation->id]),
'icon' => 'arrow-up',
'label' => Craft::t('commerce', 'Export Inventory'),
];

return $this->asCpScreen()
->title($title)
->site(Cp::requestedSite())
->selectableSites(Craft::$app->getSites()->getEditableSites())
->action(null)
->crumbs($crumbs)
->actionMenuItems(fn() => $items)
->contentTemplate('commerce/inventory/levels/_index', compact(
'inventoryLocations',
'currentLocation',
Expand Down Expand Up @@ -532,7 +562,7 @@ public function actionUpdateLevels(): Response
}

if (count($errors) > 0) {
return $this->asFailure(Craft::t('commerce', 'Inventory was not updated.',),
return $this->asFailure(Craft::t('commerce', 'Inventory was not updated.'),
['errors' => $errors]
);
}
Expand Down
203 changes: 203 additions & 0 deletions src/controllers/InventoryImportexportController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<?php

namespace craft\commerce\controllers;

use Craft;
use craft\commerce\db\Table;
use craft\commerce\Plugin;
use craft\commerce\queue\jobs\ImportInventory;
use craft\commerce\web\assets\commercecp\CommerceCpAsset;
use craft\web\Controller;
use craft\web\CpScreenResponseBehavior;
use craft\web\UploadedFile;
use League\Csv\Writer;
use yii\base\InvalidConfigException;
use yii\web\Response;

/**
* Inventory Importexport controller
*/
class InventoryImportexportController extends Controller
{
public $defaultAction = 'index';
protected array|int|bool $allowAnonymous = self::ALLOW_ANONYMOUS_NEVER;

/**
* commerce/inventory-importexport action
*/
public function actionIndex(): Response
{
$params = [];

return $this->_importScreen()
->prepareScreen(function(Response $response, string $containerId) {
/** @var CpScreenResponseBehavior $response */
$view = Craft::$app->getView();
$view->registerJsWithVars(
fn($containerId) => <<<JS
$(function() {
var \$container = $('#' + $containerId);
if (\$container.length) {
new Craft.Commerce.InventoryImportFileUploader('#' + $containerId);
}
});
JS,
[$containerId]
);
})
->contentTemplate('commerce/inventory/importexport/_importScreen', $params);
}

public function actionImportInventory()
{
$this->requirePostRequest();
$this->requirePermission('commerce-manageInventoryStockLevels');

$tempFilename = Craft::$app->getRequest()->getBodyParam('importFilename');
if (!$tempFilename) {
return $this->asFailure(Craft::t('commerce', 'No file specified.'));
}

$tempDirectory = Craft::$app->getPath()->getTempPath() . '/commerce-inventory-import';
$tempFilePath = $tempDirectory . '/' . $tempFilename;

if (!file_exists($tempFilePath)) {
return $this->asFailure(Craft::t('commerce', 'File not found.'));
}

// Create and queue the import job
$jobId = Craft::$app->getQueue()->push(new ImportInventory([
'description' => Craft::t('commerce', 'Import inventory from CSV'),
'filePath' => $tempFilePath,
]));

$message = Craft::t('commerce', 'Inventory import has been queued.');
return $this->asSuccess($message);
}

private function _importScreen()
{
// Register Commerce CP Assets
$this->view->registerAssetBundle(CommerceCpAsset::class);

return $this->asCpScreen()
->action('commerce/inventory-importexport/import-inventory')
->addCrumb(Craft::t('commerce', 'Inventory'), 'commerce/inventory')
->selectedSubnavItem('inventory')
->redirectUrl('commerce/inventory')
->title(Craft::t('commerce', 'Import Inventory'))
->formAttributes(['enctype' => 'multipart/form-data', 'accept-charset' => 'UTF-8'])
->metaSidebarTemplate('commerce/inventory/importexport/_importMeta')
->submitButtonLabel(Craft::t('commerce', 'Import'));
}

/**
* @return Response
* @throws InvalidConfigException
* @throws \yii\web\ForbiddenHttpException
* @throws \yii\web\HttpException
* @throws \yii\web\RangeNotSatisfiableHttpException
*/
public function actionExport(): Response
{
$this->requirePermission('commerce-manageInventoryStockLevels');

$inventoryLocationId = (int)Craft::$app->getRequest()->getParam('inventoryLocationId');
$inventoryLocation = Plugin::getInstance()->getInventoryLocations()->getInventoryLocationById($inventoryLocationId);

$inventoryQuery = Plugin::getInstance()->getInventory()->getInventoryLevelQuery()
->andWhere(['inventoryLocationId' => $inventoryLocation->id]);

$inventoryQuery->leftJoin(['purchasables' => Table::PURCHASABLES], '[[purchasables.id]] = [[ii.purchasableId]]');
$inventoryQuery->addSelect(['sku' => 'purchasables.sku']);
$inventoryQuery->addSelect(['description' => 'purchasables.description']);

$response = Craft::$app->getResponse();
$response->headers->set('Content-Type', 'text/csv');
$response->headers->set('Content-Disposition', 'attachment; filename="inventory.csv"');
$response->format = \yii\web\Response::FORMAT_RAW;

// Open output stream
$stream = fopen('php://output', 'w');

// Create a CSV writer instance
$csv = Writer::createFromStream($stream);

$csv->insertOne(['location', 'item', 'description', 'action', 'amount', 'notes']);

foreach ($inventoryQuery->each() as $row) {
$data = [
$row['inventoryLocationId'],
$row['sku'],
$row['description'],
'set',
$row['onHandTotal'],
'',
];
$csv->insertOne($data);
}

// Close the stream (optional, but good practice)
fclose($stream);

// End the response to prevent further output
Craft::$app->end();
}

public function actionExampleTemplate()
{
// return csv example template
$this->requirePermission('commerce-manageInventoryStockLevels');

$csvFile = Writer::createFromString('location,item,action,amount,notes');

return Craft::$app->getResponse()->sendContentAsFile(
$csvFile->toString(),
Craft::t('commerce', 'inventory-import-template') . '.csv',
['mimeType' => 'text/csv']
);
}

/**
* Upload a file to a temporary directory
*
* @return Response
*/
public function actionUploadTempFile(): Response
{
$this->requirePostRequest();
$this->requirePermission('commerce-manageInventoryStockLevels');
$this->requireAcceptsJson();

$file = UploadedFile::getInstanceByName('file');

if (!$file) {
return $this->asFailure(Craft::t('commerce', 'No file uploaded'));
}

// Check file type
if ($file->extension !== 'csv') {
return $this->asFailure(Craft::t('commerce', 'Only CSV files are allowed'));
}

// Create temp directory if it doesn't exist
$tempDirectory = Craft::$app->getPath()->getTempPath() . '/commerce-inventory-import';
if (!is_dir($tempDirectory)) {
mkdir($tempDirectory, 0777, true);
}

// Generate unique filename
$filename = 'inventory-import-' . uniqid() . '.csv';
$tempPath = $tempDirectory . '/' . $filename;

// Save the file
if (!$file->saveAs($tempPath)) {
return $this->asFailure(Craft::t('commerce', 'Could not save the file'));
}

return $this->asJson([
'success' => true,
'filename' => $filename,
]);
}
}
2 changes: 1 addition & 1 deletion src/elements/traits/OrderValidatorsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public function validateAddress(string $attribute): void
// Set live scenario for addresses to match CP
$address?->setScenario(Address::SCENARIO_LIVE);

if ($address && !$address->validate()) {
if ($address && (!$address->validate())) {
$this->addModelErrors($address, $attribute);
}

Expand Down
Loading
Loading