diff --git a/application/admin/controller/Forecast.php b/application/admin/controller/Forecast.php index ff4f19b..2cc990a 100644 --- a/application/admin/controller/Forecast.php +++ b/application/admin/controller/Forecast.php @@ -33,29 +33,29 @@ class Forecast extends GranaryBackend } } unset($row); - $granaries = array_values(array_filter(GranarySite::granaries(->filterSites), function () { return in_array(['varieties'], ['小麦', '稻谷'], true); })); - if (!empty()) { - = array_column(, 'id'); - = Db::name('grain_forecast')->where('gid', 'in', ) + $granaries = array_values(array_filter(GranarySite::granaries($this->filterSites), function ($g) { return in_array($g['varieties'], ['小麦', '稻谷'], true); })); + if (!empty($granaries)) { + $gids = array_column($granaries, 'id'); + $envRecords = Db::name('grain_forecast')->where('gid', 'in', $gids) ->field('gid,tm_i,hm_i,tm_o,hm_o,observed_date')->order('id DESC')->select(); - = []; - foreach ( as ) { - if (!isset([['gid']])) { - [['gid']] = [ - 'tm_i' => (float)['tm_i'], - 'hm_i' => (float)['hm_i'], - 'tm_o' => (float)['tm_o'], - 'hm_o' => (float)['hm_o'], - 'observed_date' => ['observed_date'] + $envMap = []; + foreach ($envRecords as $rec) { + if (!isset($envMap[$rec['gid']])) { + $envMap[$rec['gid']] = [ + 'tm_i' => (float)$rec['tm_i'], + 'hm_i' => (float)$rec['hm_i'], + 'tm_o' => (float)$rec['tm_o'], + 'hm_o' => (float)$rec['hm_o'], + 'observed_date' => $rec['observed_date'] ]; } } - foreach ( as &) { - ['last_env'] = isset([['id']]) ? [['id']] : null; + foreach ($granaries as &$g) { + $g['last_env'] = isset($envMap[$g['id']]) ? $envMap[$g['id']] : null; } - unset(); + unset($g); } - return json(['code' => 200, 'list' => , 'total' => , 'granaries' => ]); + return json(['code' => 200, 'list' => $list, 'total' => $total, 'granaries' => $granaries]); } public function detail() @@ -76,6 +76,38 @@ class Forecast extends GranaryBackend }); } + public function edit($ids = null) + { + return $this->write(function ($service) use ($ids) { + $id = $this->request->post('id/d', $ids ?: 0); + $id = $service->update($id, $this->request->post(), $this->auth->id); + $service->generate($id); + return $id; + }); + } + + public function dell($ids = null) + { + if (!$this->request->isPost()) return json(['code' => 405, 'msg' => '请使用POST提交']); + $id = $this->request->post('id', $ids ?: $this->request->get('id')); + if (empty($id)) return json(['code' => 400, 'msg' => '请指定要删除的记录编号']); + try { + $service = new GrainForecast(); + $service->delete($id); + return json(['code' => 200, 'msg' => '删除成功']); + } catch (\InvalidArgumentException $e) { + return json(['code' => 400, 'msg' => $e->getMessage()]); + } catch (\Exception $e) { + \think\Log::record('Grain forecast delete: ' . $e->getMessage(), 'error'); + return json(['code' => 500, 'msg' => '删除失败,请刷新后重试']); + } + } + + public function del($ids = null) + { + return $this->dell($ids); + } + public function retry() { return $this->write(function ($service) { diff --git a/application/admin/view/forecast/index.html b/application/admin/view/forecast/index.html index 5b10e49..6344570 100644 --- a/application/admin/view/forecast/index.html +++ b/application/admin/view/forecast/index.html @@ -27,11 +27,14 @@ 录入温湿度并预测

选择仓库,录入采集当天的仓内外温湿度,生成未来品质指标预测;取得检测结果后,在对应周期补录实测值并查看偏差。

- + + + +
关闭
@@ -55,7 +58,13 @@ - + + + @@ -97,7 +106,10 @@

中储粮控温技术规范:低温储粮 ≤15℃,准低温储粮 ≤20℃,常温储粮 ≤25℃(>25℃需通风或机械降温)。仓内相对湿度建议 ≤70%~75%。预测目标日期按采集日期加周期计算。

-
取消保存并生成预测
+
+ 取消 + {{ isEdit ? '保存修改并重新生成预测' : '保存并生成预测' }} +
diff --git a/application/api/controller/Forecast.php b/application/api/controller/Forecast.php new file mode 100644 index 0000000..d6a86f0 --- /dev/null +++ b/application/api/controller/Forecast.php @@ -0,0 +1,20 @@ +forecast(); + } +} diff --git a/application/api/controller/Index.php b/application/api/controller/Index.php index a3fbd25..7b5aaf9 100644 --- a/application/api/controller/Index.php +++ b/application/api/controller/Index.php @@ -265,10 +265,10 @@ class Index extends Api */ public function env() { - = ->request->get('site', 'center'); - if (!GranarySite::valid()) ->error('无效库区'); + $site = $this->request->get('site', 'center'); + if (!GranarySite::valid($site)) $this->error('无效库区'); - = [ + $envData = [ 'center' => [ 'site' => 'center', 'name' => '遵义直属库中心库区', @@ -291,29 +291,29 @@ class Index extends Api ] ]; - = isset([]) ? [] : ['center']; + $data = isset($envData[$site]) ? $envData[$site] : $envData['center']; // 优先读取该库区最新一条人工录入或设定的温湿度快照 try { - = Db::name('grain_forecast') + $latestRecord = Db::name('grain_forecast') ->alias('f') ->join('granary g', 'f.gid = g.id') - ->where('g.site', ) + ->where('g.site', $site) ->order('f.id DESC') ->field('f.tm_i, f.hm_i, f.tm_o, f.hm_o, f.update_time') ->find(); - if () { - ['tm_i'] = (float)['tm_i']; - ['hm_i'] = (float)['hm_i']; - ['tm_o'] = (float)['tm_o']; - ['hm_o'] = (float)['hm_o']; - ['update_time'] = (int)['update_time']; + if ($latestRecord) { + $data['tm_i'] = (float)$latestRecord['tm_i']; + $data['hm_i'] = (float)$latestRecord['hm_i']; + $data['tm_o'] = (float)$latestRecord['tm_o']; + $data['hm_o'] = (float)$latestRecord['hm_o']; + $data['update_time'] = (int)$latestRecord['update_time']; } - } catch (\Exception ) { + } catch (\Exception $e) { // 异常时安全兜底基准工况 } - ->success('获取成功', ); + $this->success('获取成功', $data); } /** @@ -355,6 +355,7 @@ class Index extends Api $remoteData = $this->callRemoteForecast($remoteUrl, [ 'type' => $grainType, + 'commodity' => $grainType, 'month' => $targetMonth, 'day' => $targetDay, 'tm_i' => $tm_i, diff --git a/application/common/controller/GranaryBackend.php b/application/common/controller/GranaryBackend.php index 5607219..df4be56 100644 --- a/application/common/controller/GranaryBackend.php +++ b/application/common/controller/GranaryBackend.php @@ -32,7 +32,7 @@ class GranaryBackend extends Backend 'granary' => ['index', 'add', 'detail', 'dell'], 'testing' => ['index', 'add', 'edit', 'detail', 'sort', 'dell'], 'content' => ['index', 'add', 'status', 'getcontent', 'dell'], - 'forecast' => ['index', 'detail', 'add', 'retry', 'actual'], + 'forecast' => ['index', 'detail', 'add', 'edit', 'retry', 'actual', 'dell', 'del'], ]; if (!isset($actions[$controller]) || !in_array($action, $actions[$controller], true)) $this->error('不支持此操作'); $table = $controller === 'forecast' ? 'grain_forecast' : $controller; diff --git a/application/common/library/GrainForecast.php b/application/common/library/GrainForecast.php index 286ddab..42981e5 100644 --- a/application/common/library/GrainForecast.php +++ b/application/common/library/GrainForecast.php @@ -88,7 +88,7 @@ class GrainForecast { $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']]; + $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, @@ -194,4 +194,103 @@ class GrainForecast 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; + } } diff --git a/public/assets/js/backend/forecast.js b/public/assets/js/backend/forecast.js index 7d01f2d..99ee85f 100644 --- a/public/assets/js/backend/forecast.js +++ b/public/assets/js/backend/forecast.js @@ -4,7 +4,7 @@ define(['jquery', 'bootstrap', 'backend', 'backend/forecast/components'], functi new Vue({ el: '#forecast-app', components: {'forecast-list': components.list, 'forecast-create': components.create, 'forecast-detail': components.detail}, - data: { rows: [], granaries: [], total: 0, page: 1, gid: '', site: '', siteOptions: Config.granarySites || [], loading: false, saving: false, creating: false, viewing: false, selected: null, loadSequence: 0 }, + data: { rows: [], granaries: [], total: 0, page: 1, gid: '', site: '', siteOptions: Config.granarySites || [], loading: false, saving: false, creating: false, editing: false, editData: null, viewing: false, selected: null, loadSequence: 0 }, created: function () { this.load(); }, methods: { api: function (action, data, method) { @@ -32,6 +32,25 @@ define(['jquery', 'bootstrap', 'backend', 'backend/forecast/components'], functi changePage: function (page) { this.page = page; this.load(); }, newRecord: function () { this.creating = true; }, closeCreate: function (done) { if (!this.saving) { this.creating = false; if (typeof done === 'function') done(); } }, + editRecord: function (row) { + var self = this; + this.api('detail', {id: row.id}).then(function (res) { + var d = res.data; + var horizons = (d.points || []).map(function (p) { return Number(p.horizon); }); + self.editData = { + id: d.id, + gid: d.gid, + observed_date: d.observed_date, + horizons: horizons.length ? horizons : [7, 15, 30, 60], + tm_i: d.tm_i, + hm_i: d.hm_i, + tm_o: d.tm_o, + hm_o: d.hm_o + }; + self.editing = true; + }).catch(function () {}); + }, + closeEdit: function (done) { if (!this.saving) { this.editing = false; this.editData = null; if (typeof done === 'function') done(); } }, closeDetail: function (done) { var self = this; if (this.saving) return; @@ -41,6 +60,27 @@ define(['jquery', 'bootstrap', 'backend', 'backend/forecast/components'], functi var self = this; this.api('detail', {id: id}).then(function (res) { self.selected = res.data; self.viewing = true; }).catch(function () {}); }, + deleteRecord: function (row) { + var self = this; + if (this.saving) return; + this.$confirm('确定要删除【' + (row.granary_name || '') + ' · ' + (row.observed_date || '') + '】的温湿度预测记录吗?删除后关联周期的预测与实测值将一并移除且不可恢复。', '删除记录确认', { + confirmButtonText: '确定删除', + cancelButtonText: '取消', + type: 'warning' + }).then(function () { + self.saving = true; + self.api('dell', {id: row.id}, 'POST').then(function () { + self.$message.success('删除成功'); + if (self.viewing && self.selected && self.selected.id === row.id) { + self.viewing = false; + self.selected = null; + } + self.load(); + }).catch(function () {}).then(function () { + self.saving = false; + }); + }).catch(function () {}); + }, write: function (action, data, callback) { if (this.saving) return; var self = this; @@ -48,7 +88,7 @@ define(['jquery', 'bootstrap', 'backend', 'backend/forecast/components'], functi this.api(action, data, 'POST').then(function (res) { self.selected = res.data; if (action !== 'actual' && res.data.status === 'failed') self.$message.warning('温湿度已保存,算法暂不可用,可稍后重新生成'); - else self.$message.success(action === 'actual' ? '实测值已保存' : res.data.status === 'ready' ? '预测已生成并保存' : '记录已保存'); + else self.$message.success(action === 'actual' ? '实测值已保存' : action === 'edit' ? '修改成功并已更新预测' : res.data.status === 'ready' ? '预测已生成并保存' : '记录已保存'); if (callback) callback(); self.load(); }).catch(function () {}).then(function () { self.saving = false; }); @@ -57,6 +97,10 @@ define(['jquery', 'bootstrap', 'backend', 'backend/forecast/components'], functi var self = this; this.write('add', form, function () { self.creating = false; self.viewing = true; }); }, + updateRecord: function (form) { + var self = this; + this.write('edit', form, function () { self.editing = false; self.editData = null; self.viewing = true; }); + }, saveActual: function (form) { this.write('actual', form); }, retry: function (id) { var self = this; diff --git a/public/assets/js/backend/forecast/components.js b/public/assets/js/backend/forecast/components.js index 59f134c..6f3194e 100644 --- a/public/assets/js/backend/forecast/components.js +++ b/public/assets/js/backend/forecast/components.js @@ -82,19 +82,32 @@ define([], function () { } }, create: { - template: '#forecast-create-template', props: ['granaries', 'busy', 'siteDefaultEnv'], - data: function () { return { - form: {gid: '', observed_date: today(), horizons: [7, 15, 30, 60], tm_i: 18.5, hm_i: 70.0, tm_o: 32.0, hm_o: 55.0, request_key: requestKey()}, - dateOptions: dateOptions, - presets: PRESETS, - selectedPresetKey: 'quasi_low', - fields: [ - {key: 'tm_i', label: '仓内温度', unit: '℃', min: -50, max: 80, step: 0.1, placeholder: '例:18.5'}, - {key: 'hm_i', label: '仓内相对湿度', unit: '%', min: 0, max: 100, step: 0.1, placeholder: '例:70.0'}, - {key: 'tm_o', label: '仓外温度', unit: '℃', min: -50, max: 80, step: 0.1, placeholder: '例:32.0'}, - {key: 'hm_o', label: '仓外相对湿度', unit: '%', min: 0, max: 100, step: 0.1, placeholder: '例:55.0'} - ] - }; }, + template: '#forecast-create-template', props: ['granaries', 'busy', 'initialData', 'isEdit'], + data: function () { + var d = this.initialData; + return { + form: { + id: d && d.id ? d.id : '', + gid: d && d.gid ? d.gid : '', + observed_date: d && d.observed_date ? d.observed_date : today(), + horizons: d && d.horizons ? d.horizons.slice() : [7, 15, 30, 60], + tm_i: d && d.tm_i !== undefined ? Number(d.tm_i) : 18.5, + hm_i: d && d.hm_i !== undefined ? Number(d.hm_i) : 70.0, + tm_o: d && d.tm_o !== undefined ? Number(d.tm_o) : 32.0, + hm_o: d && d.hm_o !== undefined ? Number(d.hm_o) : 55.0, + request_key: requestKey() + }, + dateOptions: dateOptions, + presets: PRESETS, + selectedPresetKey: d ? '' : 'quasi_low', + fields: [ + {key: 'tm_i', label: '仓内温度', unit: '℃', min: -50, max: 80, step: 0.1, placeholder: '例:18.5'}, + {key: 'hm_i', label: '仓内相对湿度', unit: '%', min: 0, max: 100, step: 0.1, placeholder: '例:70.0'}, + {key: 'tm_o', label: '仓外温度', unit: '℃', min: -50, max: 80, step: 0.1, placeholder: '例:32.0'}, + {key: 'hm_o', label: '仓外相对湿度', unit: '%', min: 0, max: 100, step: 0.1, placeholder: '例:55.0'} + ] + }; + }, computed: { tempStatus: function () { return evaluateTemp(this.form.tm_i, this.form.hm_i); @@ -107,7 +120,9 @@ define([], function () { watch: { 'form.gid': function (newGid) { if (!newGid) return; - this.onGranaryChange(newGid); + if (!this.isEdit) { + this.onGranaryChange(newGid); + } } }, methods: { @@ -119,7 +134,7 @@ define([], function () { this.form.hm_i = p.hm_i; this.form.tm_o = p.tm_o; this.form.hm_o = p.hm_o; - this..success('已套用【' + p.name + '】规范温湿度'); + this.$message.success('已套用【' + p.name + '】规范温湿度'); }, onGranaryChange: function (gid) { var granary = this.granaries.find(function (g) { return String(g.id) === String(gid); }); @@ -131,18 +146,18 @@ define([], function () { this.form.tm_o = Number(granary.last_env.tm_o); this.form.hm_o = Number(granary.last_env.hm_o); this.selectedPresetKey = ''; - this..info('已自动读取【' + granary.name + '】最近一次温湿度记录'); + this.$message.info('已自动读取【' + granary.name + '】最近一次温湿度记录'); } else if (this.form.tm_i === '' || this.form.tm_i === undefined) { this.applyPreset('quasi_low'); } }, submit: function () { - if (!this.form.gid || !this.form.observed_date || !this.form.horizons.length) return this..warning('请选择仓库、采集日期和预测周期'); + if (!this.form.gid || !this.form.observed_date || !this.form.horizons.length) return this.$message.warning('请选择仓库、采集日期和预测周期'); for (var i = 0; i < this.fields.length; i++) { var f = this.fields[i], v = this.form[f.key]; - if (v === '' || !Number.isFinite(Number(v)) || Number(v) < f.min || Number(v) > f.max) return this..warning('请填写有效的' + f.label + '(' + f.min + '~' + f.max + ')'); + if (v === '' || !Number.isFinite(Number(v)) || Number(v) < f.min || Number(v) > f.max) return this.$message.warning('请填写有效的' + f.label + '(' + f.min + '~' + f.max + ')'); } - this.('submit', JSON.parse(JSON.stringify(this.form))); + this.$emit('submit', JSON.parse(JSON.stringify(this.form))); } } }, @@ -182,7 +197,7 @@ define([], function () { }, confirmDiscard: function () { if (this.busy) return Promise.reject(); - return this.dirty ? this.('当前周期有未保存的实测数据,确定放弃修改?', '未保存的数据', {type: 'warning'}) : Promise.resolve(); + return this.dirty ? this.$confirm('当前周期有未保存的实测数据,确定放弃修改?', '未保存的数据', {type: 'warning'}) : Promise.resolve(); }, beforeLeave: function () { return this.confirmDiscard().then(function () { return true; }); @@ -193,10 +208,10 @@ define([], function () { var m = this.record.metrics[i], v = this.actual[m.key]; if (v === '') continue; any = true; - if (!Number.isFinite(Number(v)) || Number(v) < 0 || Number(v) > m.max) return this..warning('请填写有效的' + m.name + '实测值'); + if (!Number.isFinite(Number(v)) || Number(v) < 0 || Number(v) > m.max) return this.$message.warning('请填写有效的' + m.name + '实测值'); } - if (any && !this.actualDate) return this..warning('请选择实际检测日期'); - this.('save', {point_id: this.point.id, version: this.point.version, actual_date: this.actualDate, actual: Object.assign({}, this.actual)}); + if (any && !this.actualDate) return this.$message.warning('请选择实际检测日期'); + this.$emit('save', {point_id: this.point.id, version: this.point.version, actual_date: this.actualDate, actual: Object.assign({}, this.actual)}); } } }