Files
zyt/server/app/common/service/pharmacy/EjMedicineBootstrapService.php
T
2026-07-22 14:50:34 +08:00

482 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}