'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'], 'commodity' => $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']; } public function delete($id) { $ids = is_array($id) ? $id : (strpos((string)$id, ',') !== false ? explode(',', (string)$id) : [(int)$id]); $ids = array_values(array_filter(array_map('intval', $ids))); if (empty($ids)) { throw new \InvalidArgumentException('请选择要删除的记录'); } return Db::transaction(function () use ($ids) { Db::name('grain_forecast_point')->where('forecast_id', 'in', $ids)->delete(); return Db::name('grain_forecast')->where('id', 'in', $ids)->delete(); }); } public function update($id, array $input, $adminId) { $id = (int)$id; $existing = Db::name('grain_forecast')->where('id', $id)->find(); if (!$existing) { throw new \InvalidArgumentException('预测记录不存在'); } $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); $grainType = $granary['varieties'] === '小麦' ? 'wheat' : 'rice'; $row = [ 'gid' => $gid, 'granary_name' => $granary['name'], 'storage' => $granary['storage'], 'grain_type' => $grainType, 'observed_date' => $date->format('Y-m-d'), 'status' => 'pending', 'error' => '', '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); } Db::transaction(function () use ($id, $row, $horizons, $date, $grainType, $existing) { Db::name('grain_forecast')->where('id', $id)->update($row); $oldPoints = Db::name('grain_forecast_point')->where('forecast_id', $id)->select(); $oldPointsByHorizon = []; foreach ($oldPoints as $op) { $oldPointsByHorizon[(int)$op['horizon']] = $op; } $grainTypeChanged = ($existing['grain_type'] !== $grainType); foreach ($horizons as $days) { $targetDate = $date->modify('+' . $days . ' days')->format('Y-m-d'); if (isset($oldPointsByHorizon[$days]) && !$grainTypeChanged) { Db::name('grain_forecast_point')->where('id', $oldPointsByHorizon[$days]['id'])->update([ 'target_date' => $targetDate, 'predicted' => '{}', 'update_time' => time() ]); } else { if (isset($oldPointsByHorizon[$days])) { Db::name('grain_forecast_point')->where('id', $oldPointsByHorizon[$days]['id'])->delete(); } Db::name('grain_forecast_point')->insert([ 'forecast_id' => $id, 'horizon' => $days, 'target_date' => $targetDate, 'predicted' => '{}', 'actual' => '{}', 'version' => 0, 'update_time' => time() ]); } } foreach ($oldPointsByHorizon as $h => $op) { if (!in_array($h, $horizons, true)) { Db::name('grain_forecast_point')->where('id', $op['id'])->delete(); } } }); return (int)$id; } }