From a7d61b9fcc66d74edec5de47363aa6dafa66f7b1 Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Tue, 18 Aug 2026 03:08:53 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=20external=5Fid=20=D0=B8=D0=B7=20=D1=83=D0=B6?= =?UTF-8?q?=D0=B5=20=D1=83=D0=B2=D0=B8=D0=B4=D0=B5=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поле External ID при создании/изменении устройства теперь input+datalist: можно вписать значение вручную (как раньше) или выбрать из подсказок — идентификаторы устройств, от которых device-control-service уже получал MQTT-телеметрию/ack (Redis-множество devices:known), но которые ещё не зарегистрированы в Postgres. Уже зарегистрированные id из списка подсказок исключаются. DeviceShadow::knownExternalIds() — новый read-метод (SMEMBERS devices:known), зеркалит то же множество, что Go-сервисы уже читают/ пишут для health-check/online-детекции. --- .../app/Http/Controllers/DeviceController.php | 20 ++++++++++++++----- laravel-app/app/Services/DeviceShadow.php | 14 +++++++++++++ .../resources/js/Pages/Devices/Create.vue | 3 ++- .../resources/js/Pages/Devices/Edit.vue | 3 ++- .../resources/js/Pages/Devices/Form.vue | 9 +++++++++ .../tests/Feature/DeviceControllerTest.php | 19 ++++++++++++++++++ 6 files changed, 61 insertions(+), 7 deletions(-) diff --git a/laravel-app/app/Http/Controllers/DeviceController.php b/laravel-app/app/Http/Controllers/DeviceController.php index 524eeb9..a537299 100644 --- a/laravel-app/app/Http/Controllers/DeviceController.php +++ b/laravel-app/app/Http/Controllers/DeviceController.php @@ -59,11 +59,11 @@ class DeviceController extends Controller ]); } - public function create() + public function create(DeviceShadow $shadow) { $this->authorize('create', Device::class); - return Inertia::render('Devices/Create', $this->formOptions()); + return Inertia::render('Devices/Create', $this->formOptions($shadow)); } public function store(DeviceRequest $request) @@ -75,13 +75,13 @@ class DeviceController extends Controller return redirect()->route('devices.index')->with('status', 'Устройство добавлено.'); } - public function edit(Device $device) + public function edit(Device $device, DeviceShadow $shadow) { $this->authorize('update', $device); return Inertia::render('Devices/Edit', [ 'device' => $device->only(['id', 'name', 'zone_id', 'device_type_id', 'external_id', 'protocol']), - ...$this->formOptions(), + ...$this->formOptions($shadow), ]); } @@ -150,11 +150,21 @@ class DeviceController extends Controller /** * @return array */ - private function formOptions(): array + private function formOptions(DeviceShadow $shadow): array { + $registeredIds = Device::pluck('external_id')->all(); + $unregisteredExternalIds = collect($shadow->knownExternalIds()) + ->diff($registeredIds) + ->sort() + ->values(); + return [ 'zones' => ZoneResource::collection(Zone::orderBy('name')->get())->resolve(), 'deviceTypes' => DeviceTypeResource::collection(DeviceType::orderBy('code')->get())->resolve(), + // external_id of devices device-control-service has already seen + // over MQTT but nobody has registered yet — offered as + // suggestions (not a closed list) when adding/editing a device. + 'unregisteredExternalIds' => $unregisteredExternalIds, ]; } } diff --git a/laravel-app/app/Services/DeviceShadow.php b/laravel-app/app/Services/DeviceShadow.php index 554603a..a636a33 100644 --- a/laravel-app/app/Services/DeviceShadow.php +++ b/laravel-app/app/Services/DeviceShadow.php @@ -54,4 +54,18 @@ class DeviceShadow return array_combine($externalIds, array_map(fn ($v) => $v ?: 'unknown', $values)); } + + /** + * external_id of every device device-control-service has ever heard + * from over MQTT (`devices:known`, a Redis Set it maintains) — includes + * devices that sent telemetry/ack before anyone registered them in + * Postgres. Used to suggest an external_id when adding a device instead + * of requiring it to be typed exactly from memory. + * + * @return array + */ + public function knownExternalIds(): array + { + return Redis::smembers('devices:known'); + } } diff --git a/laravel-app/resources/js/Pages/Devices/Create.vue b/laravel-app/resources/js/Pages/Devices/Create.vue index 380505c..3074d69 100644 --- a/laravel-app/resources/js/Pages/Devices/Create.vue +++ b/laravel-app/resources/js/Pages/Devices/Create.vue @@ -5,6 +5,7 @@ import Form from '@/Pages/Devices/Form.vue'; defineProps({ zones: { type: Array, required: true }, deviceTypes: { type: Array, required: true }, + unregisteredExternalIds: { type: Array, default: () => [] }, }); @@ -17,7 +18,7 @@ defineProps({
-
+
diff --git a/laravel-app/resources/js/Pages/Devices/Edit.vue b/laravel-app/resources/js/Pages/Devices/Edit.vue index 9f58e13..92b042f 100644 --- a/laravel-app/resources/js/Pages/Devices/Edit.vue +++ b/laravel-app/resources/js/Pages/Devices/Edit.vue @@ -6,6 +6,7 @@ defineProps({ device: { type: Object, required: true }, zones: { type: Array, required: true }, deviceTypes: { type: Array, required: true }, + unregisteredExternalIds: { type: Array, default: () => [] }, }); @@ -18,7 +19,7 @@ defineProps({
- +
diff --git a/laravel-app/resources/js/Pages/Devices/Form.vue b/laravel-app/resources/js/Pages/Devices/Form.vue index 0b00ca7..2feb9fd 100644 --- a/laravel-app/resources/js/Pages/Devices/Form.vue +++ b/laravel-app/resources/js/Pages/Devices/Form.vue @@ -6,6 +6,7 @@ const props = defineProps({ device: { type: Object, default: null }, zones: { type: Array, required: true }, deviceTypes: { type: Array, required: true }, + unregisteredExternalIds: { type: Array, default: () => [] }, }); const isEdit = !!props.device; @@ -76,9 +77,17 @@ function submit() { id="external_id" v-model="form.external_id" type="text" + list="unregistered-external-ids" required + placeholder="можно ввести вручную или выбрать увиденное устройство" class="mt-1 block w-full font-mono border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" > + + +

+ От {{ unregisteredExternalIds.length }} {{ unregisteredExternalIds.length === 1 ? 'устройства' : 'устройств' }} уже приходил сигнал, но они ещё не зарегистрированы — начните вводить, чтобы увидеть подсказки. +

{{ form.errors.external_id }}
diff --git a/laravel-app/tests/Feature/DeviceControllerTest.php b/laravel-app/tests/Feature/DeviceControllerTest.php index 71b5c14..161ff7f 100644 --- a/laravel-app/tests/Feature/DeviceControllerTest.php +++ b/laravel-app/tests/Feature/DeviceControllerTest.php @@ -130,4 +130,23 @@ class DeviceControllerTest extends TestCase ->where('telemetry.0.value', 24.5) ); } + + public function test_create_form_suggests_unregistered_external_ids(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + // Already registered — must be excluded even though device-control-service still knows it. + $this->makeDevice($owner, ['turn_on']); + + $this->mock(DeviceShadow::class, function ($mock) { + $mock->shouldReceive('knownExternalIds')->once()->andReturn(['fan-1', 'sensor-2']); + }); + + $response = $this->actingAs($owner)->get(route('devices.create')); + + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('Devices/Create') + ->where('unregisteredExternalIds', ['sensor-2']) + ); + } }