feat: add zyt medicine bootstrap
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\pharmacy;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\pharmacy\MedicineMappingLists;
|
||||
use app\adminapi\logic\pharmacy\MedicineMappingLogic;
|
||||
use app\adminapi\validate\pharmacy\MedicineMappingValidate;
|
||||
|
||||
class MedicineMappingController extends BaseAdminController
|
||||
{
|
||||
public function lists()
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\pharmacy;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use think\facade\Db;
|
||||
|
||||
class MedicineMappingLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
{
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$rows = $this->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",
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\pharmacy;
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\service\pharmacy\EjMedicineCatalogSyncService;
|
||||
use app\common\service\pharmacy\EjMedicineMappingPolicy;
|
||||
use think\facade\Db;
|
||||
use think\facade\Config;
|
||||
use Throwable;
|
||||
|
||||
class MedicineMappingLogic extends BaseLogic
|
||||
{
|
||||
public static function save(array $params, int $operatorId, string $operatorName): bool
|
||||
{
|
||||
self::$error = '';
|
||||
try {
|
||||
Db::transaction(function () use ($params, $operatorId, $operatorName): void {
|
||||
$localId = (int) $params['local_medicine_id'];
|
||||
$medicineCode = trim((string) $params['medicine_code']);
|
||||
$local = Db::name('doctor_medicine')->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<string,mixed>|false */
|
||||
public static function sync()
|
||||
{
|
||||
self::$error = '';
|
||||
try {
|
||||
return EjMedicineCatalogSyncService::sync(200);
|
||||
} catch (Throwable $exception) {
|
||||
self::setError($exception->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
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<int,array<string,mixed>> */
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\validate\pharmacy;
|
||||
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
class MedicineMappingValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'local_medicine_id' => '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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\pharmacy\EjMedicineBootstrapService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class EjPharmacyBootstrapMedicines extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\pharmacy\EjMedicineCatalogSyncService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class EjPharmacySyncCatalog extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\pharmacy;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
class EjMedicineCatalog extends BaseModel
|
||||
{
|
||||
protected $name = 'ej_medicine_catalog';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $dateFormat = false;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\pharmacy;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
class EjMedicineMapping extends BaseModel
|
||||
{
|
||||
use SoftDelete;
|
||||
protected $name = 'ej_medicine_mapping';
|
||||
protected $deleteTime = 'delete_time';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $dateFormat = false;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\pharmacy;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
class EjPharmacyCallbackInbox extends BaseModel
|
||||
{
|
||||
protected $name = 'ej_pharmacy_callback_inbox';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $dateFormat = false;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\pharmacy;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
class EjPharmacySubmission extends BaseModel
|
||||
{
|
||||
protected $name = 'ej_pharmacy_submission';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $dateFormat = false;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\pharmacy;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
class PharmacySubmissionClaim extends BaseModel
|
||||
{
|
||||
protected $name = 'pharmacy_submission_claim';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $dateFormat = false;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class EjMedicineBootstrapItem
|
||||
{
|
||||
/** @param array<string,mixed> $row @return array<string,mixed> */
|
||||
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<int,array<string,mixed>> $rows @return list<array<string,mixed>> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
final class EjMedicineBootstrapService
|
||||
{
|
||||
private const SOURCE_SYSTEM = 'zyt';
|
||||
private const BOOTSTRAP_RUN_ID = 'zyt-medicine-bootstrap-v1';
|
||||
private const EXPECTED_MEDICINE_COUNT = 654;
|
||||
|
||||
public static function assertCommandGate(bool $replace, string $confirm, int $batchSize): void
|
||||
{
|
||||
if (!$replace) {
|
||||
throw new InvalidArgumentException('必须显式提供 --replace 才能替换恩济药材投影');
|
||||
}
|
||||
if (!hash_equals('RESET_TEST_CATALOG', $confirm)) {
|
||||
throw new InvalidArgumentException('必须提供 --confirm=RESET_TEST_CATALOG');
|
||||
}
|
||||
if ($batchSize < 1 || $batchSize > 500) {
|
||||
throw new InvalidArgumentException('--batch-size 必须在 1 到 500 之间');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $items @return list<array<string,mixed>> */
|
||||
public static function buildBatches(array $items, int $batchSize): array
|
||||
{
|
||||
if ($batchSize < 1 || $batchSize > 500) {
|
||||
throw new InvalidArgumentException('药材导入批次大小必须在 1 到 500 之间');
|
||||
}
|
||||
usort($items, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
|
||||
(string) ($left['source_medicine_id'] ?? ''),
|
||||
(string) ($right['source_medicine_id'] ?? '')
|
||||
));
|
||||
|
||||
$batches = [];
|
||||
foreach (array_chunk($items, $batchSize) as $index => $batchItems) {
|
||||
$ordinal = $index + 1;
|
||||
$contentJson = json_encode(
|
||||
$batchItems,
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
$contentIdentity = substr(hash('sha256', $contentJson), 0, 32);
|
||||
$batches[] = [
|
||||
'source_system' => self::SOURCE_SYSTEM,
|
||||
'import_id' => sprintf('%s-%04d-%s', self::BOOTSTRAP_RUN_ID, $ordinal, $contentIdentity),
|
||||
'items' => $batchItems,
|
||||
];
|
||||
}
|
||||
return $batches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $response
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<string,bool> $seenCodes
|
||||
* @param array<int,bool> $seenVersions
|
||||
* @return list<array{source_medicine_id:string,medicine_code:string,catalog_version:int,action:string}>
|
||||
*/
|
||||
public static function validateImportResponse(
|
||||
array $response,
|
||||
array $payload,
|
||||
array &$seenCodes,
|
||||
array &$seenVersions
|
||||
): array {
|
||||
$httpStatus = (int) ($response['http_status'] ?? 0);
|
||||
$body = $response['body'] ?? null;
|
||||
if (!in_array($httpStatus, [200, 201], true) || !is_array($body) || (int) ($body['code'] ?? -1) !== 0) {
|
||||
$message = is_array($body) ? trim((string) ($body['message'] ?? '')) : '';
|
||||
throw new RuntimeException(sprintf(
|
||||
'恩济药材导入失败 HTTP %d%s',
|
||||
$httpStatus,
|
||||
$message === '' ? '' : ':' . $message
|
||||
));
|
||||
}
|
||||
$data = $body['data'] ?? null;
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('恩济药材导入响应缺少 data');
|
||||
}
|
||||
$expectedImportId = (string) ($payload['import_id'] ?? '');
|
||||
if (!hash_equals($expectedImportId, (string) ($data['import_id'] ?? ''))) {
|
||||
throw new RuntimeException('恩济药材导入响应 import_id 不匹配');
|
||||
}
|
||||
|
||||
$expectedItems = $payload['items'] ?? null;
|
||||
$responseItems = $data['items'] ?? null;
|
||||
if (!is_array($expectedItems) || !is_array($responseItems)) {
|
||||
throw new RuntimeException('恩济药材导入响应 items 无效');
|
||||
}
|
||||
$itemCount = count($expectedItems);
|
||||
$createdCount = (int) ($data['created_count'] ?? -1);
|
||||
$existingCount = (int) ($data['existing_count'] ?? -1);
|
||||
if (
|
||||
(int) ($data['item_count'] ?? -1) !== $itemCount
|
||||
|| count($responseItems) !== $itemCount
|
||||
|| $createdCount < 0
|
||||
|| $existingCount < 0
|
||||
|| $createdCount + $existingCount !== $itemCount
|
||||
|| !is_bool($data['idempotent'] ?? null)
|
||||
) {
|
||||
throw new RuntimeException('恩济药材导入响应计数或幂等标记不完整');
|
||||
}
|
||||
|
||||
$expectedSourceIds = array_map(
|
||||
static fn (array $item): string => (string) ($item['source_medicine_id'] ?? ''),
|
||||
$expectedItems
|
||||
);
|
||||
$nextSeenCodes = $seenCodes;
|
||||
$nextSeenVersions = $seenVersions;
|
||||
$normalized = [];
|
||||
$responseSourceIds = [];
|
||||
$actions = ['created' => 0, 'existing' => 0];
|
||||
foreach ($responseItems as $item) {
|
||||
if (!is_array($item)) {
|
||||
throw new RuntimeException('恩济药材导入响应 item 必须是对象');
|
||||
}
|
||||
$sourceId = self::canonicalSourceId($item['source_medicine_id'] ?? null);
|
||||
if (isset($responseSourceIds[$sourceId])) {
|
||||
throw new RuntimeException("恩济药材导入响应 source_medicine_id 重复:{$sourceId}");
|
||||
}
|
||||
$responseSourceIds[$sourceId] = true;
|
||||
$code = trim((string) ($item['medicine_code'] ?? ''));
|
||||
if ($code === '' || mb_strlen($code) > 32 || isset($nextSeenCodes[$code])) {
|
||||
throw new RuntimeException("恩济药材导入响应 medicine_code 为空、过长或重复:{$code}");
|
||||
}
|
||||
$version = filter_var($item['catalog_version'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($version === false || $version < 1 || isset($nextSeenVersions[$version])) {
|
||||
throw new RuntimeException('恩济药材导入响应 catalog_version 缺失或重复');
|
||||
}
|
||||
$action = (string) ($item['action'] ?? '');
|
||||
if (!array_key_exists($action, $actions)) {
|
||||
throw new RuntimeException('恩济药材导入响应 action 无效');
|
||||
}
|
||||
++$actions[$action];
|
||||
$nextSeenCodes[$code] = true;
|
||||
$nextSeenVersions[$version] = true;
|
||||
$normalized[] = [
|
||||
'source_medicine_id' => $sourceId,
|
||||
'medicine_code' => $code,
|
||||
'catalog_version' => $version,
|
||||
'action' => $action,
|
||||
];
|
||||
}
|
||||
usort($normalized, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
|
||||
$left['source_medicine_id'],
|
||||
$right['source_medicine_id']
|
||||
));
|
||||
sort($expectedSourceIds, SORT_NATURAL);
|
||||
$actualSourceIds = array_column($normalized, 'source_medicine_id');
|
||||
sort($actualSourceIds, SORT_NATURAL);
|
||||
if ($expectedSourceIds !== $actualSourceIds) {
|
||||
throw new RuntimeException('恩济药材导入响应 source_medicine_id 不完整或不匹配');
|
||||
}
|
||||
if ($actions['created'] !== $createdCount || $actions['existing'] !== $existingCount) {
|
||||
throw new RuntimeException('恩济药材导入响应 action 与计数不一致');
|
||||
}
|
||||
if ($data['idempotent'] === true && ($createdCount !== 0 || $existingCount !== $itemCount)) {
|
||||
throw new RuntimeException('恩济药材导入响应幂等标记与 action 不一致');
|
||||
}
|
||||
|
||||
$seenCodes = $nextSeenCodes;
|
||||
$seenVersions = $nextSeenVersions;
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
|
||||
* @param null|callable(array<string,mixed>):array<string,mixed> $importer
|
||||
* @param null|callable(array<int,array<string,mixed>>):array<string,int> $projectionReplacer
|
||||
* @return array{source_count:int,batch_count:int,catalog:int,active_mappings:int,unmapped:int}
|
||||
*/
|
||||
public static function execute(
|
||||
int $batchSize = 100,
|
||||
?callable $sourceLoader = null,
|
||||
?callable $importer = null,
|
||||
?callable $projectionReplacer = null
|
||||
): array {
|
||||
if ($batchSize < 1 || $batchSize > 500) {
|
||||
throw new InvalidArgumentException('药材导入批次大小必须在 1 到 500 之间');
|
||||
}
|
||||
$sourceRows = $sourceLoader === null ? self::loadSourceRows() : $sourceLoader();
|
||||
$items = EjMedicineBootstrapItem::fromRows($sourceRows);
|
||||
if (count($items) !== self::EXPECTED_MEDICINE_COUNT) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'药材 bootstrap 要求恰好 %d 条启用且未删除的本地药材,当前为 %d 条',
|
||||
self::EXPECTED_MEDICINE_COUNT,
|
||||
count($items)
|
||||
));
|
||||
}
|
||||
$batches = self::buildBatches($items, $batchSize);
|
||||
if ($importer === null) {
|
||||
if (!EjPharmacyClient::isConfigured()) {
|
||||
throw new RuntimeException('恩济药房接口未启用或配置不完整');
|
||||
}
|
||||
$client = new EjPharmacyClient();
|
||||
$importer = static fn (array $payload): array => $client->importMedicines($payload);
|
||||
}
|
||||
|
||||
$sourceById = [];
|
||||
foreach ($items as $item) {
|
||||
$sourceById[(string) $item['source_medicine_id']] = $item;
|
||||
}
|
||||
$seenCodes = [];
|
||||
$seenVersions = [];
|
||||
$projectionRows = [];
|
||||
foreach ($batches as $payload) {
|
||||
$responseItems = self::validateImportResponse(
|
||||
$importer($payload),
|
||||
$payload,
|
||||
$seenCodes,
|
||||
$seenVersions
|
||||
);
|
||||
foreach ($responseItems as $responseItem) {
|
||||
$sourceId = $responseItem['source_medicine_id'];
|
||||
$source = $sourceById[$sourceId];
|
||||
$projectionRows[] = [
|
||||
'local_medicine_id' => (int) $sourceId,
|
||||
'medicine_code' => $responseItem['medicine_code'],
|
||||
'name' => $source['name'],
|
||||
'brand' => '',
|
||||
'unit' => $source['unit'],
|
||||
'settlement_price' => $source['settlement_price'],
|
||||
'retail_price' => $source['retail_price'],
|
||||
'status' => $source['status'],
|
||||
'catalog_version' => $responseItem['catalog_version'],
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($projectionRows, static fn (array $left, array $right): int => $left['local_medicine_id'] <=> $right['local_medicine_id']);
|
||||
$verification = $projectionReplacer === null
|
||||
? self::replaceProjection($projectionRows)
|
||||
: $projectionReplacer($projectionRows);
|
||||
self::assertProjectionVerification($verification, self::EXPECTED_MEDICINE_COUNT);
|
||||
|
||||
return [
|
||||
'source_count' => count($items),
|
||||
'batch_count' => count($batches),
|
||||
'catalog' => (int) $verification['catalog'],
|
||||
'active_mappings' => (int) $verification['active_mappings'],
|
||||
'unmapped' => (int) $verification['unmapped'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $projectionRows
|
||||
* @param callable(callable():array<string,int>):array<string,int> $transaction
|
||||
* @param callable():void $referenceLocker
|
||||
* @param callable():array<string,int> $referenceCounter
|
||||
* @param callable():void $projectionLocker
|
||||
* @param callable(array<int,array<string,mixed>>):void $replacer
|
||||
* @param callable():array<string,int> $verifier
|
||||
* @return array<string,int>
|
||||
*/
|
||||
public static function replaceProjectionWith(
|
||||
array $projectionRows,
|
||||
callable $transaction,
|
||||
callable $referenceLocker,
|
||||
callable $referenceCounter,
|
||||
callable $projectionLocker,
|
||||
callable $replacer,
|
||||
callable $verifier
|
||||
): array {
|
||||
return $transaction(static function () use (
|
||||
$projectionRows,
|
||||
$referenceLocker,
|
||||
$referenceCounter,
|
||||
$projectionLocker,
|
||||
$replacer,
|
||||
$verifier
|
||||
): array {
|
||||
$referenceLocker();
|
||||
$references = $referenceCounter();
|
||||
foreach (['submissions', 'callbacks', 'business_links'] as $key) {
|
||||
if ((int) ($references[$key] ?? -1) !== 0) {
|
||||
throw new RuntimeException('恩济药材投影已有提交、回调或业务引用,禁止 bootstrap 替换');
|
||||
}
|
||||
}
|
||||
$projectionLocker();
|
||||
$replacer($projectionRows);
|
||||
$verification = $verifier();
|
||||
self::assertProjectionVerification($verification, count($projectionRows));
|
||||
return $verification;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private static function loadSourceRows(): array
|
||||
{
|
||||
return Db::name('doctor_medicine')
|
||||
->field('id,name,unit,settlement_price,retail_price,status')
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $projectionRows @return array<string,int> */
|
||||
private static function replaceProjection(array $projectionRows): array
|
||||
{
|
||||
return self::replaceProjectionWith(
|
||||
$projectionRows,
|
||||
static fn (callable $operation): array => Db::transaction($operation),
|
||||
static function (): void {
|
||||
Db::name('ej_pharmacy_submission')
|
||||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('ej_pharmacy_callback_inbox')
|
||||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('pharmacy_submission_claim')
|
||||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('tcm_prescription_order')
|
||||
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
},
|
||||
static function (): array {
|
||||
$directClaims = (int) Db::name('pharmacy_submission_claim')
|
||||
->where('target', 'direct')
|
||||
->count();
|
||||
$linkedOrders = (int) Db::name('tcm_prescription_order')
|
||||
->where(function ($query): void {
|
||||
$query->whereNotNull('ej_pharmacy_order_no')
|
||||
->whereOr('ej_pharmacy_submit_time', '>', 0)
|
||||
->whereOr('ej_pharmacy_status', '<>', '')
|
||||
->whereOr('ej_pharmacy_status_version', '>', 0);
|
||||
})
|
||||
->count();
|
||||
return [
|
||||
'submissions' => (int) Db::name('ej_pharmacy_submission')->count(),
|
||||
'callbacks' => (int) Db::name('ej_pharmacy_callback_inbox')->count(),
|
||||
'business_links' => $directClaims + $linkedOrders,
|
||||
];
|
||||
},
|
||||
static function () use ($projectionRows): void {
|
||||
Db::name('ej_pharmacy_sync_state')->where('id', 1)->lock(true)->find();
|
||||
Db::name('ej_medicine_catalog')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('ej_medicine_mapping')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
Db::name('doctor_medicine')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
|
||||
|
||||
$lockedRows = Db::name('doctor_medicine')
|
||||
->field('id,name,unit,settlement_price,retail_price,status')
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$lockedItems = EjMedicineBootstrapItem::fromRows($lockedRows);
|
||||
$expectedItems = array_map(static fn (array $row): array => [
|
||||
'source_medicine_id' => (string) $row['local_medicine_id'],
|
||||
'name' => (string) $row['name'],
|
||||
'brand' => '',
|
||||
'unit' => (string) $row['unit'],
|
||||
'settlement_price' => (string) $row['settlement_price'],
|
||||
'retail_price' => (string) $row['retail_price'],
|
||||
'status' => (int) $row['status'],
|
||||
], $projectionRows);
|
||||
if ($lockedItems !== $expectedItems) {
|
||||
throw new RuntimeException('本地药材源快照在远端导入期间发生变化,已拒绝替换投影');
|
||||
}
|
||||
},
|
||||
static function (array $rows): void {
|
||||
Db::name('ej_medicine_mapping')->where('id', '>=', 0)->delete();
|
||||
Db::name('ej_medicine_catalog')->where('id', '>=', 0)->delete();
|
||||
$now = time();
|
||||
$catalogRows = [];
|
||||
$mappingRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$catalogRows[] = [
|
||||
'medicine_code' => $row['medicine_code'],
|
||||
'name' => $row['name'],
|
||||
'brand' => '',
|
||||
'unit' => $row['unit'],
|
||||
'settlement_price' => $row['settlement_price'],
|
||||
'retail_price' => $row['retail_price'],
|
||||
'status' => $row['status'],
|
||||
'catalog_version' => $row['catalog_version'],
|
||||
'remote_deleted' => 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
];
|
||||
$mappingRows[] = [
|
||||
'local_medicine_id' => $row['local_medicine_id'],
|
||||
'medicine_code' => $row['medicine_code'],
|
||||
'status' => 1,
|
||||
'operator_id' => 0,
|
||||
'operator_name' => 'system-bootstrap',
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
'delete_time' => null,
|
||||
];
|
||||
}
|
||||
foreach (array_chunk($catalogRows, 500) as $chunk) {
|
||||
Db::name('ej_medicine_catalog')->insertAll($chunk);
|
||||
}
|
||||
foreach (array_chunk($mappingRows, 500) as $chunk) {
|
||||
Db::name('ej_medicine_mapping')->insertAll($chunk);
|
||||
}
|
||||
|
||||
$stateValues = [
|
||||
'cursor' => 0,
|
||||
'last_success_time' => $now,
|
||||
'last_failure_time' => 0,
|
||||
'last_error_summary' => '',
|
||||
'lock_token' => '',
|
||||
'lock_expires_at' => 0,
|
||||
'update_time' => $now,
|
||||
];
|
||||
$updated = Db::name('ej_pharmacy_sync_state')->where('id', 1)->update($stateValues);
|
||||
if ($updated === 0 && !Db::name('ej_pharmacy_sync_state')->where('id', 1)->find()) {
|
||||
Db::name('ej_pharmacy_sync_state')->insert($stateValues + ['id' => 1, 'create_time' => $now]);
|
||||
}
|
||||
},
|
||||
static function () use ($projectionRows): array {
|
||||
$expectedLocalIds = array_map(
|
||||
static fn (array $row): int => (int) $row['local_medicine_id'],
|
||||
$projectionRows
|
||||
);
|
||||
$actualLocalIds = array_map(
|
||||
'intval',
|
||||
Db::name('ej_medicine_mapping')
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->order('local_medicine_id', 'asc')
|
||||
->column('local_medicine_id')
|
||||
);
|
||||
if ($expectedLocalIds !== $actualLocalIds) {
|
||||
throw new RuntimeException('恩济药材 bootstrap 映射未精确覆盖全部本地药材 id');
|
||||
}
|
||||
return [
|
||||
'catalog' => (int) Db::name('ej_medicine_catalog')->count(),
|
||||
'active_mappings' => count($actualLocalIds),
|
||||
'unmapped' => (int) Db::name('doctor_medicine')->alias('l')
|
||||
->leftJoin(
|
||||
'ej_medicine_mapping m',
|
||||
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
|
||||
)
|
||||
->where('l.status', 1)
|
||||
->whereNull('l.delete_time')
|
||||
->whereNull('m.id')
|
||||
->count('l.id'),
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $verification */
|
||||
private static function assertProjectionVerification(array $verification, int $expected): void
|
||||
{
|
||||
if (
|
||||
(int) ($verification['catalog'] ?? -1) !== $expected
|
||||
|| (int) ($verification['active_mappings'] ?? -1) !== $expected
|
||||
|| (int) ($verification['unmapped'] ?? -1) !== 0
|
||||
) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'恩济药材 bootstrap 最终验证失败:catalog=%d active_mappings=%d unmapped=%d expected=%d',
|
||||
(int) ($verification['catalog'] ?? -1),
|
||||
(int) ($verification['active_mappings'] ?? -1),
|
||||
(int) ($verification['unmapped'] ?? -1),
|
||||
$expected
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private static function canonicalSourceId(mixed $value): string
|
||||
{
|
||||
if (is_int($value)) {
|
||||
$value = (string) $value;
|
||||
}
|
||||
if (!is_string($value) || preg_match('/^\d+$/D', $value) !== 1) {
|
||||
throw new RuntimeException('恩济药材导入响应 source_medicine_id 无效');
|
||||
}
|
||||
$value = ltrim($value, '0');
|
||||
if ($value === '') {
|
||||
throw new RuntimeException('恩济药材导入响应 source_medicine_id 无效');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class EjMedicineCatalogSyncPolicy
|
||||
{
|
||||
/** @return array{items:array<int,array<string,mixed>>,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<string,mixed>|null $existing
|
||||
* @param array<string,mixed> $remote
|
||||
* @return array{action:string,values:array<string,mixed>,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, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use app\common\model\pharmacy\EjMedicineCatalog;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
use think\facade\Config;
|
||||
use Throwable;
|
||||
|
||||
final class EjMedicineCatalogSyncService
|
||||
{
|
||||
private const STATE_ID = 1;
|
||||
private const LOCK_TTL = 600;
|
||||
|
||||
/** @return array{pages:int,pulled:int,received:int,created:int,updated:int,unchanged:int,deactivated:int,cursor:int} */
|
||||
public static function sync(int $limit = 200): array
|
||||
{
|
||||
if (!(bool) Config::get('ej_pharmacy.catalog_sync_enabled', false)) {
|
||||
throw new RuntimeException('恩济药房增量目录同步已关闭,请使用一次性 bootstrap 命令初始化药材目录');
|
||||
}
|
||||
if (!EjPharmacyClient::isConfigured()) {
|
||||
throw new RuntimeException('洛阳药房接口未启用或配置不完整');
|
||||
}
|
||||
|
||||
self::ensureStateRow();
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$client = new EjPharmacyClient();
|
||||
$workflow = new EjMedicineCatalogSyncWorkflow(
|
||||
static fn (): bool => 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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class EjMedicineCatalogSyncWorkflow
|
||||
{
|
||||
private $acquireLock;
|
||||
private $releaseLock;
|
||||
private $loadCursor;
|
||||
private $fetchPage;
|
||||
private $mergePage;
|
||||
private $markSuccess;
|
||||
private $markFailure;
|
||||
|
||||
public function __construct(
|
||||
callable $acquireLock,
|
||||
callable $releaseLock,
|
||||
callable $loadCursor,
|
||||
callable $fetchPage,
|
||||
callable $mergePage,
|
||||
callable $markSuccess,
|
||||
callable $markFailure
|
||||
) {
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class EjMedicineMappingPolicy
|
||||
{
|
||||
/** @param array<string,mixed> $local @param array<string,mixed> $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<string,mixed> $local
|
||||
* @param array<string,mixed>|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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class EjPharmacyCallbackFailureTransition
|
||||
{
|
||||
/** @param callable(int,array<string,mixed>,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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class EjPharmacyCallbackRetryException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class EjPharmacyCallbackWorkflow
|
||||
{
|
||||
private Closure $loadInbox;
|
||||
private Closure $createInbox;
|
||||
private Closure $reloadInbox;
|
||||
private Closure $process;
|
||||
private Closure $markFailed;
|
||||
private Closure $isDuplicateKey;
|
||||
|
||||
public function __construct(
|
||||
callable $loadInbox,
|
||||
callable $createInbox,
|
||||
callable $reloadInbox,
|
||||
callable $process,
|
||||
callable $markFailed,
|
||||
callable $isDuplicateKey
|
||||
) {
|
||||
$this->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<string,mixed> $payload @return array<string,mixed> */
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use RuntimeException;
|
||||
use think\facade\Config;
|
||||
|
||||
final class EjPharmacyClient
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $appKey;
|
||||
private string $appSecret;
|
||||
/** @var null|Closure(string,string,string,array<int,string>):array{http_status:int,body:array<string,mixed>,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<string,mixed>,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<string,mixed> $payload @return array{http_status:int,body:array<string,mixed>,request_id:string} */
|
||||
public function importMedicines(array $payload): array
|
||||
{
|
||||
return $this->request('POST', '/api/openapi/v1/medicine-imports', $payload);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload @return array{http_status:int,body:array<string,mixed>,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<string,mixed>,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<string,mixed>|null $payload @param array<string,int|string> $query @return array{http_status:int,body:array<string,mixed>,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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class EjPharmacyPayload
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $order
|
||||
* @param array<string,mixed> $prescription
|
||||
* @param array<int,string> $medicineMappings
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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']
|
||||
?? ''
|
||||
)),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
final class EjPharmacyShipmentPolicy
|
||||
{
|
||||
/** @param array<string,mixed> $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<string,mixed> $payload */
|
||||
public static function nextFulfillmentStatus(int $currentStatus, array $payload): int
|
||||
{
|
||||
if (self::isShippedEvent($payload) && in_array($currentStatus, [1, 2], true)) {
|
||||
return 5;
|
||||
}
|
||||
|
||||
return $currentStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
final class EjPharmacySignature
|
||||
{
|
||||
public static function canonical(string $method, string $path, string $timestamp, string $nonce, string $body): string
|
||||
{
|
||||
return implode("\n", [
|
||||
strtoupper(trim($method)),
|
||||
$path,
|
||||
trim($timestamp),
|
||||
trim($nonce),
|
||||
hash('sha256', $body),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function sign(string $secret, string $canonical): string
|
||||
{
|
||||
return hash_hmac('sha256', $canonical, $secret);
|
||||
}
|
||||
|
||||
public static function verify(string $secret, string $canonical, string $signature): bool
|
||||
{
|
||||
return $secret !== '' && $signature !== '' && hash_equals(self::sign($secret, $canonical), strtolower(trim($signature)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class EjPharmacyTrackingPolicy
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed>|null $current Active tracking for this order.
|
||||
* @param array<string,mixed>|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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
|
||||
final class LockedPharmacySnapshotMutation
|
||||
{
|
||||
/**
|
||||
* @param callable():array<string,mixed> $lockOrder
|
||||
* @param callable(array<string,mixed>):?array<string,mixed> $lockClaim
|
||||
* @param callable(array<string,mixed>,?array<string,mixed>):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<string,mixed>,?array<string,mixed>):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<int,array<string,mixed>>):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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class PharmacyHerbIdentityResolver
|
||||
{
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $herbs
|
||||
* @param callable(array<int,int>):array<int,array<string,mixed>> $loadByIds
|
||||
* @param callable(array<int,string>):array<int,array<string,mixed>> $loadByNames
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
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<string,mixed> $row */
|
||||
private static function isActive(array $row): bool
|
||||
{
|
||||
return (int) ($row['id'] ?? 0) > 0
|
||||
&& (int) ($row['status'] ?? 0) === 1
|
||||
&& ($row['delete_time'] ?? null) === null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class PharmacyLogisticsValue
|
||||
{
|
||||
public static function normalize(mixed $value, int $maxLength, string $label): string
|
||||
{
|
||||
$normalized = preg_replace(
|
||||
'/^[\s\p{Z}\x{200B}\x{2060}\x{FEFF}]+|[\s\p{Z}\x{200B}\x{2060}\x{FEFF}]+$/u',
|
||||
'',
|
||||
(string) $value
|
||||
);
|
||||
if ($normalized === null) {
|
||||
throw new InvalidArgumentException($label . '格式无效');
|
||||
}
|
||||
if (mb_strlen($normalized) > $maxLength) {
|
||||
throw new InvalidArgumentException($label . '长度不能超过' . $maxLength . '个字符');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class PharmacyReconciliationRequiredException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Throwable;
|
||||
|
||||
final class PharmacyRemoteOutcomeClassifier
|
||||
{
|
||||
public static function isConfirmedEjNoCreateHttpStatus(int $httpStatus): bool
|
||||
{
|
||||
return in_array($httpStatus, [400, 401, 403, 404, 405, 415, 422], true);
|
||||
}
|
||||
|
||||
public static function isConfirmedNoCreate(Throwable $exception): bool
|
||||
{
|
||||
return $exception instanceof PharmacyRemoteRejectedException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** The pharmacy explicitly confirmed that no remote order was created. */
|
||||
final class PharmacyRemoteRejectedException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class PharmacyRemoteSnapshotPolicy
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $order
|
||||
* @param array<string,mixed>|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<string,mixed> $order
|
||||
* @param array<string,mixed>|null $claim
|
||||
*/
|
||||
public static function assertMutable(array $order, ?array $claim): void
|
||||
{
|
||||
if (self::isLocked($order, $claim)) {
|
||||
throw new DomainException('订单已提交药房,患者、地址、处方与发货药房快照不可修改;请先完成远端取消确认,取消后创建新版本');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class PharmacySubmissionClaimPolicy
|
||||
{
|
||||
/** @param array<string,mixed> $claim @return array<string,mixed> */
|
||||
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('药房提交状态异常,请先对账处理');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
use think\facade\Config;
|
||||
|
||||
final class PharmacySubmissionClaimService
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
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<string,mixed> $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<string,mixed>|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<string,mixed> */
|
||||
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<string,mixed> $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<string,mixed> $claim @return array<string,mixed> */
|
||||
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<string,mixed> $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<string,mixed> */
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use Closure;
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
final class PharmacySubmissionClaimWorkflow
|
||||
{
|
||||
private Closure $acquireClaim;
|
||||
private Closure $invokeRemote;
|
||||
private Closure $markSuccess;
|
||||
private Closure $markFailure;
|
||||
private Closure $markReconcile;
|
||||
|
||||
public function __construct(
|
||||
callable $acquireClaim,
|
||||
callable $invokeRemote,
|
||||
callable $markSuccess,
|
||||
callable $markFailure,
|
||||
callable $markReconcile
|
||||
) {
|
||||
$this->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<string,mixed> */
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class PharmacySubmissionReconciliationPolicy
|
||||
{
|
||||
/** @param array<string,mixed> $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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
final class PharmacySupplyMode
|
||||
{
|
||||
/** @param array<string,mixed> $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 => '自营',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\pharmacy;
|
||||
|
||||
final class PharmacyUploadPermissionAlias
|
||||
{
|
||||
public const LEGACY_URI = 'tcm.prescriptionorder/submitgancaorecipel';
|
||||
public const CANONICAL_URI = 'tcm.prescriptionorder/uploadtopharmacy';
|
||||
public const RECONCILE_URI = 'tcm.prescriptionorder/confirmgancaosubmission';
|
||||
|
||||
public static function canonicalUri(string $uri): string
|
||||
{
|
||||
$uri = strtolower($uri);
|
||||
return in_array($uri, [self::LEGACY_URI, self::CANONICAL_URI], true)
|
||||
? self::CANONICAL_URI
|
||||
: $uri;
|
||||
}
|
||||
|
||||
public static function isControlled(string $uri): bool
|
||||
{
|
||||
return in_array(strtolower($uri), [self::LEGACY_URI, self::CANONICAL_URI, self::RECONCILE_URI], true);
|
||||
}
|
||||
|
||||
/** @param array<int,string> $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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user