482 lines
22 KiB
PHP
482 lines
22 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\service\pharmacy;
|
||
|
||
use InvalidArgumentException;
|
||
use RuntimeException;
|
||
use think\facade\Db;
|
||
|
||
final class EjMedicineBootstrapService
|
||
{
|
||
private const SOURCE_SYSTEM = 'zyt';
|
||
private const BOOTSTRAP_RUN_ID = 'zyt-medicine-bootstrap-v1';
|
||
private const EXPECTED_MEDICINE_COUNT = 654;
|
||
|
||
public static function assertCommandGate(bool $replace, string $confirm, int $batchSize): void
|
||
{
|
||
if (!$replace) {
|
||
throw new InvalidArgumentException('必须显式提供 --replace 才能替换恩济药材投影');
|
||
}
|
||
if (!hash_equals('RESET_TEST_CATALOG', $confirm)) {
|
||
throw new InvalidArgumentException('必须提供 --confirm=RESET_TEST_CATALOG');
|
||
}
|
||
if ($batchSize < 1 || $batchSize > 500) {
|
||
throw new InvalidArgumentException('--batch-size 必须在 1 到 500 之间');
|
||
}
|
||
}
|
||
|
||
/** @param list<array<string,mixed>> $items @return list<array<string,mixed>> */
|
||
public static function buildBatches(array $items, int $batchSize): array
|
||
{
|
||
if ($batchSize < 1 || $batchSize > 500) {
|
||
throw new InvalidArgumentException('药材导入批次大小必须在 1 到 500 之间');
|
||
}
|
||
usort($items, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
|
||
(string) ($left['source_medicine_id'] ?? ''),
|
||
(string) ($right['source_medicine_id'] ?? '')
|
||
));
|
||
|
||
$batches = [];
|
||
foreach (array_chunk($items, $batchSize) as $index => $batchItems) {
|
||
$ordinal = $index + 1;
|
||
$contentJson = json_encode(
|
||
$batchItems,
|
||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||
);
|
||
$contentIdentity = substr(hash('sha256', $contentJson), 0, 32);
|
||
$batches[] = [
|
||
'source_system' => self::SOURCE_SYSTEM,
|
||
'import_id' => sprintf('%s-%04d-%s', self::BOOTSTRAP_RUN_ID, $ordinal, $contentIdentity),
|
||
'items' => $batchItems,
|
||
];
|
||
}
|
||
return $batches;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $response
|
||
* @param array<string,mixed> $payload
|
||
* @param array<string,bool> $seenCodes
|
||
* @param array<int,bool> $seenVersions
|
||
* @return list<array{source_medicine_id:string,medicine_code:string,catalog_version:int,action:string}>
|
||
*/
|
||
public static function validateImportResponse(
|
||
array $response,
|
||
array $payload,
|
||
array &$seenCodes,
|
||
array &$seenVersions
|
||
): array {
|
||
$httpStatus = (int) ($response['http_status'] ?? 0);
|
||
$body = $response['body'] ?? null;
|
||
if (!in_array($httpStatus, [200, 201], true) || !is_array($body) || (int) ($body['code'] ?? -1) !== 0) {
|
||
$message = is_array($body) ? trim((string) ($body['message'] ?? '')) : '';
|
||
throw new RuntimeException(sprintf(
|
||
'恩济药材导入失败 HTTP %d%s',
|
||
$httpStatus,
|
||
$message === '' ? '' : ':' . $message
|
||
));
|
||
}
|
||
$data = $body['data'] ?? null;
|
||
if (!is_array($data)) {
|
||
throw new RuntimeException('恩济药材导入响应缺少 data');
|
||
}
|
||
$expectedImportId = (string) ($payload['import_id'] ?? '');
|
||
if (!hash_equals($expectedImportId, (string) ($data['import_id'] ?? ''))) {
|
||
throw new RuntimeException('恩济药材导入响应 import_id 不匹配');
|
||
}
|
||
|
||
$expectedItems = $payload['items'] ?? null;
|
||
$responseItems = $data['items'] ?? null;
|
||
if (!is_array($expectedItems) || !is_array($responseItems)) {
|
||
throw new RuntimeException('恩济药材导入响应 items 无效');
|
||
}
|
||
$itemCount = count($expectedItems);
|
||
$createdCount = (int) ($data['created_count'] ?? -1);
|
||
$existingCount = (int) ($data['existing_count'] ?? -1);
|
||
if (
|
||
(int) ($data['item_count'] ?? -1) !== $itemCount
|
||
|| count($responseItems) !== $itemCount
|
||
|| $createdCount < 0
|
||
|| $existingCount < 0
|
||
|| $createdCount + $existingCount !== $itemCount
|
||
|| !is_bool($data['idempotent'] ?? null)
|
||
) {
|
||
throw new RuntimeException('恩济药材导入响应计数或幂等标记不完整');
|
||
}
|
||
|
||
$expectedSourceIds = array_map(
|
||
static fn (array $item): string => (string) ($item['source_medicine_id'] ?? ''),
|
||
$expectedItems
|
||
);
|
||
$nextSeenCodes = $seenCodes;
|
||
$nextSeenVersions = $seenVersions;
|
||
$normalized = [];
|
||
$responseSourceIds = [];
|
||
$actions = ['created' => 0, 'existing' => 0];
|
||
foreach ($responseItems as $item) {
|
||
if (!is_array($item)) {
|
||
throw new RuntimeException('恩济药材导入响应 item 必须是对象');
|
||
}
|
||
$sourceId = self::canonicalSourceId($item['source_medicine_id'] ?? null);
|
||
if (isset($responseSourceIds[$sourceId])) {
|
||
throw new RuntimeException("恩济药材导入响应 source_medicine_id 重复:{$sourceId}");
|
||
}
|
||
$responseSourceIds[$sourceId] = true;
|
||
$code = trim((string) ($item['medicine_code'] ?? ''));
|
||
if ($code === '' || mb_strlen($code) > 32 || isset($nextSeenCodes[$code])) {
|
||
throw new RuntimeException("恩济药材导入响应 medicine_code 为空、过长或重复:{$code}");
|
||
}
|
||
$version = filter_var($item['catalog_version'] ?? null, FILTER_VALIDATE_INT);
|
||
if ($version === false || $version < 1 || isset($nextSeenVersions[$version])) {
|
||
throw new RuntimeException('恩济药材导入响应 catalog_version 缺失或重复');
|
||
}
|
||
$action = (string) ($item['action'] ?? '');
|
||
if (!array_key_exists($action, $actions)) {
|
||
throw new RuntimeException('恩济药材导入响应 action 无效');
|
||
}
|
||
++$actions[$action];
|
||
$nextSeenCodes[$code] = true;
|
||
$nextSeenVersions[$version] = true;
|
||
$normalized[] = [
|
||
'source_medicine_id' => $sourceId,
|
||
'medicine_code' => $code,
|
||
'catalog_version' => $version,
|
||
'action' => $action,
|
||
];
|
||
}
|
||
usort($normalized, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
|
||
$left['source_medicine_id'],
|
||
$right['source_medicine_id']
|
||
));
|
||
sort($expectedSourceIds, SORT_NATURAL);
|
||
$actualSourceIds = array_column($normalized, 'source_medicine_id');
|
||
sort($actualSourceIds, SORT_NATURAL);
|
||
if ($expectedSourceIds !== $actualSourceIds) {
|
||
throw new RuntimeException('恩济药材导入响应 source_medicine_id 不完整或不匹配');
|
||
}
|
||
if ($actions['created'] !== $createdCount || $actions['existing'] !== $existingCount) {
|
||
throw new RuntimeException('恩济药材导入响应 action 与计数不一致');
|
||
}
|
||
if ($data['idempotent'] === true && ($createdCount !== 0 || $existingCount !== $itemCount)) {
|
||
throw new RuntimeException('恩济药材导入响应幂等标记与 action 不一致');
|
||
}
|
||
|
||
$seenCodes = $nextSeenCodes;
|
||
$seenVersions = $nextSeenVersions;
|
||
return $normalized;
|
||
}
|
||
|
||
/**
|
||
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
|
||
* @param null|callable(array<string,mixed>):array<string,mixed> $importer
|
||
* @param null|callable(array<int,array<string,mixed>>):array<string,int> $projectionReplacer
|
||
* @return array{source_count:int,batch_count:int,catalog:int,active_mappings:int,unmapped:int}
|
||
*/
|
||
public static function execute(
|
||
int $batchSize = 100,
|
||
?callable $sourceLoader = null,
|
||
?callable $importer = null,
|
||
?callable $projectionReplacer = null
|
||
): array {
|
||
if ($batchSize < 1 || $batchSize > 500) {
|
||
throw new InvalidArgumentException('药材导入批次大小必须在 1 到 500 之间');
|
||
}
|
||
$sourceRows = $sourceLoader === null ? self::loadSourceRows() : $sourceLoader();
|
||
$items = EjMedicineBootstrapItem::fromRows($sourceRows);
|
||
if (count($items) !== self::EXPECTED_MEDICINE_COUNT) {
|
||
throw new RuntimeException(sprintf(
|
||
'药材 bootstrap 要求恰好 %d 条启用且未删除的本地药材,当前为 %d 条',
|
||
self::EXPECTED_MEDICINE_COUNT,
|
||
count($items)
|
||
));
|
||
}
|
||
$batches = self::buildBatches($items, $batchSize);
|
||
if ($importer === null) {
|
||
if (!EjPharmacyClient::isConfigured()) {
|
||
throw new RuntimeException('恩济药房接口未启用或配置不完整');
|
||
}
|
||
$client = new EjPharmacyClient();
|
||
$importer = static fn (array $payload): array => $client->importMedicines($payload);
|
||
}
|
||
|
||
$sourceById = [];
|
||
foreach ($items as $item) {
|
||
$sourceById[(string) $item['source_medicine_id']] = $item;
|
||
}
|
||
$seenCodes = [];
|
||
$seenVersions = [];
|
||
$projectionRows = [];
|
||
foreach ($batches as $payload) {
|
||
$responseItems = self::validateImportResponse(
|
||
$importer($payload),
|
||
$payload,
|
||
$seenCodes,
|
||
$seenVersions
|
||
);
|
||
foreach ($responseItems as $responseItem) {
|
||
$sourceId = $responseItem['source_medicine_id'];
|
||
$source = $sourceById[$sourceId];
|
||
$projectionRows[] = [
|
||
'local_medicine_id' => (int) $sourceId,
|
||
'medicine_code' => $responseItem['medicine_code'],
|
||
'name' => $source['name'],
|
||
'brand' => '',
|
||
'unit' => $source['unit'],
|
||
'settlement_price' => $source['settlement_price'],
|
||
'retail_price' => $source['retail_price'],
|
||
'status' => $source['status'],
|
||
'catalog_version' => $responseItem['catalog_version'],
|
||
];
|
||
}
|
||
}
|
||
usort($projectionRows, static fn (array $left, array $right): int => $left['local_medicine_id'] <=> $right['local_medicine_id']);
|
||
$verification = $projectionReplacer === null
|
||
? self::replaceProjection($projectionRows)
|
||
: $projectionReplacer($projectionRows);
|
||
self::assertProjectionVerification($verification, self::EXPECTED_MEDICINE_COUNT);
|
||
|
||
return [
|
||
'source_count' => count($items),
|
||
'batch_count' => count($batches),
|
||
'catalog' => (int) $verification['catalog'],
|
||
'active_mappings' => (int) $verification['active_mappings'],
|
||
'unmapped' => (int) $verification['unmapped'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<int,array<string,mixed>> $projectionRows
|
||
* @param callable(callable():array<string,int>):array<string,int> $transaction
|
||
* @param callable():void $referenceLocker
|
||
* @param callable():array<string,int> $referenceCounter
|
||
* @param callable():void $projectionLocker
|
||
* @param callable(array<int,array<string,mixed>>):void $replacer
|
||
* @param callable():array<string,int> $verifier
|
||
* @return array<string,int>
|
||
*/
|
||
public static function replaceProjectionWith(
|
||
array $projectionRows,
|
||
callable $transaction,
|
||
callable $referenceLocker,
|
||
callable $referenceCounter,
|
||
callable $projectionLocker,
|
||
callable $replacer,
|
||
callable $verifier
|
||
): array {
|
||
return $transaction(static function () use (
|
||
$projectionRows,
|
||
$referenceLocker,
|
||
$referenceCounter,
|
||
$projectionLocker,
|
||
$replacer,
|
||
$verifier
|
||
): array {
|
||
$referenceLocker();
|
||
$references = $referenceCounter();
|
||
foreach (['submissions', 'callbacks', 'business_links'] as $key) {
|
||
if ((int) ($references[$key] ?? -1) !== 0) {
|
||
throw new RuntimeException('恩济药材投影已有提交、回调或业务引用,禁止 bootstrap 替换');
|
||
}
|
||
}
|
||
$projectionLocker();
|
||
$replacer($projectionRows);
|
||
$verification = $verifier();
|
||
self::assertProjectionVerification($verification, count($projectionRows));
|
||
return $verification;
|
||
});
|
||
}
|
||
|
||
/** @return array<int,array<string,mixed>> */
|
||
private static function loadSourceRows(): array
|
||
{
|
||
return Db::name('doctor_medicine')
|
||
->field('id,name,unit,settlement_price,retail_price,status')
|
||
->where('status', 1)
|
||
->whereNull('delete_time')
|
||
->order('id', 'asc')
|
||
->select()
|
||
->toArray();
|
||
}
|
||
|
||
/** @param array<int,array<string,mixed>> $projectionRows @return array<string,int> */
|
||
private static function replaceProjection(array $projectionRows): array
|
||
{
|
||
return self::replaceProjectionWith(
|
||
$projectionRows,
|
||
static fn (callable $operation): array => Db::transaction($operation),
|
||
static function (): void {
|
||
Db::name('ej_pharmacy_submission')
|
||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||
Db::name('ej_pharmacy_callback_inbox')
|
||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||
Db::name('pharmacy_submission_claim')
|
||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||
Db::name('tcm_prescription_order')
|
||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||
},
|
||
static function (): array {
|
||
$directClaims = (int) Db::name('pharmacy_submission_claim')
|
||
->where('target', 'direct')
|
||
->count();
|
||
$linkedOrders = (int) Db::name('tcm_prescription_order')
|
||
->where(function ($query): void {
|
||
$query->whereNotNull('ej_pharmacy_order_no')
|
||
->whereOr('ej_pharmacy_submit_time', '>', 0)
|
||
->whereOr('ej_pharmacy_status', '<>', '')
|
||
->whereOr('ej_pharmacy_status_version', '>', 0);
|
||
})
|
||
->count();
|
||
return [
|
||
'submissions' => (int) Db::name('ej_pharmacy_submission')->count(),
|
||
'callbacks' => (int) Db::name('ej_pharmacy_callback_inbox')->count(),
|
||
'business_links' => $directClaims + $linkedOrders,
|
||
];
|
||
},
|
||
static function () use ($projectionRows): void {
|
||
Db::name('ej_pharmacy_sync_state')->where('id', 1)->lock(true)->find();
|
||
Db::name('ej_medicine_catalog')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||
Db::name('ej_medicine_mapping')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||
Db::name('doctor_medicine')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||
|
||
$lockedRows = Db::name('doctor_medicine')
|
||
->field('id,name,unit,settlement_price,retail_price,status')
|
||
->where('status', 1)
|
||
->whereNull('delete_time')
|
||
->order('id', 'asc')
|
||
->select()
|
||
->toArray();
|
||
$lockedItems = EjMedicineBootstrapItem::fromRows($lockedRows);
|
||
$expectedItems = array_map(static fn (array $row): array => [
|
||
'source_medicine_id' => (string) $row['local_medicine_id'],
|
||
'name' => (string) $row['name'],
|
||
'brand' => '',
|
||
'unit' => (string) $row['unit'],
|
||
'settlement_price' => (string) $row['settlement_price'],
|
||
'retail_price' => (string) $row['retail_price'],
|
||
'status' => (int) $row['status'],
|
||
], $projectionRows);
|
||
if ($lockedItems !== $expectedItems) {
|
||
throw new RuntimeException('本地药材源快照在远端导入期间发生变化,已拒绝替换投影');
|
||
}
|
||
},
|
||
static function (array $rows): void {
|
||
Db::name('ej_medicine_mapping')->where('id', '>=', 0)->delete();
|
||
Db::name('ej_medicine_catalog')->where('id', '>=', 0)->delete();
|
||
$now = time();
|
||
$catalogRows = [];
|
||
$mappingRows = [];
|
||
foreach ($rows as $row) {
|
||
$catalogRows[] = [
|
||
'medicine_code' => $row['medicine_code'],
|
||
'name' => $row['name'],
|
||
'brand' => '',
|
||
'unit' => $row['unit'],
|
||
'settlement_price' => $row['settlement_price'],
|
||
'retail_price' => $row['retail_price'],
|
||
'status' => $row['status'],
|
||
'catalog_version' => $row['catalog_version'],
|
||
'remote_deleted' => 0,
|
||
'create_time' => $now,
|
||
'update_time' => $now,
|
||
];
|
||
$mappingRows[] = [
|
||
'local_medicine_id' => $row['local_medicine_id'],
|
||
'medicine_code' => $row['medicine_code'],
|
||
'status' => 1,
|
||
'operator_id' => 0,
|
||
'operator_name' => 'system-bootstrap',
|
||
'create_time' => $now,
|
||
'update_time' => $now,
|
||
'delete_time' => null,
|
||
];
|
||
}
|
||
foreach (array_chunk($catalogRows, 500) as $chunk) {
|
||
Db::name('ej_medicine_catalog')->insertAll($chunk);
|
||
}
|
||
foreach (array_chunk($mappingRows, 500) as $chunk) {
|
||
Db::name('ej_medicine_mapping')->insertAll($chunk);
|
||
}
|
||
|
||
$stateValues = [
|
||
'cursor' => 0,
|
||
'last_success_time' => $now,
|
||
'last_failure_time' => 0,
|
||
'last_error_summary' => '',
|
||
'lock_token' => '',
|
||
'lock_expires_at' => 0,
|
||
'update_time' => $now,
|
||
];
|
||
$updated = Db::name('ej_pharmacy_sync_state')->where('id', 1)->update($stateValues);
|
||
if ($updated === 0 && !Db::name('ej_pharmacy_sync_state')->where('id', 1)->find()) {
|
||
Db::name('ej_pharmacy_sync_state')->insert($stateValues + ['id' => 1, 'create_time' => $now]);
|
||
}
|
||
},
|
||
static function () use ($projectionRows): array {
|
||
$expectedLocalIds = array_map(
|
||
static fn (array $row): int => (int) $row['local_medicine_id'],
|
||
$projectionRows
|
||
);
|
||
$actualLocalIds = array_map(
|
||
'intval',
|
||
Db::name('ej_medicine_mapping')
|
||
->where('status', 1)
|
||
->whereNull('delete_time')
|
||
->order('local_medicine_id', 'asc')
|
||
->column('local_medicine_id')
|
||
);
|
||
if ($expectedLocalIds !== $actualLocalIds) {
|
||
throw new RuntimeException('恩济药材 bootstrap 映射未精确覆盖全部本地药材 id');
|
||
}
|
||
return [
|
||
'catalog' => (int) Db::name('ej_medicine_catalog')->count(),
|
||
'active_mappings' => count($actualLocalIds),
|
||
'unmapped' => (int) Db::name('doctor_medicine')->alias('l')
|
||
->leftJoin(
|
||
'ej_medicine_mapping m',
|
||
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
|
||
)
|
||
->where('l.status', 1)
|
||
->whereNull('l.delete_time')
|
||
->whereNull('m.id')
|
||
->count('l.id'),
|
||
];
|
||
}
|
||
);
|
||
}
|
||
|
||
/** @param array<string,mixed> $verification */
|
||
private static function assertProjectionVerification(array $verification, int $expected): void
|
||
{
|
||
if (
|
||
(int) ($verification['catalog'] ?? -1) !== $expected
|
||
|| (int) ($verification['active_mappings'] ?? -1) !== $expected
|
||
|| (int) ($verification['unmapped'] ?? -1) !== 0
|
||
) {
|
||
throw new RuntimeException(sprintf(
|
||
'恩济药材 bootstrap 最终验证失败:catalog=%d active_mappings=%d unmapped=%d expected=%d',
|
||
(int) ($verification['catalog'] ?? -1),
|
||
(int) ($verification['active_mappings'] ?? -1),
|
||
(int) ($verification['unmapped'] ?? -1),
|
||
$expected
|
||
));
|
||
}
|
||
}
|
||
|
||
private static function canonicalSourceId(mixed $value): string
|
||
{
|
||
if (is_int($value)) {
|
||
$value = (string) $value;
|
||
}
|
||
if (!is_string($value) || preg_match('/^\d+$/D', $value) !== 1) {
|
||
throw new RuntimeException('恩济药材导入响应 source_medicine_id 无效');
|
||
}
|
||
$value = ltrim($value, '0');
|
||
if ($value === '') {
|
||
throw new RuntimeException('恩济药材导入响应 source_medicine_id 无效');
|
||
}
|
||
return $value;
|
||
}
|
||
}
|