diff --git a/admin/src/api/pharmacy.ts b/admin/src/api/pharmacy.ts new file mode 100644 index 00000000..40e48c45 --- /dev/null +++ b/admin/src/api/pharmacy.ts @@ -0,0 +1,87 @@ +import request from '@/utils/request' + +export interface MedicineMappingQuery { + page_no: number + page_size: number + local_name?: string + remote_keyword?: string + mapping_status?: '' | 'mapped' | 'unmapped' | 'invalid' +} + +export interface MedicineMappingRow { + local_medicine_id: number + local_name: string + local_unit: string + local_status: number + mapping_id: number | null + mapping_status: number + medicine_code: string | null + remote_name: string | null + remote_brand: string | null + remote_unit: string | null + settlement_price: string | number | null + retail_price: string | number | null + catalog_version: number | null + remote_status: number | null + remote_deleted: number | null + operator_name: string | null + mapping_update_time: number | null +} + +export interface CatalogOption { + medicine_code: string + name: string + brand: string + unit: string + settlement_price: string | number + retail_price: string | number + catalog_version: number + status: number +} + +export interface PharmacySyncStatus { + sync_enabled: boolean + cursor: number + last_success_time: number + last_failure_time: number + last_error_summary: string + is_syncing: boolean + catalog_total: number + catalog_active: number + unmapped_local: number +} + +export interface PharmacySyncResult { + pages: number + pulled: number + received: number + created: number + updated: number + unchanged: number + deactivated: number + cursor: number +} + +export function medicineMappingLists(params: MedicineMappingQuery) { + return request.get({ url: '/pharmacy.medicineMapping/lists', params }) +} + +export function medicineMappingStatus() { + return request.get({ url: '/pharmacy.medicineMapping/status' }) +} + +export function medicineCatalogOptions(params: { keyword?: string; limit?: number }) { + return request.get({ url: '/pharmacy.medicineMapping/catalogOptions', params }) +} + +export function medicineMappingSave(params: { local_medicine_id: number; medicine_code: string }) { + return request.post({ url: '/pharmacy.medicineMapping/save', params }) +} + +export function medicineMappingUnlink(params: { local_medicine_id: number }) { + return request.post({ url: '/pharmacy.medicineMapping/unlink', params }) +} + +export function medicineCatalogSync() { + return request.post({ url: '/pharmacy.medicineMapping/sync' }) +} diff --git a/admin/src/views/pharmacy/medicine_mapping/index.vue b/admin/src/views/pharmacy/medicine_mapping/index.vue new file mode 100644 index 00000000..4498b703 --- /dev/null +++ b/admin/src/views/pharmacy/medicine_mapping/index.vue @@ -0,0 +1,686 @@ + + + + + diff --git a/admin/src/views/pharmacy/medicine_mapping/latest-request.d.mts b/admin/src/views/pharmacy/medicine_mapping/latest-request.d.mts new file mode 100644 index 00000000..bdacb88f --- /dev/null +++ b/admin/src/views/pharmacy/medicine_mapping/latest-request.d.mts @@ -0,0 +1,12 @@ +export interface LatestRequestTicket { + readonly generation: number + readonly snapshot: T +} + +export interface LatestRequestGuard { + next(snapshot: T): LatestRequestTicket + invalidate(): number + isLatest(ticket: LatestRequestTicket): boolean +} + +export function createLatestRequestGuard(): LatestRequestGuard diff --git a/admin/src/views/pharmacy/medicine_mapping/latest-request.mjs b/admin/src/views/pharmacy/medicine_mapping/latest-request.mjs new file mode 100644 index 00000000..04581a06 --- /dev/null +++ b/admin/src/views/pharmacy/medicine_mapping/latest-request.mjs @@ -0,0 +1,22 @@ +export function createLatestRequestGuard() { + let generation = 0 + + return { + next(snapshot) { + generation += 1 + const stableSnapshot = Array.isArray(snapshot) + ? [...snapshot] + : snapshot && typeof snapshot === 'object' + ? { ...snapshot } + : snapshot + return Object.freeze({ generation, snapshot: stableSnapshot }) + }, + invalidate() { + generation += 1 + return generation + }, + isLatest(ticket) { + return ticket?.generation === generation + } + } +} diff --git a/server/.example.env b/server/.example.env index e6923109..b5636f98 100755 --- a/server/.example.env +++ b/server/.example.env @@ -1,5 +1,17 @@ APP_DEBUG = true +# 恩济药房 ERP:必须写在第一个 INI 分区之前 +# 在 ej 后台创建商户后,将一次性返回的 app_key、app_secret、webhook_secret 填入下方 +EJ_PHARMACY_ENABLED = false +EJ_PHARMACY_CATALOG_SYNC_ENABLED = false +EJ_PHARMACY_BASE_URL = "https://ej.example.com" +EJ_PHARMACY_APP_KEY = "" +EJ_PHARMACY_APP_SECRET = "" +EJ_PHARMACY_CALLBACK_SECRET = "" +EJ_PHARMACY_SUBMISSION_LEASE_SECONDS = 300 +EJ_PHARMACY_CONNECT_TIMEOUT = 5 +EJ_PHARMACY_REQUEST_TIMEOUT = 30 + [APP] DEFAULT_TIMEZONE = "Asia/Shanghai" @@ -64,4 +76,4 @@ LOGISTICS_KUAIDI100_KEY = ; GANCAO_SCM_CALLBACK_URL = https://你的公网域名/gancao/recipel-notify ; GANCAO_SCM_CRADLE_STORE = 甄养堂互联网医院 ; GANCAO_SCM_DF_ID = 101 -; GANCAO_SCM_EXPRESS_TYPE = general \ No newline at end of file +; GANCAO_SCM_EXPRESS_TYPE = general diff --git a/server/app/adminapi/controller/pharmacy/MedicineMappingController.php b/server/app/adminapi/controller/pharmacy/MedicineMappingController.php new file mode 100644 index 00000000..f51c5039 --- /dev/null +++ b/server/app/adminapi/controller/pharmacy/MedicineMappingController.php @@ -0,0 +1,60 @@ +dataLists(new MedicineMappingLists()); + } + + public function status() + { + return $this->data(MedicineMappingLogic::status()); + } + + public function catalogOptions() + { + return $this->data(MedicineMappingLogic::catalogOptions( + (string) $this->request->get('keyword', ''), + (int) $this->request->get('limit', 30) + )); + } + + public function save() + { + $params = (new MedicineMappingValidate())->post()->goCheck('save'); + $name = (string) ($this->adminInfo['name'] ?? $this->adminInfo['nickname'] ?? ''); + if (!MedicineMappingLogic::save($params, $this->adminId, $name)) { + return $this->fail(MedicineMappingLogic::getError()); + } + return $this->success('映射已保存', [], 1, 1); + } + + public function unlink() + { + $params = (new MedicineMappingValidate())->post()->goCheck('unlink'); + $name = (string) ($this->adminInfo['name'] ?? $this->adminInfo['nickname'] ?? ''); + if (!MedicineMappingLogic::unlink((int) $params['local_medicine_id'], $this->adminId, $name)) { + return $this->fail(MedicineMappingLogic::getError()); + } + return $this->success('映射已解除', [], 1, 1); + } + + public function sync() + { + $result = MedicineMappingLogic::sync(); + if ($result === false) { + return $this->fail(MedicineMappingLogic::getError()); + } + return $this->success('目录同步完成', $result); + } +} diff --git a/server/app/adminapi/lists/pharmacy/MedicineMappingLists.php b/server/app/adminapi/lists/pharmacy/MedicineMappingLists.php new file mode 100644 index 00000000..b56ee5f5 --- /dev/null +++ b/server/app/adminapi/lists/pharmacy/MedicineMappingLists.php @@ -0,0 +1,108 @@ +query() + ->field($this->fields()) + ->limit($this->limitOffset, $this->limitLength) + ->order('l.id', 'desc') + ->select() + ->toArray(); + foreach ($rows as &$row) { + foreach ([ + 'local_medicine_id', 'local_status', 'mapping_id', 'mapping_status', + 'operator_id', 'mapping_update_time', 'catalog_version', 'remote_status', 'remote_deleted', + ] as $field) { + if ($row[$field] !== null) { + $row[$field] = (int) $row[$field]; + } + } + } + unset($row); + return $rows; + } + + public function count(): int + { + return (int) $this->query()->count('l.id'); + } + + private function query() + { + $query = 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' + ) + ->leftJoin('ej_medicine_catalog c', 'c.medicine_code = m.medicine_code') + ->whereNull('l.delete_time'); + + $localName = trim((string) ($this->params['local_name'] ?? '')); + if ($localName !== '') { + $query->where('l.name', 'like', '%' . $localName . '%'); + } + $remoteKeyword = trim((string) ($this->params['remote_keyword'] ?? '')); + if ($remoteKeyword !== '') { + $query->where(function ($nested) use ($remoteKeyword): void { + $nested->where('c.name', 'like', '%' . $remoteKeyword . '%') + ->whereOr('c.medicine_code', 'like', '%' . $remoteKeyword . '%'); + }); + } + + $mappingStatus = trim((string) ($this->params['mapping_status'] ?? '')); + if ($mappingStatus === 'mapped') { + $query->whereNotNull('m.id')->where('c.status', 1)->where('c.remote_deleted', 0); + } elseif ($mappingStatus === 'unmapped') { + $query->whereNull('m.id'); + } elseif ($mappingStatus === 'invalid') { + $query->whereNotNull('m.id') + ->where(function ($nested): void { + $nested->whereNull('c.id') + ->whereOr('c.status', '<>', 1) + ->whereOr('c.remote_deleted', 1); + }); + } + + return $query; + } + + private function fields(): string + { + return implode(',', [ + 'l.id AS local_medicine_id', + 'l.name AS local_name', + 'l.unit AS local_unit', + 'l.status AS local_status', + 'm.id AS mapping_id', + 'm.medicine_code', + 'm.operator_id', + 'm.operator_name', + 'm.update_time AS mapping_update_time', + 'c.name AS remote_name', + 'c.brand AS remote_brand', + 'c.unit AS remote_unit', + 'c.settlement_price', + 'c.retail_price', + 'c.catalog_version', + 'c.status AS remote_status', + 'c.remote_deleted', + "CASE WHEN m.id IS NULL THEN 0 " + . "WHEN c.id IS NULL OR c.status <> 1 OR c.remote_deleted = 1 THEN 2 ELSE 1 END AS mapping_status", + ]); + } +} diff --git a/server/app/adminapi/logic/pharmacy/MedicineMappingLogic.php b/server/app/adminapi/logic/pharmacy/MedicineMappingLogic.php new file mode 100644 index 00000000..fb2d753f --- /dev/null +++ b/server/app/adminapi/logic/pharmacy/MedicineMappingLogic.php @@ -0,0 +1,162 @@ +where('id', $localId)->lock(true)->find(); + $remote = Db::name('ej_medicine_catalog')->where('medicine_code', $medicineCode)->lock(true)->find(); + EjMedicineMappingPolicy::assertValid($local ?: [], $remote ?: []); + + $now = time(); + $mapping = Db::name('ej_medicine_mapping') + ->where('local_medicine_id', $localId) + ->lock(true) + ->find(); + $values = [ + 'medicine_code' => $medicineCode, + 'status' => 1, + 'operator_id' => $operatorId, + 'operator_name' => mb_substr(trim($operatorName), 0, 80), + 'update_time' => $now, + 'delete_time' => null, + ]; + if ($mapping) { + Db::name('ej_medicine_mapping')->where('id', (int) $mapping['id'])->update($values); + return; + } + Db::name('ej_medicine_mapping')->insert(array_merge($values, [ + 'local_medicine_id' => $localId, + 'create_time' => $now, + ])); + }); + return true; + } catch (Throwable $exception) { + self::setError(self::isDuplicateKey($exception) + ? '该本地药材映射刚被其他操作更新,请刷新后重试' + : $exception->getMessage()); + return false; + } + } + + public static function unlink(int $localMedicineId, int $operatorId, string $operatorName): bool + { + self::$error = ''; + try { + Db::transaction(function () use ($localMedicineId, $operatorId, $operatorName): void { + $local = Db::name('doctor_medicine')->where('id', $localMedicineId)->lock(true)->find(); + $mapping = Db::name('ej_medicine_mapping') + ->where('local_medicine_id', $localMedicineId) + ->lock(true) + ->find(); + $decision = EjMedicineMappingPolicy::unlinkDecision($local ?: [], $mapping ?: null); + if ($decision['already_unlinked']) { + return; + } + + $now = time(); + Db::name('ej_medicine_mapping') + ->where('id', $decision['mapping_id']) + ->where('local_medicine_id', $localMedicineId) + ->where('status', 1) + ->whereNull('delete_time') + ->update([ + 'status' => 0, + 'operator_id' => $operatorId, + 'operator_name' => mb_substr(trim($operatorName), 0, 80), + 'update_time' => $now, + 'delete_time' => $now, + ]); + }); + return true; + } catch (Throwable $exception) { + self::setError($exception->getMessage()); + return false; + } + } + + /** @return array|false */ + public static function sync() + { + self::$error = ''; + try { + return EjMedicineCatalogSyncService::sync(200); + } catch (Throwable $exception) { + self::setError($exception->getMessage()); + return false; + } + } + + /** @return array */ + public static function status(): array + { + $state = Db::name('ej_pharmacy_sync_state')->where('id', 1)->find() ?: []; + $catalogTotal = (int) Db::name('ej_medicine_catalog')->count(); + $catalogActive = (int) Db::name('ej_medicine_catalog') + ->where('status', 1)->where('remote_deleted', 0)->count(); + $unmappedLocal = (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'); + + return [ + 'sync_enabled' => (bool) Config::get('ej_pharmacy.catalog_sync_enabled', false), + 'cursor' => (int) ($state['cursor'] ?? 0), + 'last_success_time' => (int) ($state['last_success_time'] ?? 0), + 'last_failure_time' => (int) ($state['last_failure_time'] ?? 0), + 'last_error_summary' => (string) ($state['last_error_summary'] ?? ''), + 'is_syncing' => !empty($state['lock_token']) && (int) ($state['lock_expires_at'] ?? 0) >= time(), + 'catalog_total' => $catalogTotal, + 'catalog_active' => $catalogActive, + 'unmapped_local' => $unmappedLocal, + ]; + } + + /** @return array> */ + public static function catalogOptions(string $keyword, int $limit = 30): array + { + $query = Db::name('ej_medicine_catalog') + ->where('status', 1) + ->where('remote_deleted', 0); + $keyword = trim($keyword); + if ($keyword !== '') { + $query->where(function ($nested) use ($keyword): void { + $nested->where('name', 'like', '%' . $keyword . '%') + ->whereOr('medicine_code', 'like', '%' . $keyword . '%'); + }); + } + return $query + ->field('medicine_code,name,brand,unit,settlement_price,retail_price,catalog_version,status') + ->order('catalog_version', 'desc') + ->limit(min(max($limit, 1), 50)) + ->select() + ->toArray(); + } + + private static function isDuplicateKey(Throwable $exception): bool + { + return (string) $exception->getCode() === '23000' + || str_contains(strtolower($exception->getMessage()), 'duplicate'); + } +} diff --git a/server/app/adminapi/validate/pharmacy/MedicineMappingValidate.php b/server/app/adminapi/validate/pharmacy/MedicineMappingValidate.php new file mode 100644 index 00000000..6dfcbe66 --- /dev/null +++ b/server/app/adminapi/validate/pharmacy/MedicineMappingValidate.php @@ -0,0 +1,33 @@ + 'require|integer|gt:0', + 'medicine_code' => 'require|max:32', + ]; + + protected $message = [ + 'local_medicine_id.require' => '本地药材ID不能为空', + 'local_medicine_id.integer' => '本地药材ID格式错误', + 'local_medicine_id.gt' => '本地药材ID格式错误', + 'medicine_code.require' => '请选择洛阳药房药材', + 'medicine_code.max' => '洛阳药房药材编码不能超过32个字符', + ]; + + public function sceneSave(): self + { + return $this->only(['local_medicine_id', 'medicine_code']); + } + + public function sceneUnlink(): self + { + return $this->only(['local_medicine_id']); + } +} diff --git a/server/app/command/EjPharmacyBootstrapMedicines.php b/server/app/command/EjPharmacyBootstrapMedicines.php new file mode 100644 index 00000000..78c528cc --- /dev/null +++ b/server/app/command/EjPharmacyBootstrapMedicines.php @@ -0,0 +1,48 @@ +setName('ej-pharmacy:bootstrap-medicines') + ->setDescription('一次性导入并原子替换恩济药房药材目录投影') + ->addOption('replace', null, Option::VALUE_NONE, '确认替换本地 EJ 药材投影') + ->addOption('confirm', null, Option::VALUE_OPTIONAL, '破坏性操作确认令牌:RESET_TEST_CATALOG', '') + ->addOption('batch-size', null, Option::VALUE_OPTIONAL, '远端导入批次大小(1-500)', 100); + } + + protected function execute(Input $input, Output $output) + { + try { + $batchSize = (int) $input->getOption('batch-size'); + EjMedicineBootstrapService::assertCommandGate( + (bool) $input->getOption('replace'), + (string) $input->getOption('confirm'), + $batchSize + ); + $result = EjMedicineBootstrapService::execute($batchSize); + $output->writeln(sprintf( + 'bootstrap 完成 source=%d batches=%d catalog=%d active_mappings=%d unmapped=%d', + $result['source_count'], + $result['batch_count'], + $result['catalog'], + $result['active_mappings'], + $result['unmapped'] + )); + return 0; + } catch (\Throwable $exception) { + $output->error($exception->getMessage()); + return 1; + } + } +} diff --git a/server/app/command/EjPharmacySyncCatalog.php b/server/app/command/EjPharmacySyncCatalog.php new file mode 100644 index 00000000..ec9f0f2a --- /dev/null +++ b/server/app/command/EjPharmacySyncCatalog.php @@ -0,0 +1,41 @@ +setName('ej-pharmacy:sync-catalog') + ->setDescription('增量同步洛阳药房 ERP 药材目录') + ->addOption('limit', 'l', Option::VALUE_OPTIONAL, '每页数量', 200); + } + + protected function execute(Input $input, Output $output) + { + try { + $stats = EjMedicineCatalogSyncService::sync(max((int) $input->getOption('limit'), 1)); + $output->writeln(sprintf( + '同步完成 pages=%d pulled=%d created=%d updated=%d deactivated=%d cursor=%d', + $stats['pages'], + $stats['pulled'], + $stats['created'], + $stats['updated'], + $stats['deactivated'], + $stats['cursor'] + )); + return 0; + } catch (\Throwable $exception) { + $output->error($exception->getMessage()); + return 1; + } + } +} diff --git a/server/app/common/model/pharmacy/EjMedicineCatalog.php b/server/app/common/model/pharmacy/EjMedicineCatalog.php new file mode 100644 index 00000000..4cf14b4d --- /dev/null +++ b/server/app/common/model/pharmacy/EjMedicineCatalog.php @@ -0,0 +1,14 @@ + $row @return array */ + public static function fromRow(array $row): array + { + $sourceId = self::sourceId($row['id'] ?? null); + $name = self::text($row['name'] ?? null, '药材名称', 120); + $unit = self::text($row['unit'] ?? null, '药材单位', 24); + $status = $row['status'] ?? null; + if (!in_array($status, [0, 1, '0', '1'], true)) { + throw new InvalidArgumentException("本地药材 {$sourceId} 状态必须为 0 或 1"); + } + + return [ + 'source_medicine_id' => $sourceId, + 'name' => $name, + 'brand' => '', + 'unit' => $unit, + 'settlement_price' => self::roundPrice($row['settlement_price'] ?? null), + 'retail_price' => self::roundPrice($row['retail_price'] ?? null), + 'status' => (int) $status, + ]; + } + + /** @param array> $rows @return list> */ + public static function fromRows(array $rows): array + { + $items = array_map([self::class, 'fromRow'], $rows); + usort($items, static fn (array $left, array $right): int => self::compareIds( + (string) $left['source_medicine_id'], + (string) $right['source_medicine_id'] + )); + + $previousId = null; + foreach ($items as $item) { + $sourceId = (string) $item['source_medicine_id']; + if ($previousId !== null && hash_equals($previousId, $sourceId)) { + throw new InvalidArgumentException("本地药材 source_medicine_id 重复:{$sourceId}"); + } + $previousId = $sourceId; + } + return $items; + } + + public static function roundPrice(mixed $value): string + { + if (!is_string($value) || preg_match('/^(0|[1-9]\d*)\.(\d{1,6})$/D', $value, $matches) !== 1) { + throw new InvalidArgumentException('药材价格必须是最多六位小数的非负十进制字符串'); + } + $whole = ltrim($matches[1], '0'); + $whole = $whole === '' ? '0' : $whole; + $fraction = str_pad($matches[2], 6, '0'); + $fourDecimals = substr($fraction, 0, 4); + if ((int) $fraction[4] < 5) { + return $whole . '.' . $fourDecimals; + } + + $digits = self::addOne($whole . $fourDecimals); + if (strlen($digits) < 5) { + $digits = str_pad($digits, 5, '0', STR_PAD_LEFT); + } + return substr($digits, 0, -4) . '.' . substr($digits, -4); + } + + public static function compareIds(string $left, string $right): int + { + return strlen($left) <=> strlen($right) ?: strcmp($left, $right); + } + + private static function sourceId(mixed $value): string + { + if (is_int($value)) { + $value = (string) $value; + } + if (!is_string($value) || preg_match('/^\d+$/D', $value) !== 1) { + throw new InvalidArgumentException('本地药材 id 必须是正整数'); + } + $value = ltrim($value, '0'); + if ($value === '') { + throw new InvalidArgumentException('本地药材 id 必须是正整数'); + } + return $value; + } + + private static function text(mixed $value, string $field, int $maxLength): string + { + if (!is_string($value)) { + throw new InvalidArgumentException("{$field}必须是字符串"); + } + $trimmed = preg_replace('/\A[\s\p{Z}\p{Cf}]+|[\s\p{Z}\p{Cf}]+\z/u', '', $value); + if (!is_string($trimmed) || $trimmed === '' || mb_strlen($trimmed) > $maxLength) { + throw new InvalidArgumentException("{$field}不能为空且不能超过 {$maxLength} 个字符"); + } + return $trimmed; + } + + private static function addOne(string $digits): string + { + $characters = str_split($digits); + for ($index = count($characters) - 1; $index >= 0; --$index) { + if ($characters[$index] !== '9') { + $characters[$index] = (string) ((int) $characters[$index] + 1); + return implode('', $characters); + } + $characters[$index] = '0'; + } + return '1' . implode('', $characters); + } +} diff --git a/server/app/common/service/pharmacy/EjMedicineBootstrapService.php b/server/app/common/service/pharmacy/EjMedicineBootstrapService.php new file mode 100644 index 00000000..883c1148 --- /dev/null +++ b/server/app/common/service/pharmacy/EjMedicineBootstrapService.php @@ -0,0 +1,481 @@ + 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; + } +} diff --git a/server/app/common/service/pharmacy/EjMedicineCatalogSyncPolicy.php b/server/app/common/service/pharmacy/EjMedicineCatalogSyncPolicy.php new file mode 100644 index 00000000..14840a41 --- /dev/null +++ b/server/app/common/service/pharmacy/EjMedicineCatalogSyncPolicy.php @@ -0,0 +1,92 @@ +>,next_cursor:int,has_more:bool} */ + public static function parsePage(array $response, int $cursor): array + { + $body = is_array($response['body'] ?? null) ? $response['body'] : []; + $httpStatus = (int) ($response['http_status'] ?? 0); + if ($httpStatus < 200 || $httpStatus >= 300 || (int) ($body['code'] ?? -1) !== 0) { + $message = trim((string) ($body['message'] ?? '')); + throw new RuntimeException($message !== '' ? $message : '洛阳药房药材目录同步失败'); + } + + $data = is_array($body['data'] ?? null) ? $body['data'] : []; + $items = is_array($data['items'] ?? null) ? array_values(array_filter( + $data['items'], + static fn ($item): bool => is_array($item) + )) : []; + $nextCursor = max(0, (int) ($data['next_cursor'] ?? $cursor)); + $hasMore = !empty($data['has_more']); + if ($hasMore && $nextCursor <= $cursor) { + throw new RuntimeException('洛阳药房药材目录游标未推进,已停止同步'); + } + + return ['items' => $items, 'next_cursor' => $nextCursor, 'has_more' => $hasMore]; + } + + /** + * @param array|null $existing + * @param array $remote + * @return array{action:string,values:array,deactivated:int} + */ + public static function merge(?array $existing, array $remote): array + { + $code = trim((string) ($remote['medicine_code'] ?? '')); + if ($code === '') { + throw new RuntimeException('洛阳药房药材目录包含空 medicine_code'); + } + + $deleted = !empty($remote['deleted']) || !empty($remote['remote_deleted']); + $values = [ + 'medicine_code' => $code, + 'name' => trim((string) ($remote['name'] ?? '')), + 'brand' => trim((string) ($remote['brand'] ?? '')), + 'unit' => trim((string) ($remote['unit'] ?? '')), + 'settlement_price' => self::decimal($remote['settlement_price'] ?? 0), + 'retail_price' => self::decimal($remote['retail_price'] ?? 0), + 'status' => $deleted ? 0 : (int) ($remote['status'] ?? 0), + 'catalog_version' => max(0, (int) ($remote['catalog_version'] ?? 0)), + 'remote_deleted' => $deleted ? 1 : 0, + ]; + + if ($existing === null) { + return [ + 'action' => 'created', + 'values' => $values, + 'deactivated' => $values['status'] === 0 ? 1 : 0, + ]; + } + + $existingComparable = [ + 'medicine_code' => trim((string) ($existing['medicine_code'] ?? '')), + 'name' => trim((string) ($existing['name'] ?? '')), + 'brand' => trim((string) ($existing['brand'] ?? '')), + 'unit' => trim((string) ($existing['unit'] ?? '')), + 'settlement_price' => self::decimal($existing['settlement_price'] ?? 0), + 'retail_price' => self::decimal($existing['retail_price'] ?? 0), + 'status' => (int) ($existing['status'] ?? 0), + 'catalog_version' => max(0, (int) ($existing['catalog_version'] ?? 0)), + 'remote_deleted' => (int) ($existing['remote_deleted'] ?? 0), + ]; + $deactivated = $existingComparable['status'] === 1 && $values['status'] === 0 ? 1 : 0; + + return [ + 'action' => $existingComparable === $values ? 'unchanged' : 'updated', + 'values' => $values, + 'deactivated' => $deactivated, + ]; + } + + private static function decimal(mixed $value): string + { + return number_format(max(0.0, (float) $value), 4, '.', ''); + } +} diff --git a/server/app/common/service/pharmacy/EjMedicineCatalogSyncService.php b/server/app/common/service/pharmacy/EjMedicineCatalogSyncService.php new file mode 100644 index 00000000..902042be --- /dev/null +++ b/server/app/common/service/pharmacy/EjMedicineCatalogSyncService.php @@ -0,0 +1,156 @@ + self::acquireLock($token), + static fn () => self::releaseLock($token), + static fn (): int => (int) (Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)->value('cursor') ?? 0), + static fn (int $cursor, int $pageLimit): array => $client->medicines($cursor, $pageLimit), + static fn (array $items, int $nextCursor): array => self::mergePage($items, $nextCursor, $token), + static function (int $cursor) use ($token): void { + Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID) + ->where('lock_token', $token)->update([ + 'cursor' => $cursor, + 'last_success_time' => time(), + 'last_error_summary' => '', + 'update_time' => time(), + ]); + }, + static function (int $cursor, string $error) use ($token): void { + Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID) + ->where('lock_token', $token)->update([ + 'cursor' => $cursor, + 'last_failure_time' => time(), + 'last_error_summary' => $error, + 'update_time' => time(), + ]); + } + ); + + return $workflow->sync($limit); + } + + private static function ensureStateRow(): void + { + if (Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)->find()) { + return; + } + try { + Db::name('ej_pharmacy_sync_state')->insert([ + 'id' => self::STATE_ID, + 'cursor' => 0, + 'lock_token' => '', + 'lock_expires_at' => 0, + 'create_time' => time(), + 'update_time' => time(), + ]); + } catch (Throwable $exception) { + if (!self::isDuplicateKey($exception)) { + throw $exception; + } + } + } + + private static function acquireLock(string $token): bool + { + $now = time(); + $updated = Db::name('ej_pharmacy_sync_state') + ->where('id', self::STATE_ID) + ->where(function ($query) use ($now): void { + $query->where('lock_token', '')->whereOr('lock_expires_at', '<', $now); + }) + ->update([ + 'lock_token' => $token, + 'lock_expires_at' => $now + self::LOCK_TTL, + 'update_time' => $now, + ]); + return $updated === 1; + } + + private static function releaseLock(string $token): void + { + Db::name('ej_pharmacy_sync_state') + ->where('id', self::STATE_ID) + ->where('lock_token', $token) + ->update(['lock_token' => '', 'lock_expires_at' => 0, 'update_time' => time()]); + } + + /** @return array{created:int,updated:int,unchanged:int,deactivated:int} */ + private static function mergePage(array $items, int $nextCursor, string $token): array + { + return Db::transaction(function () use ($items, $nextCursor, $token): array { + $stats = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'deactivated' => 0]; + foreach ($items as $item) { + $code = trim((string) ($item['medicine_code'] ?? '')); + $model = $code === '' ? null : EjMedicineCatalog::where('medicine_code', $code)->lock(true)->find(); + $result = EjMedicineCatalogSyncPolicy::merge($model ? $model->toArray() : null, $item); + $values = $result['values']; + if ($result['action'] === 'created') { + EjMedicineCatalog::create($values); + } elseif ($result['action'] === 'updated' && $model) { + unset($values['medicine_code']); + $model->save($values); + } + ++$stats[$result['action']]; + $stats['deactivated'] += $result['deactivated']; + + if ($result['deactivated'] === 1) { + $now = time(); + Db::name('ej_medicine_mapping') + ->where('medicine_code', $code) + ->where('status', 1) + ->update(['status' => 0, 'delete_time' => $now, 'update_time' => $now]); + } + } + + $state = Db::name('ej_pharmacy_sync_state') + ->where('id', self::STATE_ID) + ->lock(true) + ->find(); + if (!$state || !hash_equals((string) $state['lock_token'], $token)) { + throw new RuntimeException('洛阳药房目录同步锁已失效,请重试'); + } + Db::name('ej_pharmacy_sync_state') + ->where('id', self::STATE_ID) + ->update([ + 'cursor' => $nextCursor, + 'lock_expires_at' => time() + self::LOCK_TTL, + 'update_time' => time(), + ]); + return $stats; + }); + } + + private static function isDuplicateKey(Throwable $exception): bool + { + return (string) $exception->getCode() === '23000' + || str_contains(strtolower($exception->getMessage()), 'duplicate'); + } +} diff --git a/server/app/common/service/pharmacy/EjMedicineCatalogSyncWorkflow.php b/server/app/common/service/pharmacy/EjMedicineCatalogSyncWorkflow.php new file mode 100644 index 00000000..066dfe12 --- /dev/null +++ b/server/app/common/service/pharmacy/EjMedicineCatalogSyncWorkflow.php @@ -0,0 +1,95 @@ +acquireLock = $acquireLock; + $this->releaseLock = $releaseLock; + $this->loadCursor = $loadCursor; + $this->fetchPage = $fetchPage; + $this->mergePage = $mergePage; + $this->markSuccess = $markSuccess; + $this->markFailure = $markFailure; + } + + /** @return array{pages:int,pulled:int,received:int,created:int,updated:int,unchanged:int,deactivated:int,cursor:int} */ + public function sync(int $limit = 200): array + { + if (!(bool) ($this->acquireLock)()) { + throw new DomainException('洛阳药房目录正在同步,请稍后重试'); + } + + $cursor = 0; + $stats = [ + 'pages' => 0, + 'pulled' => 0, + 'received' => 0, + 'created' => 0, + 'updated' => 0, + 'unchanged' => 0, + 'deactivated' => 0, + 'cursor' => $cursor, + ]; + + try { + $cursor = max(0, (int) ($this->loadCursor)()); + $stats['cursor'] = $cursor; + for ($page = 0; $page < 1000; ++$page) { + $parsed = EjMedicineCatalogSyncPolicy::parsePage( + ($this->fetchPage)($cursor, min(max($limit, 1), 500)), + $cursor + ); + $merged = ($this->mergePage)($parsed['items'], $parsed['next_cursor']); + ++$stats['pages']; + $pulled = count($parsed['items']); + $stats['pulled'] += $pulled; + $stats['received'] += $pulled; + foreach (['created', 'updated', 'unchanged', 'deactivated'] as $key) { + $stats[$key] += (int) ($merged[$key] ?? 0); + } + $cursor = $parsed['next_cursor']; + $stats['cursor'] = $cursor; + if (!$parsed['has_more']) { + ($this->markSuccess)($cursor, $stats); + return $stats; + } + } + throw new RuntimeException('洛阳药房药材目录分页超过安全上限'); + } catch (Throwable $exception) { + ($this->markFailure)($cursor, self::summarizeError($exception->getMessage())); + throw $exception; + } finally { + ($this->releaseLock)(); + } + } + + public static function summarizeError(string $message): string + { + $message = preg_replace('/(app[_-]?secret|signature|token|authorization)\s*[:=]\s*[^\s,;]+/i', '$1=[redacted]', $message) ?? $message; + return mb_substr(trim($message), 0, 500); + } +} diff --git a/server/app/common/service/pharmacy/EjMedicineMappingPolicy.php b/server/app/common/service/pharmacy/EjMedicineMappingPolicy.php new file mode 100644 index 00000000..993b06f2 --- /dev/null +++ b/server/app/common/service/pharmacy/EjMedicineMappingPolicy.php @@ -0,0 +1,62 @@ + $local @param array $remote */ + public static function assertValid(array $local, array $remote): void + { + if ((int) ($local['id'] ?? 0) <= 0) { + throw new DomainException('本地药材不存在'); + } + if (!empty($local['delete_time'])) { + throw new DomainException('本地药材已删除,不能建立映射'); + } + if ((int) ($local['status'] ?? 0) !== 1) { + throw new DomainException('本地药材已停用,不能建立映射'); + } + + if (trim((string) ($remote['medicine_code'] ?? '')) === '') { + throw new DomainException('洛阳药房药材不存在'); + } + if (!empty($remote['remote_deleted'])) { + throw new DomainException('洛阳药房药材已删除,不能建立映射'); + } + if ((int) ($remote['status'] ?? 0) !== 1) { + throw new DomainException('洛阳药房药材已停用,不能建立映射'); + } + } + + /** + * @param array $local + * @param array|null $mapping + * @return array{mapping_id:int,already_unlinked:bool} + */ + public static function unlinkDecision(array $local, ?array $mapping): array + { + $localId = (int) ($local['id'] ?? 0); + if ($localId <= 0) { + throw new DomainException('本地药材不存在'); + } + if ($mapping === null) { + return ['mapping_id' => 0, 'already_unlinked' => true]; + } + if ((int) ($mapping['local_medicine_id'] ?? 0) !== $localId) { + throw new DomainException('药材映射归属不匹配'); + } + + $mappingId = (int) ($mapping['id'] ?? 0); + if ($mappingId <= 0) { + throw new DomainException('药材映射记录无效'); + } + return [ + 'mapping_id' => $mappingId, + 'already_unlinked' => (int) ($mapping['status'] ?? 0) !== 1 || !empty($mapping['delete_time']), + ]; + } +} diff --git a/server/app/common/service/pharmacy/EjPharmacyCallbackFailureTransition.php b/server/app/common/service/pharmacy/EjPharmacyCallbackFailureTransition.php new file mode 100644 index 00000000..d31218de --- /dev/null +++ b/server/app/common/service/pharmacy/EjPharmacyCallbackFailureTransition.php @@ -0,0 +1,29 @@ +,string):bool $conditionalUpdate */ + public static function apply(int $inboxId, string $error, callable $conditionalUpdate): bool + { + if ($inboxId <= 0) { + throw new InvalidArgumentException('callback inbox id is missing'); + } + + return (bool) Closure::fromCallable($conditionalUpdate)( + $inboxId, + [ + 'process_status' => 'FAILED', + 'error_message' => mb_substr($error, 0, 1000), + 'update_time' => time(), + ], + 'PROCESSED' + ); + } +} diff --git a/server/app/common/service/pharmacy/EjPharmacyCallbackRetryException.php b/server/app/common/service/pharmacy/EjPharmacyCallbackRetryException.php new file mode 100644 index 00000000..87261be8 --- /dev/null +++ b/server/app/common/service/pharmacy/EjPharmacyCallbackRetryException.php @@ -0,0 +1,11 @@ +loadInbox = Closure::fromCallable($loadInbox); + $this->createInbox = Closure::fromCallable($createInbox); + $this->reloadInbox = Closure::fromCallable($reloadInbox); + $this->process = Closure::fromCallable($process); + $this->markFailed = Closure::fromCallable($markFailed); + $this->isDuplicateKey = Closure::fromCallable($isDuplicateKey); + } + + /** @param array $payload @return array */ + public function handle(array $payload): array + { + $eventId = trim((string) ($payload['event_id'] ?? '')); + if ($eventId === '') { + return ['http_status' => 422, 'message' => 'event_id is required', 'duplicate' => false]; + } + + $inbox = ($this->loadInbox)($eventId); + if (is_array($inbox) && strtoupper((string) ($inbox['process_status'] ?? '')) === 'PROCESSED') { + return ['http_status' => 200, 'message' => 'ok', 'duplicate' => true]; + } + + if (!is_array($inbox)) { + try { + $inbox = ($this->createInbox)($payload); + } catch (Throwable $exception) { + if (!(bool) ($this->isDuplicateKey)($exception)) { + return ['http_status' => 500, 'message' => $exception->getMessage(), 'duplicate' => false]; + } + $inbox = ($this->reloadInbox)($eventId); + if (!is_array($inbox)) { + return ['http_status' => 500, 'message' => 'callback inbox race could not be reloaded', 'duplicate' => false]; + } + if (strtoupper((string) ($inbox['process_status'] ?? '')) === 'PROCESSED') { + return ['http_status' => 200, 'message' => 'ok', 'duplicate' => true]; + } + } + } + + try { + ($this->process)($inbox, $payload); + return ['http_status' => 200, 'message' => 'ok', 'duplicate' => false]; + } catch (EjPharmacyCallbackRetryException $exception) { + ($this->markFailed)($inbox, $exception->getMessage()); + return ['http_status' => 503, 'message' => $exception->getMessage(), 'duplicate' => false]; + } catch (InvalidArgumentException|DomainException $exception) { + ($this->markFailed)($inbox, $exception->getMessage()); + return ['http_status' => 422, 'message' => $exception->getMessage(), 'duplicate' => false]; + } catch (Throwable $exception) { + ($this->markFailed)($inbox, $exception->getMessage()); + return ['http_status' => 500, 'message' => $exception->getMessage(), 'duplicate' => false]; + } + } +} diff --git a/server/app/common/service/pharmacy/EjPharmacyClient.php b/server/app/common/service/pharmacy/EjPharmacyClient.php new file mode 100644 index 00000000..44723be6 --- /dev/null +++ b/server/app/common/service/pharmacy/EjPharmacyClient.php @@ -0,0 +1,134 @@ +):array{http_status:int,body:array,request_id:string} */ + private ?Closure $transport; + + public function __construct( + ?string $baseUrl = null, + ?string $appKey = null, + ?string $appSecret = null, + ?callable $transport = null + ) + { + $this->baseUrl = rtrim($baseUrl ?? (string) Config::get('ej_pharmacy.base_url', ''), '/'); + $this->appKey = $appKey ?? (string) Config::get('ej_pharmacy.app_key', ''); + $this->appSecret = $appSecret ?? (string) Config::get('ej_pharmacy.app_secret', ''); + if ($this->baseUrl === '' || $this->appKey === '' || $this->appSecret === '') { + throw new RuntimeException('恩济药房接口未配置完整'); + } + $this->transport = $transport === null ? null : Closure::fromCallable($transport); + } + + public static function isConfigured(): bool + { + return (bool) Config::get('ej_pharmacy.enabled', false) + && trim((string) Config::get('ej_pharmacy.base_url', '')) !== '' + && trim((string) Config::get('ej_pharmacy.app_key', '')) !== '' + && trim((string) Config::get('ej_pharmacy.app_secret', '')) !== ''; + } + + /** @return array{http_status:int,body:array,request_id:string} */ + public function medicines(int $after = 0, int $limit = 100): array + { + return $this->request('GET', '/api/openapi/v1/medicines', null, [ + 'after' => max($after, 0), + 'limit' => min(max($limit, 1), 500), + ]); + } + + /** @param array $payload @return array{http_status:int,body:array,request_id:string} */ + public function importMedicines(array $payload): array + { + return $this->request('POST', '/api/openapi/v1/medicine-imports', $payload); + } + + /** @param array $payload @return array{http_status:int,body:array,request_id:string} */ + public function createPrescriptionOrder(array $payload): array + { + return $this->request('POST', '/api/openapi/v1/prescription-orders', $payload); + } + + /** @return array{http_status:int,body:array,request_id:string} */ + public function prescriptionOrder(string $sourceOrderNo, int $sourceRevision = 0): array + { + $query = ['source_system' => 'zyt']; + if ($sourceRevision > 0) { + $query['source_revision'] = $sourceRevision; + } + return $this->request( + 'GET', + '/api/openapi/v1/prescription-orders/' . rawurlencode($sourceOrderNo), + null, + $query + ); + } + + /** @param array|null $payload @param array $query @return array{http_status:int,body:array,request_id:string} */ + private function request(string $method, string $path, ?array $payload = null, array $query = []): array + { + $queryString = $query === [] ? '' : http_build_query($query, '', '&', PHP_QUERY_RFC3986); + $pathWithQuery = $path . ($queryString !== '' ? '?' . $queryString : ''); + $body = $payload === null + ? '' + : (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + $timestamp = (string) time(); + $nonce = bin2hex(random_bytes(16)); + $requestId = bin2hex(random_bytes(16)); + $canonical = EjPharmacySignature::canonical($method, $pathWithQuery, $timestamp, $nonce, $body); + + $headers = [ + 'Accept: application/json', + 'Content-Type: application/json; charset=utf-8', + 'X-App-Key: ' . $this->appKey, + 'X-Timestamp: ' . $timestamp, + 'X-Nonce: ' . $nonce, + 'X-Signature: ' . EjPharmacySignature::sign($this->appSecret, $canonical), + 'X-Request-Id: ' . $requestId, + 'Expect:', + ]; + + if ($this->transport !== null) { + return ($this->transport)(strtoupper($method), $pathWithQuery, $body, $headers); + } + + $ch = curl_init($this->baseUrl . $pathWithQuery); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => strtoupper($method), + CURLOPT_HTTPHEADER => $headers, + CURLOPT_CONNECTTIMEOUT => (int) Config::get('ej_pharmacy.connect_timeout', 5), + CURLOPT_TIMEOUT => (int) Config::get('ej_pharmacy.request_timeout', 30), + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + if ($payload !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $raw = curl_exec($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + if ($raw === false) { + throw new RuntimeException('恩济药房通信失败:' . $error); + } + $decoded = json_decode((string) $raw, true); + if (!is_array($decoded)) { + throw new RuntimeException('恩济药房返回了无效 JSON,HTTP ' . $httpStatus); + } + + return ['http_status' => $httpStatus, 'body' => $decoded, 'request_id' => $requestId]; + } +} diff --git a/server/app/common/service/pharmacy/EjPharmacyPayload.php b/server/app/common/service/pharmacy/EjPharmacyPayload.php new file mode 100644 index 00000000..e215efc3 --- /dev/null +++ b/server/app/common/service/pharmacy/EjPharmacyPayload.php @@ -0,0 +1,101 @@ + $order + * @param array $prescription + * @param array $medicineMappings + * @return array + */ + public static function build(array $order, array $prescription, array $medicineMappings, int $revision): array + { + $orderNo = trim((string) ($order['order_no'] ?? '')); + if ($orderNo === '') { + throw new InvalidArgumentException('order_no is required'); + } + $herbs = $prescription['herbs'] ?? []; + if (!is_array($herbs) || $herbs === []) { + throw new InvalidArgumentException('prescription herbs are required'); + } + $medicines = []; + foreach ($herbs as $herb) { + if (!is_array($herb)) { + continue; + } + $medicineId = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0); + $name = trim((string) ($herb['name'] ?? $herb['title'] ?? '')); + $medicineCode = trim((string) ($medicineMappings[$medicineId] ?? '')); + if ($medicineId <= 0 || $medicineCode === '') { + throw new DomainException('Unmapped medicine: ' . ($name !== '' ? $name : (string) $medicineId)); + } + $quantity = (float) ($herb['dose'] ?? $herb['dosage'] ?? $herb['quantity'] ?? 0); + if ($quantity <= 0) { + throw new InvalidArgumentException('Medicine quantity must be positive: ' . $name); + } + $medicines[] = [ + 'source_medicine_id' => (string) $medicineId, + 'medicine_code' => $medicineCode, + 'name' => $name, + 'quantity' => number_format($quantity, 4, '.', ''), + 'unit' => trim((string) ($herb['unit'] ?? '克')) ?: '克', + 'usage' => trim((string) ($herb['usage'] ?? '')), + ]; + } + if ($medicines === []) { + throw new InvalidArgumentException('prescription herbs are required'); + } + + return [ + 'source_system' => 'zyt', + 'source_order_no' => $orderNo, + 'source_revision' => max($revision, 1), + 'patient' => [ + 'source_patient_id' => (string) ($prescription['patient_id'] ?? ''), + 'name' => trim((string) ($prescription['patient_name'] ?? $order['recipient_name'] ?? '')), + 'id_card' => trim((string) ($prescription['id_card'] ?? '')), + 'mobile' => trim((string) ($prescription['phone'] ?? $order['recipient_phone'] ?? '')), + ], + 'shipping' => [ + 'recipient_name' => trim((string) ($order['recipient_name'] ?? $prescription['patient_name'] ?? '')), + 'recipient_mobile' => trim((string) ($order['recipient_phone'] ?? $prescription['phone'] ?? '')), + 'province' => trim((string) ($order['shipping_province'] ?? '')), + 'city' => trim((string) ($order['shipping_city'] ?? '')), + 'district' => trim((string) ($order['shipping_district'] ?? '')), + 'address' => trim((string) ($order['shipping_address'] ?? '')), + ], + 'prescription' => [ + 'source_prescription_id' => (string) ($prescription['id'] ?? ''), + 'diagnosis' => trim((string) ( + $prescription['clinical_diagnosis'] + ?? $prescription['diagnosis'] + ?? $prescription['diagnosis_name'] + ?? '' + )), + 'processing_type' => trim((string) ($prescription['processing_type'] ?? 'decoction')) ?: 'decoction', + 'dose_count' => max((int) ($order['dose_count'] ?? $prescription['dose_count'] ?? 1), 1), + 'doctor' => [ + 'source_doctor_id' => (string) ($prescription['creator_id'] ?? $prescription['doctor_id'] ?? ''), + 'name' => trim((string) ($prescription['doctor_name'] ?? '')), + ], + 'doctor_signature' => is_array($prescription['doctor_signature'] ?? null) + ? $prescription['doctor_signature'] + : [], + 'medicines' => $medicines, + 'instructions' => trim((string) ( + $prescription['usage_instruction'] + ?? $prescription['instructions'] + ?? $prescription['advice'] + ?? '' + )), + ], + ]; + } +} diff --git a/server/app/common/service/pharmacy/EjPharmacyShipmentPolicy.php b/server/app/common/service/pharmacy/EjPharmacyShipmentPolicy.php new file mode 100644 index 00000000..57753899 --- /dev/null +++ b/server/app/common/service/pharmacy/EjPharmacyShipmentPolicy.php @@ -0,0 +1,25 @@ + $payload */ + public static function isShippedEvent(array $payload): bool + { + return strtoupper(trim((string) ($payload['event_type'] ?? ''))) === 'ORDER_SHIPPED' + || strtoupper(trim((string) ($payload['status'] ?? ''))) === 'SHIPPED'; + } + + /** @param array $payload */ + public static function nextFulfillmentStatus(int $currentStatus, array $payload): int + { + if (self::isShippedEvent($payload) && in_array($currentStatus, [1, 2], true)) { + return 5; + } + + return $currentStatus; + } +} diff --git a/server/app/common/service/pharmacy/EjPharmacySignature.php b/server/app/common/service/pharmacy/EjPharmacySignature.php new file mode 100644 index 00000000..49f8ec1d --- /dev/null +++ b/server/app/common/service/pharmacy/EjPharmacySignature.php @@ -0,0 +1,29 @@ +|null $current Active tracking for this order. + * @param array|null $matching Tracking already owning the incoming number. + * @return array{action:string,archive_current:bool} + */ + public static function select(?array $current, ?array $matching, int $orderId, string $trackingNumber): array + { + $trackingNumber = trim($trackingNumber); + if ($current !== null && trim((string) ($current['tracking_number'] ?? '')) === $trackingNumber) { + return ['action' => 'REUSE_CURRENT', 'archive_current' => false]; + } + + if ($matching !== null) { + $ownerOrderId = (int) ($matching['order_id'] ?? 0); + if ($ownerOrderId !== 0 && $ownerOrderId !== $orderId) { + throw new DomainException('该运单号已关联其他订单,禁止重新绑定'); + } + + return ['action' => 'REUSE_MATCHING', 'archive_current' => $current !== null]; + } + + return ['action' => 'CREATE', 'archive_current' => $current !== null]; + } +} diff --git a/server/app/common/service/pharmacy/LockedPharmacySnapshotMutation.php b/server/app/common/service/pharmacy/LockedPharmacySnapshotMutation.php new file mode 100644 index 00000000..0519b58d --- /dev/null +++ b/server/app/common/service/pharmacy/LockedPharmacySnapshotMutation.php @@ -0,0 +1,92 @@ + $lockOrder + * @param callable(array):?array $lockClaim + * @param callable(array,?array):mixed $mutation + */ + public static function run( + callable $lockOrder, + callable $lockClaim, + callable $mutation, + bool $assertMutable = true + ): mixed { + $order = Closure::fromCallable($lockOrder)(); + if ($order === []) { + throw new DomainException('订单不存在'); + } + $claim = Closure::fromCallable($lockClaim)($order); + if ($assertMutable) { + PharmacyRemoteSnapshotPolicy::assertMutable($order, $claim); + } + + $result = Closure::fromCallable($mutation)($order, $claim); + if ($result === false) { + throw new DomainException('受保护变更未完成,事务已回滚'); + } + + return $result; + } + + /** @param callable(array,?array):mixed $mutation */ + public static function execute( + int $orderId, + callable $mutation, + bool $assertMutable = true, + int $revision = 1 + ): mixed { + return Db::transaction(static fn (): mixed => self::run( + static fn (): array => (array) (Db::name('tcm_prescription_order') + ->where('id', $orderId) + ->whereNull('delete_time') + ->lock(true) + ->find() ?: []), + static fn (): ?array => Db::name('pharmacy_submission_claim') + ->where('prescription_order_id', $orderId) + ->where('source_revision', max($revision, 1)) + ->lock(true) + ->find() ?: null, + $mutation, + $assertMutable + )); + } + + /** @param callable(array>):mixed $mutation */ + public static function executeForPrescription(int $prescriptionId, callable $mutation): mixed + { + return Db::transaction(static function () use ($prescriptionId, $mutation): mixed { + $orders = Db::name('tcm_prescription_order') + ->where('prescription_id', $prescriptionId) + ->whereNull('delete_time') + ->order('id', 'asc') + ->lock(true) + ->select() + ->toArray(); + foreach ($orders as $order) { + $claim = Db::name('pharmacy_submission_claim') + ->where('prescription_order_id', (int) $order['id']) + ->where('source_revision', 1) + ->lock(true) + ->find(); + PharmacyRemoteSnapshotPolicy::assertMutable($order, $claim ?: null); + } + + $result = Closure::fromCallable($mutation)($orders); + if ($result === false) { + throw new DomainException('受保护变更未完成,事务已回滚'); + } + + return $result; + }); + } +} diff --git a/server/app/common/service/pharmacy/PharmacyHerbIdentityResolver.php b/server/app/common/service/pharmacy/PharmacyHerbIdentityResolver.php new file mode 100644 index 00000000..4eac6f3c --- /dev/null +++ b/server/app/common/service/pharmacy/PharmacyHerbIdentityResolver.php @@ -0,0 +1,100 @@ +> $herbs + * @param callable(array):array> $loadByIds + * @param callable(array):array> $loadByNames + * @return array> + */ + public static function resolve(array $herbs, callable $loadByIds, callable $loadByNames): array + { + $ids = []; + $names = []; + foreach ($herbs as $herb) { + if (!is_array($herb)) { + continue; + } + $id = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0); + if ($id > 0) { + $ids[] = $id; + continue; + } + $name = trim((string) ($herb['name'] ?? $herb['title'] ?? '')); + if ($name !== '') { + $names[] = $name; + } + } + + $byId = []; + foreach ($ids === [] ? [] : $loadByIds(array_values(array_unique($ids))) as $row) { + if (self::isActive($row)) { + $byId[(int) $row['id']] = $row; + } + } + $byName = []; + foreach ($names === [] ? [] : $loadByNames(array_values(array_unique($names))) as $row) { + if (!self::isActive($row)) { + continue; + } + $name = trim((string) ($row['name'] ?? '')); + if ($name !== '') { + $byName[$name][] = $row; + } + } + + $resolved = []; + foreach ($herbs as $herb) { + if (!is_array($herb)) { + continue; + } + $name = trim((string) ($herb['name'] ?? $herb['title'] ?? '')); + $id = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0); + if ($id > 0) { + $row = $byId[$id] ?? null; + if (!is_array($row)) { + throw new DomainException('药材“' . ($name !== '' ? $name : (string) $id) . '”对应的本地药材不存在或已停用'); + } + } else { + if ($name === '') { + throw new DomainException('药材名称不能为空'); + } + $candidates = $byName[$name] ?? []; + if (count($candidates) === 0) { + throw new DomainException('药材“' . $name . '”未在本地药材库中找到'); + } + if (count($candidates) !== 1) { + throw new DomainException('药材“' . $name . '”存在多个同名记录,请重新选择具体药材'); + } + $row = $candidates[0]; + $id = (int) $row['id']; + } + + $herb['medicine_id'] = $id; + $herb['name'] = trim((string) ($row['name'] ?? $name)); + unset($herb['id'], $herb['title'], $herb['local_medicine_id']); + $resolved[] = $herb; + } + + if ($resolved === []) { + throw new DomainException('处方药材不能为空'); + } + + return $resolved; + } + + /** @param array $row */ + private static function isActive(array $row): bool + { + return (int) ($row['id'] ?? 0) > 0 + && (int) ($row['status'] ?? 0) === 1 + && ($row['delete_time'] ?? null) === null; + } +} diff --git a/server/app/common/service/pharmacy/PharmacyLogisticsValue.php b/server/app/common/service/pharmacy/PharmacyLogisticsValue.php new file mode 100644 index 00000000..b4998f05 --- /dev/null +++ b/server/app/common/service/pharmacy/PharmacyLogisticsValue.php @@ -0,0 +1,27 @@ + $maxLength) { + throw new InvalidArgumentException($label . '长度不能超过' . $maxLength . '个字符'); + } + + return $normalized; + } +} diff --git a/server/app/common/service/pharmacy/PharmacyReconciliationRequiredException.php b/server/app/common/service/pharmacy/PharmacyReconciliationRequiredException.php new file mode 100644 index 00000000..1fd2ee7b --- /dev/null +++ b/server/app/common/service/pharmacy/PharmacyReconciliationRequiredException.php @@ -0,0 +1,11 @@ + $order + * @param array|null $claim + */ + public static function isLocked(array $order, ?array $claim): bool + { + if (trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== '') { + return true; + } + if (trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== '') { + return true; + } + if ((int) ($order['gancao_submit_time'] ?? 0) > 0 || (int) ($order['ej_pharmacy_submit_time'] ?? 0) > 0) { + return true; + } + + return is_array($claim) && in_array( + strtoupper((string) ($claim['status'] ?? '')), + ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'], + true + ); + } + + /** + * @param array $order + * @param array|null $claim + */ + public static function assertMutable(array $order, ?array $claim): void + { + if (self::isLocked($order, $claim)) { + throw new DomainException('订单已提交药房,患者、地址、处方与发货药房快照不可修改;请先完成远端取消确认,取消后创建新版本'); + } + } +} diff --git a/server/app/common/service/pharmacy/PharmacySubmissionClaimPolicy.php b/server/app/common/service/pharmacy/PharmacySubmissionClaimPolicy.php new file mode 100644 index 00000000..e12e2d66 --- /dev/null +++ b/server/app/common/service/pharmacy/PharmacySubmissionClaimPolicy.php @@ -0,0 +1,44 @@ + $claim @return array */ + public static function existingDecision(array $claim, string $requestedTarget, ?int $now = null): array + { + $target = (string) ($claim['target'] ?? ''); + $status = strtoupper(trim((string) ($claim['status'] ?? ''))); + if ($status === 'SUCCESS') { + if ($target !== $requestedTarget) { + throw new DomainException('该订单已上传其他药房'); + } + return ['action' => 'IDEMPOTENT'] + $claim; + } + if ($status === 'PENDING') { + $leaseExpiresAt = (int) ($claim['lease_expires_at'] ?? 0); + if ($leaseExpiresAt > 0 && $leaseExpiresAt <= ($now ?? time())) { + return [ + 'action' => $target === 'gancao' ? 'RECONCILE' : 'RETRY', + 'lease_expired' => true, + ] + $claim; + } + throw new DomainException('该订单正在上传药房,请勿重复提交'); + } + if (in_array($status, ['UNKNOWN', 'PENDING_RECONCILE'], true)) { + if ($target !== $requestedTarget) { + throw new DomainException('该订单远端结果待对账,禁止切换药房'); + } + return ['action' => 'RECONCILE'] + $claim; + } + if ($status === 'FAILED') { + return ['action' => 'RETRY'] + $claim; + } + + throw new DomainException('药房提交状态异常,请先对账处理'); + } +} diff --git a/server/app/common/service/pharmacy/PharmacySubmissionClaimService.php b/server/app/common/service/pharmacy/PharmacySubmissionClaimService.php new file mode 100644 index 00000000..0531cf36 --- /dev/null +++ b/server/app/common/service/pharmacy/PharmacySubmissionClaimService.php @@ -0,0 +1,441 @@ + */ + public static function acquire( + int $orderId, + int $revision, + string $target, + int $operatorId, + string $operatorName + ): array { + self::assertTarget($target); + $revision = max($revision, 1); + + return Db::transaction(function () use ($orderId, $revision, $target, $operatorId, $operatorName): array { + $order = Db::name('tcm_prescription_order') + ->where('id', $orderId) + ->whereNull('delete_time') + ->lock(true) + ->find(); + if (!$order) { + throw new DomainException('订单不存在'); + } + + $expectedTarget = self::targetForShipMode((string) ($order['ship_mode'] ?? 'gancao')); + if ($expectedTarget !== $target) { + throw new DomainException('发货药房已变更,请刷新后重试'); + } + + $claim = Db::name('pharmacy_submission_claim') + ->where('prescription_order_id', $orderId) + ->where('source_revision', $revision) + ->lock(true) + ->find(); + if ($claim) { + $decision = PharmacySubmissionClaimPolicy::existingDecision($claim, $target); + if ($decision['action'] === 'IDEMPOTENT') { + return [ + 'target' => $target, + 'token' => (string) $claim['claim_token'], + 'status' => 'SUCCESS', + 'idempotent' => true, + 'result' => self::idempotentResult($target, (string) ($claim['remote_order_no'] ?? '')), + ]; + } + if ($decision['action'] === 'RECONCILE') { + if (!empty($decision['lease_expired'])) { + $claim = self::expirePendingGancaoClaim($claim, $operatorId, $operatorName); + } + return [ + 'target' => $target, + 'token' => (string) $claim['claim_token'], + 'status' => (string) $claim['status'], + 'idempotency_key' => (string) $claim['idempotency_key'], + 'idempotent' => false, + 'reconcile' => true, + ]; + } + } + + if (self::hasAnyRemoteOrder($order)) { + throw new DomainException('该订单已存在远程药房单号,不可重复提交'); + } + + $token = bin2hex(random_bytes(16)); + $now = time(); + $leaseSeconds = max(30, (int) Config::get('ej_pharmacy.submission_lease_seconds', 300)); + $values = [ + 'target' => $target, + 'status' => 'PENDING', + 'claim_token' => $token, + 'idempotency_key' => hash('sha256', $orderId . ':' . $revision . ':' . $target), + 'remote_order_no' => '', + 'request_id' => '', + 'error_message' => '', + 'operator_id' => $operatorId, + 'operator_name' => mb_substr(trim($operatorName), 0, 80), + 'claimed_at' => $now, + 'lease_expires_at' => $now + $leaseSeconds, + 'completed_at' => 0, + 'failed_at' => 0, + 'update_time' => $now, + ]; + if ($claim) { + Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id'])->update($values); + } else { + Db::name('pharmacy_submission_claim')->insert($values + [ + 'prescription_order_id' => $orderId, + 'source_revision' => $revision, + 'create_time' => $now, + ]); + } + + return $values + ['idempotent' => false]; + }); + } + + /** @param array $result */ + public static function markSuccess( + int $orderId, + int $revision, + string $target, + string $token, + array $result + ): bool { + self::assertTarget($target); + $remoteOrderNo = trim((string) ( + $result['remote_order_no'] + ?? $result['pharmacy_order_no'] + ?? $result['recipel_order_no'] + ?? '' + )); + if ($remoteOrderNo === '') { + throw new DomainException('药房返回缺少远程订单号'); + } + + return Db::transaction(function () use ($orderId, $revision, $target, $token, $result, $remoteOrderNo): bool { + $order = Db::name('tcm_prescription_order') + ->where('id', $orderId) + ->whereNull('delete_time') + ->lock(true) + ->find(); + if (!$order || self::hasConflictingRemoteOrder($order, $target)) { + return false; + } + $claim = Db::name('pharmacy_submission_claim') + ->where('prescription_order_id', $orderId) + ->where('source_revision', max($revision, 1)) + ->where('target', $target) + ->where('claim_token', $token) + ->whereIn('status', ['PENDING', 'PENDING_RECONCILE']) + ->lock(true) + ->find(); + if (!$claim) { + return false; + } + + $now = time(); + $claimUpdated = Db::name('pharmacy_submission_claim') + ->where('id', (int) $claim['id']) + ->where('target', $target) + ->where('claim_token', $token) + ->whereIn('status', ['PENDING', 'PENDING_RECONCILE']) + ->update([ + 'status' => 'SUCCESS', + 'remote_order_no' => mb_substr($remoteOrderNo, 0, 64), + 'request_id' => mb_substr((string) ($result['request_id'] ?? ''), 0, 64), + 'error_message' => '', + 'completed_at' => $now, + 'failed_at' => 0, + 'lease_expires_at' => 0, + 'update_time' => $now, + ]); + if ($claimUpdated !== 1) { + return false; + } + + $orderValues = $target === 'direct' + ? [ + 'ej_pharmacy_order_no' => mb_substr($remoteOrderNo, 0, 40), + 'ej_pharmacy_submit_time' => $now, + 'ej_pharmacy_status' => (string) ($result['status'] ?? 'PENDING_REVIEW'), + 'ej_pharmacy_review_status' => (string) ($result['review_status'] ?? 'PENDING'), + 'ej_pharmacy_status_version' => (int) ($result['status_version'] ?? 1), + ] + : [ + 'gancao_reciperl_order_no' => mb_substr($remoteOrderNo, 0, 32), + 'gancao_submit_time' => $now, + ]; + Db::name('tcm_prescription_order')->where('id', $orderId)->update($orderValues); + + return true; + }); + } + + public static function markFailure( + int $orderId, + int $revision, + string $target, + string $token, + string $error + ): bool { + return Db::transaction(function () use ($orderId, $revision, $target, $token, $error): bool { + $order = Db::name('tcm_prescription_order') + ->where('id', $orderId) + ->whereNull('delete_time') + ->lock(true) + ->find(); + if (!$order) { + return false; + } + $claim = Db::name('pharmacy_submission_claim') + ->where('prescription_order_id', $orderId) + ->where('source_revision', max($revision, 1)) + ->where('target', $target) + ->where('claim_token', $token) + ->where('status', 'PENDING') + ->lock(true) + ->find(); + if (!$claim) { + return false; + } + + $now = time(); + return Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id']) + ->where('status', 'PENDING') + ->update([ + 'status' => 'FAILED', + 'error_message' => mb_substr($error, 0, 1000), + 'failed_at' => $now, + 'lease_expires_at' => 0, + 'update_time' => $now, + ]) === 1; + }); + } + + public static function markReconcile( + int $orderId, + int $revision, + string $target, + string $token, + string $error + ): bool { + return Db::transaction(function () use ($orderId, $revision, $target, $token, $error): bool { + Db::name('tcm_prescription_order') + ->where('id', $orderId) + ->lock(true) + ->find(); + $claim = Db::name('pharmacy_submission_claim') + ->where('prescription_order_id', $orderId) + ->where('source_revision', max($revision, 1)) + ->where('target', $target) + ->where('claim_token', $token) + ->whereIn('status', ['PENDING', 'PENDING_RECONCILE']) + ->lock(true) + ->find(); + if (!$claim) { + return false; + } + + return Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id']) + ->whereIn('status', ['PENDING', 'PENDING_RECONCILE']) + ->update([ + 'status' => 'PENDING_RECONCILE', + 'error_message' => mb_substr($error, 0, 1000), + 'failed_at' => 0, + 'lease_expires_at' => 0, + 'update_time' => time(), + ]) === 1; + }); + } + + /** @return array|null */ + public static function claimForOrder(int $orderId, int $revision = 1): ?array + { + $claim = Db::name('pharmacy_submission_claim') + ->where('prescription_order_id', $orderId) + ->where('source_revision', max($revision, 1)) + ->find(); + + return is_array($claim) ? $claim : null; + } + + /** @return array */ + public static function resolveGancao( + int $orderId, + int $revision, + string $resolution, + string $remoteOrderNo, + string $note, + int $operatorId, + string $operatorName + ): array { + return Db::transaction(function () use ( + $orderId, + $revision, + $resolution, + $remoteOrderNo, + $note, + $operatorId, + $operatorName + ): array { + $order = Db::name('tcm_prescription_order') + ->where('id', $orderId) + ->whereNull('delete_time') + ->lock(true) + ->find(); + if (!$order) { + throw new DomainException('订单不存在'); + } + $claim = Db::name('pharmacy_submission_claim') + ->where('prescription_order_id', $orderId) + ->where('source_revision', max($revision, 1)) + ->lock(true) + ->find(); + if (!$claim) { + throw new DomainException('未找到待核对的甘草提交'); + } + $resolved = PharmacySubmissionReconciliationPolicy::resolve( + $claim, + $resolution, + $remoteOrderNo, + $note + ); + if ($resolved['status'] === 'SUCCESS' && self::hasConflictingRemoteOrder($order, 'gancao')) { + throw new DomainException('订单已存在洛阳药房单号,不能确认甘草成功'); + } + + $now = time(); + $updated = Db::name('pharmacy_submission_claim') + ->where('id', (int) $claim['id']) + ->where('claim_token', (string) $claim['claim_token']) + ->whereIn('status', ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE']) + ->update([ + 'status' => $resolved['status'], + 'remote_order_no' => mb_substr($resolved['remote_order_no'], 0, 64), + 'error_message' => mb_substr($resolved['note'], 0, 1000), + 'lease_expires_at' => 0, + 'completed_at' => $resolved['status'] === 'SUCCESS' ? $now : 0, + 'failed_at' => $resolved['status'] === 'FAILED' ? $now : 0, + 'update_time' => $now, + ]); + if ($updated !== 1) { + throw new DomainException('提交状态已变化,请刷新后重新核对'); + } + if ($resolved['status'] === 'SUCCESS') { + Db::name('tcm_prescription_order')->where('id', $orderId)->update([ + 'gancao_reciperl_order_no' => mb_substr($resolved['remote_order_no'], 0, 32), + 'gancao_submit_time' => $now, + ]); + } + Db::name('pharmacy_submission_claim_audit')->insert([ + 'claim_id' => (int) $claim['id'], + 'prescription_order_id' => $orderId, + 'source_revision' => max($revision, 1), + 'target' => 'gancao', + 'action' => strtoupper(trim($resolution)), + 'from_status' => strtoupper((string) $claim['status']), + 'to_status' => $resolved['status'], + 'remote_order_no' => mb_substr($resolved['remote_order_no'], 0, 64), + 'note' => mb_substr($resolved['note'], 0, 1000), + 'operator_id' => $operatorId, + 'operator_name' => mb_substr(trim($operatorName), 0, 80), + 'create_time' => $now, + ]); + + return $resolved + ['claim_id' => (int) $claim['id']]; + }); + } + + /** @param array $order */ + public static function hasAnyRemoteOrder(array $order): bool + { + return trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== '' + || trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== ''; + } + + private static function targetForShipMode(string $shipMode): string + { + return strtolower(trim($shipMode)) === 'direct' ? 'direct' : 'gancao'; + } + + /** @param array $claim @return array */ + private static function expirePendingGancaoClaim(array $claim, int $operatorId, string $operatorName): array + { + $now = time(); + $newToken = bin2hex(random_bytes(16)); + $updated = Db::name('pharmacy_submission_claim') + ->where('id', (int) $claim['id']) + ->where('status', 'PENDING') + ->where('claim_token', (string) $claim['claim_token']) + ->update([ + 'status' => 'PENDING_RECONCILE', + 'claim_token' => $newToken, + 'error_message' => '提交租约已超时,甘草远端结果不确定,须人工核对', + 'operator_id' => $operatorId, + 'operator_name' => mb_substr(trim($operatorName), 0, 80), + 'lease_expires_at' => 0, + 'update_time' => $now, + ]); + if ($updated !== 1) { + throw new DomainException('提交租约状态已变化,请刷新后重试'); + } + Db::name('pharmacy_submission_claim_audit')->insert([ + 'claim_id' => (int) $claim['id'], + 'prescription_order_id' => (int) $claim['prescription_order_id'], + 'source_revision' => (int) $claim['source_revision'], + 'target' => 'gancao', + 'action' => 'LEASE_EXPIRED', + 'from_status' => 'PENDING', + 'to_status' => 'PENDING_RECONCILE', + 'remote_order_no' => '', + 'note' => '租约超时后轮换 claim token,禁止自动重提', + 'operator_id' => $operatorId, + 'operator_name' => mb_substr(trim($operatorName), 0, 80), + 'create_time' => $now, + ]); + + return array_replace($claim, [ + 'status' => 'PENDING_RECONCILE', + 'claim_token' => $newToken, + 'lease_expires_at' => 0, + ]); + } + + private static function assertTarget(string $target): void + { + if (!in_array($target, ['gancao', 'direct'], true)) { + throw new DomainException('不支持的药房目标'); + } + } + + /** @param array $order */ + private static function hasConflictingRemoteOrder(array $order, string $target): bool + { + if ($target === 'direct') { + return trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== ''; + } + + return trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== ''; + } + + /** @return array */ + private static function idempotentResult(string $target, string $remoteOrderNo): array + { + if ($target === 'direct') { + return ['pharmacy' => 'ej', 'pharmacy_order_no' => $remoteOrderNo, 'remote_order_no' => $remoteOrderNo]; + } + + return ['pharmacy' => 'gancao', 'recipel_order_no' => $remoteOrderNo, 'remote_order_no' => $remoteOrderNo]; + } +} diff --git a/server/app/common/service/pharmacy/PharmacySubmissionClaimWorkflow.php b/server/app/common/service/pharmacy/PharmacySubmissionClaimWorkflow.php new file mode 100644 index 00000000..ba3b98bc --- /dev/null +++ b/server/app/common/service/pharmacy/PharmacySubmissionClaimWorkflow.php @@ -0,0 +1,92 @@ +acquireClaim = Closure::fromCallable($acquireClaim); + $this->invokeRemote = Closure::fromCallable($invokeRemote); + $this->markSuccess = Closure::fromCallable($markSuccess); + $this->markFailure = Closure::fromCallable($markFailure); + $this->markReconcile = Closure::fromCallable($markReconcile); + } + + /** @return array */ + public function execute(string $target): array + { + if (!in_array($target, ['gancao', 'direct'], true)) { + throw new InvalidArgumentException('Unsupported pharmacy target'); + } + + $claim = ($this->acquireClaim)($target); + if (!empty($claim['idempotent'])) { + $result = is_array($claim['result'] ?? null) ? $claim['result'] : []; + return $result + ['target' => $target, 'idempotent' => true]; + } + + $token = trim((string) ($claim['token'] ?? $claim['claim_token'] ?? '')); + if ($token === '') { + throw new DomainException('药房提交凭证缺失'); + } + $claimStatus = strtoupper(trim((string) ($claim['status'] ?? ''))); + if ($target === 'gancao' && ( + !empty($claim['reconcile']) + || in_array($claimStatus, ['UNKNOWN', 'PENDING_RECONCILE'], true) + )) { + throw new PharmacyReconciliationRequiredException( + '甘草药房远端结果待核对,当前禁止重提;请等待人工或后续对账' + ); + } + + try { + $result = ($this->invokeRemote)($target, $token, $claim); + if (!is_array($result)) { + throw new DomainException('药房返回数据格式错误'); + } + } catch (Throwable $exception) { + if (PharmacyRemoteOutcomeClassifier::isConfirmedNoCreate($exception)) { + ($this->markFailure)($target, $token, $exception->getMessage(), $claim); + } else { + ($this->markReconcile)($target, $token, $exception->getMessage(), $claim); + } + throw $exception; + } + + try { + $finalized = (bool) ($this->markSuccess)($target, $token, $result, $claim); + } catch (Throwable $exception) { + ($this->markReconcile)($target, $token, '远端成功但本地回写异常:' . $exception->getMessage(), $claim); + throw new PharmacyReconciliationRequiredException( + '远端可能已创建订单,本地回写失败,必须对账后再操作', + 0, + $exception + ); + } + if (!$finalized) { + ($this->markReconcile)($target, $token, '远端成功但本地提交凭证无法完成', $claim); + throw new PharmacyReconciliationRequiredException('远端已返回成功,本地回写未完成,必须对账后再操作'); + } + + return $result + ['target' => $target, 'idempotent' => false]; + } +} diff --git a/server/app/common/service/pharmacy/PharmacySubmissionReconciliationPolicy.php b/server/app/common/service/pharmacy/PharmacySubmissionReconciliationPolicy.php new file mode 100644 index 00000000..4015a4c0 --- /dev/null +++ b/server/app/common/service/pharmacy/PharmacySubmissionReconciliationPolicy.php @@ -0,0 +1,49 @@ + $claim @return array{status:string,remote_order_no:string,note:string} */ + public static function resolve( + array $claim, + string $resolution, + string $remoteOrderNo, + string $note, + ?int $now = null + ): array + { + if (strtolower(trim((string) ($claim['target'] ?? ''))) !== 'gancao') { + throw new DomainException('仅甘草药房不确定提交支持人工确认'); + } + $status = strtoupper(trim((string) ($claim['status'] ?? ''))); + $expiredPending = $status === 'PENDING' + && (int) ($claim['lease_expires_at'] ?? 0) > 0 + && (int) $claim['lease_expires_at'] <= ($now ?? time()); + if (!$expiredPending && !in_array($status, ['UNKNOWN', 'PENDING_RECONCILE'], true)) { + throw new DomainException('当前提交状态无需人工确认'); + } + $resolution = strtoupper(trim($resolution)); + if (!in_array($resolution, ['CONFIRM_SUCCESS', 'CONFIRM_NOT_CREATED'], true)) { + throw new DomainException('不支持的人工确认结果'); + } + $note = trim($note); + if ($note === '') { + throw new DomainException('请填写甘草后台核对依据'); + } + $remoteOrderNo = trim($remoteOrderNo); + if ($resolution === 'CONFIRM_SUCCESS' && $remoteOrderNo === '') { + throw new DomainException('确认成功时必须填写甘草药方单号'); + } + + return [ + 'status' => $resolution === 'CONFIRM_SUCCESS' ? 'SUCCESS' : 'FAILED', + 'remote_order_no' => $resolution === 'CONFIRM_SUCCESS' ? $remoteOrderNo : '', + 'note' => $note, + ]; + } +} diff --git a/server/app/common/service/pharmacy/PharmacySupplyMode.php b/server/app/common/service/pharmacy/PharmacySupplyMode.php new file mode 100644 index 00000000..c40141bc --- /dev/null +++ b/server/app/common/service/pharmacy/PharmacySupplyMode.php @@ -0,0 +1,30 @@ + $order */ + public static function resolve(array $order): string + { + if (strtolower(trim((string) ($order['ship_mode'] ?? ''))) === 'direct') { + return 'direct'; + } + if (trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== '') { + return 'gancao'; + } + + return 'self'; + } + + public static function label(string $mode): string + { + return match (strtolower(trim($mode))) { + 'direct' => '洛阳直发', + 'gancao' => '甘草', + default => '自营', + }; + } +} diff --git a/server/app/common/service/pharmacy/PharmacyUploadPermissionAlias.php b/server/app/common/service/pharmacy/PharmacyUploadPermissionAlias.php new file mode 100644 index 00000000..cff6619c --- /dev/null +++ b/server/app/common/service/pharmacy/PharmacyUploadPermissionAlias.php @@ -0,0 +1,38 @@ + $adminUris */ + public static function allows(string $accessUri, array $adminUris): bool + { + $canonicalAccess = self::canonicalUri($accessUri); + foreach ($adminUris as $uri) { + if (self::canonicalUri((string) $uri) === $canonicalAccess) { + return true; + } + } + + return false; + } +} diff --git a/server/config/console.php b/server/config/console.php index f1a89ac1..e2accb9a 100755 --- a/server/config/console.php +++ b/server/config/console.php @@ -32,6 +32,8 @@ return [ 'qywx:sync-msg-archive' => 'app\\command\\QywxSyncMsgArchive', // 甘草订单物流路由同步(GET_TASK_ROUTE_LIST) 'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute', + 'ej-pharmacy:sync-catalog' => 'app\\command\\EjPharmacySyncCatalog', + 'ej-pharmacy:bootstrap-medicines' => 'app\\command\\EjPharmacyBootstrapMedicines', // 历史 internal_cost:批量甘草预报价回填(CTM_PREVIEW) 'tcm:backfill-internal-cost' => 'app\\command\\TcmBackfillPrescriptionOrderInternalCost', // 批量回填业务订单签收时间到物流库(导出读库即可,不再逐单 HTTP) diff --git a/server/config/ej_pharmacy.php b/server/config/ej_pharmacy.php new file mode 100644 index 00000000..5ea46d2f --- /dev/null +++ b/server/config/ej_pharmacy.php @@ -0,0 +1,18 @@ + (int) env('EJ_PHARMACY_SUBMISSION_LEASE_SECONDS', 300), + 'enabled' => filter_var(env('EJ_PHARMACY_ENABLED', false), FILTER_VALIDATE_BOOLEAN), + 'catalog_sync_enabled' => filter_var( + env('EJ_PHARMACY_CATALOG_SYNC_ENABLED', false), + FILTER_VALIDATE_BOOLEAN + ), + 'base_url' => rtrim(trim((string) env('EJ_PHARMACY_BASE_URL', '')), '/'), + 'app_key' => trim((string) env('EJ_PHARMACY_APP_KEY', '')), + 'app_secret' => trim((string) env('EJ_PHARMACY_APP_SECRET', '')), + 'callback_secret' => trim((string) env('EJ_PHARMACY_CALLBACK_SECRET', '')), + 'connect_timeout' => max((int) env('EJ_PHARMACY_CONNECT_TIMEOUT', 5), 1), + 'request_timeout' => max((int) env('EJ_PHARMACY_REQUEST_TIMEOUT', 30), 5), +]; diff --git a/server/sql/1.9.20260721/luoyang_pharmacy_erp.sql b/server/sql/1.9.20260721/luoyang_pharmacy_erp.sql new file mode 100644 index 00000000..7fe39820 --- /dev/null +++ b/server/sql/1.9.20260721/luoyang_pharmacy_erp.sql @@ -0,0 +1,384 @@ +-- zyt projection and integration state for the Luoyang pharmacy ERP. +-- Existing-order DDL is guarded because this migration may be resumed after a partial deployment. + +SET @schema_name := DATABASE(); + +SET @ddl := IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order' + AND COLUMN_NAME = 'ej_pharmacy_order_no' + ), + 'SELECT 1', + 'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_order_no` varchar(40) NULL DEFAULT NULL COMMENT ''Remote ej ERP order number'' AFTER `ship_mode`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order' + AND COLUMN_NAME = 'ej_pharmacy_submit_time' + ), + 'SELECT 1', + 'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_submit_time` int unsigned NOT NULL DEFAULT 0 AFTER `ej_pharmacy_order_no`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order' + AND COLUMN_NAME = 'ej_pharmacy_status' + ), + 'SELECT 1', + 'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_status` varchar(32) NOT NULL DEFAULT '''' AFTER `ej_pharmacy_submit_time`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order' + AND COLUMN_NAME = 'ej_pharmacy_review_status' + ), + 'SELECT 1', + 'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_review_status` varchar(24) NOT NULL DEFAULT '''' AFTER `ej_pharmacy_status`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order' + AND COLUMN_NAME = 'ej_pharmacy_status_version' + ), + 'SELECT 1', + 'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_status_version` bigint unsigned NOT NULL DEFAULT 0 AFTER `ej_pharmacy_review_status`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order' + AND COLUMN_NAME = 'ej_pharmacy_current_step' + ), + 'SELECT 1', + 'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_current_step` varchar(100) NOT NULL DEFAULT '''' AFTER `ej_pharmacy_status_version`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order' + AND COLUMN_NAME = 'ej_pharmacy_remark' + ), + 'SELECT 1', + 'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_remark` varchar(500) NOT NULL DEFAULT '''' AFTER `ej_pharmacy_current_step`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- Repair a partial deployment that created the order number as NOT NULL DEFAULT ''. +ALTER TABLE `zyt_tcm_prescription_order` + MODIFY COLUMN `ej_pharmacy_order_no` varchar(40) NULL DEFAULT NULL COMMENT 'Remote ej ERP order number'; +UPDATE `zyt_tcm_prescription_order` SET `ej_pharmacy_order_no` = NULL +WHERE `ej_pharmacy_order_no` = ''; + +SET @ddl := IF( + EXISTS ( + SELECT 1 FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order' + AND INDEX_NAME = 'uk_ej_pharmacy_order_no' + ), + 'SELECT 1', + 'ALTER TABLE `zyt_tcm_prescription_order` ADD UNIQUE KEY `uk_ej_pharmacy_order_no` (`ej_pharmacy_order_no`)' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + EXISTS ( + SELECT 1 FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order' + AND INDEX_NAME = 'idx_ej_pharmacy_status' + ), + 'SELECT 1', + 'ALTER TABLE `zyt_tcm_prescription_order` ADD KEY `idx_ej_pharmacy_status` (`ej_pharmacy_status`,`ej_pharmacy_status_version`)' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- EJ callbacks may carry longer Unicode carrier codes and tracking numbers than the legacy order projection. +ALTER TABLE `zyt_tcm_prescription_order` + MODIFY COLUMN `express_company` varchar(32) NOT NULL DEFAULT '' COMMENT 'Carrier code', + MODIFY COLUMN `tracking_number` varchar(100) NOT NULL DEFAULT '' COMMENT 'Tracking number'; +ALTER TABLE `zyt_express_tracking` + MODIFY COLUMN `tracking_number` varchar(100) NOT NULL DEFAULT '' COMMENT 'Tracking number'; +ALTER TABLE `zyt_express_trace` + MODIFY COLUMN `tracking_number` varchar(100) NOT NULL DEFAULT '' COMMENT 'Tracking number'; + +CREATE TABLE IF NOT EXISTS `zyt_ej_medicine_catalog` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `medicine_code` varchar(32) NOT NULL, + `name` varchar(120) NOT NULL, + `brand` varchar(120) NOT NULL DEFAULT '', + `unit` varchar(24) NOT NULL, + `settlement_price` decimal(12,4) NOT NULL DEFAULT 0.0000, + `retail_price` decimal(12,4) NOT NULL DEFAULT 0.0000, + `status` tinyint unsigned NOT NULL DEFAULT 1, + `catalog_version` bigint unsigned NOT NULL DEFAULT 0, + `remote_deleted` tinyint unsigned NOT NULL DEFAULT 0, + `create_time` int unsigned NOT NULL DEFAULT 0, + `update_time` int unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_medicine_code` (`medicine_code`), + KEY `idx_catalog_version` (`catalog_version`), + KEY `idx_name_status` (`name`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `zyt_ej_medicine_mapping` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `local_medicine_id` bigint unsigned NOT NULL, + `medicine_code` varchar(32) NOT NULL, + `status` tinyint unsigned NOT NULL DEFAULT 1, + `operator_id` bigint unsigned NOT NULL DEFAULT 0, + `operator_name` varchar(80) NOT NULL DEFAULT '', + `create_time` int unsigned NOT NULL DEFAULT 0, + `update_time` int unsigned NOT NULL DEFAULT 0, + `delete_time` int unsigned DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_local_medicine` (`local_medicine_id`), + UNIQUE KEY `uk_medicine_code` (`medicine_code`), + KEY `idx_medicine_status` (`medicine_code`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET @ddl := IF( + EXISTS ( + SELECT 1 FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_ej_medicine_mapping' + AND INDEX_NAME = 'uk_medicine_code' + ), + 'SELECT 1', + 'ALTER TABLE `zyt_ej_medicine_mapping` ADD UNIQUE KEY `uk_medicine_code` (`medicine_code`)' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS `zyt_ej_pharmacy_submission` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `prescription_order_id` bigint unsigned NOT NULL, + `source_revision` int unsigned NOT NULL DEFAULT 1, + `request_hash` char(64) NOT NULL, + `request_id` varchar(64) NOT NULL DEFAULT '', + `remote_order_no` varchar(40) NOT NULL DEFAULT '', + `status` varchar(24) NOT NULL DEFAULT 'PENDING', + `http_status` int unsigned NOT NULL DEFAULT 0, + `response_summary` json DEFAULT NULL, + `error_message` varchar(1000) NOT NULL DEFAULT '', + `operator_id` bigint unsigned NOT NULL DEFAULT 0, + `operator_name` varchar(80) NOT NULL DEFAULT '', + `create_time` int unsigned NOT NULL DEFAULT 0, + `update_time` int unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_revision` (`prescription_order_id`,`source_revision`), + KEY `idx_status_time` (`status`,`update_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `zyt_pharmacy_submission_claim` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `prescription_order_id` bigint unsigned NOT NULL, + `source_revision` int unsigned NOT NULL DEFAULT 1, + `target` varchar(16) NOT NULL, + `status` varchar(24) NOT NULL DEFAULT 'PENDING', + `claim_token` char(32) NOT NULL, + `idempotency_key` char(64) NOT NULL, + `remote_order_no` varchar(64) NOT NULL DEFAULT '', + `request_id` varchar(64) NOT NULL DEFAULT '', + `error_message` varchar(1000) NOT NULL DEFAULT '', + `operator_id` bigint unsigned NOT NULL DEFAULT 0, + `operator_name` varchar(80) NOT NULL DEFAULT '', + `claimed_at` int unsigned NOT NULL DEFAULT 0, + `lease_expires_at` int unsigned NOT NULL DEFAULT 0, + `completed_at` int unsigned NOT NULL DEFAULT 0, + `failed_at` int unsigned NOT NULL DEFAULT 0, + `create_time` int unsigned NOT NULL DEFAULT 0, + `update_time` int unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_revision` (`prescription_order_id`,`source_revision`), + UNIQUE KEY `uk_claim_token` (`claim_token`), + KEY `idx_status_time` (`status`,`update_time`), + KEY `idx_target_remote` (`target`,`remote_order_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE `zyt_pharmacy_submission_claim` + MODIFY COLUMN `status` varchar(24) NOT NULL DEFAULT 'PENDING'; + +SET @ddl := IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_pharmacy_submission_claim' + AND COLUMN_NAME = 'lease_expires_at' + ), + 'SELECT 1', + 'ALTER TABLE `zyt_pharmacy_submission_claim` ADD COLUMN `lease_expires_at` int unsigned NOT NULL DEFAULT 0 AFTER `claimed_at`' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +UPDATE `zyt_pharmacy_submission_claim` +SET `lease_expires_at` = `claimed_at` + 300 +WHERE `status` = 'PENDING' AND `lease_expires_at` = 0 AND `claimed_at` > 0; + +CREATE TABLE IF NOT EXISTS `zyt_pharmacy_submission_claim_audit` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `claim_id` bigint unsigned NOT NULL, + `prescription_order_id` bigint unsigned NOT NULL, + `source_revision` int unsigned NOT NULL DEFAULT 1, + `target` varchar(16) NOT NULL, + `action` varchar(32) NOT NULL, + `from_status` varchar(24) NOT NULL, + `to_status` varchar(24) NOT NULL, + `remote_order_no` varchar(64) NOT NULL DEFAULT '', + `note` varchar(1000) NOT NULL DEFAULT '', + `operator_id` bigint unsigned NOT NULL DEFAULT 0, + `operator_name` varchar(80) NOT NULL DEFAULT '', + `create_time` int unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_claim_time` (`claim_id`,`create_time`), + KEY `idx_order_time` (`prescription_order_id`,`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `zyt_ej_pharmacy_callback_inbox` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `event_id` char(36) NOT NULL, + `pharmacy_order_no` varchar(40) NOT NULL, + `source_order_no` varchar(64) NOT NULL, + `event_type` varchar(48) NOT NULL, + `status_version` bigint unsigned NOT NULL DEFAULT 0, + `payload` json NOT NULL, + `process_status` varchar(24) NOT NULL DEFAULT 'PENDING', + `processed_time` int unsigned NOT NULL DEFAULT 0, + `error_message` varchar(1000) NOT NULL DEFAULT '', + `create_time` int unsigned NOT NULL DEFAULT 0, + `update_time` int unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_event_id` (`event_id`), + KEY `idx_process_status` (`process_status`,`id`), + KEY `idx_order_version` (`pharmacy_order_no`,`status_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `zyt_ej_pharmacy_sync_state` ( + `id` tinyint unsigned NOT NULL, + `cursor` bigint unsigned NOT NULL DEFAULT 0, + `last_success_time` int unsigned NOT NULL DEFAULT 0, + `last_failure_time` int unsigned NOT NULL DEFAULT 0, + `last_error_summary` varchar(500) NOT NULL DEFAULT '', + `lock_token` char(32) NOT NULL DEFAULT '', + `lock_expires_at` int unsigned NOT NULL DEFAULT 0, + `create_time` int unsigned NOT NULL DEFAULT 0, + `update_time` int unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `zyt_ej_pharmacy_sync_state` (`id`, `cursor`, `create_time`, `update_time`) +VALUES (1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()) +ON DUPLICATE KEY UPDATE `id` = VALUES(`id`); + +-- Page sits beside the existing doctor medicine library; API nodes make every endpoint visible to AuthMiddleware. +SET @doctor_medicine_menu_id := ( + SELECT `id` + FROM `zyt_system_menu` + WHERE `perms` = 'doctor.medicine/lists' + OR (`component` = 'doctor/medicine' AND `type` = 'C') + ORDER BY (`perms` = 'doctor.medicine/lists') DESC, `id` ASC + LIMIT 1 +); +SET @doctor_medicine_parent_id := ( + SELECT `pid` FROM `zyt_system_menu` WHERE `id` = @doctor_medicine_menu_id LIMIT 1 +); + +INSERT INTO `zyt_system_menu` ( + `pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, + `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time` +) +SELECT + @doctor_medicine_parent_id, 'C', '洛阳药房药材映射', '', 0, + 'pharmacy.medicineMapping/lists', 'pharmacy/medicine-mapping', 'pharmacy/medicine_mapping/index', + '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @doctor_medicine_parent_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'pharmacy.medicineMapping/lists' + ); + +SET @medicine_mapping_menu_id := ( + SELECT `id` FROM `zyt_system_menu` WHERE `perms` = 'pharmacy.medicineMapping/lists' LIMIT 1 +); + +INSERT INTO `zyt_system_menu` ( + `pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, + `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time` +) +SELECT + @medicine_mapping_menu_id, 'A', `permission_node`.`name`, '', `permission_node`.`sort`, + `permission_node`.`perms`, '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM ( + SELECT '查看同步状态' AS `name`, 10 AS `sort`, 'pharmacy.medicineMapping/status' AS `perms` + UNION ALL SELECT '检索远端药材', 20, 'pharmacy.medicineMapping/catalogOptions' + UNION ALL SELECT '同步远端目录', 30, 'pharmacy.medicineMapping/sync' + UNION ALL SELECT '保存药材映射', 40, 'pharmacy.medicineMapping/save' + UNION ALL SELECT '解除药材映射', 50, 'pharmacy.medicineMapping/unlink' +) AS `permission_node` +WHERE @medicine_mapping_menu_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM `zyt_system_menu` AS `existing_menu` + WHERE `existing_menu`.`perms` = `permission_node`.`perms` + ); + +-- New clients use this explicit unified-upload permission. The historical +-- submitGancaoRecipel node remains valid and its controller still routes by ship_mode. +SET @prescription_order_menu_id := ( + SELECT `id` FROM `zyt_system_menu` WHERE `perms` = 'tcm.prescriptionOrder/lists' LIMIT 1 +); +INSERT INTO `zyt_system_menu` ( + `pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, + `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time` +) +SELECT + @prescription_order_menu_id, 'A', '上传药房', '', 10, + 'tcm.prescriptionOrder/uploadToPharmacy', '', '', '', '', 0, 1, 0, + UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @prescription_order_menu_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'tcm.prescriptionOrder/uploadToPharmacy' + ); + +-- Historical route retained as a controlled compatibility alias for the same pharmacy-upload capability. +INSERT INTO `zyt_system_menu` ( + `pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, + `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time` +) +SELECT + @prescription_order_menu_id, 'A', '上传药房(兼容入口)', '', 11, + 'tcm.prescriptionOrder/submitGancaoRecipel', '', '', '', '', 0, 1, 0, + UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @prescription_order_menu_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'tcm.prescriptionOrder/submitGancaoRecipel' + ); + +-- Manual reconciliation can finalize an uncertain remote result or release it for retry, +-- so it requires a separate high-risk permission from ordinary pharmacy upload. +INSERT INTO `zyt_system_menu` ( + `pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, + `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time` +) +SELECT + @prescription_order_menu_id, 'A', '人工核对甘草提交', '', 12, + 'tcm.prescriptionOrder/confirmGancaoSubmission', '', '', '', '', 0, 1, 0, + UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @prescription_order_menu_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'tcm.prescriptionOrder/confirmGancaoSubmission' + ); diff --git a/server/tests/pharmacy/callback_auth_integration.php b/server/tests/pharmacy/callback_auth_integration.php new file mode 100644 index 00000000..e6ff3a0c --- /dev/null +++ b/server/tests/pharmacy/callback_auth_integration.php @@ -0,0 +1,379 @@ + $response->getCode(); + +final class CallbackContractResponse +{ + public function __construct(private readonly int $status) + { + } + + public function getCode(): int + { + return $this->status; + } +} + +$validPayload = [ + 'event_id' => 'evt-integration-1', + 'pharmacy_order_no' => 'EJ-1001', + 'source_order_no' => 'PO-1001', +]; + +$makeController = static function (string $body, EjPharmacyCallbackWorkflow $workflow): EjPharmacyCallbackController { + return new class($body, $workflow) extends EjPharmacyCallbackController { + public function __construct( + private readonly string $testBody, + private readonly EjPharmacyCallbackWorkflow $testWorkflow + ) { + } + + protected function callbackBody(): string + { + return $this->testBody; + } + + protected function isAuthenticCallback(string $body): bool + { + return true; + } + + protected function callbackWorkflow(): EjPharmacyCallbackWorkflow + { + return $this->testWorkflow; + } + + protected function callbackResponse(array $payload, int $httpStatus) + { + return new CallbackContractResponse($httpStatus); + } + + protected function logCallbackFailure(string $message): void + { + } + }; +}; + +$processedCalls = 0; +$processedWorkflow = new EjPharmacyCallbackWorkflow( + static fn (): array => ['id' => 1, 'process_status' => 'PROCESSED'], + static fn (): array => [], + static fn (): ?array => null, + static function () use (&$processedCalls): array { + ++$processedCalls; + return ['process_status' => 'PROCESSED']; + }, + static function (): void {}, + static fn (): bool => false +); +$processedResponse = $makeController( + json_encode($validPayload, JSON_THROW_ON_ERROR), + $processedWorkflow +)->webhook(); +$assertSame(200, $responseStatus($processedResponse), 'processed callbacks must return HTTP 200'); +$assertSame(0, $processedCalls, 'only a PROCESSED inbox may return immediate success'); + +$pendingCalls = 0; +$pendingWorkflow = new EjPharmacyCallbackWorkflow( + static fn (): array => ['id' => 2, 'process_status' => 'PENDING'], + static fn (): array => [], + static fn (): ?array => null, + static function () use (&$pendingCalls): array { + ++$pendingCalls; + return ['process_status' => 'PROCESSED']; + }, + static function (): void {}, + static fn (): bool => false +); +$pendingResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $pendingWorkflow)->webhook(); +$assertSame(200, $responseStatus($pendingResponse), 'a successfully retried PENDING inbox must return HTTP 200'); +$assertSame(1, $pendingCalls, 'a PENDING inbox must retry business processing'); + +$failedCalls = 0; +$failedWorkflow = new EjPharmacyCallbackWorkflow( + static fn (): array => ['id' => 3, 'process_status' => 'FAILED'], + static fn (): array => [], + static fn (): ?array => null, + static function () use (&$failedCalls): array { + ++$failedCalls; + return ['process_status' => 'PROCESSED']; + }, + static function (): void {}, + static fn (): bool => false +); +$failedResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $failedWorkflow)->webhook(); +$assertSame(200, $responseStatus($failedResponse), 'a successfully retried FAILED inbox must return HTTP 200'); +$assertSame(1, $failedCalls, 'a FAILED inbox must retry business processing'); + +$retryWorkflow = new EjPharmacyCallbackWorkflow( + static fn (): array => ['id' => 4, 'process_status' => 'PENDING'], + static fn (): array => [], + static fn (): ?array => null, + static function (): never { + throw new EjPharmacyCallbackRetryException('业务订单关联尚未建立'); + }, + static function (): void {}, + static fn (): bool => false +); +$retryResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $retryWorkflow)->webhook(); +$assertSame(503, $responseStatus($retryResponse), 'a missing order association must ask EJ to retry'); + +$runtimeWorkflow = new EjPharmacyCallbackWorkflow( + static function (): never { + throw new \RuntimeException('database unavailable'); + }, + static fn (): array => [], + static fn (): ?array => null, + static fn (): array => ['process_status' => 'PROCESSED'], + static function (): void {}, + static fn (): bool => false +); +$runtimeResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $runtimeWorkflow)->webhook(); +$assertSame(500, $responseStatus($runtimeResponse), 'database and unknown failures must return HTTP 500'); + +$unusedWorkflow = new EjPharmacyCallbackWorkflow( + static fn (): ?array => null, + static fn (): array => [], + static fn (): ?array => null, + static fn (): array => ['process_status' => 'PROCESSED'], + static function (): void {}, + static fn (): bool => false +); +$malformedResponse = $makeController('{', $unusedWorkflow)->webhook(); +$assertSame(400, $responseStatus($malformedResponse), 'malformed callback JSON must return HTTP 400'); + +$missingFieldResponse = $makeController( + json_encode(['event_id' => 'evt-missing'], JSON_THROW_ON_ERROR), + $unusedWorkflow +)->webhook(); +$assertSame(422, $responseStatus($missingFieldResponse), 'business-invalid callback JSON must return HTTP 422'); + +$duplicateReloads = 0; +$duplicateProcesses = 0; +$duplicateWorkflow = new EjPharmacyCallbackWorkflow( + static fn (): ?array => null, + static function (): never { + throw new \RuntimeException('SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry'); + }, + static function () use (&$duplicateReloads): array { + ++$duplicateReloads; + return ['id' => 5, 'process_status' => 'PENDING']; + }, + static function () use (&$duplicateProcesses): array { + ++$duplicateProcesses; + return ['process_status' => 'PROCESSED']; + }, + static function (): void {}, + static fn (\Throwable $exception): bool => str_contains($exception->getMessage(), '1062') +); +$duplicateResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $duplicateWorkflow)->webhook(); +$assertSame(200, $responseStatus($duplicateResponse), 'a duplicate inbox insert race must be reloaded and processed'); +$assertSame(1, $duplicateReloads, 'a duplicate inbox insert must reload the winning row once'); +$assertSame(1, $duplicateProcesses, 'a reloaded PENDING duplicate must be processed once'); + +$callbackRaceState = 'PENDING'; +$callbackRaceUpdated = EjPharmacyCallbackFailureTransition::apply( + 7, + 'late failure after concurrent success', + static function (int $inboxId, array $values, string $protectedStatus) use (&$callbackRaceState): bool { + $callbackRaceState = 'PROCESSED'; + if ($callbackRaceState === $protectedStatus) { + return false; + } + $callbackRaceState = (string) $values['process_status']; + return true; + } +); +$assertSame(false, $callbackRaceUpdated, 'failure CAS must report no update after concurrent callback success'); +$assertSame('PROCESSED', $callbackRaceState, 'concurrent callback success must never be overwritten as FAILED'); + +$assertSame( + 'SF', + PharmacyLogisticsValue::normalize("\u{200B}\u{00A0}SF\u{3000}\u{FEFF}", 32, '快递公司'), + 'logistics values must normalize Unicode edge whitespace' +); + +$assertSame( + true, + EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_SHIPPED', 'status' => 'PROCESSING']), + 'ORDER_SHIPPED event type must trigger local shipment fulfillment' +); +$assertSame( + true, + EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_UPDATED', 'status' => 'SHIPPED']), + 'SHIPPED remote status must trigger local shipment fulfillment' +); +$assertSame( + false, + EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_UPDATED', 'status' => 'PROCESSING']), + 'non-shipment callbacks must not change local fulfillment' +); +foreach ([1, 2] as $pendingFulfillment) { + $assertSame( + 5, + EjPharmacyShipmentPolicy::nextFulfillmentStatus($pendingFulfillment, ['event_type' => 'ORDER_SHIPPED']), + "shipment callback must advance fulfillment {$pendingFulfillment} to shipped" + ); +} +foreach ([3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as $protectedFulfillment) { + $assertSame( + $protectedFulfillment, + EjPharmacyShipmentPolicy::nextFulfillmentStatus($protectedFulfillment, ['status' => 'SHIPPED']), + "shipment callback must not regress/overwrite protected fulfillment {$protectedFulfillment}" + ); +} + +$sameTrackingDecision = EjPharmacyTrackingPolicy::select( + ['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-OLD'], + null, + 7, + 'SF-OLD' +); +$assertSame('REUSE_CURRENT', $sameTrackingDecision['action'], 'same-number callbacks must reuse the active order tracking'); +$replacementDecision = EjPharmacyTrackingPolicy::select( + ['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-OLD'], + null, + 7, + 'SF-NEW' +); +$assertSame('CREATE', $replacementDecision['action'], 'a replacement number must get a fresh tracking row'); +$assertSame(true, $replacementDecision['archive_current'], 'replacing a number must archive the old active tracking'); +$historicalDecision = EjPharmacyTrackingPolicy::select( + ['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-CURRENT'], + ['id' => 8, 'order_id' => 7, 'tracking_number' => 'SF-OLD'], + 7, + 'SF-OLD' +); +$assertSame('REUSE_MATCHING', $historicalDecision['action'], 'a same-order historical number may be promoted without duplicating its traces'); +try { + EjPharmacyTrackingPolicy::select( + ['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-OLD'], + ['id' => 99, 'order_id' => 8, 'tracking_number' => 'SF-NEW'], + 7, + 'SF-NEW' + ); + throw new RuntimeException('cross-order tracking ownership conflict unexpectedly accepted'); +} catch (DomainException $exception) { + $assertSame(true, str_contains($exception->getMessage(), '其他订单'), 'cross-order tracking numbers must be rejected without rebinding'); +} + +$overlongWorkflowCalls = 0; +$overlongWorkflow = new EjPharmacyCallbackWorkflow( + static function () use (&$overlongWorkflowCalls): array { + ++$overlongWorkflowCalls; + return ['id' => 6, 'process_status' => 'PENDING']; + }, + static fn (): array => [], + static fn (): ?array => null, + static fn (): array => ['process_status' => 'PROCESSED'], + static function (): void {}, + static fn (): bool => false +); +$overlongResponse = $makeController( + json_encode($validPayload + ['tracking_number' => str_repeat('运', 101)], JSON_THROW_ON_ERROR), + $overlongWorkflow +)->webhook(); +$assertSame(422, $responseStatus($overlongResponse), 'overlong logistics values must return HTTP 422'); +$assertSame(0, $overlongWorkflowCalls, 'logistics validation must finish before any inbox/workflow read or write'); + +$middleware = new AuthMiddleware(); +$aliasMethod = new ReflectionMethod($middleware, 'matchPermissionAlias'); +$assertSame( + true, + $aliasMethod->invoke($middleware, 'tcm.prescriptionorder/uploadtopharmacy', ['tcm.prescriptionorder/submitgancaorecipel']), + 'the historical permission must grant the unified upload URI' +); +$assertSame( + true, + $aliasMethod->invoke($middleware, 'tcm.prescriptionorder/submitgancaorecipel', ['tcm.prescriptionorder/uploadtopharmacy']), + 'the unified permission must grant the historical upload URI' +); +$assertSame( + false, + $aliasMethod->invoke($middleware, 'tcm.prescriptionorder/export', ['tcm.prescriptionorder/uploadtopharmacy']), + 'the upload alias must not expand to unrelated URIs' +); +$assertSame( + false, + $aliasMethod->invoke($middleware, 'tcm.prescriptionorder/confirmgancaosubmission', ['tcm.prescriptionorder/uploadtopharmacy']), + 'ordinary pharmacy upload permission must not grant manual Gancao reconciliation' +); + +$controllerSource = (string) file_get_contents( + dirname(__DIR__, 2) . '/app/api/controller/EjPharmacyCallbackController.php' +); +$assertSame( + 2, + substr_count($controllerSource, 'PharmacyLogisticsValue::normalize('), + 'carrier and tracking values must each be normalized exactly once before persistence' +); +$assertSame( + 0, + substr_count($controllerSource, "(string) (\$payload['tracking_number']"), + 'order, tracking, and trace writes must not consume the raw tracking number' +); +$assertSame( + true, + strpos($controllerSource, '$this->normalizeLogistics($payload)') + < strpos($controllerSource, '$this->callbackWorkflow()->handle($payload)'), + 'callback logistics must be normalized before the workflow can write its inbox row' +); +$assertSame( + true, + str_contains($controllerSource, 'EjPharmacyCallbackFailureTransition::apply(') + && str_contains($controllerSource, "where('process_status', '<>', \$protectedStatus)"), + 'callback failure persistence must use a conditional status CAS that protects PROCESSED' +); +$versionGateStart = strpos($controllerSource, 'if ($incomingVersion >'); +$versionGateEnd = strpos($controllerSource, '$inboxModel->save', $versionGateStart); +$versionGatedSource = substr($controllerSource, $versionGateStart, $versionGateEnd - $versionGateStart); +$assertSame( + true, + str_contains($versionGatedSource, 'EjPharmacyShipmentPolicy::nextFulfillmentStatus('), + 'shipment fulfillment advancement must occur inside the callback version gate transaction' +); +$assertSame( + true, + str_contains($versionGatedSource, 'self::syncLogistics('), + 'ExpressTracking synchronization must remain inside the callback version gate' +); +$assertSame( + true, + str_contains($versionGatedSource, 'ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder('), + 'EJ shipment callbacks must reuse the existing shipped-order assistant release linkage' +); +$assertSame( + true, + str_contains($controllerSource, "where('order_id', (int) \$order->id)") + && str_contains($controllerSource, 'EjPharmacyTrackingPolicy::select(') + && str_contains($controllerSource, "'order_type' => 'prescription_history'"), + 'EJ callbacks must isolate replacement numbers and reject cross-order tracking ownership conflicts' +); + +echo "zyt pharmacy callback/auth integration tests passed: {$passed}\n"; diff --git a/server/tests/pharmacy/mapping_request_race.mjs b/server/tests/pharmacy/mapping_request_race.mjs new file mode 100644 index 00000000..f99fbcd5 --- /dev/null +++ b/server/tests/pharmacy/mapping_request_race.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { createLatestRequestGuard } from '../../../admin/src/views/pharmacy/medicine_mapping/latest-request.mjs' + +const list = createLatestRequestGuard() +const firstList = list.next({ page_no: 1, local_name: 'A' }) +const secondList = list.next({ page_no: 2, local_name: 'B' }) +assert.deepEqual(firstList.snapshot, { page_no: 1, local_name: 'A' }) +assert.equal(list.isLatest(firstList), false) +assert.equal(list.isLatest(secondList), true) + +const catalog = createLatestRequestGuard() +const rowA = catalog.next({ localMedicineId: 1, keyword: 'A' }) +catalog.invalidate() +const rowB = catalog.next({ localMedicineId: 2, keyword: 'B' }) +assert.equal(catalog.isLatest(rowA), false) +assert.equal(catalog.isLatest(rowB), true) +assert.deepEqual(rowB.snapshot, { localMedicineId: 2, keyword: 'B' }) + +const statusRequests = createLatestRequestGuard() +const mountedStatus = statusRequests.next({ source: 'mount' }) +const syncedStatus = statusRequests.next({ source: 'sync' }) +const savedStatus = statusRequests.next({ source: 'save' }) +const unlinkedStatus = statusRequests.next({ source: 'unlink' }) +assert.equal(statusRequests.isLatest(mountedStatus), false) +assert.equal(statusRequests.isLatest(syncedStatus), false) +assert.equal(statusRequests.isLatest(savedStatus), false) +assert.equal(statusRequests.isLatest(unlinkedStatus), true) +list.next({ page_no: 3, local_name: 'C' }) +catalog.invalidate() +assert.equal(statusRequests.isLatest(unlinkedStatus), true) + +const page = readFileSync( + new URL('../../../admin/src/views/pharmacy/medicine_mapping/index.vue', import.meta.url), + 'utf8' +) +assert.match(page, /medicineMappingLists\(ticket\.snapshot\)/) +assert.match(page, /listRequests\.isLatest\(ticket\)/) +assert.match(page, /catalogRequests\.invalidate\(\)/) +assert.match(page, /catalogRequests\.isLatest\(ticket\)/) +assert.match(page, /localMedicineId/) +assert.match(page, /const initialTicket = await searchCatalogNow\(rowSnapshot\.local_name, localMedicineId\)/) +assert.match(page, /catalogRequests\.isLatest\(initialTicket\)/) +assert.match(page, /const statusRequests = createLatestRequestGuard/) + +const loadStatusStart = page.indexOf('const loadStatus = async () => {') +const loadStatusEnd = page.indexOf('\nconst search =', loadStatusStart) +assert.ok(loadStatusStart >= 0 && loadStatusEnd > loadStatusStart) +const loadStatusSource = page.slice(loadStatusStart, loadStatusEnd) +assert.match(loadStatusSource, /const ticket = statusRequests\.next\(undefined\)/) +assert.match(loadStatusSource, /const nextStatus = await medicineMappingStatus\(\)/) +assert.match( + loadStatusSource, + /if \(statusRequests\.isLatest\(ticket\)\) \{\s*status\.value = nextStatus\s*\}/ +) +assert.match( + loadStatusSource, + /finally\s*\{\s*if \(statusRequests\.isLatest\(ticket\)\) \{\s*statusLoading\.value = false\s*\}/ +) +assert.equal(page.match(/loadStatus\(\)/g)?.length, 4) + +console.log('zyt mapping latest-request behavior and production wiring passed: 25') diff --git a/server/tests/pharmacy/medicine_bootstrap_mysql_integration.php b/server/tests/pharmacy/medicine_bootstrap_mysql_integration.php new file mode 100644 index 00000000..4009803f --- /dev/null +++ b/server/tests/pharmacy/medicine_bootstrap_mysql_integration.php @@ -0,0 +1,232 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + $stage = 'create_database'; + try { + $admin->exec("CREATE DATABASE `{$databaseName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + } catch (PDOException $exception) { + if ($configuredDatabase === '' || !in_array((string) $exception->getCode(), ['42000', '1044'], true)) { + throw $exception; + } + $temporaryTables = true; + $databaseName = $configuredDatabase; + } + $stage = 'test'; + $pdo = new PDO( + sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $hostname, $port, $databaseName), + $username, + $password, + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + $tableKind = $temporaryTables ? 'CREATE TEMPORARY TABLE' : 'CREATE TABLE'; + $pdo->exec("{$tableKind} projection_catalog ( + medicine_code varchar(32) NOT NULL PRIMARY KEY, + local_medicine_id bigint unsigned NOT NULL, + name varchar(120) NOT NULL + ) ENGINE=InnoDB"); + $pdo->exec("{$tableKind} projection_mapping ( + local_medicine_id bigint unsigned NOT NULL PRIMARY KEY, + medicine_code varchar(32) NOT NULL UNIQUE, + operator_id bigint unsigned NOT NULL, + operator_name varchar(80) NOT NULL + ) ENGINE=InnoDB"); + foreach (['submissions', 'callbacks', 'business_links'] as $table) { + $pdo->exec("{$tableKind} `{$table}` (id bigint unsigned NOT NULL PRIMARY KEY) ENGINE=InnoDB"); + } + $pdo->exec("INSERT INTO projection_catalog VALUES ('OLD001', 999, '旧投影')"); + $pdo->exec("INSERT INTO projection_mapping VALUES (999, 'OLD001', 8, 'old-operator')"); + + $transaction = static function (callable $operation) use ($pdo): array { + $pdo->beginTransaction(); + try { + $result = $operation(); + $pdo->commit(); + return $result; + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $exception; + } + }; + $referenceCounter = static function () use ($pdo): array { + return [ + 'submissions' => (int) $pdo->query('SELECT COUNT(*) FROM submissions')->fetchColumn(), + 'callbacks' => (int) $pdo->query('SELECT COUNT(*) FROM callbacks')->fetchColumn(), + 'business_links' => (int) $pdo->query('SELECT COUNT(*) FROM business_links')->fetchColumn(), + ]; + }; + $referenceLocker = static function () use ($pdo): void { + foreach (['submissions', 'callbacks', 'business_links'] as $table) { + $pdo->query("SELECT id FROM `{$table}` ORDER BY id FOR UPDATE")->fetchAll(); + } + }; + $locker = static function () use ($pdo): void { + $pdo->query('SELECT medicine_code FROM projection_catalog ORDER BY medicine_code FOR UPDATE')->fetchAll(); + $pdo->query('SELECT local_medicine_id FROM projection_mapping ORDER BY local_medicine_id FOR UPDATE')->fetchAll(); + }; + $verifier = static function () use ($pdo): array { + return [ + 'catalog' => (int) $pdo->query('SELECT COUNT(*) FROM projection_catalog')->fetchColumn(), + 'active_mappings' => (int) $pdo->query('SELECT COUNT(*) FROM projection_mapping')->fetchColumn(), + 'unmapped' => (int) $pdo->query( + 'SELECT COUNT(*) FROM projection_catalog c LEFT JOIN projection_mapping m ' + . 'ON m.medicine_code = c.medicine_code WHERE m.local_medicine_id IS NULL' + )->fetchColumn(), + ]; + }; + $rows = [ + ['local_medicine_id' => 1, 'medicine_code' => 'EJ000001', 'name' => '黄芪'], + ['local_medicine_id' => 2, 'medicine_code' => 'EJ000002', 'name' => '党参'], + ]; + $replacer = static function (array $nextRows) use ($pdo): void { + $pdo->exec('DELETE FROM projection_mapping'); + $pdo->exec('DELETE FROM projection_catalog'); + $catalog = $pdo->prepare( + 'INSERT INTO projection_catalog (medicine_code,local_medicine_id,name) VALUES (?,?,?)' + ); + $mapping = $pdo->prepare( + 'INSERT INTO projection_mapping (local_medicine_id,medicine_code,operator_id,operator_name) VALUES (?,?,0,?)' + ); + foreach ($nextRows as $row) { + $catalog->execute([$row['medicine_code'], $row['local_medicine_id'], $row['name']]); + $mapping->execute([$row['local_medicine_id'], $row['medicine_code'], 'system-bootstrap']); + } + }; + + $pdo->exec('INSERT INTO submissions VALUES (1)'); + try { + EjMedicineBootstrapService::replaceProjectionWith( + $rows, + $transaction, + $referenceLocker, + $referenceCounter, + $locker, + $replacer, + $verifier + ); + throw new RuntimeException('nonzero MySQL reference gate unexpectedly allowed replacement'); + } catch (RuntimeException $exception) { + if (!str_contains($exception->getMessage(), '业务引用')) { + throw $exception; + } + } + $pdo->exec('DELETE FROM submissions'); + if ((string) $pdo->query('SELECT medicine_code FROM projection_catalog')->fetchColumn() !== 'OLD001') { + throw new RuntimeException('nonzero MySQL reference gate mutated the old projection'); + } + + $result = EjMedicineBootstrapService::replaceProjectionWith( + $rows, + $transaction, + $referenceLocker, + $referenceCounter, + $locker, + $replacer, + $verifier + ); + if ($result !== ['catalog' => 2, 'active_mappings' => 2, 'unmapped' => 0]) { + throw new RuntimeException('successful MySQL projection replacement did not verify exactly'); + } + $operators = $pdo->query( + 'SELECT CONCAT(operator_id, ":", operator_name) FROM projection_mapping ORDER BY local_medicine_id' + )->fetchAll(PDO::FETCH_COLUMN); + if ($operators !== ['0:system-bootstrap', '0:system-bootstrap']) { + throw new RuntimeException('bootstrap mappings did not preserve the system operator identity'); + } + + $pdo->exec('DELETE FROM projection_mapping'); + $pdo->exec('DELETE FROM projection_catalog'); + $pdo->exec("INSERT INTO projection_catalog VALUES ('OLD002', 998, '回滚旧投影')"); + $pdo->exec("INSERT INTO projection_mapping VALUES (998, 'OLD002', 7, 'rollback-operator')"); + try { + EjMedicineBootstrapService::replaceProjectionWith( + $rows, + $transaction, + $referenceLocker, + $referenceCounter, + $locker, + static function (array $nextRows) use ($replacer): void { + $replacer($nextRows); + throw new RuntimeException('forced MySQL replacement failure'); + }, + $verifier + ); + throw new RuntimeException('forced MySQL replacement failure unexpectedly committed'); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'forced MySQL replacement failure') { + throw $exception; + } + } + $rolledBack = $pdo->query( + 'SELECT c.medicine_code,c.local_medicine_id,c.name,m.operator_id,m.operator_name ' + . 'FROM projection_catalog c JOIN projection_mapping m USING (medicine_code)' + )->fetch(); + if ($rolledBack !== [ + 'medicine_code' => 'OLD002', + 'local_medicine_id' => 998, + 'name' => '回滚旧投影', + 'operator_id' => 7, + 'operator_name' => 'rollback-operator', + ]) { + throw new RuntimeException('MySQL rollback did not restore the old projection exactly'); + } + + fwrite(STDOUT, $temporaryTables + ? "medicine bootstrap MySQL integration passed: temporary_tables\n" + : "medicine bootstrap MySQL integration passed: temporary_database\n"); +} catch (PDOException $exception) { + if ($stage === 'test') { + throw $exception; + } + fwrite(STDOUT, sprintf( + "medicine bootstrap MySQL integration skipped: temporary database unavailable stage=%s sqlstate=%s\n", + $stage, + (string) $exception->getCode() + )); + exit(0); +} finally { + $pdo = null; + if ($admin instanceof PDO && !$temporaryTables) { + try { + $admin->exec("DROP DATABASE IF EXISTS `{$databaseName}`"); + } catch (Throwable) { + } + } +} diff --git a/server/tests/pharmacy/route_contracts.php b/server/tests/pharmacy/route_contracts.php new file mode 100644 index 00000000..4f586172 --- /dev/null +++ b/server/tests/pharmacy/route_contracts.php @@ -0,0 +1,98 @@ + (int) Db::name('ej_pharmacy_submission')->count()"), + 'bootstrap replacement must lock reference sources before checking the zero-reference gate' +); +$assertTrue( + str_contains($bootstrapServiceSource, "Db::name('doctor_medicine')->where('id', '>=', 0)") + && str_contains($bootstrapServiceSource, '本地药材源快照在远端导入期间发生变化'), + 'bootstrap replacement must lock and revalidate the complete local medicine source snapshot' +); diff --git a/server/tests/pharmacy/run.php b/server/tests/pharmacy/run.php new file mode 100644 index 00000000..e847571a --- /dev/null +++ b/server/tests/pharmacy/run.php @@ -0,0 +1,1599 @@ + $target, 'token' => 'token-direct', 'status' => 'PENDING']; + return $claimState + ['idempotent' => false]; + }, + static function (string $target) use (&$claimWorkflow, &$competingTargetBlocked): array { + try { + $claimWorkflow->execute($target === 'direct' ? 'gancao' : 'direct'); + } catch (DomainException $exception) { + $competingTargetBlocked = str_contains($exception->getMessage(), '正在上传'); + } + return ['remote_order_no' => 'EJ-1']; + }, + static function (string $target, string $token) use (&$claimState): bool { + if (!is_array($claimState) || $claimState['target'] !== $target || $claimState['token'] !== $token) { + return false; + } + $claimState['status'] = 'SUCCESS'; + return true; + }, + static function (): bool { return false; } + , + static function (): bool { return false; } +); +$claimResult = $claimWorkflow->execute('direct'); +$assertSame(true, $competingTargetBlocked, 'one order revision must not concurrently claim two pharmacy targets'); +$assertSame('SUCCESS', $claimState['status'], 'winning pharmacy claim must complete successfully'); +$assertSame('EJ-1', $claimResult['remote_order_no'], 'claim workflow must return the remote result'); + +$claimTokenShapeSeen = ''; +$claimTokenShapeWorkflow = new PharmacySubmissionClaimWorkflow( + static fn (): array => [ + 'target' => 'direct', + 'claim_token' => 'persisted-claim-token', + 'status' => 'PENDING', + 'idempotent' => false, + ], + static function (string $target, string $token) use (&$claimTokenShapeSeen): array { + $claimTokenShapeSeen = $token; + return ['remote_order_no' => 'EJ-TOKEN-SHAPE']; + }, + static fn (): bool => true, + static fn (): bool => false, + static fn (): bool => false +); +$claimTokenShapeWorkflow->execute('direct'); +$assertSame( + 'persisted-claim-token', + $claimTokenShapeSeen, + 'claim workflow must accept the persisted claim_token shape returned by a fresh production claim' +); + +$retryState = null; +$attempt = 0; +$retryWorkflow = new PharmacySubmissionClaimWorkflow( + static function (string $target) use (&$retryState): array { + if (is_array($retryState) && in_array($retryState['status'], ['PENDING', 'SUCCESS'], true)) { + throw new DomainException('该订单已有有效药房提交'); + } + $retryState = [ + 'target' => $target, + 'token' => 'token-' . $target, + 'status' => 'PENDING', + 'idempotent' => false, + ]; + return $retryState; + }, + static function (string $target) use (&$attempt): array { + ++$attempt; + if ($attempt === 1) { + throw new PharmacyRemoteRejectedException('远端明确拒绝'); + } + return ['remote_order_no' => strtoupper($target) . '-2']; + }, + static function (string $target, string $token) use (&$retryState): bool { + if ($retryState['target'] !== $target || $retryState['token'] !== $token || $retryState['status'] !== 'PENDING') { + return false; + } + $retryState['status'] = 'SUCCESS'; + return true; + }, + static function (string $target, string $token) use (&$retryState): bool { + if ($retryState['target'] !== $target || $retryState['token'] !== $token || $retryState['status'] !== 'PENDING') { + return false; + } + $retryState['status'] = 'FAILED'; + return true; + }, + static function (): bool { return false; } +); +try { + $retryWorkflow->execute('direct'); + throw new RuntimeException('confirmed rejection unexpectedly succeeded'); +} catch (RuntimeException $exception) { + $assertSame('远端明确拒绝', $exception->getMessage(), 'confirmed rejection must propagate after marking claim failed'); +} +$assertSame('FAILED', $retryState['status'], 'confirmed no-create rejection may release the revision for a controlled retry'); +$retryResult = $retryWorkflow->execute('gancao'); +$assertSame('SUCCESS', $retryState['status'], 'failed claim may retry using the newly selected pharmacy target'); +$assertSame('GANCAO-2', $retryResult['remote_order_no'], 'retry must return the second target result'); + +$staleFailureMarked = 0; +$staleReconcileMarked = 0; +$staleWorkflow = new PharmacySubmissionClaimWorkflow( + static fn (): array => ['target' => 'direct', 'token' => 'stale-token', 'status' => 'PENDING'], + static fn (): array => ['remote_order_no' => 'EJ-STALE'], + static fn (): bool => false, + static function () use (&$staleFailureMarked): bool { ++$staleFailureMarked; return false; }, + static function () use (&$staleReconcileMarked): bool { ++$staleReconcileMarked; return true; } +); +try { + $staleWorkflow->execute('direct'); + throw new RuntimeException('stale claim unexpectedly overwrote submission state'); +} catch (PharmacyReconciliationRequiredException $exception) { + $assertTrue(str_contains($exception->getMessage(), '对账'), 'stale token/target CAS must require reconciliation'); +} +$assertSame(0, $staleFailureMarked, 'remote success with local finalize failure must never be released as failed'); +$assertSame(1, $staleReconcileMarked, 'remote success with local finalize failure must enter reconciliation'); + +$uncertainState = ['target' => 'direct', 'token' => 'uncertain-token', 'status' => 'PENDING']; +$uncertainFailureMarked = 0; +$uncertainReconcileMarked = 0; +$uncertainWorkflow = new PharmacySubmissionClaimWorkflow( + static fn (): array => $uncertainState, + static function (): array { throw new RuntimeException('connect timeout after request send'); }, + static fn (): bool => true, + static function () use (&$uncertainFailureMarked): bool { ++$uncertainFailureMarked; return true; }, + static function () use (&$uncertainReconcileMarked, &$uncertainState): bool { + ++$uncertainReconcileMarked; + $uncertainState['status'] = 'PENDING_RECONCILE'; + return true; + } +); +try { + $uncertainWorkflow->execute('direct'); + throw new RuntimeException('uncertain remote outcome unexpectedly succeeded'); +} catch (RuntimeException $exception) { + $assertTrue(str_contains($exception->getMessage(), 'timeout'), 'transport uncertainty must propagate for operator visibility'); +} +$assertSame(0, $uncertainFailureMarked, 'transport uncertainty must never release the revision as failed'); +$assertSame(1, $uncertainReconcileMarked, 'transport uncertainty must enter reconciliation exactly once'); +$assertSame('PENDING_RECONCILE', $uncertainState['status'], 'uncertain claim must remain active for same-target reconciliation'); +$gancaoLostResponseRemoteCalls = 0; +$gancaoLostResponseSuccessMarks = 0; +$gancaoLostResponseFailureMarks = 0; +$gancaoLostResponseReconcileMarks = 0; +$gancaoLostResponseWorkflow = new PharmacySubmissionClaimWorkflow( + static fn (): array => [ + 'target' => 'gancao', + 'claim_token' => 'gancao-lost-response-token', + 'status' => 'PENDING_RECONCILE', + 'reconcile' => true, + 'idempotent' => false, + ], + static function () use (&$gancaoLostResponseRemoteCalls): array { + ++$gancaoLostResponseRemoteCalls; + return ['remote_order_no' => 'MUST-NOT-SUBMIT']; + }, + static function () use (&$gancaoLostResponseSuccessMarks): bool { + ++$gancaoLostResponseSuccessMarks; + return true; + }, + static function () use (&$gancaoLostResponseFailureMarks): bool { + ++$gancaoLostResponseFailureMarks; + return true; + }, + static function () use (&$gancaoLostResponseReconcileMarks): bool { + ++$gancaoLostResponseReconcileMarks; + return true; + } +); +try { + $gancaoLostResponseWorkflow->execute('gancao'); + throw new RuntimeException('Gancao lost-response retry unexpectedly submitted again'); +} catch (PharmacyReconciliationRequiredException $exception) { + $assertTrue( + str_contains($exception->getMessage(), '结果待核对') && str_contains($exception->getMessage(), '禁止重提'), + 'Gancao reconciliation claim must return an actionable no-resubmit error' + ); +} +$assertSame(0, $gancaoLostResponseRemoteCalls, 'Gancao lost-response retry must not invoke any remote submit path'); +$assertSame(0, $gancaoLostResponseSuccessMarks, 'blocked Gancao reconciliation must not finalize success'); +$assertSame(0, $gancaoLostResponseFailureMarks, 'blocked Gancao reconciliation must never release the claim as failed'); +$assertSame(0, $gancaoLostResponseReconcileMarks, 'already-reconciling Gancao claim must remain unchanged'); +$assertSame( + true, + PharmacyRemoteOutcomeClassifier::isConfirmedEjNoCreateHttpStatus(422), + 'explicit EJ validation rejection may release a claim for controlled retry' +); +foreach ([0, 302, 408, 409, 425, 429, 499, 500, 503] as $uncertainHttpStatus) { + $assertSame( + false, + PharmacyRemoteOutcomeClassifier::isConfirmedEjNoCreateHttpStatus($uncertainHttpStatus), + "EJ HTTP {$uncertainHttpStatus} must remain pending reconciliation" + ); +} +$sameTargetReconcile = PharmacySubmissionClaimPolicy::existingDecision( + [ + 'target' => 'direct', + 'status' => 'PENDING_RECONCILE', + 'claim_token' => 'stable-token', + 'idempotency_key' => 'stable-key', + ], + 'direct' +); +$assertSame('RECONCILE', $sameTargetReconcile['action'], 'uncertain claim may only continue through same-target reconciliation'); +$assertSame('stable-token', $sameTargetReconcile['claim_token'], 'same-target reconciliation must preserve claim token'); +$assertSame('stable-key', $sameTargetReconcile['idempotency_key'], 'same-target reconciliation must preserve idempotency key'); + +$now = 1_800_000_000; +try { + PharmacySubmissionClaimPolicy::existingDecision( + [ + 'target' => 'direct', + 'status' => 'PENDING', + 'claim_token' => 'live-token', + 'lease_expires_at' => $now + 30, + ], + 'direct', + $now + ); + throw new RuntimeException('a live submission lease unexpectedly allowed another claimant'); +} catch (DomainException $exception) { + $assertTrue(str_contains($exception->getMessage(), '正在上传'), 'a live submission lease must fence competing workers'); +} +$expiredDirectDecision = PharmacySubmissionClaimPolicy::existingDecision( + [ + 'target' => 'direct', + 'status' => 'PENDING', + 'claim_token' => 'stale-direct-token', + 'idempotency_key' => 'stable-direct-key', + 'lease_expires_at' => $now - 1, + ], + 'direct', + $now +); +$assertSame('RETRY', $expiredDirectDecision['action'], 'an expired EJ lease must be reclaimable'); +$assertSame(true, $expiredDirectDecision['lease_expired'], 'an expired EJ lease must be marked for token rotation/fencing'); +$assertSame('stable-direct-key', $expiredDirectDecision['idempotency_key'], 'EJ lease recovery must preserve remote idempotency key'); +$expiredGancaoDecision = PharmacySubmissionClaimPolicy::existingDecision( + [ + 'target' => 'gancao', + 'status' => 'PENDING', + 'claim_token' => 'stale-gancao-token', + 'lease_expires_at' => $now - 1, + ], + 'gancao', + $now +); +$assertSame('RECONCILE', $expiredGancaoDecision['action'], 'an expired Gancao lease must enter reconciliation'); +$assertSame(true, $expiredGancaoDecision['lease_expired'], 'Gancao lease recovery must fence the stale worker'); + +$manualSuccess = PharmacySubmissionReconciliationPolicy::resolve( + ['target' => 'gancao', 'status' => 'PENDING_RECONCILE'], + 'CONFIRM_SUCCESS', + 'GC-MANUAL-1', + '已在甘草后台核对订单' +); +$assertSame('SUCCESS', $manualSuccess['status'], 'manual remote-success confirmation must finalize the claim'); +$assertSame('GC-MANUAL-1', $manualSuccess['remote_order_no'], 'manual success must persist the verified Gancao order number'); +$manualNotCreated = PharmacySubmissionReconciliationPolicy::resolve( + ['target' => 'gancao', 'status' => 'UNKNOWN'], + 'CONFIRM_NOT_CREATED', + '', + '甘草后台确认未生成订单' +); +$assertSame('FAILED', $manualNotCreated['status'], 'manual no-create confirmation must release the claim for an intentional retry'); +$expiredPendingGancaoResolution = PharmacySubmissionReconciliationPolicy::resolve( + ['target' => 'gancao', 'status' => 'PENDING', 'lease_expires_at' => $now - 1], + 'CONFIRM_NOT_CREATED', + '', + '甘草后台确认未生成订单', + $now +); +$assertSame( + 'FAILED', + $expiredPendingGancaoResolution['status'], + 'an expired Gancao PENDING lease must be recoverable through explicit operator confirmation' +); +foreach ( + [ + [['target' => 'direct', 'status' => 'PENDING_RECONCILE'], 'CONFIRM_NOT_CREATED', '', 'checked'], + [['target' => 'gancao', 'status' => 'SUCCESS'], 'CONFIRM_NOT_CREATED', '', 'checked'], + [['target' => 'gancao', 'status' => 'UNKNOWN'], 'CONFIRM_SUCCESS', '', 'checked'], + [['target' => 'gancao', 'status' => 'UNKNOWN'], 'CONFIRM_NOT_CREATED', '', ''], + [['target' => 'gancao', 'status' => 'PENDING', 'lease_expires_at' => $now + 30], 'CONFIRM_NOT_CREATED', '', 'checked'], + ] as [$claimToResolve, $resolution, $remoteOrderNo, $note] +) { + try { + PharmacySubmissionReconciliationPolicy::resolve($claimToResolve, $resolution, $remoteOrderNo, $note); + throw new RuntimeException('invalid manual reconciliation unexpectedly succeeded'); + } catch (DomainException) { + $assertTrue(true, 'manual reconciliation must reject invalid target/status/evidence'); + } +} + +$assertSame( + 'direct', + PharmacySupplyMode::resolve(['ship_mode' => 'direct', 'ej_pharmacy_order_no' => '']), + 'direct orders must remain visible as Luoyang direct before remote upload' +); +$assertSame( + 'direct', + PharmacySupplyMode::resolve(['ship_mode' => 'direct', 'ej_pharmacy_order_no' => 'EJ-1']), + 'uploaded Luoyang orders must resolve as direct' +); +$assertSame( + 'gancao', + PharmacySupplyMode::resolve(['ship_mode' => 'gancao', 'gancao_reciperl_order_no' => 'GC-1']), + 'orders with a Gancao remote number must resolve as Gancao' +); +$assertSame( + 'self', + PharmacySupplyMode::resolve(['ship_mode' => 'gancao', 'gancao_reciperl_order_no' => '']), + 'legacy non-uploaded non-direct orders must retain the self-operated bucket' +); +$assertSame('洛阳直发', PharmacySupplyMode::label('direct'), 'direct export and UI label must be explicit'); +try { + PharmacySubmissionClaimPolicy::existingDecision( + ['target' => 'direct', 'status' => 'PENDING_RECONCILE'], + 'gancao' + ); + throw new RuntimeException('uncertain claim unexpectedly switched pharmacy target'); +} catch (DomainException $exception) { + $assertTrue(str_contains($exception->getMessage(), '对账'), 'uncertain claim must block a competing pharmacy target'); +} +$mutationEvents = []; +$mutationResult = LockedPharmacySnapshotMutation::run( + static function () use (&$mutationEvents): array { + $mutationEvents[] = 'order'; + return ['id' => 10]; + }, + static function () use (&$mutationEvents): ?array { + $mutationEvents[] = 'claim'; + return ['status' => 'FAILED']; + }, + static function () use (&$mutationEvents): string { + $mutationEvents[] = 'save'; + return 'saved'; + } +); +$assertSame(['order', 'claim', 'save'], $mutationEvents, 'snapshot mutation must lock order then claim before saving'); +$assertSame('saved', $mutationResult, 'mutable snapshot must execute its mutation inside the lock scope'); +$blockedMutationRan = false; +try { + LockedPharmacySnapshotMutation::run( + static fn (): array => ['id' => 10], + static fn (): array => ['status' => 'PENDING_RECONCILE'], + static function () use (&$blockedMutationRan): void { $blockedMutationRan = true; } + ); + throw new RuntimeException('locked snapshot mutation unexpectedly ran'); +} catch (DomainException $exception) { + $assertTrue(str_contains($exception->getMessage(), '不可修改'), 'locked mutation must return the snapshot policy error'); +} +$assertSame(false, $blockedMutationRan, 'locked snapshot must be rejected before its save callback'); +try { + LockedPharmacySnapshotMutation::run( + static fn (): array => ['id' => 10], + static fn (): ?array => null, + static fn (): bool => false + ); + throw new RuntimeException('false mutation result unexpectedly committed'); +} catch (DomainException $exception) { + $assertTrue(str_contains($exception->getMessage(), '未完成'), 'false mutation result must abort its transaction'); +} + +$identityRows = [ + ['id' => 101, 'name' => '黄芪', 'status' => 1, 'delete_time' => null], + ['id' => 102, 'name' => '甘草', 'status' => 1, 'delete_time' => null], + ['id' => 103, 'name' => '甘草', 'status' => 1, 'delete_time' => null], +]; +$loadIdentityIds = static function (array $ids) use ($identityRows): array { + return array_values(array_filter( + $identityRows, + static fn (array $row): bool => in_array((int) $row['id'], $ids, true) + )); +}; +$loadIdentityNames = static function (array $names) use ($identityRows): array { + return array_values(array_filter( + $identityRows, + static fn (array $row): bool => in_array((string) $row['name'], $names, true) + )); +}; +$normalizedIdentity = PharmacyHerbIdentityResolver::resolve([ + ['medicine_id' => 101, 'name' => '旧黄芪名', 'dosage' => 10], +], $loadIdentityIds, $loadIdentityNames); +$assertSame(101, $normalizedIdentity[0]['medicine_id'], 'explicit local medicine id must be preserved'); +$assertSame('黄芪', $normalizedIdentity[0]['name'], 'explicit local medicine id must refresh the snapshotted name'); +$legacyIdentity = PharmacyHerbIdentityResolver::resolve([ + ['name' => '黄芪', 'dosage' => 10], +], $loadIdentityIds, $loadIdentityNames); +$assertSame(101, $legacyIdentity[0]['medicine_id'], 'legacy name-only herb must resolve when exactly one local medicine matches'); +try { + PharmacyHerbIdentityResolver::resolve([ + ['name' => '甘草', 'dosage' => 6], + ], $loadIdentityIds, $loadIdentityNames); + throw new RuntimeException('duplicate medicine name unexpectedly resolved to one id'); +} catch (DomainException $exception) { + $assertTrue( + str_contains($exception->getMessage(), '甘草') && str_contains($exception->getMessage(), '多个同名'), + 'duplicate local medicine names must block upload/save with an actionable herb error' + ); +} +try { + PharmacyHerbIdentityResolver::resolve([ + ['name' => '未收录药材', 'dosage' => 3], + ], $loadIdentityIds, $loadIdentityNames); + throw new RuntimeException('missing medicine name unexpectedly resolved'); +} catch (DomainException $exception) { + $assertTrue(str_contains($exception->getMessage(), '未收录药材'), 'missing local medicine must name the blocked herb'); +} + +$assertSame( + false, + PharmacyRemoteSnapshotPolicy::isLocked( + ['gancao_reciperl_order_no' => '', 'ej_pharmacy_order_no' => null], + ['status' => 'FAILED'] + ), + 'failed claim without a remote order must leave the snapshot editable for retry or mode change' +); +$assertSame( + true, + PharmacyRemoteSnapshotPolicy::isLocked( + ['gancao_reciperl_order_no' => '', 'ej_pharmacy_order_no' => null], + ['status' => 'PENDING'] + ), + 'pending pharmacy claim must lock the remote snapshot' +); +$assertSame( + true, + PharmacyRemoteSnapshotPolicy::isLocked( + ['gancao_reciperl_order_no' => '', 'ej_pharmacy_order_no' => null], + ['status' => 'PENDING_RECONCILE'] + ), + 'uncertain pharmacy result must keep every remote snapshot mutation locked' +); +$assertSame( + true, + PharmacyRemoteSnapshotPolicy::isLocked( + ['gancao_reciperl_order_no' => '', 'ej_pharmacy_order_no' => 'EJ-LOCKED'], + null + ), + 'any remote pharmacy order number must lock the remote snapshot' +); +try { + PharmacyRemoteSnapshotPolicy::assertMutable( + ['gancao_reciperl_order_no' => 'GC-LOCKED', 'ej_pharmacy_order_no' => null], + ['status' => 'SUCCESS'] + ); + throw new RuntimeException('remote snapshot unexpectedly remained mutable'); +} catch (DomainException $exception) { + $assertTrue(str_contains($exception->getMessage(), '取消后创建新版本'), 'locked snapshot must explain the revision workflow'); +} + +$callbackPayload = ['event_id' => 'event-1', 'source_order_no' => 'PO-1', 'pharmacy_order_no' => 'EJ-1']; +$earlyFailed = ''; +$earlyWorkflow = new EjPharmacyCallbackWorkflow( + static fn (): ?array => ['id' => 1, 'process_status' => 'PENDING'], + static fn (): array => [], + static fn (): ?array => null, + static function (): array { throw new EjPharmacyCallbackRetryException('远程单号尚未回写'); }, + static function (array $inbox, string $error) use (&$earlyFailed): void { $earlyFailed = $error; }, + static fn (): bool => false +); +$earlyResult = $earlyWorkflow->handle($callbackPayload); +$assertSame(503, $earlyResult['http_status'], 'early callback must ask ej ERP to retry after local upload writeback'); +$assertTrue(str_contains($earlyFailed, '尚未回写'), 'early callback retry reason must be retained on inbox'); + +$failedRetryProcessed = 0; +$failedRetryWorkflow = new EjPharmacyCallbackWorkflow( + static fn (): ?array => ['id' => 2, 'process_status' => 'FAILED'], + static fn (): array => [], + static fn (): ?array => null, + static function () use (&$failedRetryProcessed): array { + ++$failedRetryProcessed; + return ['process_status' => 'PROCESSED']; + }, + static function (): void {}, + static fn (): bool => false +); +$failedRetryResult = $failedRetryWorkflow->handle($callbackPayload); +$assertSame(200, $failedRetryResult['http_status'], 'failed inbox row must be eligible for reprocessing'); +$assertSame(1, $failedRetryProcessed, 'failed inbox retry must execute processing exactly once'); + +$runtimeFailed = ''; +$runtimeWorkflow = new EjPharmacyCallbackWorkflow( + static fn (): ?array => ['id' => 3, 'process_status' => 'PENDING'], + static fn (): array => [], + static fn (): ?array => null, + static function (): array { throw new RuntimeException('database unavailable'); }, + static function (array $inbox, string $error) use (&$runtimeFailed): void { $runtimeFailed = $error; }, + static fn (): bool => false +); +$runtimeResult = $runtimeWorkflow->handle($callbackPayload); +$assertSame(500, $runtimeResult['http_status'], 'unknown callback processing failure must return 5xx'); +$assertSame('database unavailable', $runtimeFailed, 'runtime callback failure must remain retryable in inbox'); + +$duplicateReloaded = 0; +$duplicateWorkflow = new EjPharmacyCallbackWorkflow( + static fn (): ?array => null, + static function (): array { throw new RuntimeException('duplicate event id'); }, + static function () use (&$duplicateReloaded): ?array { + ++$duplicateReloaded; + return ['id' => 4, 'process_status' => 'PENDING']; + }, + static fn (): array => ['process_status' => 'PROCESSED'], + static function (): void {}, + static fn (Throwable $exception): bool => str_contains($exception->getMessage(), 'duplicate') +); +$duplicateResult = $duplicateWorkflow->handle($callbackPayload); +$assertSame(200, $duplicateResult['http_status'], 'concurrent duplicate inbox insert must reload and process instead of returning 422'); +$assertSame(1, $duplicateReloaded, 'duplicate inbox race must reload the winning row exactly once'); + +$processedCalls = 0; +$processedWorkflow = new EjPharmacyCallbackWorkflow( + static fn (): ?array => ['id' => 5, 'process_status' => 'PROCESSED'], + static fn (): array => [], + static fn (): ?array => null, + static function () use (&$processedCalls): array { ++$processedCalls; return []; }, + static function (): void {}, + static fn (): bool => false +); +$processedResult = $processedWorkflow->handle($callbackPayload); +$assertSame(true, $processedResult['duplicate'], 'only processed inbox rows may return immediate idempotent success'); +$assertSame(0, $processedCalls, 'processed inbox duplicate must not execute business processing again'); + +$assertSame( + 'SF', + PharmacyLogisticsValue::normalize("\u{200B}\u{00A0}SF\u{3000}\u{FEFF}", 32, '快递公司'), + 'callback logistics values must trim Unicode and zero-width edge whitespace' +); +try { + PharmacyLogisticsValue::normalize(str_repeat('运', 101), 100, '运单号'); + throw new RuntimeException('overlong Unicode tracking number unexpectedly accepted'); +} catch (InvalidArgumentException $exception) { + $assertTrue(str_contains($exception->getMessage(), '100'), 'callback logistics length error must expose the storage limit'); +} + +$legacyUploadUri = 'tcm.prescriptionorder/submitgancaorecipel'; +$unifiedUploadUri = 'tcm.prescriptionorder/uploadtopharmacy'; +$reconcileUri = 'tcm.prescriptionorder/confirmgancaosubmission'; +$assertSame( + $unifiedUploadUri, + PharmacyUploadPermissionAlias::canonicalUri($legacyUploadUri), + 'legacy Gancao-named URI must canonicalize to the unified pharmacy upload capability' +); +$assertSame(true, PharmacyUploadPermissionAlias::isControlled($legacyUploadUri), 'legacy upload alias must always remain permission controlled'); +$assertSame( + true, + PharmacyUploadPermissionAlias::allows($unifiedUploadUri, [$legacyUploadUri]), + 'role holding the legacy upload permission must be allowed to call the unified URI' +); +$assertSame( + true, + PharmacyUploadPermissionAlias::allows($legacyUploadUri, [$unifiedUploadUri]), + 'role holding the unified upload permission must be allowed to call the legacy alias' +); +$assertSame($reconcileUri, PharmacyUploadPermissionAlias::canonicalUri($reconcileUri), 'manual reconciliation must retain an independent permission identity'); +$assertSame(true, PharmacyUploadPermissionAlias::isControlled($reconcileUri), 'manual reconciliation must remain permission controlled'); +$assertSame(false, PharmacyUploadPermissionAlias::allows($reconcileUri, [$unifiedUploadUri]), 'upload permission must not authorize manual reconciliation'); +$assertSame(true, PharmacyUploadPermissionAlias::allows($reconcileUri, [$reconcileUri]), 'the dedicated reconciliation permission must authorize its own endpoint'); + +$payload = EjPharmacyPayload::build([ + 'order_no' => 'PO001', + 'recipient_name' => 'Patient A', + 'recipient_phone' => '13800000000', + 'shipping_province' => '河南省', + 'shipping_city' => '洛阳市', + 'shipping_district' => '洛龙区', + 'shipping_address' => '测试路1号', + 'dose_count' => 7, +], [ + 'id' => 11, + 'patient_name' => 'Patient A', + 'id_card' => '410000000000000000', + 'phone' => '13800000000', + 'clinical_diagnosis' => '测试诊断', + 'processing_type' => 'decoction', + 'creator_id' => 9, + 'doctor_name' => 'Doctor A', + 'usage_instruction' => '饭后温服', + 'doctor_signature' => ['url' => 'https://example.test/sign.png', 'signed_at' => '2026-07-21T10:00:00+08:00'], + 'herbs' => [ + ['medicine_id' => 101, 'name' => 'Herb A', 'dose' => '10', 'unit' => '克'], + ], +], [101 => 'EJ000001'], 1); + +$assertSame('zyt', $payload['source_system'], 'source system must be zyt'); +$assertSame('PO001', $payload['source_order_no'], 'source order number must be preserved'); +$assertSame('EJ000001', $payload['prescription']['medicines'][0]['medicine_code'], 'local medicine must map to ej code'); +$assertSame('10.0000', $payload['prescription']['medicines'][0]['quantity'], 'medicine quantity must use four decimals'); +$assertSame('测试诊断', $payload['prescription']['diagnosis'], 'ERP diagnosis must use the production prescription field'); +$assertSame('9', $payload['prescription']['doctor']['source_doctor_id'], 'ERP doctor id must use the prescription creator'); +$assertSame('饭后温服', $payload['prescription']['instructions'], 'ERP instructions must use the production prescription field'); + +$missingRejected = false; +try { + EjPharmacyPayload::build(['order_no' => 'PO002'], [ + 'patient_name' => 'A', + 'phone' => '13800000000', + 'herbs' => [['medicine_id' => 999, 'name' => 'Unknown', 'dose' => 1, 'unit' => '克']], + ], [], 1); +} catch (DomainException $exception) { + $missingRejected = str_contains($exception->getMessage(), 'Unknown'); +} +$assertTrue($missingRejected, 'unmapped medicine must block ERP upload'); + +$migrationPath = dirname(__DIR__, 2) . '/sql/1.9.20260721/luoyang_pharmacy_erp.sql'; +$migrationMatches = glob(dirname(__DIR__, 2) . '/sql/*/luoyang_pharmacy_erp.sql') ?: []; +$legacyMigrationMatches = glob(dirname(__DIR__, 2) . '/database/migrations/*luoyang_pharmacy_erp.sql') ?: []; +sort($migrationMatches); +$assertSame([$migrationPath], $migrationMatches, 'zyt ERP projection must have exactly one versioned SQL migration'); +$assertSame([], $legacyMigrationMatches, 'zyt ERP projection must not use database/migrations'); +$assertTrue(is_file($migrationPath), 'zyt ERP projection migration must exist'); +$migration = is_file($migrationPath) ? (string) file_get_contents($migrationPath) : ''; +foreach (['zyt_ej_medicine_catalog', 'zyt_ej_medicine_mapping', 'zyt_ej_pharmacy_submission', 'zyt_ej_pharmacy_callback_inbox'] as $table) { + $assertTrue(str_contains($migration, "`{$table}`"), "migration must create {$table}"); +} +$assertTrue(str_contains($migration, '`zyt_pharmacy_submission_claim`'), 'migration must create a cross-pharmacy durable claim table'); +$assertTrue( + str_contains($migration, '`status` varchar(24) NOT NULL DEFAULT \'PENDING\''), + 'claim status column must hold PENDING_RECONCILE without truncation' +); +$assertTrue( + (bool) preg_match( + '/UNIQUE KEY `uk_order_revision` \(`prescription_order_id`,`source_revision`\)/', + substr($migration, (int) strpos($migration, 'CREATE TABLE IF NOT EXISTS `zyt_pharmacy_submission_claim`')) + ), + 'cross-pharmacy claim must allow only one target per order revision' +); +foreach (['`target`', '`status`', '`claim_token`', '`idempotency_key`', '`claimed_at`', '`completed_at`', '`failed_at`'] as $claimColumn) { + $assertTrue(str_contains($migration, $claimColumn), "cross-pharmacy claim must persist {$claimColumn}"); +} +$assertTrue( + str_contains($migration, 'MODIFY COLUMN `express_company` varchar(32)') + && str_contains($migration, 'MODIFY COLUMN `tracking_number` varchar(100)'), + 'order logistics projection must match ej callback length limits' +); +$assertTrue( + str_contains($migration, 'ALTER TABLE `zyt_express_tracking`') + && str_contains($migration, 'ALTER TABLE `zyt_express_trace`'), + 'real tracking and trace tables must be widened for ej tracking numbers' +); +$assertTrue(str_contains($migration, '`ej_pharmacy_order_no`'), 'business order must store ej pharmacy order number'); +$orderProjectionSql = substr( + $migration, + 0, + (int) strpos($migration, 'CREATE TABLE IF NOT EXISTS `zyt_ej_medicine_catalog`') +); +$assertTrue( + (bool) preg_match('/`ej_pharmacy_order_no`\s+varchar\(40\)\s+NULL\s+DEFAULT\s+NULL/i', $orderProjectionSql), + 'remote order number must be nullable so unique index permits multiple pending orders' +); +$assertTrue( + !(bool) preg_match('/`ej_pharmacy_order_no`\s+varchar\(40\)\s+NOT NULL\s+DEFAULT\s+[\'\"]{2}/i', $orderProjectionSql), + 'remote order number must not retain the duplicate empty-string default' +); +$assertTrue( + str_contains($orderProjectionSql, "UPDATE `zyt_tcm_prescription_order` SET `ej_pharmacy_order_no` = NULL"), + 'empty remote order numbers must be normalized before unique index creation' +); +$assertTrue( + substr_count($orderProjectionSql, 'information_schema.COLUMNS') >= 7, + 'every existing-order column addition must be guarded for repeat execution' +); +$assertTrue( + substr_count($orderProjectionSql, 'information_schema.STATISTICS') >= 2, + 'existing-order indexes must be guarded for repeat execution' +); + +$callbackController = (string) file_get_contents( + dirname(__DIR__, 2) . '/app/api/controller/EjPharmacyCallbackController.php' +); +$prescriptionOrderLogicSource = (string) file_get_contents( + dirname(__DIR__, 2) . '/app/adminapi/logic/tcm/PrescriptionOrderLogic.php' +); +$prescriptionOrderListsSource = (string) file_get_contents( + dirname(__DIR__, 2) . '/app/adminapi/lists/tcm/PrescriptionOrderLists.php' +); +$prescriptionLogicSource = (string) file_get_contents( + dirname(__DIR__, 2) . '/app/adminapi/logic/tcm/PrescriptionLogic.php' +); +$claimServiceSource = (string) file_get_contents( + dirname(__DIR__, 2) . '/app/common/service/pharmacy/PharmacySubmissionClaimService.php' +); +$ejPharmacyClientSource = (string) file_get_contents( + dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjPharmacyClient.php' +); +$callbackLockPattern = <<<'REGEX' +/PrescriptionOrder::where\('ej_pharmacy_order_no', \$pharmacyOrderNo\).*?->whereNull\('delete_time'\).*?->lock\(true\).*?->find\(\)/s +REGEX; +$assertTrue( + (bool) preg_match($callbackLockPattern, $callbackController), + 'callback must lock the order row before comparing and advancing status_version' +); +$assertTrue( + substr_count($prescriptionOrderLogicSource, 'assertRemoteSnapshotMutable($order)') >= 4, + 'order edit, ship-mode, patient and usage mutations must share the remote snapshot lock helper' +); +$assertTrue( + str_contains($prescriptionOrderLogicSource, 'PharmacySubmissionClaimService::markReconcile('), + 'unified upload must wire uncertain outcomes to the durable reconciliation transition' +); +$assertTrue( + str_contains($prescriptionOrderLogicSource, "['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS']") + && substr_count($prescriptionOrderLogicSource, 'pharmacyClaimBlocksUpload($item,') >= 2, + 'upload eligibility must hide every active or uncertain claim and leave Gancao reconciliation as the only action' +); +$claimDetailStart = strpos($prescriptionOrderLogicSource, '$claim = PharmacySubmissionClaimService::claimForOrder($id);'); +$claimDetailEnd = strpos($prescriptionOrderLogicSource, 'return $arr;', $claimDetailStart); +$claimDetailSource = substr($prescriptionOrderLogicSource, $claimDetailStart, $claimDetailEnd - $claimDetailStart); +$assertTrue( + str_contains($claimDetailSource, "\$arr['can_upload_pharmacy'] = self::canUploadToPharmacy("), + 'order detail must expose claim-aware pharmacy upload eligibility just like the list response' +); +$assertTrue( + str_contains($prescriptionOrderLogicSource, 'private static function pharmacyClaimBlocksUpload(') + && str_contains($prescriptionOrderLogicSource, "\$mode === 'direct'") + && str_contains($prescriptionOrderLogicSource, "\$leaseExpiresAt <= time()"), + 'expired direct leases must be reclaimable from the normal upload action while active leases remain fenced' +); +$assertTrue( + str_contains($prescriptionOrderLogicSource, "!empty(\$claim['reconcile'])") + && str_contains($prescriptionOrderLogicSource, '->prescriptionOrder('), + 'same-target reconciliation must query the remote order before retrying creation' +); +$prescriptionOrderQuerySource = substr( + $ejPharmacyClientSource, + (int) strpos($ejPharmacyClientSource, 'public function prescriptionOrder('), + (int) strpos($ejPharmacyClientSource, 'private function request(', (int) strpos($ejPharmacyClientSource, 'public function prescriptionOrder(')) + - (int) strpos($ejPharmacyClientSource, 'public function prescriptionOrder(') +); +$assertTrue( + str_contains($prescriptionOrderQuerySource, "'source_system' => 'zyt'"), + 'EJ reconciliation lookup must include the source_system required by the remote order identity contract' +); +$assertTrue( + str_contains($prescriptionOrderLogicSource, 'private static function canonicalPharmacyOrder('), + 'remote payload must reload the canonical order after acquiring the durable claim' +); +$assertTrue( + substr_count($prescriptionOrderLogicSource, 'LockedPharmacySnapshotMutation::execute(') >= 7, + 'order edit, patient, ship, withdraw, audit revoke, ship-mode and usage mutations must run inside locked snapshot transactions' +); +$assertTrue( + substr_count($prescriptionLogicSource, 'LockedPharmacySnapshotMutation::executeForPrescription(') >= 4, + 'prescription edit, patient patch, delete and void must lock every linked order and claim before saving' +); +foreach (['patchPrescriptionPatient', 'edit', 'ship', 'withdraw', 'revokeRxAudit', 'setShipMode', 'patchPrescriptionUsage'] as $method) { + $start = (int) strpos($prescriptionOrderLogicSource, 'public static function ' . $method . '('); + $end = (int) strpos($prescriptionOrderLogicSource, 'private static function ' . $method . 'Locked(', $start); + $wrapperSource = substr($prescriptionOrderLogicSource, $start, $end - $start); + $assertTrue( + str_contains($wrapperSource, "self::setError('');"), + "protected order mutation {$method} must clear stale errors before acquiring snapshot locks" + ); + $assertTrue( + str_contains($wrapperSource, "if (self::getError() === '') {") + && str_contains($wrapperSource, 'self::setError($exception->getMessage());'), + "protected order mutation {$method} must preserve precise inner business errors on rollback" + ); +} +foreach (['edit', 'patchPatientContact', 'delete', 'void'] as $method) { + $start = (int) strpos($prescriptionLogicSource, 'public static function ' . $method . '('); + $end = (int) strpos($prescriptionLogicSource, 'private static function ' . $method . 'Locked(', $start); + $wrapperSource = substr($prescriptionLogicSource, $start, $end - $start); + $assertTrue( + str_contains($wrapperSource, "self::setError('');"), + "protected prescription mutation {$method} must clear stale errors before acquiring snapshot locks" + ); + $assertTrue( + str_contains($wrapperSource, "if (self::getError() === '') {") + && str_contains($wrapperSource, 'self::setError($exception->getMessage());'), + "protected prescription mutation {$method} must preserve precise inner business errors on rollback" + ); +} +$shipLockedSource = substr( + $prescriptionOrderLogicSource, + (int) strpos($prescriptionOrderLogicSource, 'private static function shipLocked('), + (int) strpos($prescriptionOrderLogicSource, 'public static function withdraw(') + - (int) strpos($prescriptionOrderLogicSource, 'private static function shipLocked(') +); +$assertTrue( + !str_contains($shipLockedSource, '->ship_mode ='), + 'shipping logistics must never overwrite the pharmacy target snapshot' +); +$assertTrue( + substr_count($prescriptionLogicSource, 'remoteSnapshotLockErrorForPrescription') >= 2, + 'consumer prescription edit and patient patch must enforce the linked order snapshot lock' +); +$assertTrue( + str_contains($claimServiceSource, 'public static function markReconcile('), + 'claim persistence must expose an explicit reconciliation transition' +); +$assertTrue( + substr_count($claimServiceSource, "Db::name('tcm_prescription_order')") >= 4, + 'acquire and every claim transition must lock the order before the claim' +); +$assertTrue( + str_contains($prescriptionOrderLogicSource, 'isConfirmedEjNoCreateHttpStatus('), + 'direct EJ submission must use the explicit no-create HTTP classifier before releasing a claim' +); +$prescriptionSnapshotCheckSource = substr( + $prescriptionOrderLogicSource, + (int) strpos($prescriptionOrderLogicSource, 'public static function remoteSnapshotLockErrorForPrescription('), + (int) strpos($prescriptionOrderLogicSource, 'private static function buildDeptPath(', (int) strpos($prescriptionOrderLogicSource, 'public static function remoteSnapshotLockErrorForPrescription(')) + - (int) strpos($prescriptionOrderLogicSource, 'public static function remoteSnapshotLockErrorForPrescription(') +); +$lockedMutationSource = (string) file_get_contents( + dirname(__DIR__, 2) . '/app/common/service/pharmacy/LockedPharmacySnapshotMutation.php' +); +$assertTrue( + !str_contains($prescriptionSnapshotCheckSource, "where('fulfillment_status', '<>', 4)"), + 'canceled linked orders must still block prescription mutations when remote state remains' +); +$assertTrue( + !str_contains($lockedMutationSource, "where('fulfillment_status', '<>', 4)"), + 'transactional prescription snapshot locking must include canceled linked orders' +); +$gancaoSubmitSource = substr( + $prescriptionOrderLogicSource, + (int) strpos($prescriptionOrderLogicSource, 'private static function submitGancaoRemote('), + (int) strpos($prescriptionOrderLogicSource, 'public static function previewGancaoRecipel(') + - (int) strpos($prescriptionOrderLogicSource, 'private static function submitGancaoRemote(') +); +$assertTrue( + substr_count($gancaoSubmitSource, 'throw new PharmacyReconciliationRequiredException(') >= 2, + 'Gancao transport failure and success-without-order-number must remain pending reconciliation' +); +$assertTrue( + !str_contains($prescriptionOrderLogicSource, "str_contains(\$error, '下单通信失败')"), + 'Gancao uncertainty classification must use explicit exception types instead of localized error text' +); + +$mappingLists = (string) file_get_contents( + dirname(__DIR__, 2) . '/app/adminapi/lists/pharmacy/MedicineMappingLists.php' +); +$assertTrue( + str_contains( + $mappingLists, + "'m.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'" + ), + 'mapping list must exclude inactive and soft-deleted mappings from remote display fields' +); + +$validLocal = ['id' => 101, 'status' => 1, 'delete_time' => null]; +$validRemote = [ + 'medicine_code' => 'EJ000001', + 'status' => 1, + 'remote_deleted' => 0, +]; +EjMedicineMappingPolicy::assertValid($validLocal, $validRemote); + +$unlinkActive = EjMedicineMappingPolicy::unlinkDecision( + ['id' => 101, 'status' => 0, 'delete_time' => null], + ['id' => 9, 'local_medicine_id' => 101, 'status' => 1, 'delete_time' => null] +); +$assertSame(9, $unlinkActive['mapping_id'], 'unlink must target the locked mapping row owned by the local medicine'); +$assertSame(false, $unlinkActive['already_unlinked'], 'active mapping must be disabled'); +$unlinkAgain = EjMedicineMappingPolicy::unlinkDecision( + ['id' => 101, 'status' => 0, 'delete_time' => null], + ['id' => 9, 'local_medicine_id' => 101, 'status' => 0, 'delete_time' => time()] +); +$assertSame(true, $unlinkAgain['already_unlinked'], 'concurrent repeated unlink must be idempotent'); +try { + EjMedicineMappingPolicy::unlinkDecision( + ['id' => 101, 'status' => 1, 'delete_time' => null], + ['id' => 9, 'local_medicine_id' => 202, 'status' => 1, 'delete_time' => null] + ); + throw new RuntimeException('foreign mapping ownership unexpectedly accepted'); +} catch (DomainException $exception) { + $assertSame('药材映射归属不匹配', $exception->getMessage(), 'unlink must not disable another local medicine mapping'); +} + +foreach ([ + [['id' => 101, 'status' => 0, 'delete_time' => null], $validRemote, '本地药材已停用,不能建立映射'], + [$validLocal, ['medicine_code' => 'EJ000001', 'status' => 0, 'remote_deleted' => 0], '洛阳药房药材已停用,不能建立映射'], + [$validLocal, ['medicine_code' => 'EJ000001', 'status' => 1, 'remote_deleted' => 1], '洛阳药房药材已删除,不能建立映射'], +] as [$local, $remote, $expectedMessage]) { + try { + EjMedicineMappingPolicy::assertValid($local, $remote); + throw new RuntimeException('invalid mapping unexpectedly accepted'); + } catch (DomainException $exception) { + $assertSame($expectedMessage, $exception->getMessage(), 'mapping policy must reject invalid endpoint'); + } +} + +$createdMerge = EjMedicineCatalogSyncPolicy::merge(null, [ + 'medicine_code' => ' EJ000002 ', + 'name' => '黄芪', + 'brand' => '测试品牌', + 'unit' => '克', + 'settlement_price' => '1.2000', + 'retail_price' => '1.8000', + 'status' => 1, + 'catalog_version' => 10, + 'deleted' => false, +]); +$assertSame('created', $createdMerge['action'], 'new catalog item must be classified as created'); +$assertSame('EJ000002', $createdMerge['values']['medicine_code'], 'remote medicine code must be trimmed'); +$assertSame(0, $createdMerge['deactivated'], 'active catalog item is not deactivated'); + +$deactivatedMerge = EjMedicineCatalogSyncPolicy::merge([ + 'medicine_code' => 'EJ000002', + 'name' => '黄芪', + 'brand' => '测试品牌', + 'unit' => '克', + 'settlement_price' => '1.2000', + 'retail_price' => '1.8000', + 'status' => 1, + 'catalog_version' => 10, + 'remote_deleted' => 0, +], [ + 'medicine_code' => 'EJ000002', + 'name' => '黄芪', + 'brand' => '测试品牌', + 'unit' => '克', + 'settlement_price' => '1.2000', + 'retail_price' => '1.8000', + 'status' => 1, + 'catalog_version' => 11, + 'deleted' => true, +]); +$assertSame('updated', $deactivatedMerge['action'], 'remote deletion must update catalog item'); +$assertSame(1, $deactivatedMerge['deactivated'], 'active-to-inactive transition must be counted'); +$assertSame(0, $deactivatedMerge['values']['status'], 'remote deletion must disable local catalog item'); + +$fetchCursors = []; +$mergedPages = []; +$released = 0; +$successState = []; +$workflow = new EjMedicineCatalogSyncWorkflow( + static fn (): bool => true, + static function () use (&$released): void { ++$released; }, + static fn (): int => 0, + static function (int $cursor, int $limit) use (&$fetchCursors): array { + $fetchCursors[] = $cursor; + if ($cursor === 0) { + return ['http_status' => 200, 'body' => ['code' => 0, 'data' => [ + 'items' => [['medicine_code' => 'EJ1']], 'next_cursor' => 10, 'has_more' => true, + ]]]; + } + return ['http_status' => 200, 'body' => ['code' => 0, 'data' => [ + 'items' => [['medicine_code' => 'EJ2']], 'next_cursor' => 12, 'has_more' => false, + ]]]; + }, + static function (array $items, int $cursor) use (&$mergedPages): array { + $mergedPages[] = [$cursor, $items]; + return ['created' => 1, 'updated' => 0, 'deactivated' => 0]; + }, + static function (int $cursor, array $stats) use (&$successState): void { + $successState = [$cursor, $stats]; + }, + static function (): void {} +); +$syncStats = $workflow->sync(200); +$assertSame([0, 10], $fetchCursors, 'empty cursor sync must fetch every page in order'); +$assertSame([10, 12], array_column($mergedPages, 0), 'each page merge must persist its next cursor'); +$assertSame(2, $syncStats['created'], 'page merge stats must aggregate'); +$assertSame(2, $syncStats['pulled'], 'sync result must expose pulled count'); +$assertSame(2, $syncStats['received'], 'received remains a compatibility alias for command callers'); +$assertSame(12, $syncStats['cursor'], 'workflow must return final cursor'); +$assertSame(1, $released, 'successful sync must release lock exactly once'); +$assertSame(12, $successState[0], 'successful sync must persist final cursor'); + +$failureCursor = null; +$releasedAfterFailure = 0; +$failedWorkflow = new EjMedicineCatalogSyncWorkflow( + static fn (): bool => true, + static function () use (&$releasedAfterFailure): void { ++$releasedAfterFailure; }, + static fn (): int => 0, + static function (int $cursor): array { + if ($cursor === 0) { + return ['http_status' => 200, 'body' => ['code' => 0, 'data' => [ + 'items' => [['medicine_code' => 'EJ1']], 'next_cursor' => 10, 'has_more' => true, + ]]]; + } + return ['http_status' => 503, 'body' => ['code' => 500, 'message' => 'temporary remote failure']]; + }, + static fn (): array => ['created' => 1, 'updated' => 0, 'deactivated' => 0], + static function (): void {}, + static function (int $cursor) use (&$failureCursor): void { $failureCursor = $cursor; } +); +try { + $failedWorkflow->sync(200); + throw new RuntimeException('remote failure unexpectedly accepted'); +} catch (RuntimeException $exception) { + $assertSame('temporary remote failure', $exception->getMessage(), 'remote business error must remain actionable'); +} +$assertSame(10, $failureCursor, 'failed sync must retain last atomically merged cursor'); +$assertSame(1, $releasedAfterFailure, 'failed sync must release lock exactly once'); + +$releasedAfterCursorFailure = 0; +$cursorFailureRecorded = 0; +$cursorFailedWorkflow = new EjMedicineCatalogSyncWorkflow( + static fn (): bool => true, + static function () use (&$releasedAfterCursorFailure): void { ++$releasedAfterCursorFailure; }, + static function (): int { throw new RuntimeException('cursor storage unavailable'); }, + static fn (): array => [], + static fn (): array => [], + static function (): void {}, + static function () use (&$cursorFailureRecorded): void { ++$cursorFailureRecorded; } +); +try { + $cursorFailedWorkflow->sync(200); + throw new RuntimeException('cursor load failure unexpectedly accepted'); +} catch (RuntimeException $exception) { + $assertSame('cursor storage unavailable', $exception->getMessage(), 'cursor load error must propagate'); +} +$assertSame(1, $releasedAfterCursorFailure, 'cursor load failure after lease acquisition must release lease'); +$assertSame(1, $cursorFailureRecorded, 'cursor load failure must record a failed synchronization'); + +$releaseWithoutLock = 0; +$lockedWorkflow = new EjMedicineCatalogSyncWorkflow( + static fn (): bool => false, + static function () use (&$releaseWithoutLock): void { ++$releaseWithoutLock; }, + static fn (): int => 0, + static fn (): array => [], + static fn (): array => [], + static function (): void {}, + static function (): void {} +); +try { + $lockedWorkflow->sync(200); + throw new RuntimeException('concurrent sync unexpectedly accepted'); +} catch (DomainException $exception) { + $assertSame('洛阳药房目录正在同步,请稍后重试', $exception->getMessage(), 'concurrent sync must return a business error'); +} +$assertSame(0, $releaseWithoutLock, 'workflow must not release a lock it did not acquire'); + +$importPayload = [ + 'source_system' => 'zyt', + 'import_id' => 'bootstrap-test-1', + 'items' => [[ + 'source_medicine_id' => '1', + 'name' => '黄芪', + 'brand' => '', + 'unit' => '克', + 'settlement_price' => '1.0000', + 'retail_price' => '2.0000', + 'status' => 1, + ]], +]; +foreach ([403, 409, 422] as $structuredStatus) { + $capturedImportRequest = []; + $client = new EjPharmacyClient( + 'https://ej.example.test', + 'app-key', + 'app-secret', + static function (string $method, string $path, string $body, array $headers) use ( + $structuredStatus, + &$capturedImportRequest + ): array { + $capturedImportRequest = [$method, $path, json_decode($body, true), $headers]; + return [ + 'http_status' => $structuredStatus, + 'body' => ['code' => $structuredStatus, 'message' => 'structured error', 'data' => ['field' => 'items']], + 'request_id' => 'request-structured', + ]; + } + ); + $structuredResponse = $client->importMedicines($importPayload); + $assertSame($structuredStatus, $structuredResponse['http_status'], 'medicine import must preserve structured HTTP status'); + $assertSame( + ['code' => $structuredStatus, 'message' => 'structured error', 'data' => ['field' => 'items']], + $structuredResponse['body'], + 'medicine import must preserve structured EJ error bodies' + ); + $assertSame('POST', $capturedImportRequest[0], 'medicine import must use POST'); + $assertSame('/api/openapi/v1/medicine-imports', $capturedImportRequest[1], 'medicine import must use the fixed EJ OpenAPI path'); + $assertSame($importPayload, $capturedImportRequest[2], 'medicine import must send the complete canonical payload'); + $assertTrue( + count(array_filter($capturedImportRequest[3], static fn (string $header): bool => str_starts_with($header, 'X-Signature: '))) === 1, + 'medicine import must reuse the existing HMAC headers' + ); +} + +$bootstrapItem = EjMedicineBootstrapItem::fromRow([ + 'id' => 9, + 'name' => "\u{200B} 黄芪 \u{200B}", + 'unit' => ' 克 ', + 'settlement_price' => '12.345650', + 'retail_price' => '0.000050', + 'status' => 1, +]); +$assertSame('9', $bootstrapItem['source_medicine_id'], 'bootstrap source medicine id must be a canonical string'); +$assertSame('黄芪', $bootstrapItem['name'], 'bootstrap medicine name must be trimmed'); +$assertSame('', $bootstrapItem['brand'], 'bootstrap brand must stay blank instead of copying supplier'); +$assertSame('12.3457', $bootstrapItem['settlement_price'], 'six-decimal source price must round half-up to four decimals'); +$assertSame('0.0001', $bootstrapItem['retail_price'], 'half-up rounding must preserve the 0.00005 boundary without floats'); +$assertSame('1.2345', EjMedicineBootstrapItem::roundPrice('1.234549'), 'rounding below half must not increment'); +$assertSame('1.2346', EjMedicineBootstrapItem::roundPrice('1.234550'), 'rounding at half must increment'); + +$sortedBootstrapItems = EjMedicineBootstrapItem::fromRows([ + ['id' => 10, 'name' => '十', 'unit' => '克', 'settlement_price' => '1.000000', 'retail_price' => '2.000000', 'status' => 1], + ['id' => 2, 'name' => '二', 'unit' => '克', 'settlement_price' => '1.000000', 'retail_price' => '2.000000', 'status' => 1], +]); +$assertSame(['2', '10'], array_column($sortedBootstrapItems, 'source_medicine_id'), 'bootstrap items must use stable numeric id order'); +try { + EjMedicineBootstrapItem::fromRows([ + ['id' => 2, 'name' => '二', 'unit' => '克', 'settlement_price' => '1.000000', 'retail_price' => '2.000000', 'status' => 1], + ['id' => '02', 'name' => '重复', 'unit' => '克', 'settlement_price' => '1.000000', 'retail_price' => '2.000000', 'status' => 1], + ]); + throw new RuntimeException('duplicate bootstrap source id unexpectedly accepted'); +} catch (InvalidArgumentException $exception) { + $assertTrue(str_contains($exception->getMessage(), '重复'), 'bootstrap preflight must reject duplicate canonical source ids'); +} + +foreach ([ + [false, 'RESET_TEST_CATALOG', 100], + [true, 'wrong', 100], + [true, 'RESET_TEST_CATALOG', 0], + [true, 'RESET_TEST_CATALOG', 501], +] as [$replace, $confirm, $batchSize]) { + try { + EjMedicineBootstrapService::assertCommandGate($replace, $confirm, $batchSize); + throw new RuntimeException('unsafe bootstrap command options unexpectedly accepted'); + } catch (InvalidArgumentException $exception) { + $assertTrue($exception->getMessage() !== '', 'bootstrap command gate must return an actionable error'); + } +} +EjMedicineBootstrapService::assertCommandGate(true, 'RESET_TEST_CATALOG', 500); + +$batchItems = [ + ['source_medicine_id' => '1', 'name' => '一', 'brand' => '', 'unit' => '克', 'settlement_price' => '1.0000', 'retail_price' => '2.0000', 'status' => 1], + ['source_medicine_id' => '2', 'name' => '二', 'brand' => '', 'unit' => '克', 'settlement_price' => '1.0000', 'retail_price' => '2.0000', 'status' => 1], + ['source_medicine_id' => '3', 'name' => '三', 'brand' => '', 'unit' => '克', 'settlement_price' => '1.0000', 'retail_price' => '2.0000', 'status' => 1], +]; +$bootstrapBatches = EjMedicineBootstrapService::buildBatches($batchItems, 2); +$assertSame(2, count($bootstrapBatches), 'bootstrap service must split imports by batch size'); +$assertSame( + $bootstrapBatches, + EjMedicineBootstrapService::buildBatches(array_reverse($batchItems), 2), + 'bootstrap batch payload and import ids must be deterministic regardless of input row order' +); +$assertTrue( + $bootstrapBatches[0]['import_id'] !== $bootstrapBatches[1]['import_id'], + 'bootstrap import id must include batch ordinal and content identity' +); + +$bootstrapSourceRows = []; +for ($bootstrapId = 1; $bootstrapId <= 654; ++$bootstrapId) { + $bootstrapSourceRows[] = [ + 'id' => $bootstrapId, + 'name' => '药材' . $bootstrapId, + 'unit' => '克', + 'settlement_price' => '1.000000', + 'retail_price' => '2.000000', + 'status' => 1, + ]; +} +$bootstrapEvents = []; +$bootstrapImportIds = []; +$bootstrapExecution = EjMedicineBootstrapService::execute( + 500, + static fn (): array => $bootstrapSourceRows, + static function (array $payload) use (&$bootstrapEvents, &$bootstrapImportIds): array { + $bootstrapEvents[] = 'import'; + $bootstrapImportIds[] = $payload['import_id']; + $items = []; + foreach ($payload['items'] as $item) { + $sourceId = (int) $item['source_medicine_id']; + $items[] = [ + 'source_medicine_id' => $item['source_medicine_id'], + 'medicine_code' => 'EJ' . str_pad((string) $sourceId, 6, '0', STR_PAD_LEFT), + 'catalog_version' => $sourceId, + 'action' => 'created', + ]; + } + return ['http_status' => 201, 'body' => ['code' => 0, 'data' => [ + 'import_id' => $payload['import_id'], + 'item_count' => count($items), + 'created_count' => count($items), + 'existing_count' => 0, + 'idempotent' => false, + 'items' => $items, + ]], 'request_id' => 'bootstrap-test']; + }, + static function (array $rows) use (&$bootstrapEvents): array { + $bootstrapEvents[] = 'replace'; + if (count($rows) !== 654) { + throw new RuntimeException('bootstrap projection did not receive all source rows'); + } + return ['catalog' => 654, 'active_mappings' => 654, 'unmapped' => 0]; + } +); +$assertSame(['import', 'import', 'replace'], $bootstrapEvents, 'projection replacement must start only after every remote batch succeeds'); +$assertSame(2, $bootstrapExecution['batch_count'], '654 rows at batch size 500 must use exactly two deterministic imports'); +$assertSame(654, $bootstrapExecution['catalog'], 'bootstrap execution must verify the exact replacement catalog count'); +$secondRunImportIds = []; +EjMedicineBootstrapService::execute( + 500, + static fn (): array => array_reverse($bootstrapSourceRows), + static function (array $payload) use (&$secondRunImportIds): array { + $secondRunImportIds[] = $payload['import_id']; + $items = array_map(static function (array $item): array { + $sourceId = (int) $item['source_medicine_id']; + return [ + 'source_medicine_id' => $item['source_medicine_id'], + 'medicine_code' => 'EJ' . str_pad((string) $sourceId, 6, '0', STR_PAD_LEFT), + 'catalog_version' => $sourceId, + 'action' => 'existing', + ]; + }, $payload['items']); + return ['http_status' => 200, 'body' => ['code' => 0, 'data' => [ + 'import_id' => $payload['import_id'], + 'item_count' => count($items), + 'created_count' => 0, + 'existing_count' => count($items), + 'idempotent' => true, + 'items' => $items, + ]], 'request_id' => 'bootstrap-retry']; + }, + static fn (): array => ['catalog' => 654, 'active_mappings' => 654, 'unmapped' => 0] +); +$assertSame($bootstrapImportIds, $secondRunImportIds, 'bootstrap retries must reuse identical import ids for identical batch content'); + +$validImportResponse = [ + 'http_status' => 201, + 'body' => ['code' => 0, 'message' => 'ok', 'data' => [ + 'import_id' => $bootstrapBatches[0]['import_id'], + 'item_count' => 2, + 'created_count' => 2, + 'existing_count' => 0, + 'idempotent' => false, + 'items' => [ + ['source_medicine_id' => '1', 'medicine_code' => 'EJ0001', 'catalog_version' => 1, 'action' => 'created'], + ['source_medicine_id' => '2', 'medicine_code' => 'EJ0002', 'catalog_version' => 2, 'action' => 'created'], + ], + ]], + 'request_id' => 'request-1', +]; +$seenCodes = []; +$seenVersions = []; +$validatedItems = EjMedicineBootstrapService::validateImportResponse( + $validImportResponse, + $bootstrapBatches[0], + $seenCodes, + $seenVersions +); +$assertSame(['1', '2'], array_column($validatedItems, 'source_medicine_id'), 'bootstrap response must preserve exact source identities'); + +$invalidImportResponse = $validImportResponse; +$invalidImportResponse['body']['data']['items'][1]['medicine_code'] = 'EJ0001'; +try { + $seenCodes = []; + $seenVersions = []; + EjMedicineBootstrapService::validateImportResponse( + $invalidImportResponse, + $bootstrapBatches[0], + $seenCodes, + $seenVersions + ); + throw new RuntimeException('duplicate response medicine code unexpectedly accepted'); +} catch (RuntimeException $exception) { + $assertTrue(str_contains($exception->getMessage(), 'medicine_code'), 'bootstrap response must reject duplicate medicine codes'); +} + +$projection = ['catalog' => ['OLD'], 'mapping' => ['OLD']]; +$projectionOrder = []; +try { + EjMedicineBootstrapService::replaceProjectionWith( + [['local_medicine_id' => 1, 'medicine_code' => 'EJ0001']], + static function (callable $operation) use (&$projection) { + $before = $projection; + try { + return $operation(); + } catch (Throwable $exception) { + $projection = $before; + throw $exception; + } + }, + static function () use (&$projectionOrder): void { $projectionOrder[] = 'reference-lock'; }, + static function () use (&$projectionOrder): array { + $projectionOrder[] = 'reference-count'; + return ['submissions' => 0, 'callbacks' => 0, 'business_links' => 0]; + }, + static function () use (&$projectionOrder): void { $projectionOrder[] = 'projection-lock'; }, + static function (array $rows) use (&$projection): void { + $projection = ['catalog' => $rows, 'mapping' => $rows]; + throw new RuntimeException('simulated projection failure'); + }, + static fn (): array => ['catalog' => 1, 'active_mappings' => 1, 'unmapped' => 0] + ); + throw new RuntimeException('projection replacement failure unexpectedly committed'); +} catch (RuntimeException $exception) { + $assertSame('simulated projection failure', $exception->getMessage(), 'projection replacement must propagate write failures'); +} +$assertSame(['catalog' => ['OLD'], 'mapping' => ['OLD']], $projection, 'projection replacement failure must roll back the old snapshot'); +$assertSame( + ['reference-lock', 'reference-count', 'projection-lock'], + $projectionOrder, + 'bootstrap transaction must lock reference sources before checking zero references and locking projections' +); + +$projectionWriteAttempted = false; +try { + EjMedicineBootstrapService::replaceProjectionWith( + [['local_medicine_id' => 1, 'medicine_code' => 'EJ0001']], + static fn (callable $operation) => $operation(), + static function (): void {}, + static fn (): array => ['submissions' => 1, 'callbacks' => 0, 'business_links' => 0], + static function (): void {}, + static function () use (&$projectionWriteAttempted): void { $projectionWriteAttempted = true; }, + static fn (): array => ['catalog' => 1, 'active_mappings' => 1, 'unmapped' => 0] + ); + throw new RuntimeException('nonzero pharmacy references unexpectedly allowed bootstrap replacement'); +} catch (RuntimeException $exception) { + $assertTrue(str_contains($exception->getMessage(), '业务引用'), 'nonzero references must block bootstrap with an actionable error'); +} +$assertSame(false, $projectionWriteAttempted, 'nonzero references must block before any projection write'); + +$failedBatchProjectionCalled = false; +$failedBatchCalls = 0; +try { + EjMedicineBootstrapService::execute( + 500, + static fn (): array => $bootstrapSourceRows, + static function (array $payload) use (&$failedBatchCalls): array { + ++$failedBatchCalls; + if ($failedBatchCalls === 2) { + return ['http_status' => 409, 'body' => ['code' => 409, 'message' => 'conflict', 'data' => null]]; + } + $items = array_map(static function (array $item): array { + $sourceId = (int) $item['source_medicine_id']; + return [ + 'source_medicine_id' => $item['source_medicine_id'], + 'medicine_code' => 'EJ' . str_pad((string) $sourceId, 6, '0', STR_PAD_LEFT), + 'catalog_version' => $sourceId, + 'action' => 'created', + ]; + }, $payload['items']); + return ['http_status' => 201, 'body' => ['code' => 0, 'data' => [ + 'import_id' => $payload['import_id'], + 'item_count' => count($items), + 'created_count' => count($items), + 'existing_count' => 0, + 'idempotent' => false, + 'items' => $items, + ]]]; + }, + static function () use (&$failedBatchProjectionCalled): array { + $failedBatchProjectionCalled = true; + return ['catalog' => 654, 'active_mappings' => 654, 'unmapped' => 0]; + } + ); + throw new RuntimeException('failed remote batch unexpectedly accepted'); +} catch (RuntimeException $exception) { + $assertTrue(str_contains($exception->getMessage(), 'HTTP 409'), 'structured remote batch failure must remain actionable'); +} +$assertSame(false, $failedBatchProjectionCalled, 'any remote batch failure must leave the old projection untouched'); + +$assertTrue(str_contains($migration, '`zyt_ej_pharmacy_sync_state`'), 'migration must persist sync cursor and outcome'); +$assertTrue( + str_contains($migration, "`component` = 'doctor/medicine'"), + 'medicine mapping menu migration must support installations whose medicine menu has no perms value' +); +foreach ([ + 'pharmacy.medicineMapping/lists', + 'pharmacy.medicineMapping/status', + 'pharmacy.medicineMapping/catalogOptions', + 'pharmacy.medicineMapping/sync', + 'pharmacy.medicineMapping/save', + 'pharmacy.medicineMapping/unlink', + 'tcm.prescriptionOrder/uploadToPharmacy', + 'tcm.prescriptionOrder/submitGancaoRecipel', +] as $permission) { + $assertTrue(str_contains($migration, "'{$permission}'"), "migration must register permission {$permission}"); +} +$assertTrue( + str_contains($migration, "'tcm.prescriptionOrder/confirmGancaoSubmission'"), + 'migration must register a dedicated manual Gancao reconciliation permission' +); +$assertTrue( + str_contains($migration, 'controlled compatibility alias for the same pharmacy-upload capability'), + 'migration must document the legacy URI as a controlled unified-upload alias' +); +$normalizedPermissions = (new AuthMiddleware())->formatUrl([ + 'pharmacy.medicineMapping/sync', + 'pharmacy.medicineMapping/save', + 'pharmacy.medicineMapping/unlink', +]); +$assertSame([ + 'pharmacy.medicinemapping/sync', + 'pharmacy.medicinemapping/save', + 'pharmacy.medicinemapping/unlink', +], $normalizedPermissions, 'AuthMiddleware must preserve independent pharmacy action permissions'); +$mappingSql = substr( + $migration, + (int) strpos($migration, 'CREATE TABLE IF NOT EXISTS `zyt_ej_medicine_mapping`'), + (int) strpos($migration, 'CREATE TABLE IF NOT EXISTS `zyt_ej_pharmacy_submission`') + - (int) strpos($migration, 'CREATE TABLE IF NOT EXISTS `zyt_ej_medicine_mapping`') +); +$assertTrue( + str_contains($mappingSql, 'UNIQUE KEY `uk_medicine_code`'), + 'bootstrap projection must map every remote medicine code to exactly one local medicine' +); + +$tcmApi = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/api/tcm.ts'); +$desktopOrderPage = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/order_list.vue'); +$mobileOrderPage = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/order_list_h5.vue'); +$gancaoReconcileComponent = (string) file_get_contents( + dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/components/GancaoSubmissionReconcileButton.vue' +); +$orderUtilsSource = (string) file_get_contents( + dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/components/prescription-order-utils.ts' +); +$mappingPage = (string) file_get_contents( + dirname(__DIR__, 3) . '/admin/src/views/pharmacy/medicine_mapping/index.vue' +); +$medicineNameSelect = (string) file_get_contents( + dirname(__DIR__, 3) . '/admin/src/components/medicine-name-select/index.vue' +); +$prescriptionEditorSources = [ + (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/components/tcm-prescription/index.vue'), + (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/index.vue'), + (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/list.vue'), + (string) file_get_contents( + dirname(__DIR__, 3) . '/admin/src/views/tcm/appointment/components/prescription-drawer.vue' + ), +]; +$prescriptionLibraryLogicSource = (string) file_get_contents( + dirname(__DIR__, 2) . '/app/adminapi/logic/tcm/PrescriptionLibraryLogic.php' +); +$assertTrue( + str_contains($tcmApi, "url: '/tcm.prescriptionOrder/uploadToPharmacy'"), + 'unified pharmacy API client must use the independently registered upload URI' +); +$assertTrue( + str_contains($desktopOrderPage, "v-perms=\"['tcm.prescriptionOrder/uploadToPharmacy']\""), + 'unified pharmacy buttons must require the new upload permission' +); +$assertTrue( + str_contains($mobileOrderPage, 'prescriptionOrderUploadToPharmacy'), + 'mobile unified pharmacy upload must call the new API client' +); +$assertTrue( + str_contains($mobileOrderPage, "v-perms=\"['tcm.prescriptionOrder/uploadToPharmacy']\""), + 'mobile unified pharmacy upload must require the new permission' +); +$assertTrue( + str_contains($prescriptionOrderListsSource, "if (\$mode === 'direct')") + && str_contains($prescriptionOrderListsSource, "LOWER(TRIM(IFNULL(`ship_mode`,''))) = 'direct'"), + 'supply filter must expose direct orders even before an EJ order number exists' +); +$assertTrue( + str_contains($prescriptionOrderLogicSource, "PharmacySupplyMode::label(PharmacySupplyMode::resolve(\$item))"), + 'export must use the same three-state supply classification as filtering and UI' +); +$assertTrue( + str_contains($orderUtilsSource, 'export function supplyModeLabel(') + && str_contains($desktopOrderPage, 'supplyModeLabel(row)') + && str_contains($mobileOrderPage, 'supplyModeLabel(row)'), + 'desktop and mobile order tags must share the three-state supply label helper' +); +$assertTrue( + substr_count($desktopOrderPage, "value: 'direct' as const") >= 1 + && substr_count($mobileOrderPage, "value: 'direct' as const") >= 1, + 'desktop and mobile filters must both expose the direct supply state' +); +$assertTrue( + str_contains($desktopOrderPage, ''), + 'mobile shipment confirmation must not present a ship-mode selector that the backend intentionally ignores' +); +$assertTrue( + str_contains($mobileOrderPage, '{{ shipDialogModeDisplay }}') + && str_contains($mobileOrderPage, '请在订单详情顶部「发货类型」中设置,此处不可修改'), + 'mobile shipment confirmation must show the persisted mode read-only and direct edits to order detail' +); +$assertTrue( + str_contains($orderUtilsSource, 'export function isRemoteSnapshotLocked('), + 'desktop and mobile order controls must share one remote snapshot lock helper' +); +$assertTrue( + substr_count($desktopOrderPage, 'isRemoteSnapshotLocked(') >= 3 + && substr_count($mobileOrderPage, 'isRemoteSnapshotLocked(') >= 3, + 'desktop and mobile edit, withdraw, audit-revoke and ship-mode controls must consistently honor remote locks' +); +$assertTrue( + str_contains($mappingPage, "row.mapping_status === 0 ? '-' : row.remote_brand || '-'"), + 'unmapped medicine brand must render as a placeholder instead of an empty remote value' +); +$assertTrue( + str_contains($mappingPage, '