first commit

This commit is contained in:
Your Name
2026-09-08 11:40:15 +08:00
commit a5353f7eb5
9568 changed files with 1646214 additions and 0 deletions
@@ -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;
}
}