first commit
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user