CaiWuXiTong/modules/EcommerceFinance/Repositories/ProductMaterialBarcodeMappingRepository.php
chenxi 16ff4dcfaa 91 料号按启用状态和最新条码映射 (#671)
91 料号仅匹配销售状态商品,并按条码档案创建时间选择最新唯一候选。
2026-10-27 10:43:46 +08:00

185 lines
7.9 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Repositories;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use RuntimeException;
use Throwable;
/**
* 商品成本使用的基础料号 → 内部商品编码映射。
*
* 只读取条码打印导入记录和供应链商品档案的固定字段;数据库账号和连接参数由
* 系统设置中的 product_barcode_catalog 来源提供,不进入仓库或业务数据。
*/
class ProductMaterialBarcodeMappingRepository
{
public const SOURCE_KEY = 'product_barcode_catalog';
/**
* 「91.C数字.数字」是条码映射库里的基础料号,后面的规格尾缀不参与查询。
*/
public static function normalizeMaterialCode(string $code): ?string
{
$code = mb_strtoupper(trim($code));
if (preg_match('/^(91\.C\d+\.\d+)(?:-.*)?$/i', $code, $matches) !== 1) {
return null;
}
return $matches[1];
}
/**
* @param list<string> $materialCodes
* @return array{
* unique:array<string,string>,
* ambiguous:array<string,list<string>>,
* missing:list<string>,
* source_rows:int
* }
*/
public function resolve(array $materialCodes, ?UpstreamReadOnlyQuery $query = null): array
{
$normalized = [];
foreach ($materialCodes as $code) {
$materialCode = self::normalizeMaterialCode((string) $code);
if ($materialCode !== null) {
$normalized[$materialCode] = true;
}
}
$materialCodes = array_keys($normalized);
sort($materialCodes, SORT_STRING);
if ($materialCodes === []) {
return [
'unique' => [],
'ambiguous' => [],
'missing' => [],
'source_rows' => 0,
];
}
$query ??= new UpstreamReadOnlyQuery(self::SOURCE_KEY, 15);
/** @var array<string,array<string,string>> $candidates */
$candidates = [];
$sourceRows = 0;
try {
foreach (array_chunk($materialCodes, 200) as $chunk) {
$placeholders = implode(', ', array_fill(0, count($chunk), '?'));
$rows = $query->select(
'WITH material_skus AS ('
. 'SELECT DISTINCT '
. "UPPER(BTRIM(updates->>'materialNo')) AS material_no, "
. 'COALESCE(NULLIF(BTRIM("normalizedSku"), \'\'), NULLIF(BTRIM("skuCode"), \'\')) AS sku_code '
. 'FROM public."BarcodePrintImportRecord" '
. "WHERE UPPER(BTRIM(updates->>'materialNo')) IN ({$placeholders}) "
. 'AND COALESCE(NULLIF(BTRIM("normalizedSku"), \'\'), NULLIF(BTRIM("skuCode"), \'\')) IS NOT NULL'
. '), barcode_matches AS ('
. 'SELECT DISTINCT material_skus.material_no, '
. "UPPER(NULLIF(BTRIM(product_rows.updates->>'barcode'), '')) AS mapped_sku, "
. 'product_rows."createdAt" AS import_record_created_at '
. 'FROM material_skus '
. 'JOIN public."BarcodePrintImportRecord" product_rows '
. 'ON COALESCE(NULLIF(BTRIM(product_rows."normalizedSku"), \'\'), '
. 'NULLIF(BTRIM(product_rows."skuCode"), \'\')) = material_skus.sku_code '
. "WHERE NULLIF(BTRIM(product_rows.updates->>'barcode'), '') IS NOT NULL"
. '), active_barcode_matches AS ('
. 'SELECT barcode_matches.material_no, barcode_matches.mapped_sku, '
. 'archive."createdAt" AS candidate_created_at, '
. 'archive."businessCreatedAt" AS business_created_at, '
. 'archive."productStatus" AS product_status, '
. 'barcode_matches.import_record_created_at '
. 'FROM barcode_matches '
. 'JOIN public."SupplyChainProductArchive" archive '
. 'ON UPPER(BTRIM(archive.barcode)) = barcode_matches.mapped_sku '
. "WHERE archive.\"productStatus\" = '销售'"
. '), base_sku_matches AS ('
. 'SELECT material_skus.material_no, '
. 'UPPER(BTRIM(archive.sku::text)) AS mapped_sku, '
. 'archive."createdAt" AS candidate_created_at, '
. 'archive."businessCreatedAt" AS business_created_at, '
. 'archive."productStatus" AS product_status, '
. 'NULL::timestamp AS import_record_created_at '
. 'FROM material_skus '
. 'JOIN public."SupplyChainProductArchive" archive '
. 'ON UPPER(BTRIM(archive.sku::text)) = UPPER(BTRIM(material_skus.sku_code)) '
. 'WHERE NOT EXISTS ('
. 'SELECT 1 FROM barcode_matches '
. 'WHERE barcode_matches.material_no = material_skus.material_no'
. ') '
. "AND archive.\"productStatus\" = '销售'"
. ') '
. 'SELECT material_no, mapped_sku, candidate_created_at, business_created_at, '
. 'product_status, import_record_created_at '
. 'FROM active_barcode_matches '
. 'UNION '
. 'SELECT material_no, mapped_sku, candidate_created_at, business_created_at, '
. 'product_status, import_record_created_at '
. 'FROM base_sku_matches',
$chunk,
);
foreach ($rows as $row) {
$sourceRows++;
$materialCode = self::normalizeMaterialCode((string) ($row['material_no'] ?? ''));
$mappedSku = mb_strtoupper(trim((string) ($row['mapped_sku'] ?? '')));
$productStatus = trim((string) ($row['product_status'] ?? ''));
if ($materialCode === null || $mappedSku === '' || $productStatus !== '销售') {
continue;
}
$createdAt = trim((string) (
$row['candidate_created_at']
?? $row['import_record_created_at']
?? ''
));
if (
! isset($candidates[$materialCode][$mappedSku])
|| $createdAt > $candidates[$materialCode][$mappedSku]
) {
$candidates[$materialCode][$mappedSku] = $createdAt;
}
}
}
} catch (Throwable $exception) {
throw new RuntimeException(
'91 基础料号条码映射读取失败,请检查商品条码映射库只读连接:' . $exception->getMessage(),
0,
$exception,
);
}
$unique = [];
$ambiguous = [];
$missing = [];
foreach ($materialCodes as $materialCode) {
$mappedCandidates = $candidates[$materialCode] ?? [];
if ($mappedCandidates === []) {
$missing[] = $materialCode;
continue;
}
$latestCreatedAt = max(array_values($mappedCandidates));
$mappedSkus = [];
foreach ($mappedCandidates as $mappedSku => $createdAt) {
if ($createdAt === $latestCreatedAt) {
$mappedSkus[] = (string) $mappedSku;
}
}
sort($mappedSkus, SORT_STRING);
if (count($mappedSkus) === 1) {
$unique[$materialCode] = $mappedSkus[0];
} else {
$ambiguous[$materialCode] = $mappedSkus;
}
}
return [
'unique' => $unique,
'ambiguous' => $ambiguous,
'missing' => $missing,
'source_rows' => $sourceRows,
];
}
}