500) { throw new InvalidArgumentException('--batch-size 必须在 1 到 500 之间'); } } /** @param list> $items @return list> */ 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 $response * @param array $payload * @param array $seenCodes * @param array $seenVersions * @return list */ 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> $sourceLoader * @param null|callable(array):array $importer * @param null|callable(array>):array $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> $projectionRows * @param callable(callable():array):array $transaction * @param callable():void $referenceLocker * @param callable():array $referenceCounter * @param callable():void $projectionLocker * @param callable(array>):void $replacer * @param callable():array $verifier * @return array */ 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> */ 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> $projectionRows @return array */ 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 $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; } }