Files
2026-09-15 10:35:56 +08:00

179 lines
7.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Esun\LingxingErp\Model\Inventory;
use Esun\LingxingErp\Model\Sku\ProductErpSkuRepository;
use Esun\LingxingErp\Model\Sync\LogWriter;
use Esun\LingxingErp\Model\Warehouse\WarehouseRepository;
use Magento\Framework\Lock\LockManagerInterface;
use Magento\InventoryApi\Api\Data\SourceItemInterface;
use Magento\InventoryApi\Api\Data\SourceItemInterfaceFactory;
use Magento\InventoryApi\Api\SourceItemsSaveInterface;
class InventorySynchronizer
{
private const LOCK_NAME = 'esun_lingxing_inventory_sync';
private const SAVE_BATCH_SIZE = 500;
public function __construct(
private readonly WarehouseRepository $warehouseRepository,
private readonly InventoryFetcher $fetcher,
private readonly ProductErpSkuRepository $productErpSkuRepository,
private readonly SourceItemInterfaceFactory $sourceItemFactory,
private readonly SourceItemsSaveInterface $sourceItemsSave,
private readonly LogWriter $logWriter,
private readonly LockManagerInterface $lockManager
) {
}
/** @return array{warehouses:int,items:int,unmapped:int,errors:array<int, string>} */
public function synchronizeAll(): array
{
if (!$this->lockManager->lock(self::LOCK_NAME, 0)) {
throw new \RuntimeException('Inventory synchronization is already running.');
}
$summary = ['warehouses' => 0, 'items' => 0, 'unmapped' => 0, 'errors' => []];
try {
foreach ($this->warehouseRepository->getInventoryTargets() as $warehouse) {
$wid = (int)$warehouse['wid'];
$started = microtime(true);
try {
$result = $this->synchronizeWarehouse($warehouse);
$summary['warehouses']++;
$summary['items'] += $result['items'];
$summary['unmapped'] += $result['unmapped'];
$this->warehouseRepository->markInventorySynchronized($wid);
$this->logWriter->write([
'direction' => 'pull',
'operation' => 'inventory_sync',
'entity_type' => 'inventory',
'external_id' => (string)$wid,
'status' => 'success',
'attempt_number' => 1,
'request_payload' => [
'wid' => $wid,
'source_code' => (string)$warehouse['source_code'],
'quantity_field' => 'product_valid_num',
],
'response_payload' => $result,
'duration_ms' => (int)round((microtime(true) - $started) * 1000),
]);
} catch (\Throwable $exception) {
$summary['errors'][$wid] = $exception->getMessage();
$this->logWriter->write([
'direction' => 'pull',
'operation' => 'inventory_sync',
'entity_type' => 'inventory',
'external_id' => (string)$wid,
'status' => 'failed',
'attempt_number' => 1,
'error_code' => (string)$exception->getCode(),
'error_message' => $exception->getMessage(),
'request_payload' => [
'wid' => $wid,
'source_code' => (string)$warehouse['source_code'],
'quantity_field' => 'product_valid_num',
],
'duration_ms' => (int)round((microtime(true) - $started) * 1000),
]);
}
}
} finally {
$this->lockManager->unlock(self::LOCK_NAME);
}
return $summary;
}
/**
* @param array<string, mixed> $warehouse
* @return array{items:int,unmapped:int,zeroed:int,preserved_without_erp_sku:int,unmapped_erp_skus:string[]}
*/
private function synchronizeWarehouse(array $warehouse): array
{
$wid = (int)$warehouse['wid'];
$sourceCode = (string)$warehouse['source_code'];
$websiteIds = $this->warehouseRepository->getWebsiteIdsForSource($sourceCode);
if ($websiteIds === []) {
throw new \RuntimeException(sprintf(
'Magento Source "%s" is not assigned to a Stock with a Website sales channel.',
$sourceCode
));
}
$rows = $this->fetcher->fetchAll($wid);
$quantitiesBySku = [];
$unmapped = [];
foreach ($rows as $row) {
$erpSku = trim((string)($row['sku'] ?? ''));
if ($erpSku === '') {
continue;
}
$magentoSkus = $this->resolveMagentoSkus($websiteIds, $erpSku);
if ($magentoSkus === []) {
$unmapped[$erpSku] = true;
continue;
}
$quantity = max(0.0, (float)($row['product_valid_num'] ?? 0));
foreach ($magentoSkus as $magentoSku) {
// The customer accepts that every Magento SKU paired to the ERP SKU receives the full ERP quantity.
$quantitiesBySku[$magentoSku] = $quantity;
}
}
$existingSkus = $this->warehouseRepository->getExistingSourceItemSkus($sourceCode);
$mappedSkus = $this->getMappedMagentoSkus($websiteIds);
$zeroCandidates = array_values(array_intersect($existingSkus, $mappedSkus));
$preservedWithoutErpSku = array_values(array_diff($existingSkus, $mappedSkus));
$zeroed = array_values(array_diff($zeroCandidates, array_keys($quantitiesBySku)));
foreach ($zeroed as $sku) {
$quantitiesBySku[$sku] = 0.0;
}
$sourceItems = [];
foreach ($quantitiesBySku as $sku => $quantity) {
$sourceItem = $this->sourceItemFactory->create();
$sourceItem->setSourceCode($sourceCode);
$sourceItem->setSku($sku);
$sourceItem->setQuantity($quantity);
$sourceItem->setStatus($quantity > 0
? SourceItemInterface::STATUS_IN_STOCK
: SourceItemInterface::STATUS_OUT_OF_STOCK);
$sourceItems[] = $sourceItem;
}
foreach (array_chunk($sourceItems, self::SAVE_BATCH_SIZE) as $batch) {
$this->sourceItemsSave->execute($batch);
}
return [
'items' => count($quantitiesBySku),
'unmapped' => count($unmapped),
'zeroed' => count($zeroed),
'preserved_without_erp_sku' => count($preservedWithoutErpSku),
'unmapped_erp_skus' => array_slice(array_keys($unmapped), 0, 200),
];
}
/** @param int[] $websiteIds */
private function resolveMagentoSkus(array $websiteIds, string $erpSku): array
{
$matches = [];
foreach ($websiteIds as $websiteId) {
foreach ($this->productErpSkuRepository->findMskusByErpSku($websiteId, $erpSku) as $sku) {
$matches[$sku] = true;
}
}
return array_map('strval', array_keys($matches));
}
/** @param int[] $websiteIds @return string[] */
private function getMappedMagentoSkus(array $websiteIds): array
{
$mapped = [];
foreach ($websiteIds as $websiteId) {
foreach ($this->productErpSkuRepository->getWebsiteProductSkusWithErpSku($websiteId) as $sku) {
$mapped[$sku] = true;
}
}
return array_map('strval', array_keys($mapped));
}
}