feat(testing): 增加质检报告独立编辑与详情页面,不再使用弹窗
这个提交包含在:
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\library\GranarySite;
|
||||
use think\Db;
|
||||
|
||||
/** Apply site authorization to every grain-management read and write route. */
|
||||
class GranaryBackend extends Backend
|
||||
{
|
||||
protected $granarySites = [];
|
||||
protected $filterSites = [];
|
||||
|
||||
public function _initialize()
|
||||
{
|
||||
parent::_initialize();
|
||||
$this->initializeGranarySiteAccess();
|
||||
}
|
||||
|
||||
protected function initializeGranarySiteAccess()
|
||||
{
|
||||
$this->granarySites = GranarySite::allowed($this->auth);
|
||||
$site = $this->request->get('site', '');
|
||||
if ($site !== '' && (!GranarySite::valid($site) || !in_array($site, $this->granarySites, true))) {
|
||||
$this->error('无权访问此库区');
|
||||
}
|
||||
$this->filterSites = $site === '' ? $this->granarySites : [$site];
|
||||
$this->assignconfig('granarySites', GranarySite::options($this->granarySites));
|
||||
|
||||
$controller = strtolower($this->request->controller());
|
||||
$action = strtolower($this->request->action());
|
||||
$actions = [
|
||||
'granary' => ['index', 'add', 'detail', 'dell'],
|
||||
'testing' => ['index', 'add', 'sort', 'dell'],
|
||||
'content' => ['index', 'add', 'status', 'getcontent', 'dell'],
|
||||
'forecast' => ['index', 'detail', 'add', 'retry', 'actual'],
|
||||
];
|
||||
if (!isset($actions[$controller]) || !in_array($action, $actions[$controller], true)) $this->error('不支持此操作');
|
||||
$table = $controller === 'forecast' ? 'grain_forecast' : $controller;
|
||||
$id = $this->request->post('id', $this->request->get('id', ''));
|
||||
if ($action === 'actual') {
|
||||
$id = Db::name('grain_forecast_point')->where('id', $this->request->post('point_id/d', 0))->value('forecast_id');
|
||||
if (!$id) $this->error('预测周期不存在');
|
||||
}
|
||||
if ($id !== '' && $id !== null && $id !== 0 && $id !== '0') {
|
||||
if (!is_scalar($id) || !preg_match('/^\d+(,\d+)*$/D', (string)$id)) $this->error('记录编号不正确');
|
||||
foreach (explode(',', (string)$id) as $recordId) {
|
||||
$gid = $controller === 'granary' ? $recordId : Db::name($table)->where('id', $recordId)->value('gid');
|
||||
if (!GranarySite::permits($gid, $this->granarySites)) $this->error('记录不存在或无权访问所属库区');
|
||||
}
|
||||
}
|
||||
$gid = $this->request->post('gid', $this->request->get('gid', 0));
|
||||
if ($gid && !GranarySite::permits($gid, $this->granarySites)) $this->error('无权访问此仓库');
|
||||
if ($controller !== 'granary' && $action === 'add' && $this->request->isPost() && !$gid) $this->error('请选择所属仓库');
|
||||
if ($controller === 'content' && $action === 'index') {
|
||||
$rid = $this->request->get('rid/d', 0);
|
||||
if ($rid && !GranarySite::permits($rid, $this->granarySites)) $this->error('无权访问此仓库');
|
||||
}
|
||||
if ($controller === 'forecast' && $action === 'add' && $this->request->isPost()) {
|
||||
$existingGid = Db::name('grain_forecast')->where('request_key', $this->request->post('request_key', ''))->value('gid');
|
||||
if ($existingGid && !GranarySite::permits($existingGid, $this->granarySites)) $this->error('无权访问原预测记录');
|
||||
}
|
||||
}
|
||||
|
||||
protected function scopeRecords($query)
|
||||
{
|
||||
$ids = array_column(GranarySite::granaries($this->filterSites), 'id');
|
||||
return $query->where('gid', 'in', $ids ?: [0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\library;
|
||||
|
||||
use think\Db;
|
||||
|
||||
/** Saved environmental conditions, immutable predictions and dated measurements. */
|
||||
class GrainForecast
|
||||
{
|
||||
public static function metrics($type)
|
||||
{
|
||||
return $type === 'wheat' ? [
|
||||
['key' => 'wheat_moisture', 'name' => '粮食水分', 'unit' => '%', 'max' => 100],
|
||||
['key' => 'wheat_GWAC', 'name' => '面筋吸水量', 'unit' => '%', 'max' => 1000],
|
||||
['key' => 'wheat_DON', 'name' => '脱氧雪腐镰刀菌烯醇(呕吐毒素)', 'unit' => 'μg/kg', 'max' => 100000000],
|
||||
['key' => 'wheat_ZEN', 'name' => '玉米赤霉烯酮', 'unit' => 'μg/kg', 'max' => 100000000],
|
||||
] : [
|
||||
['key' => 'paddy_moisture', 'name' => '粮食水分', 'unit' => '%', 'max' => 100],
|
||||
['key' => 'paddy_fatacid', 'name' => '脂肪酸值', 'unit' => 'mg/100g', 'max' => 100000000],
|
||||
['key' => 'paddy_AFB1', 'name' => '黄曲霉毒素B1', 'unit' => 'μg/kg', 'max' => 100000000],
|
||||
];
|
||||
}
|
||||
|
||||
public static function number($value, $label, $min, $max)
|
||||
{
|
||||
if (!is_scalar($value) || is_bool($value) || !is_numeric($value)
|
||||
|| !is_finite((float)$value) || $value < $min || $value > $max) {
|
||||
throw new \InvalidArgumentException($label . '须为' . $min . '至' . $max . '之间的数值');
|
||||
}
|
||||
return round((float)$value, 4);
|
||||
}
|
||||
|
||||
public static function date($value, $label)
|
||||
{
|
||||
$date = is_string($value) ? \DateTimeImmutable::createFromFormat('!Y-m-d', $value) : false;
|
||||
if (!$date || $date->format('Y-m-d') !== $value || $value < '2000-01-01' || $value > date('Y-m-d')) {
|
||||
throw new \InvalidArgumentException($label . '须为2000年至今天之间的有效日期');
|
||||
}
|
||||
return $date;
|
||||
}
|
||||
|
||||
public function create(array $input, $adminId)
|
||||
{
|
||||
$key = isset($input['request_key']) ? $input['request_key'] : '';
|
||||
if (!is_string($key) || !preg_match('/^[a-f0-9]{32,64}$/D', $key)) {
|
||||
throw new \InvalidArgumentException('请求标识无效,请刷新后重试');
|
||||
}
|
||||
$existing = Db::name('grain_forecast')->where('request_key', $key)->find();
|
||||
if ($existing) {
|
||||
if ((int)$existing['admin_id'] !== (int)$adminId) throw new \InvalidArgumentException('请求标识已使用');
|
||||
return (int)$existing['id'];
|
||||
}
|
||||
$gid = isset($input['gid']) && is_scalar($input['gid']) ? (int)$input['gid'] : 0;
|
||||
$granary = Db::name('granary')->where('id', $gid)->find();
|
||||
if (!$granary || !in_array($granary['varieties'], ['小麦', '稻谷'], true)) {
|
||||
throw new \InvalidArgumentException('请选择小麦或稻谷仓库');
|
||||
}
|
||||
$date = self::date(isset($input['observed_date']) ? $input['observed_date'] : '', '温湿度采集日期');
|
||||
$horizons = isset($input['horizons']) ? $input['horizons'] : [];
|
||||
if (!is_array($horizons) || !$horizons || count($horizons) > 4) throw new \InvalidArgumentException('请选择预测周期');
|
||||
foreach ($horizons as $day) {
|
||||
if (!is_scalar($day) || !in_array((string)$day, ['7', '15', '30', '60'], true)) throw new \InvalidArgumentException('预测周期仅支持7、15、30、60天');
|
||||
}
|
||||
$horizons = array_unique(array_map('intval', $horizons));
|
||||
sort($horizons);
|
||||
$row = ['gid' => $gid, 'granary_name' => $granary['name'], 'storage' => $granary['storage'],
|
||||
'grain_type' => $granary['varieties'] === '小麦' ? 'wheat' : 'rice',
|
||||
'observed_date' => $date->format('Y-m-d'), 'request_key' => $key, 'admin_id' => (int)$adminId,
|
||||
'status' => 'pending', 'create_time' => time(), 'update_time' => time()];
|
||||
foreach (['tm_i' => '仓内温度', 'tm_o' => '仓外温度', 'hm_i' => '仓内湿度', 'hm_o' => '仓外湿度'] as $field => $label) {
|
||||
$isTemp = strpos($field, 'tm_') === 0;
|
||||
$row[$field] = self::number(isset($input[$field]) ? $input[$field] : null, $label, $isTemp ? -50 : 0, $isTemp ? 80 : 100);
|
||||
}
|
||||
return Db::transaction(function () use ($row, $horizons, $date) {
|
||||
// The unique request key also prevents concurrent duplicate batches.
|
||||
$id = Db::name('grain_forecast')->insertGetId($row);
|
||||
foreach ($horizons as $days) {
|
||||
Db::name('grain_forecast_point')->insert(['forecast_id' => $id, 'horizon' => $days,
|
||||
'target_date' => $date->modify('+' . $days . ' days')->format('Y-m-d'),
|
||||
'predicted' => '{}', 'actual' => '{}', 'version' => 0, 'update_time' => time()]);
|
||||
}
|
||||
return (int)$id;
|
||||
});
|
||||
}
|
||||
|
||||
/** The API document defines month/day as a calendar date, not a duration. */
|
||||
protected function predict(array $record, array $point)
|
||||
{
|
||||
$date = new \DateTimeImmutable($point['target_date']);
|
||||
$params = array_intersect_key($record, array_flip(['tm_i', 'tm_o', 'hm_i', 'hm_o']));
|
||||
$params += ['month' => (int)$date->format('n'), 'day' => (int)$date->format('j'), 'type' => $record['grain_type']];
|
||||
$url = 'http://www.probe-cdy-pests.cn:8116/alg/grainquality/forcast?' . http_build_query($params);
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 6,
|
||||
CURLOPT_PROXY => '', CURLOPT_FOLLOWLOCATION => false]);
|
||||
$raw = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$json = $raw !== false ? json_decode($raw, true) : null;
|
||||
if ($status !== 200 || !is_array($json) || !isset($json['code']) || (string)$json['code'] !== '0'
|
||||
|| empty($json['output']['targets']) || !is_array($json['output']['targets'])) {
|
||||
throw new \RuntimeException('算法服务暂不可用,温湿度记录已保存,请稍后重新生成');
|
||||
}
|
||||
return $json['output']['targets'];
|
||||
}
|
||||
|
||||
public function generate($id)
|
||||
{
|
||||
$record = Db::name('grain_forecast')->where('id', $id)->find();
|
||||
if (!$record) throw new \InvalidArgumentException('预测记录不存在');
|
||||
if ($record['status'] === 'ready') return;
|
||||
// Recover an interrupted request after its bounded remote calls have expired.
|
||||
$locked = Db::name('grain_forecast')->where('id', $id)->where(function ($q) {
|
||||
$q->where('status', 'in', ['pending', 'failed'])->whereOr(function ($q) {
|
||||
$q->where('status', 'processing')->where('attempt_time', '<', time() - 120);
|
||||
});
|
||||
})->update(['status' => 'processing', 'attempt_time' => time(), 'update_time' => time(), 'error' => '']);
|
||||
if (!$locked) return;
|
||||
try {
|
||||
$points = Db::name('grain_forecast_point')->where('forecast_id', $id)->order('horizon')->select();
|
||||
$predictions = [];
|
||||
foreach ($points as $point) {
|
||||
$targets = $this->predict($record, $point);
|
||||
$values = [];
|
||||
foreach (self::metrics($record['grain_type']) as $metric) {
|
||||
$key = $metric['key'];
|
||||
$values[$key] = self::number(isset($targets[$key]) ? $targets[$key] : null, $metric['name'] . '预测值', 0, $metric['max']);
|
||||
}
|
||||
$predictions[$point['id']] = json_encode($values, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
Db::transaction(function () use ($id, $predictions) {
|
||||
foreach ($predictions as $pointId => $values) {
|
||||
Db::name('grain_forecast_point')->where('id', $pointId)->update(['predicted' => $values]);
|
||||
}
|
||||
Db::name('grain_forecast')->where('id', $id)->update(['status' => 'ready', 'error' => '', 'update_time' => time()]);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
Db::name('grain_forecast')->where('id', $id)->update(['status' => 'failed',
|
||||
'error' => '算法服务暂不可用或返回数据不完整,请稍后重新生成', 'update_time' => time()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function detail($id)
|
||||
{
|
||||
$record = Db::name('grain_forecast')->where('id', $id)->find();
|
||||
if (!$record) throw new \InvalidArgumentException('预测记录不存在');
|
||||
unset($record['request_key']);
|
||||
$record['metrics'] = self::metrics($record['grain_type']);
|
||||
$record['points'] = Db::name('grain_forecast_point')->where('forecast_id', $id)->order('horizon')->select();
|
||||
foreach ($record['points'] as &$point) {
|
||||
$point['predicted'] = json_decode($point['predicted'], true) ?: [];
|
||||
$point['actual'] = json_decode($point['actual'], true) ?: [];
|
||||
$point['rows'] = [];
|
||||
foreach ($record['metrics'] as $metric) {
|
||||
$key = $metric['key'];
|
||||
$predicted = isset($point['predicted'][$key]) ? $point['predicted'][$key] : null;
|
||||
$actual = isset($point['actual'][$key]) ? $point['actual'][$key] : null;
|
||||
$delta = $predicted !== null && $actual !== null ? round($actual - $predicted, 4) : null;
|
||||
$point['rows'][] = $metric + ['predicted' => $predicted, 'actual' => $actual, 'delta' => $delta,
|
||||
'error_percent' => $delta !== null && $predicted != 0 ? round($delta / abs($predicted) * 100, 2) : null];
|
||||
}
|
||||
}
|
||||
unset($point);
|
||||
return $record;
|
||||
}
|
||||
|
||||
public function saveActual(array $input, $adminId)
|
||||
{
|
||||
$point = Db::name('grain_forecast_point')->where('id', isset($input['point_id']) ? (int)$input['point_id'] : 0)->find();
|
||||
if (!$point) throw new \InvalidArgumentException('预测周期不存在');
|
||||
$record = Db::name('grain_forecast')->where('id', $point['forecast_id'])->find();
|
||||
$actual = isset($input['actual']) ? $input['actual'] : [];
|
||||
if (!is_array($actual)) throw new \InvalidArgumentException('实测数据格式错误');
|
||||
$metrics = self::metrics($record['grain_type']);
|
||||
if (array_diff(array_keys($actual), array_column($metrics, 'key'))) throw new \InvalidArgumentException('实测指标与粮食品种不匹配');
|
||||
$values = [];
|
||||
foreach ($metrics as $metric) {
|
||||
$key = $metric['key'];
|
||||
$value = isset($actual[$key]) ? $actual[$key] : null;
|
||||
$values[$key] = $value === '' || $value === null ? null : self::number($value, $metric['name'], 0, $metric['max']);
|
||||
}
|
||||
$filled = count(array_filter($values, function ($v) { return $v !== null; }));
|
||||
$actualDate = null;
|
||||
if ($filled) {
|
||||
$date = self::date(isset($input['actual_date']) ? $input['actual_date'] : '', '实际检测日期');
|
||||
if ($date->format('Y-m-d') < $record['observed_date']) throw new \InvalidArgumentException('实际检测日期不能早于温湿度采集日期');
|
||||
$actualDate = $date->format('Y-m-d');
|
||||
}
|
||||
$version = isset($input['version']) && is_scalar($input['version']) ? (int)$input['version'] : -1;
|
||||
$saved = Db::name('grain_forecast_point')->where('id', $point['id'])->where('version', $version)->update([
|
||||
'actual' => json_encode($values, JSON_UNESCAPED_UNICODE), 'actual_date' => $actualDate,
|
||||
'actual_admin_id' => (int)$adminId, 'version' => $version + 1, 'update_time' => time(),
|
||||
]);
|
||||
if (!$saved) throw new \InvalidArgumentException('实测数据已被其他人更新,请关闭并重新打开后填写');
|
||||
return (int)$point['forecast_id'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
namespace app\common\library;
|
||||
|
||||
use think\Db;
|
||||
|
||||
/** Canonical site identities shared by management and screen APIs. */
|
||||
class GranarySite
|
||||
{
|
||||
const NAMES = ['center' => '遵义中心库', 'xishui' => '习水库'];
|
||||
const STORAGE = ['center' => '遵义直属库有限公司中心库区', 'xishui' => '中央储备粮遵义直属库有限公司习水分公司'];
|
||||
|
||||
public static function valid($site)
|
||||
{
|
||||
return is_string($site) && isset(self::NAMES[$site]);
|
||||
}
|
||||
|
||||
public static function allowed($auth)
|
||||
{
|
||||
if ($auth->isSuperAdmin()) return array_keys(self::NAMES);
|
||||
$scope = Db::name('admin')->where('id', $auth->id)->value('granary_sites');
|
||||
return $scope === 'all' ? array_keys(self::NAMES) : (self::valid($scope) ? [$scope] : []);
|
||||
}
|
||||
|
||||
public static function options(array $sites)
|
||||
{
|
||||
$options = [];
|
||||
foreach ($sites as $site) $options[] = ['id' => $site, 'name' => self::NAMES[$site], 'storage' => self::STORAGE[$site]];
|
||||
return $options;
|
||||
}
|
||||
|
||||
public static function granaries(array $sites)
|
||||
{
|
||||
$rows = Db::name('granary')->where('site', 'in', $sites ?: ['__none__'])
|
||||
->field('id,name,num,site,storage,varieties,granary_type')->order('site,num')->select();
|
||||
foreach ($rows as &$row) {
|
||||
$row['site_name'] = self::NAMES[$row['site']];
|
||||
$row['label'] = $row['site_name'] . ' · ' . $row['name'];
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public static function permits($gid, array $sites)
|
||||
{
|
||||
return (bool) Db::name('granary')->where('id', (int)$gid)->where('site', 'in', $sites ?: ['__none__'])->count();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,12 @@ namespace app\common\library;
|
||||
/** Numeric warning rules shared by the editor and persistence layer. */
|
||||
class QualityRule
|
||||
{
|
||||
public static function isNumber($value)
|
||||
{
|
||||
return (is_int($value) || is_float($value) || is_string($value))
|
||||
&& is_numeric($value) && is_finite((float)$value);
|
||||
}
|
||||
|
||||
public static function direction($item)
|
||||
{
|
||||
if (isset($item['direction']) && in_array($item['direction'], ['upper', 'lower'], true)) {
|
||||
@@ -12,14 +18,22 @@ class QualityRule
|
||||
}
|
||||
$warning = isset($item['warning']) ? $item['warning'] : null;
|
||||
$alert = isset($item['alert']) ? $item['alert'] : null;
|
||||
return is_numeric($warning) && is_numeric($alert) && $warning > $alert ? 'lower' : 'upper';
|
||||
return self::isNumber($warning) && self::isNumber($alert) && $warning > $alert ? 'lower' : 'upper';
|
||||
}
|
||||
|
||||
public static function validate($content)
|
||||
public static function validate($content, $originalRules = [])
|
||||
{
|
||||
if (!is_array($content) || empty($content)) {
|
||||
return '请填写检测参数';
|
||||
}
|
||||
$numericRules = [];
|
||||
foreach ($originalRules as $original) {
|
||||
if (isset($original['warning'], $original['alert'])
|
||||
&& self::isNumber($original['warning']) && self::isNumber($original['alert'])) {
|
||||
$key = !empty($original['id']) ? 'id:' . $original['id'] : 'num:' . $original['num'];
|
||||
$numericRules[$key] = true;
|
||||
}
|
||||
}
|
||||
foreach ($content as $item) {
|
||||
if (!is_array($item)) {
|
||||
return '检测参数格式无效';
|
||||
@@ -30,7 +44,23 @@ class QualityRule
|
||||
if (!isset($item['warning'], $item['alert'])) {
|
||||
return '请填写预警值和警告值';
|
||||
}
|
||||
if (is_numeric($item['warning']) && is_numeric($item['alert'])) {
|
||||
$warningNumeric = self::isNumber($item['warning']);
|
||||
$alertNumeric = self::isNumber($item['alert']);
|
||||
$numeric = $warningNumeric && $alertNumeric;
|
||||
$key = !empty($item['id']) ? 'id:' . $item['id'] : 'num:' . (isset($item['num']) ? $item['num'] : '');
|
||||
$expectsNumeric = isset($numericRules[$key]) || (isset($item['is_number']) && (string)$item['is_number'] === '1')
|
||||
|| $warningNumeric || $alertNumeric;
|
||||
if ($expectsNumeric && !$numeric) {
|
||||
return $item['name'] . ':预警值和警告值必须都是有限数字';
|
||||
}
|
||||
if (!isset($item['val']) || (is_string($item['val']) && trim($item['val']) === '')) {
|
||||
return $item['name'] . ':请填写检测值,缺测可填 /';
|
||||
}
|
||||
if ($numeric && !self::isNumber($item['val'])
|
||||
&& !preg_match('/^(?:\/|--?|未检测|未提供|暂无|未检出|未检出\s*ND|ND)$/ui', trim((string)$item['val']))) {
|
||||
return $item['name'] . ':检测值必须是有限数字,缺测可填 /';
|
||||
}
|
||||
if ($numeric) {
|
||||
$upper = self::direction($item) === 'upper';
|
||||
if (($upper && $item['warning'] > $item['alert'])
|
||||
|| (!$upper && $item['warning'] < $item['alert'])) {
|
||||
|
||||
@@ -20,7 +20,7 @@ class ContentModel extends Model
|
||||
public function granary()
|
||||
{
|
||||
return $this->hasOne(GranaryModel::class, 'id', 'gid')
|
||||
->field(['id', 'name', 'num', 'code', 'addr', 'storage']);
|
||||
->field(['id', 'name', 'num', 'code', 'addr', 'site', 'storage']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestingModel extends Model
|
||||
public function granary()
|
||||
{
|
||||
return $this->hasOne(GranaryModel::class, 'id', 'gid')
|
||||
->field(['id', 'name', 'num', 'code', 'addr', 'storage', 'varieties']);
|
||||
->field(['id', 'name', 'num', 'code', 'addr', 'site', 'storage', 'varieties']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
在新工单中引用
屏蔽一个用户