From 65a693052ef1027c3d150d02e36965bb61bfaa40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E7=8C=AB?= Date: Fri, 4 Sep 2026 02:26:32 +0800 Subject: [PATCH] task(740424d0-c60b-47bd-a601-ded7a24bf7a6): Add M5 survey point data module (2.9/2.14/2.15 + manual entry) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补偿提交:commitOnArchive 连续第三轮未落盘(M3/M4 同款故障),由主控在子会话工作树直接补交。 M5 采集·测点数据模块四功能点: - M5-01 (2.9) PointApi/PointModels/PointRepository 归一提取 + SurveyGroupPointSource 平台测点选择器(pointId 贯通 payload)+ 测组监测点列表页(TaskDetail 入口) - M5-02 (2.14) + M5-03 (2.15) 采集页上下文信息卡(并发拉取、失败降级不阻塞) - M5-04 手动录入:buildManual 同构 payload 入同一条 Room 上传队列 (entrySource=MANUAL),复核确认 + 已入队反馈 - upload_queue entry_source 列迁移 v1→v2(ALTER TABLE 非破坏迁移) --- .../com/stec/cmd/feature/collect/BleModule.kt | 4 +- .../stec/cmd/feature/collect/CollectModels.kt | 13 + .../feature/collect/CollectPayloadBuilder.kt | 37 +++ .../cmd/feature/collect/CollectViewModel.kt | 113 ++++++- .../collect/CollectWorkspaceFragment.kt | 145 +++++++++ .../feature/collect/ManualEntryFragment.kt | 275 ++++++++++++++++++ .../feature/collect/ManualEntryViewModel.kt | 151 ++++++++++ .../feature/collect/MeasurementPointSource.kt | 37 ++- .../cmd/feature/collect/PointRepository.kt | 92 ++++++ .../feature/collect/SurveyPointListAdapter.kt | 111 +++++++ .../collect/SurveyPointListFragment.kt | 103 +++++++ .../collect/SurveyPointListViewModel.kt | 54 ++++ .../cmd/feature/task/TaskDetailFragment.kt | 13 + app/src/main/res/layout/fragment_collect.xml | 162 ++++++++++- .../main/res/layout/fragment_manual_entry.xml | 171 +++++++++++ .../res/layout/fragment_survey_point_list.xml | 80 +++++ .../main/res/layout/fragment_task_detail.xml | 10 + app/src/main/res/layout/item_survey_point.xml | 51 ++++ app/src/main/res/values/strings.xml | 60 +++- .../com/stec/cmd/core/database/AppDatabase.kt | 2 +- .../stec/cmd/core/database/DatabaseModule.kt | 13 +- .../cmd/core/database/UploadQueueEntity.kt | 13 + .../stec/cmd/core/network/NetworkModule.kt | 6 + .../com/stec/cmd/core/network/api/PointApi.kt | 55 ++++ .../stec/cmd/core/network/api/PointModels.kt | 102 +++++++ 25 files changed, 1839 insertions(+), 34 deletions(-) create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/ManualEntryFragment.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/ManualEntryViewModel.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/PointRepository.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/SurveyPointListAdapter.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/SurveyPointListFragment.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/collect/SurveyPointListViewModel.kt create mode 100644 app/src/main/res/layout/fragment_manual_entry.xml create mode 100644 app/src/main/res/layout/fragment_survey_point_list.xml create mode 100644 app/src/main/res/layout/item_survey_point.xml create mode 100644 core/network/src/main/kotlin/com/stec/cmd/core/network/api/PointApi.kt create mode 100644 core/network/src/main/kotlin/com/stec/cmd/core/network/api/PointModels.kt diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/BleModule.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/BleModule.kt index f2a1ad8..bf04b28 100644 --- a/app/src/main/kotlin/com/stec/cmd/feature/collect/BleModule.kt +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/BleModule.kt @@ -28,7 +28,7 @@ annotation class FakeBleDriverImpl * 采集·蓝牙 Hilt 装配: * - [BleDriver] 单例实为 [SwitchableBleDriver](演示/系统按开关路由); * - 限定符定义在 app 层,core:ble 保持零 DI 依赖; - * - 测点源默认绑定本地手动实现(M5 平台测点源交付后在此替换绑定)。 + * - 测点源绑定平台实现(M5-01 按任务测组拉 2.9,无任务回退本地手动测点)。 */ @Module @InstallIn(SingletonComponent::class) @@ -63,5 +63,5 @@ abstract class BleBindModule { @Binds @Singleton - abstract fun bindPointSource(impl: ManualPointSource): MeasurementPointSource + abstract fun bindPointSource(impl: SurveyGroupPointSource): MeasurementPointSource } diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectModels.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectModels.kt index 0a477f6..0d364e8 100644 --- a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectModels.kt +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectModels.kt @@ -2,7 +2,9 @@ package com.stec.cmd.feature.collect import androidx.annotation.StringRes import com.stec.cmd.core.ble.ScannedDevice +import com.stec.cmd.core.network.api.ControlPointItem import com.stec.cmd.core.network.api.TaskItem +import com.stec.cmd.core.network.api.WorkPointItem import com.stec.cmd.protocol.ie1000.Ie1000Reading import com.stec.cmd.protocol.vm208.Vm208Quality import com.stec.cmd.protocol.vm208.Vm208Reading @@ -48,6 +50,17 @@ data class CollectTarget( val pointId: String = "", ) +/** + * 采集上下文信息卡数据(M5-02/M5-03):当前项目的基准点与工点, + * 仅作采集参考信息展示,拉取失败时卡片降级、不阻塞采集主动线。 + */ +data class CollectContextUi( + /** 基准点清单(2.14 ControlPointList,水平位移等计算的基准)。 */ + val controlPoints: List = emptyList(), + /** 工点清单(2.15 WorkPointList,工点/测项结构)。 */ + val workPoints: List = emptyList(), +) + /** 采集会话状态:Idle → Preparing(连接中)→ Running(就绪采集中)→ Idle。 */ sealed interface CollectSessionState { data object Idle : CollectSessionState diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectPayloadBuilder.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectPayloadBuilder.kt index 2bb9828..7e5dc79 100644 --- a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectPayloadBuilder.kt +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectPayloadBuilder.kt @@ -3,6 +3,7 @@ package com.stec.cmd.feature.collect import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter +import com.stec.cmd.core.network.api.TaskItem import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -56,6 +57,42 @@ object CollectPayloadBuilder { is CollectSample.Vm208 -> sample.frequency.toString() } + /** + * M5-04 手动录入/补录 payload:五必填字段与蓝牙路径完全一致 + * (ProjectID/PointID/Value/MonitorDate/MonitoringPlanID),设备字段以 + * MANUAL/手动录入 标识(M6 上传通道按平台约定精化时可丢弃扩展键)。 + * [monitorDateText] 须为 [MONITOR_DATE_FORMAT] 口径(UI 默认当前时间可改)。 + */ + fun buildManual( + pointName: String, + pointId: String, + value: String, + monitorDateText: String, + task: TaskItem?, + projectId: String?, + ): String { + val root: JsonObject = buildJsonObject { + put("ProjectID", projectId.orEmpty()) + put("PointID", pointId) + put("Value", value) + put("MonitorDate", monitorDateText) + put("MonitoringPlanID", task?.PlanID.orEmpty()) + // —— 扩展现场字段(与蓝牙路径同键名,来源标识 MANUAL)—— + put("DeviceType", SOURCE_MANUAL) + put("DeviceName", SOURCE_MANUAL_LABEL) + put("PointName", pointName) + task?.TaskCode?.let { put("TaskCode", it) } + put("Extra", buildJsonObject { put("source", SOURCE_MANUAL) }) + } + return root.toString() + } + + /** 手动录入来源标识(DeviceType/Extra.source)。 */ + const val SOURCE_MANUAL = "MANUAL" + + /** 手动录入设备名占位(DeviceName/队列 deviceId)。 */ + const val SOURCE_MANUAL_LABEL = "手动录入" + private fun extraJson(sample: CollectSample): JsonObject = when (sample) { is CollectSample.Ie1000 -> buildJsonObject { put("temperature", sample.temperature.toString()) diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectViewModel.kt index 3c442d6..1598077 100644 --- a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectViewModel.kt +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectViewModel.kt @@ -18,6 +18,7 @@ import com.stec.cmd.session.SessionRepository import com.stec.cmd.session.SessionState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -31,7 +32,9 @@ import javax.inject.Inject * 采集工作台(M4-01/02/08 交互编排 + 会话/连接/数据流转发)。 * * 门控沿用 M3:SignedIn 且三要素就绪才可见扫描区;会话离开 SignedIn 复位; - * selectedProject 变化复位任务列表并重拉(当前监测计划任务 2.7)。 + * selectedProject 变化复位任务列表并重拉(当前监测计划任务 2.7), + * 并并发刷新采集上下文(基准点 2.14 / 工点 2.15,M5-02/03)。 + * 选定任务按测组拉平台测点(2.9,M5-01),平台测点 ID 贯通上传 payload。 */ @HiltViewModel class CollectViewModel @Inject constructor( @@ -39,6 +42,7 @@ class CollectViewModel @Inject constructor( private val sessionManager: CollectSessionManager, private val taskRepository: TaskRepository, private val pointSource: MeasurementPointSource, + private val pointRepository: PointRepository, projectSession: ProjectSession, sessionRepository: SessionRepository, private val configRepository: ConfigRepository, @@ -87,9 +91,22 @@ class CollectViewModel @Inject constructor( val points: StateFlow> = pointSource.points + /** 平台测点拉取中(2.9 按任务测组)。 */ + private val _pointsLoading = MutableStateFlow(false) + val pointsLoading: StateFlow = _pointsLoading.asStateFlow() + private val _selectedPoint = MutableStateFlow("") val selectedPoint: StateFlow = _selectedPoint.asStateFlow() + /** 当前选中测点的平台 ID(手动文本为空串,上传 PointID 占位)。 */ + private val _selectedPointId = MutableStateFlow("") + val selectedPointId: StateFlow = _selectedPointId.asStateFlow() + + // ---- M5-02/03 采集上下文(基准点/工点)---- + + private val _contextInfo = MutableStateFlow>(UiState.Idle) + val contextInfo: StateFlow> = _contextInfo.asStateFlow() + // ---- 会话/连接/数据(CollectSessionManager 转发)---- val collectSession = sessionManager.session @@ -112,7 +129,10 @@ class CollectViewModel @Inject constructor( if (state is SessionState.SignedIn) { if (configRepository.snapshotStateFlow.value.isReady && _tasks.value is UiState.Idle - ) refreshTasks() + ) { + refreshTasks() + refreshContext() + } } else { resetSessionUi() } @@ -123,15 +143,23 @@ class CollectViewModel @Inject constructor( if (snapshot.isReady && sessionState.value is SessionState.SignedIn && _tasks.value is UiState.Idle - ) refreshTasks() + ) { + refreshTasks() + refreshContext() + } } } viewModelScope.launch { selectedProject.collect { _tasks.value = UiState.Idle _selectedTask.value = null + _selectedPoint.value = "" + _selectedPointId.value = "" if (it != null && sessionState.value is SessionState.SignedIn) { refreshTasks() + refreshContext() + } else { + _contextInfo.value = UiState.Idle } } } @@ -215,16 +243,72 @@ class CollectViewModel @Inject constructor( fun selectTask(task: TaskItem?) { _selectedTask.value = task + _selectedPoint.value = "" + _selectedPointId.value = "" + refreshPoints(task) } - /** 手动录入测点进本地源(历史留痕,M5 平台测点源交付后由其替换)。 */ + /** + * M5-01:选定任务按测组拉平台测点(2.9)。无 GroupID 不拉取; + * 失败仅提示,选择任务动线不被阻塞(测点可手输)。 + */ + private fun refreshPoints(task: TaskItem?) { + if (task?.GroupID.isNullOrBlank()) return + if (_pointsLoading.value) return + viewModelScope.launch { + _pointsLoading.value = true + try { + pointSource.pointsOf(task) + } catch (e: ApiError) { + _events.tryEmit( + CollectEvent.Message(R.string.collect_points_load_failed_fmt, listOf(e.userMessage())), + ) + } finally { + _pointsLoading.value = false + } + } + } + + /** 从平台测点对话框选定测点:点名回填输入框,平台 ID 随会话贯通。 */ + fun pickPoint(point: MeasurementPoint) { + _selectedPoint.value = point.name + _selectedPointId.value = point.id + } + + /** 手输测点(或输入框文本变化):按名称匹配平台测点取 ID,匹配不到按手动测点占位。 */ fun selectPoint(name: String) { - _selectedPoint.value = name.trim() + val trimmed = name.trim() + _selectedPoint.value = trimmed + _selectedPointId.value = pointSource.points.value + .firstOrNull { it.name == trimmed }?.id.orEmpty() + } + + /** M5-02/03:并发拉取当前项目基准点(2.14)与工点(2.15),失败仅降级上下文卡。 */ + fun refreshContext() { + val projectId = selectedProject.value?.ID + if (projectId.isNullOrBlank()) { + _contextInfo.value = UiState.Idle + return + } + if (_contextInfo.value is UiState.Loading) return + viewModelScope.launch { + _contextInfo.value = UiState.Loading + _contextInfo.value = try { + val control = async { pointRepository.controlPoints(projectId) } + val work = async { pointRepository.workPoints(projectId) } + UiState.Success(CollectContextUi(controlPoints = control.await(), workPoints = work.await())) + } catch (e: ApiError) { + UiState.Error(e.userMessage()) + } + } } // ---- 会话 ---- - /** 开始采集(M4-08):须已选设备且填写测点;测点录入本地源留痕。 */ + /** + * 开始采集(M4-08):须已选设备且填写测点;平台测点带 ID 贯通(M5-01), + * 非平台点仅本地留痕(历史行为)。 + */ fun startCollect(pointInput: String) { val device = _selectedDevice.value ?: run { _events.tryEmit(CollectEvent.Message(R.string.collect_need_device)) @@ -236,8 +320,18 @@ class CollectViewModel @Inject constructor( return } selectPoint(point) - viewModelScope.launch { pointSource.record(point) } - sessionManager.start(device, CollectTarget(task = _selectedTask.value, pointName = point)) + val isPlatformPoint = pointSource.points.value.any { it.name == point } + if (!isPlatformPoint) { + viewModelScope.launch { pointSource.record(point) } + } + sessionManager.start( + device, + CollectTarget( + task = _selectedTask.value, + pointName = point, + pointId = _selectedPointId.value, + ), + ) } fun stopCollect() = sessionManager.stop() @@ -265,6 +359,9 @@ class CollectViewModel @Inject constructor( _selectedDevice.value = null _tasks.value = UiState.Idle _selectedTask.value = null + _selectedPoint.value = "" + _selectedPointId.value = "" + _contextInfo.value = UiState.Idle if (collectSession.value !is CollectSessionState.Idle) stopCollect() } diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectWorkspaceFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectWorkspaceFragment.kt index 2f0ca67..2c0b3a2 100644 --- a/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectWorkspaceFragment.kt +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/CollectWorkspaceFragment.kt @@ -79,6 +79,15 @@ class CollectWorkspaceFragment : Fragment() { if (binding.switchAutoReconnect.isPressed) viewModel.setAutoReconnect(checked) } binding.btnPickTask.setOnClickListener { showTaskPicker() } + binding.btnPickPoint.setOnClickListener { showPointPicker() } + binding.etPoint.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) viewModel.selectPoint(binding.etPoint.text?.toString().orEmpty()) + } + binding.btnControlDetail.setOnClickListener { showControlPointDetails() } + binding.btnWorkpointDetail.setOnClickListener { showWorkPointDetails() } + binding.btnManualEntry.setOnClickListener { + openOnTop(com.stec.cmd.feature.collect.ManualEntryFragment()) + } binding.btnStartCollect.setOnClickListener { viewModel.startCollect(binding.etPoint.text?.toString().orEmpty()) } @@ -106,6 +115,8 @@ class CollectWorkspaceFragment : Fragment() { launch { viewModel.tasks.collect { render() } } launch { viewModel.selectedTask.collect { render() } } launch { viewModel.selectedPoint.collect { render() } } + launch { viewModel.pointsLoading.collect { render() } } + launch { viewModel.contextInfo.collect { render() } } launch { viewModel.collectSession.collect { render() } } launch { viewModel.gattState.collect { render() } } launch { viewModel.latestSample.collect { renderSample(it) } } @@ -152,6 +163,11 @@ class CollectWorkspaceFragment : Fragment() { binding.btnOpenSettings.isVisible = granted == false binding.btnGrantPermission.isVisible = granted != false + // M5-02/03 上下文卡与 M5-04 手动录入入口:门控通过即常驻(与蓝牙权限无关) + binding.cardContext.isVisible = true + binding.cardManual.isVisible = true + renderContext() + val inSession = viewModel.collectSession.value !is CollectSessionState.Idle binding.cardConnect.isVisible = granted == true binding.cardSession.isVisible = granted == true @@ -183,6 +199,7 @@ class CollectWorkspaceFragment : Fragment() { val idle = running is CollectSessionState.Idle binding.btnPickTask.isVisible = idle binding.tilPoint.isVisible = idle + binding.rowPoint.isVisible = idle binding.btnStartCollect.isVisible = idle binding.btnStopCollect.isVisible = !idle if (idle) { @@ -190,6 +207,14 @@ class CollectWorkspaceFragment : Fragment() { binding.btnPickTask.text = task?.let { taskTitle(it) } ?: getString(R.string.collect_pick_task) binding.btnStartCollect.isEnabled = selected != null + binding.btnPickPoint.isEnabled = !viewModel.pointsLoading.value + binding.btnPickPoint.text = getString( + if (viewModel.pointsLoading.value) { + R.string.collect_points_loading + } else { + R.string.collect_pick_point + }, + ) if (binding.etPoint.text.isNullOrBlank() && viewModel.selectedPoint.value.isNotEmpty()) { binding.etPoint.setText(viewModel.selectedPoint.value) } @@ -296,6 +321,124 @@ class CollectWorkspaceFragment : Fragment() { task.WorkPointName?.takeIf { it.isNotBlank() } ?: task.period.orEmpty(), ) + /** M5-02/03 上下文卡:加载/降级/数据三态渲染,任何失败不动采集主动线。 */ + private fun renderContext() { + when (val state = viewModel.contextInfo.value) { + is UiState.Loading -> { + binding.progressContext.isVisible = true + binding.tvContextError.isVisible = false + binding.rowControl.isVisible = false + binding.rowWorkpoint.isVisible = false + } + + is UiState.Error -> { + binding.progressContext.isVisible = false + binding.tvContextError.text = getString( + R.string.collect_context_error_fmt, + state.message, + ) + binding.tvContextError.isVisible = true + binding.rowControl.isVisible = false + binding.rowWorkpoint.isVisible = false + } + + is UiState.Success -> { + binding.progressContext.isVisible = false + binding.tvContextError.isVisible = false + binding.rowControl.isVisible = true + binding.rowWorkpoint.isVisible = true + binding.tvControlSummary.text = getString( + R.string.collect_context_control_count_fmt, + state.data.controlPoints.size, + ) + binding.btnControlDetail.isEnabled = state.data.controlPoints.isNotEmpty() + binding.tvWorkpointSummary.text = getString( + R.string.collect_context_workpoint_count_fmt, + state.data.workPoints.size, + ) + binding.btnWorkpointDetail.isEnabled = state.data.workPoints.isNotEmpty() + } + + else -> { + binding.progressContext.isVisible = false + binding.tvContextError.isVisible = false + binding.rowControl.isVisible = false + binding.rowWorkpoint.isVisible = false + } + } + } + + /** M5-01 平台测点选择对话框(按任务测组 2.9 数据;空列表引导手输)。 */ + private fun showPointPicker() { + val points = viewModel.points.value + if (points.isEmpty()) { + showSnackbar(getString(R.string.collect_point_picker_empty)) + return + } + val subtitle = viewModel.selectedTask.value?.MonitoringType + ?.takeIf { it.isNotBlank() } + ?: getString(R.string.collect_task_not_bound) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.collect_point_dialog_title_fmt, subtitle)) + .setItems(points.map { it.name }.toTypedArray()) { _, which -> + val point = points[which] + viewModel.pickPoint(point) + binding.etPoint.setText(point.name) + } + .show() + } + + /** M5-02 基准点明细对话框(点名/X/Y/H)。 */ + private fun showControlPointDetails() { + val data = (viewModel.contextInfo.value as? UiState.Success)?.data ?: return + if (data.controlPoints.isEmpty()) { + showSnackbar(getString(R.string.collect_context_control_empty)) + return + } + val lines = data.controlPoints.map { + getString( + R.string.collect_control_item_fmt, + it.Point.orDash(), + it.PointX.orDash(), + it.PointY.orDash(), + it.PointZ.orDash(), + ) + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.collect_control_dialog_title) + .setItems(lines.toTypedArray(), null) + .show() + } + + /** M5-03 工点明细对话框(名称/起止时间;当前任务工点置顶标记)。 */ + private fun showWorkPointDetails() { + val data = (viewModel.contextInfo.value as? UiState.Success)?.data ?: return + if (data.workPoints.isEmpty()) { + showSnackbar(getString(R.string.collect_context_workpoint_empty)) + return + } + val currentWorkPointId = viewModel.selectedTask.value?.WorkPointID + val lines = data.workPoints.map { workPoint -> + val text = getString( + R.string.collect_workpoint_item_fmt, + workPoint.Name.orDash(), + workPoint.StartDate.orDash(), + workPoint.EndDate.orDash(), + ) + if (currentWorkPointId != null && workPoint.ID == currentWorkPointId) { + getString(R.string.collect_workpoint_current_mark_fmt, text) + } else { + text + } + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.collect_workpoint_dialog_title) + .setItems(lines.toTypedArray(), null) + .show() + } + + private fun String?.orDash(): String = this?.takeIf { it.isNotBlank() } ?: "-" + private fun showEvent(event: CollectEvent) { val text = when (event) { is CollectEvent.Connected -> @@ -330,6 +473,8 @@ class CollectWorkspaceFragment : Fragment() { binding.cardSession.isVisible = false binding.cardData.isVisible = false binding.cardExcitation.isVisible = false + binding.cardContext.isVisible = false + binding.cardManual.isVisible = false if (action == GateAction.NONE) return binding.tvGateHint.setText( when (action) { diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/ManualEntryFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/ManualEntryFragment.kt new file mode 100644 index 0000000..ee9d232 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/ManualEntryFragment.kt @@ -0,0 +1,275 @@ +package com.stec.cmd.feature.collect + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.stec.cmd.R +import com.stec.cmd.core.common.UiState +import com.stec.cmd.core.network.api.TaskItem +import com.stec.cmd.databinding.FragmentManualEntryBinding +import com.stec.cmd.feature.mine.ConfigFragment +import com.stec.cmd.feature.mine.LoginFragment +import com.stec.cmd.session.SessionState +import dagger.hilt.android.AndroidEntryPoint +import java.time.LocalDateTime +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +/** + * 手动录入/补录(M5-04):无法蓝牙采集时人工录入观测值, + * 复核确认后与蓝牙采集同构 payload 入同一条上传队列(entrySource=MANUAL)。 + */ +@AndroidEntryPoint +class ManualEntryFragment : Fragment() { + + private var _binding: FragmentManualEntryBinding? = null + private val binding get() = checkNotNull(_binding) + + private val viewModel: ManualEntryViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentManualEntryBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } + binding.etDate.setText(nowText()) + binding.btnPickTask.setOnClickListener { showTaskPicker() } + binding.btnPickPoint.setOnClickListener { showPointPicker() } + binding.etPoint.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) syncPointId(binding.etPoint.text?.toString().orEmpty()) + } + binding.btnReview.setOnClickListener { reviewAndEnqueue() } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.events.collect { event -> + when (event) { + is ManualEntryEvent.Message -> showSnackbar(event.text) + } + } + } + launch { viewModel.sessionState.collect { render() } } + launch { viewModel.configSnapshot.collect { render() } } + launch { viewModel.selectedProject.collect { render() } } + launch { viewModel.tasks.collect { render() } } + launch { viewModel.selectedTask.collect { render() } } + launch { viewModel.pointsLoading.collect { render() } } + } + } + } + + /** 门控渲染 + 表单态;门控通过且任务未加载时自动拉 2.7 任务列表。 */ + private fun render() = with(viewModel) { + when { + sessionState.value is SessionState.Restoring -> showGate(GateAction.NONE) + sessionState.value is SessionState.SignedOut -> showGate(GateAction.LOGIN) + !configSnapshot.value.isReady -> showGate(GateAction.CONFIG) + selectedProject.value == null -> showGate(GateAction.PROJECT) + else -> { + binding.cardGate.isVisible = false + binding.cardForm.isVisible = true + if (tasks.value is UiState.Idle) refreshTasks() + } + } + val task = selectedTask.value + binding.btnPickTask.text = task?.let { taskTitle(it) } + ?: getString(R.string.manual_entry_pick_task) + binding.btnPickPoint.isEnabled = !pointsLoading.value + binding.btnPickPoint.text = getString( + if (pointsLoading.value) R.string.collect_points_loading else R.string.manual_entry_pick_point, + ) + } + + /** 门控卡:与采集页动线一致(登录/配置/去首页选项目)。 */ + private fun showGate(action: GateAction) { + binding.cardGate.isVisible = action != GateAction.NONE + binding.cardForm.isVisible = false + if (action == GateAction.NONE) return + binding.tvGateHint.setText( + when (action) { + GateAction.LOGIN -> R.string.collect_gate_signed_out + GateAction.CONFIG -> R.string.collect_gate_no_config + else -> R.string.collect_gate_no_project + }, + ) + binding.btnGateAction.setText( + when (action) { + GateAction.LOGIN -> R.string.collect_gate_go_login + GateAction.CONFIG -> R.string.collect_gate_go_config + else -> R.string.collect_gate_go_home + }, + ) + binding.btnGateAction.setOnClickListener { + when (action) { + GateAction.LOGIN -> openOnTop(LoginFragment()) + GateAction.CONFIG -> openOnTop(ConfigFragment()) + else -> { + requireActivity() + .findViewById(R.id.bottom_nav) + .selectedItemId = R.id.nav_home + } + } + } + } + + /** 任务选择对话框(2.7 计划任务 + 不绑定选项)。 */ + private fun showTaskPicker() { + val state = viewModel.tasks.value + if (state is UiState.Error) { + viewModel.refreshTasks() + return + } + val tasks = (state as? UiState.Success)?.data.orEmpty() + val titles = tasks.map(::taskTitle) + getString(R.string.manual_entry_task_none) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.manual_entry_pick_task) + .setItems(titles.toTypedArray()) { _, which -> + viewModel.selectTask(tasks.getOrNull(which)) + } + .show() + } + + /** 平台测点选择对话框(按任务测组 2.9 数据;空列表引导手输)。 */ + private fun showPointPicker() { + val points = viewModel.points.value + if (points.isEmpty()) { + showSnackbar(getString(R.string.collect_point_picker_empty)) + return + } + val subtitle = viewModel.selectedTask.value?.MonitoringType + ?.takeIf { it.isNotBlank() } + ?: getString(R.string.collect_task_not_bound) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.collect_point_dialog_title_fmt, subtitle)) + .setItems(points.map { it.name }.toTypedArray()) { _, which -> + val point = points[which] + binding.etPoint.setText(point.name) + syncPointId(point.name) + } + .show() + } + + /** 手输/选择测点后同步平台 ID(点名为平台点时带 ID 入 payload)。 */ + private fun syncPointId(name: String) { + val trimmed = name.trim() + viewModel.points.value + .firstOrNull { it.name == trimmed } + ?.let { point -> viewModel.pickPoint(point) } + } + + /** 格式校验 → 复核对话框 → 确认入队。 */ + private fun reviewAndEnqueue() { + val pointName = binding.etPoint.text?.toString()?.trim().orEmpty() + if (pointName.isEmpty()) { + showSnackbar(getString(R.string.manual_entry_need_point)) + return + } + val value = binding.etValue.text?.toString()?.trim().orEmpty() + if (value.isEmpty()) { + showSnackbar(getString(R.string.manual_entry_need_value)) + return + } + value.toDoubleOrNull() ?: run { + showSnackbar(getString(R.string.manual_entry_value_invalid)) + return + } + val dateText = binding.etDate.text?.toString()?.trim().orEmpty() + if (dateText.isEmpty()) { + showSnackbar(getString(R.string.manual_entry_need_date)) + return + } + runCatching { LocalDateTime.parse(dateText, CollectPayloadBuilder.MONITOR_DATE_FORMAT) } + .onFailure { + showSnackbar(getString(R.string.manual_entry_date_invalid)) + return + } + + val taskText = viewModel.selectedTask.value?.let { taskTitle(it) } + ?: getString(R.string.manual_entry_task_none) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.manual_entry_review_title) + .setMessage( + getString(R.string.manual_entry_review_fmt, taskText, pointName, value, dateText), + ) + .setPositiveButton(R.string.manual_entry_review_confirm) { _, _ -> + enqueue(pointName, value, dateText) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun enqueue(pointName: String, value: String, dateText: String) { + val pointId = viewModel.points.value + .firstOrNull { it.name == pointName }?.id.orEmpty() + viewLifecycleOwner.lifecycleScope.launch { + val queued = viewModel.enqueue( + pointName = pointName, + pointId = pointId, + value = value, + monitorDateText = dateText, + ) + if (queued) { + showSnackbar(getString(R.string.manual_entry_queued)) + binding.etValue.setText("") + binding.etDate.setText(nowText()) + } else { + showSnackbar(getString(R.string.manual_entry_queued_failed)) + } + } + } + + private fun nowText(): String = + LocalDateTime.now().format(CollectPayloadBuilder.MONITOR_DATE_FORMAT) + + private fun taskTitle(task: TaskItem): String = getString( + R.string.collect_task_fmt, + task.TaskCode?.takeIf { it.isNotBlank() } ?: task.SimpleName.orEmpty(), + task.MonitoringType.orEmpty(), + task.WorkPointName?.takeIf { it.isNotBlank() } ?: task.period.orEmpty(), + ) + + private fun showSnackbar(text: String) { + Snackbar.make(binding.root, text, Snackbar.LENGTH_SHORT).show() + } + + /** 子页跳转(登录/配置):add + hide + addToBackStack(壳层同款模式)。 */ + private fun openOnTop(fragment: Fragment) { + parentFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.fragment_container, fragment, "manual_entry_sub") + hide(this@ManualEntryFragment) + addToBackStack(null) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + /** 门控动作类型。 */ + private enum class GateAction { + NONE, + LOGIN, + CONFIG, + PROJECT, + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/ManualEntryViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/ManualEntryViewModel.kt new file mode 100644 index 0000000..9900e94 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/ManualEntryViewModel.kt @@ -0,0 +1,151 @@ +package com.stec.cmd.feature.collect + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.stec.cmd.core.common.UiState +import com.stec.cmd.core.database.UploadEntrySource +import com.stec.cmd.core.database.UploadQueueDao +import com.stec.cmd.core.database.UploadQueueEntity +import com.stec.cmd.core.database.UploadStatus +import com.stec.cmd.core.network.ApiError +import com.stec.cmd.core.network.api.TaskItem +import com.stec.cmd.feature.mine.userMessage +import com.stec.cmd.feature.task.TaskRepository +import com.stec.cmd.session.ProjectSession +import com.stec.cmd.session.SessionRepository +import com.stec.cmd.session.SessionState +import com.stec.cmd.config.ConfigRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * 手动录入/补录(M5-04):表单数据编排 + 与蓝牙采集同队列入队。 + * + * 任务列表复用 2.7(采集页同源);测点经 [MeasurementPointSource](选任务按 + * 测组拉 2.9,无任务手输);入队 payload 与蓝牙路径同构(CollectPayloadBuilder), + * entrySource=MANUAL 与 BLE 共存一条 upload_queue,M6 上传通道统一消费。 + */ +@HiltViewModel +class ManualEntryViewModel @Inject constructor( + private val taskRepository: TaskRepository, + private val pointSource: MeasurementPointSource, + private val uploadQueueDao: UploadQueueDao, + private val projectSession: ProjectSession, + sessionRepository: SessionRepository, + private val configRepository: ConfigRepository, +) : ViewModel() { + + /** 会话状态流(Restoring/SignedOut/SignedIn 门控)。 */ + val sessionState = sessionRepository.state + + /** 连接配置快照流(三要素缺失时引导配置)。 */ + val configSnapshot = configRepository.snapshotStateFlow + + /** 当前选中项目(页面门控与 payload 归属)。 */ + val selectedProject = projectSession.selectedProject + + private val _tasks = MutableStateFlow>>(UiState.Idle) + val tasks: StateFlow>> = _tasks.asStateFlow() + + private val _selectedTask = MutableStateFlow(null) + val selectedTask: StateFlow = _selectedTask.asStateFlow() + + val points: StateFlow> = pointSource.points + + private val _pointsLoading = MutableStateFlow(false) + val pointsLoading: StateFlow = _pointsLoading.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 8) + val events: SharedFlow = _events.asSharedFlow() + + private var taskJob: Job? = null + + /** 拉取当前项目计划任务(2.7,采集页同源);项目/会话就绪时由 Fragment 触发。 */ + fun refreshTasks() { + if (_tasks.value is UiState.Loading) return + taskJob?.cancel() + taskJob = viewModelScope.launch { + _tasks.value = UiState.Loading + _tasks.value = try { + UiState.Success(taskRepository.planTasks(selectedProject.value?.ID)) + } catch (e: ApiError) { + UiState.Error(e.userMessage()) + } + } + } + + /** 选定任务:清空测点选择并按测组拉平台测点(2.9);失败仅提示不阻塞手输。 */ + fun selectTask(task: TaskItem?) { + _selectedTask.value = task + viewModelScope.launch { + _pointsLoading.value = true + try { + pointSource.pointsOf(task) + } catch (e: ApiError) { + _events.tryEmit(ManualEntryEvent.Message(e.userMessage())) + } finally { + _pointsLoading.value = false + } + } + } + + /** + * 复核确认后入队(UI 已做格式校验):构造与蓝牙采集同构 payload, + * entrySource=MANUAL 入同一条 Room 上传队列(pending,M6 通道消费)。 + * @return 入队是否成功 + */ + suspend fun enqueue( + pointName: String, + pointId: String, + value: String, + monitorDateText: String, + ): Boolean = withContext(Dispatchers.IO) { + try { + val payload = CollectPayloadBuilder.buildManual( + pointName = pointName, + pointId = pointId, + value = value, + monitorDateText = monitorDateText, + task = _selectedTask.value, + projectId = projectSession.selectedProject.value?.ID, + ) + uploadQueueDao.enqueue( + UploadQueueEntity( + deviceType = CollectPayloadBuilder.SOURCE_MANUAL, + deviceId = CollectPayloadBuilder.SOURCE_MANUAL_LABEL, + dataType = _selectedTask.value?.MonitoringType?.takeIf { it.isNotBlank() } + ?: DATA_TYPE_MANUAL, + payloadJson = payload, + status = UploadStatus.PENDING, + createdAt = System.currentTimeMillis(), + entrySource = UploadEntrySource.MANUAL, + ), + ) + true + } catch (e: Exception) { + false + } + } + + private companion object { + /** 无任务绑定时队列 dataType 占位。 */ + const val DATA_TYPE_MANUAL = "手动录入" + } +} + +/** 手动录入页一次性事件。 */ +sealed interface ManualEntryEvent { + + /** 提示文本(如测点拉取失败但仍可手输)。 */ + data class Message(val text: String) : ManualEntryEvent +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/MeasurementPointSource.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/MeasurementPointSource.kt index f2d38e8..dee338a 100644 --- a/app/src/main/kotlin/com/stec/cmd/feature/collect/MeasurementPointSource.kt +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/MeasurementPointSource.kt @@ -7,39 +7,56 @@ import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton -/** 测点候选:[id] 为平台测点 ID(M5 交付前手动测点为空串)。 */ +/** 测点候选:[id] 为平台测点 ID(手动录入测点为空串,上传 PointID 占位)。 */ data class MeasurementPoint( val id: String, val name: String, ) /** - * 测点源抽象(M4-08 预留接口)。 + * 测点源抽象(M4-08 预留接口,M5 回填平台实现)。 * - * M4 交付 [ManualPointSource] 本地手动源;M5 子任务回填真实平台测点源 - * (按任务测组拉取 GetPointBySurveyGroup)后经 Hilt 替换绑定,采集会话零改动。 + * 采集会话零改动:UI 下拉/对话框读 [points],选任务经 [pointsOf] 按测组刷新, + * 自由文本经 [record] 留痕。 */ interface MeasurementPointSource { - /** 可用测点流(UI 下拉/历史展示;M5 实现按任务测组实时刷新)。 */ + /** 可用测点流(最近一次 [pointsOf] 的平台测组测点,或本地手动测点)。 */ val points: StateFlow> - /** 拉取 [task] 测组可用测点;task 为 null 时返回本地手动测点。 */ + /** 拉取 [task] 测组可用测点;task 为 null 或无 GroupID 时返回本地手动测点。 */ suspend fun pointsOf(task: TaskItem?): List /** 手动录入测点;空名/重复返回既有项或 null。 */ suspend fun record(name: String): MeasurementPoint? } -/** 本地手动测点源(会话内存态,应用进程内保留历史录入)。 */ +/** + * 平台测点源(M5-01):选任务按测组拉 2.9 监测点列表,平台测点 ID 贯通 + * CollectTarget.pointId → 上传 payload PointID;无任务/无 GroupID/拉取失败时 + * 保留本地手动测点(承接原 ManualPointSource 行为,动线不断)。 + */ @Singleton -class ManualPointSource @Inject constructor() : MeasurementPointSource { +class SurveyGroupPointSource @Inject constructor( + private val pointRepository: PointRepository, +) : MeasurementPointSource { + private val platform = MutableStateFlow>(emptyList()) private val manual = MutableStateFlow>(emptyList()) - override val points: StateFlow> = manual.asStateFlow() + override val points: StateFlow> = platform.asStateFlow() - override suspend fun pointsOf(task: TaskItem?): List = manual.value + override suspend fun pointsOf(task: TaskItem?): List { + val groupId = task?.GroupID?.takeIf { it.isNotBlank() } ?: run { + platform.value = emptyList() + return manual.value + } + val fetched = pointRepository.pointsBySurveyGroup(groupId).map { item -> + MeasurementPoint(id = item.ID.orEmpty(), name = item.displayName.orEmpty()) + }.filter { it.name.isNotEmpty() } + platform.value = fetched + return fetched + } override suspend fun record(name: String): MeasurementPoint? { val trimmed = name.trim() diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/PointRepository.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/PointRepository.kt new file mode 100644 index 0000000..cf28a1b --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/PointRepository.kt @@ -0,0 +1,92 @@ +package com.stec.cmd.feature.collect + +import com.stec.cmd.core.network.ApiCaller +import com.stec.cmd.core.network.ApiError +import com.stec.cmd.core.network.api.ControlPointItem +import com.stec.cmd.core.network.api.MonitorPointItem +import com.stec.cmd.core.network.api.PointApi +import com.stec.cmd.core.network.api.WorkPointItem +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 测点数据仓储(M5-01/02/03,2.9 GetPointBySurveyGroup / 2.14 ControlPoint / 2.15 GetWorkPoint)。 + * + * 契约宽容:data 形态以各列表归一提取函数统一处理—— + * 兼容文档口径的 `{"XxxList":[…]}` 对象、平铺数组、以及二次 JSON 编码字符串 + * (ProjectRepository.projectListFromData / TaskRepository.taskListFromData 先例, + * 缺键时取对象内首个 JsonArray 兜底,防御键名大小写漂移)。 + */ +@Singleton +class PointRepository @Inject constructor( + private val pointApi: PointApi, + private val apiCaller: ApiCaller, + private val json: Json, +) { + + /** 2.9 测组监测点列表;[groupId] 必填(任务 GroupID)。 */ + suspend fun pointsBySurveyGroup(groupId: String): List = + listFromData(apiCaller.call { pointApi.getPointBySurveyGroup(groupId) }, KEY_POINT_LIST) { + ListSerializer(MonitorPointItem.serializer()) + } + + /** 2.14 项目基准点列表;[projectId] 必填。 */ + suspend fun controlPoints(projectId: String): List = + listFromData(apiCaller.call { pointApi.getControlPoints(projectId) }, KEY_CONTROL_POINT_LIST) { + ListSerializer(ControlPointItem.serializer()) + } + + /** 2.15 工点列表;[projectId]/[workPointId] 传 null 时返回本人全部进行中工点。 */ + suspend fun workPoints(projectId: String? = null, workPointId: String? = null): List = + listFromData( + apiCaller.call { pointApi.getWorkPoints(projectId, workPointId) }, + KEY_WORK_POINT_LIST, + ) { + ListSerializer(WorkPointItem.serializer()) + } + + /** + * data → 列表归一提取(TaskRepository.taskListFromData 同款宽容链): + * - JsonObject:优先取 [key](文档口径);缺键取首个 JsonArray 值兜底; + * - JsonArray:文档口径的平铺数组; + * - JsonPrimitive:二次 JSON 编码的列表字符串。 + * 结构无法识别或字段不符时以 [ApiError.EmptyBodyError] 抛出。 + */ + private inline fun listFromData( + data: JsonElement, + key: String, + serializer: () -> ListSerializer, + ): List { + val payload = when (data) { + is JsonObject -> + data[key] + ?: data.values.filterIsInstance().firstOrNull() + ?: throw ApiError.EmptyBodyError("测点数据结构异常") + else -> data + } + return try { + when (payload) { + is JsonArray -> json.decodeFromJsonElement(serializer(), payload) + is JsonPrimitive -> json.decodeFromString(serializer(), payload.content) + else -> throw ApiError.EmptyBodyError("测点数据结构异常") + } + } catch (e: SerializationException) { + throw ApiError.EmptyBodyError("测点数据解析失败:${e.message}") + } + } + + private companion object { + /** 文档 2.9/2.14/2.15:data 内列表键名(确切大小写)。 */ + const val KEY_POINT_LIST = "PointList" + const val KEY_CONTROL_POINT_LIST = "ControlPointList" + const val KEY_WORK_POINT_LIST = "WorkPointList" + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/SurveyPointListAdapter.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/SurveyPointListAdapter.kt new file mode 100644 index 0000000..0069043 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/SurveyPointListAdapter.kt @@ -0,0 +1,111 @@ +package com.stec.cmd.feature.collect + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.stec.cmd.R +import com.stec.cmd.core.network.api.MonitorPointItem +import com.stec.cmd.databinding.ItemSurveyPointBinding + +/** + * 测组监测点列表(M5-01,2.9 PointList):点名/简称 + 初值/上次观测 + + * 报警值 + 观测时间/轴力类型;无值字段以「-」占位。 + */ +class SurveyPointListAdapter : + ListAdapter(DIFF) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PointViewHolder = + PointViewHolder( + ItemSurveyPointBinding.inflate(LayoutInflater.from(parent.context), parent, false), + ) + + override fun onBindViewHolder(holder: PointViewHolder, position: Int) = + holder.bind(getItem(position)) + + inner class PointViewHolder( + private val binding: ItemSurveyPointBinding, + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: MonitorPointItem) { + val context = binding.root.context + binding.tvPointTitle.text = context.getString( + R.string.survey_point_item_title_fmt, + item.PointName.orDash(context), + item.PointSimpleName.orDash(context), + ) + binding.tvPointMeta.text = buildString { + append( + context.getString( + R.string.survey_point_initial_fmt, + item.initialSummary(context), + ), + ) + append("\n") + append( + context.getString( + R.string.survey_point_last_fmt, + item.lastMonitorSummary(context), + ), + ) + } + binding.tvPointMonitor.text = buildString { + append( + context.getString( + R.string.survey_point_monitor_fmt, + item.MonitorDate.orDash(context), + ), + ) + item.AxiaType?.takeIf { it.isNotBlank() }?.let { + append(" · ") + append(context.getString(R.string.survey_point_axia_fmt, it)) + } + } + binding.tvPointAlarm.isVisible = + !item.RateAlarm.isNullOrBlank() || !item.TotalAlarm.isNullOrBlank() + binding.tvPointAlarm.text = context.getString( + R.string.survey_point_alarm_fmt, + item.RateAlarm.orDash(context), + item.TotalAlarm.orDash(context), + ) + } + } + + private companion object { + val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: MonitorPointItem, newItem: MonitorPointItem) = + oldItem.ID == newItem.ID && oldItem.PointName == newItem.PointName + + override fun areContentsTheSame(oldItem: MonitorPointItem, newItem: MonitorPointItem) = + oldItem == newItem + } + } +} + +/** 空值占位。 */ +private fun String?.orDash(context: android.content.Context): String = + this?.takeIf { it.isNotBlank() } ?: context.getString(R.string.collect_point_value_none) + +/** 初值列表摘要:多条以「Attribute=值」拼接。 */ +private fun MonitorPointItem.initialSummary(context: android.content.Context): String = + InitialValueData.takeIf { it.isNotEmpty() } + ?.joinToString(" / ") { value -> + listOfNotNull( + value.Attribute, + value.valueText ?: context.getString(R.string.collect_point_value_none), + ).joinToString("=") + } + ?: context.getString(R.string.collect_point_value_none) + +/** 上次观值列表摘要(LastMonitorData/Date 归一访问器)。 */ +private fun MonitorPointItem.lastMonitorSummary(context: android.content.Context): String = + lastMonitorValues.takeIf { it.isNotEmpty() } + ?.joinToString(" / ") { value -> + listOfNotNull( + value.Attribute, + value.valueText ?: context.getString(R.string.collect_point_value_none), + ).joinToString("=") + } + ?: context.getString(R.string.collect_point_value_none) diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/SurveyPointListFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/SurveyPointListFragment.kt new file mode 100644 index 0000000..1e9945a --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/SurveyPointListFragment.kt @@ -0,0 +1,103 @@ +package com.stec.cmd.feature.collect + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import com.stec.cmd.R +import com.stec.cmd.core.common.UiState +import com.stec.cmd.core.network.api.MonitorPointItem +import com.stec.cmd.core.network.api.TaskItem +import com.stec.cmd.databinding.FragmentSurveyPointListBinding +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +/** + * 测组监测点列表页(M5-01,2.9):任务详情(M3-03)「查看测点」入口的落地页。 + * + * 传参沿用 TaskDetailFragment 的 arguments JSON 先例;无独立门控卡—— + * 入口均在任务/采集页门控之后,拉取失败以错误态+重试呈现。 + */ +@AndroidEntryPoint +class SurveyPointListFragment : Fragment() { + + private var _binding: FragmentSurveyPointListBinding? = null + private val binding get() = checkNotNull(_binding) + + private val viewModel: SurveyPointListViewModel by viewModels() + + private val adapter by lazy { SurveyPointListAdapter() } + + /** 传参解析失败按空任务处理(本端自编码 JSON,正常不触发)。 */ + private val task: TaskItem? by lazy { + arguments?.getString(ARG_TASK_JSON)?.let { raw -> + runCatching { JSON.decodeFromString(TaskItem.serializer(), raw) }.getOrNull() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentSurveyPointListBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } + binding.toolbar.title = getString( + R.string.survey_point_title_fmt, + task?.MonitoringType?.takeIf { it.isNotBlank() } ?: "-", + ) + binding.recyclerPoints.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerPoints.adapter = adapter + binding.btnRetry.setOnClickListener { viewModel.refresh() } + + viewModel.bind(task) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.points.collect { render(it) } + } + } + } + + private fun render(state: UiState>) { + binding.progressPoints.isVisible = state is UiState.Loading + binding.boxError.isVisible = state is UiState.Error + if (state is UiState.Error) { + binding.tvError.text = getString(R.string.survey_point_error_fmt, state.message) + } + val points = (state as? UiState.Success)?.data.orEmpty() + binding.tvEmpty.isVisible = state is UiState.Success && points.isEmpty() + binding.recyclerPoints.isVisible = points.isNotEmpty() + adapter.submitList(points) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val ARG_TASK_JSON = "task_json" + + private val JSON = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + fun newInstance(taskJson: String): SurveyPointListFragment = + SurveyPointListFragment().apply { + arguments = Bundle().apply { putString(ARG_TASK_JSON, taskJson) } + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/collect/SurveyPointListViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/collect/SurveyPointListViewModel.kt new file mode 100644 index 0000000..e30ed18 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/collect/SurveyPointListViewModel.kt @@ -0,0 +1,54 @@ +package com.stec.cmd.feature.collect + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.stec.cmd.core.common.UiState +import com.stec.cmd.core.network.ApiError +import com.stec.cmd.core.network.api.MonitorPointItem +import com.stec.cmd.core.network.api.TaskItem +import com.stec.cmd.feature.mine.userMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * 测组监测点列表(M5-01):按任务测组拉取 2.9 监测点列表。 + * 任务经 [bind] 注入(TaskDetailFragment newInstance(taskJson) 先例)。 + */ +@HiltViewModel +class SurveyPointListViewModel @Inject constructor( + private val pointRepository: PointRepository, +) : ViewModel() { + + private val _points = MutableStateFlow>>(UiState.Idle) + val points: StateFlow>> = _points.asStateFlow() + + private var groupId: String? = null + + /** 绑定任务并立即拉取;GroupID 缺失时直接落错误态(入口侧已隐藏,防御兜底)。 */ + fun bind(task: TaskItem?) { + groupId = task?.GroupID?.takeIf { it.isNotBlank() } + refresh() + } + + /** 拉取/重试测点列表。 */ + fun refresh() { + val id = groupId + if (id == null) { + _points.value = UiState.Error("任务缺少测组 ID,无法获取监测点") + return + } + if (_points.value is UiState.Loading) return + viewModelScope.launch { + _points.value = UiState.Loading + _points.value = try { + UiState.Success(pointRepository.pointsBySurveyGroup(id)) + } catch (e: ApiError) { + UiState.Error(e.userMessage()) + } + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/task/TaskDetailFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskDetailFragment.kt index d4af28e..0d92b3b 100644 --- a/app/src/main/kotlin/com/stec/cmd/feature/task/TaskDetailFragment.kt +++ b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskDetailFragment.kt @@ -6,9 +6,11 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.commit import com.stec.cmd.R import com.stec.cmd.core.network.api.TaskItem import com.stec.cmd.databinding.FragmentTaskDetailBinding +import com.stec.cmd.feature.collect.SurveyPointListFragment import kotlinx.serialization.json.Json /** @@ -42,6 +44,17 @@ class TaskDetailFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding.toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } render() + // M5-01:有测组 ID 的任务可跳转测组监测点列表(2.9) + binding.btnViewPoints.isVisible = task?.GroupID?.isNotBlank() == true + binding.btnViewPoints.setOnClickListener { + val raw = arguments?.getString(ARG_TASK_JSON) ?: return@setOnClickListener + parentFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.fragment_container, SurveyPointListFragment.newInstance(raw)) + hide(this@TaskDetailFragment) + addToBackStack(null) + } + } } private fun render() { diff --git a/app/src/main/res/layout/fragment_collect.xml b/app/src/main/res/layout/fragment_collect.xml index 8f2d841..7bf8861 100644 --- a/app/src/main/res/layout/fragment_collect.xml +++ b/app/src/main/res/layout/fragment_collect.xml @@ -234,6 +234,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + - + android:gravity="center_vertical" + android:orientation="horizontal"> - - + android:layout_weight="1" + android:hint="@string/collect_point_hint"> + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_manual_entry.xml b/app/src/main/res/layout/fragment_manual_entry.xml new file mode 100644 index 0000000..7fb0277 --- /dev/null +++ b/app/src/main/res/layout/fragment_manual_entry.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_survey_point_list.xml b/app/src/main/res/layout/fragment_survey_point_list.xml new file mode 100644 index 0000000..a3c2e4c --- /dev/null +++ b/app/src/main/res/layout/fragment_survey_point_list.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_task_detail.xml b/app/src/main/res/layout/fragment_task_detail.xml index 42d80df..01d0ce1 100644 --- a/app/src/main/res/layout/fragment_task_detail.xml +++ b/app/src/main/res/layout/fragment_task_detail.xml @@ -305,6 +305,16 @@ app:indicatorColor="?attr/colorPrimary" app:trackCornerRadius="4dp" app:trackThickness="8dp" /> + + + diff --git a/app/src/main/res/layout/item_survey_point.xml b/app/src/main/res/layout/item_survey_point.xml new file mode 100644 index 0000000..4349bb5 --- /dev/null +++ b/app/src/main/res/layout/item_survey_point.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d64edcb..7a44509 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -165,7 +165,7 @@ 不绑定任务 %1$s · %2$s · %3$s 测点 - 输入或选择测点名(M5 平台测点源待接入) + 输入或选择测点名 开始采集 停止采集 请先选择设备并连接 @@ -194,4 +194,62 @@ 第 %1$d 次重连(约 %2$d 秒后重试) 扫描失败(code=%1$d) 未绑定任务 + + + 选择 + 选择测点(%1$s) + 当前任务暂无平台测点,可直接输入点名 + 测点加载中… + 测点拉取失败:%1$s + - + + + 采集上下文 + 基准点(水平位移计算基准) + 工点 + 查看明细 + 基准点 %1$d 个(水平位移计算基准) + 工点 %1$d 个 + 上下文数据获取失败:%1$s(不影响采集) + 基准点明细 + %1$s | X:%2$s | Y:%3$s | H:%4$s + 工点列表 + %1$s(%2$s ~ %3$s) + ▶ %1$s(当前任务工点) + + + 手动录入 / 补录 + 无法蓝牙采集时人工录入,与蓝牙采集数据走同一条上传队列 + 手动录入 / 补录 + 选择任务 + 不绑定任务 + 选择测点 + 输入或选择测点名 + 本次观测值(数值) + 监测时间(yyyy-MM-dd HH:mm:ss) + 复核并入队 + 录入复核 + 任务:%1$s\n测点:%2$s\n数值:%3$s\n监测时间:%4$s\n\n确认后进入上传队列(待上传) + 确认入队 + 已入队待上传 + 入队失败,请重试 + 请先选择或输入测点名 + 请输入数值 + 数值格式不正确 + 请输入监测时间 + 时间格式应为 yyyy-MM-dd HH:mm:ss + 测点拉取失败:%1$s,可直接输入点名 + + + 测组测点(%1$s) + 该测组暂无监测点 + 加载失败:%1$s + 重试 + 查看测点 + %1$s(%2$s) + 初值:%1$s + 上次观测:%1$s + 上次观测时间:%1$s + 报警:速率 %1$s | 累计 %2$s + 轴力类型:%1$s diff --git a/core/database/src/main/kotlin/com/stec/cmd/core/database/AppDatabase.kt b/core/database/src/main/kotlin/com/stec/cmd/core/database/AppDatabase.kt index a891329..0ea2604 100644 --- a/core/database/src/main/kotlin/com/stec/cmd/core/database/AppDatabase.kt +++ b/core/database/src/main/kotlin/com/stec/cmd/core/database/AppDatabase.kt @@ -14,7 +14,7 @@ import androidx.room.RoomDatabase entities = [ UploadQueueEntity::class, ], - version = 1, + version = 2, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { diff --git a/core/database/src/main/kotlin/com/stec/cmd/core/database/DatabaseModule.kt b/core/database/src/main/kotlin/com/stec/cmd/core/database/DatabaseModule.kt index 326c546..3ffc1e1 100644 --- a/core/database/src/main/kotlin/com/stec/cmd/core/database/DatabaseModule.kt +++ b/core/database/src/main/kotlin/com/stec/cmd/core/database/DatabaseModule.kt @@ -2,6 +2,8 @@ package com.stec.cmd.core.database import android.content.Context import androidx.room.Room +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,6 +16,15 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object DatabaseModule { + /** v1→v2:upload_queue 加 entry_source 列(M5-04 手动录入来源标识),保数据非破坏迁移。 */ + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE upload_queue ADD COLUMN entry_source TEXT NOT NULL DEFAULT 'BLE'", + ) + } + } + @Provides @Singleton fun provideAppDatabase( @@ -22,7 +33,7 @@ object DatabaseModule { context, AppDatabase::class.java, AppDatabase.DATABASE_NAME, - ).build() + ).addMigrations(MIGRATION_1_2).build() @Provides @Singleton diff --git a/core/database/src/main/kotlin/com/stec/cmd/core/database/UploadQueueEntity.kt b/core/database/src/main/kotlin/com/stec/cmd/core/database/UploadQueueEntity.kt index c983ddc..d601ad1 100644 --- a/core/database/src/main/kotlin/com/stec/cmd/core/database/UploadQueueEntity.kt +++ b/core/database/src/main/kotlin/com/stec/cmd/core/database/UploadQueueEntity.kt @@ -49,6 +49,10 @@ data class UploadQueueEntity( /** 上传成功时间(epoch 毫秒),未上传为 null。 */ @ColumnInfo(name = "uploaded_at") val uploadedAt: Long? = null, + + /** 入队来源:BLE 蓝牙采集 / MANUAL 手动录入(见 [UploadEntrySource],M5-04)。 */ + @ColumnInfo(name = "entry_source", defaultValue = UploadEntrySource.BLE) + val entrySource: String = UploadEntrySource.BLE, ) /** 队列状态常量(S0 用 Int 避免 Room TypeConverter 样板,S1 可按需替换为枚举)。 */ @@ -65,3 +69,12 @@ object UploadStatus { /** 失败待重试。 */ const val FAILED = 3 } + +/** 入队来源常量(M5-04:手动录入与蓝牙采集共用一条队列,来源字段区分)。 */ +object UploadEntrySource { + /** 蓝牙采集(M4 动线,历史数据缺省值)。 */ + const val BLE = "BLE" + + /** 手动录入/补录(M5-04,无法蓝牙采集时人工录入)。 */ + const val MANUAL = "MANUAL" +} diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt index 7192c2f..7ed43b8 100644 --- a/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt @@ -1,6 +1,7 @@ package com.stec.cmd.core.network import com.stec.cmd.core.network.api.AuthApi +import com.stec.cmd.core.network.api.PointApi import com.stec.cmd.core.network.api.ProjectApi import com.stec.cmd.core.network.api.TaskApi import dagger.Module @@ -84,6 +85,11 @@ object NetworkModule { @Singleton fun provideTaskApi(retrofit: Retrofit): TaskApi = retrofit.create(TaskApi::class.java) + /** 测点数据接口(M5,2.9/2.14/2.15)。 */ + @Provides + @Singleton + fun providePointApi(retrofit: Retrofit): PointApi = retrofit.create(PointApi::class.java) + /** 仅满足 Retrofit 构造约束;真实地址请求期经 DynamicBaseUrlInterceptor 改写。 */ private const val PLACEHOLDER_BASE_URL = "https://placeholder.stec.invalid/" diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/PointApi.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/PointApi.kt new file mode 100644 index 0000000..c55e6fb --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/PointApi.kt @@ -0,0 +1,55 @@ +package com.stec.cmd.core.network.api + +import com.stec.cmd.core.network.ApiEnvelope +import kotlinx.serialization.json.JsonElement +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * 测点数据接口(接口管理规范 V4.5 · 测试环境 https://jcd.stec.p-q.co): + * - 2.9 获取测组包含的监测点列表(M5-01) + * - 2.14 获取基准点(M5-02,水平位移等计算的基准) + * - 2.15 获取工点数据(M5-03;目录页 2.15 笔误 ControlPoint,以正文为准) + * + * 三要素请求头(SecretKey/SystemCode/Token)由 AuthInterceptor 统一注入。 + * + * 契约宽容:文档约定 data 为 {"PointList"/"ControlPointList"/"WorkPointList":[…]}; + * 与项目/任务列表(任务 M2/M3 先例)同理保留形态偏差可能,故以 [JsonElement] + * 宽容承载,由 app 层 PointRepository 归一提取。 + */ +interface PointApi { + + /** 2.9 测组监测点列表:[groupId] 必填(测组 ID,来自 2.6/2.7 任务 GroupID)。 */ + @GET(PATH_GET_POINT_BY_SURVEY_GROUP) + suspend fun getPointBySurveyGroup( + @Query(QUERY_GROUP_ID) groupId: String, + ): ApiEnvelope + + /** 2.14 项目基准点列表:[projectId] 必填。 */ + @GET(PATH_GET_CONTROL_POINT) + suspend fun getControlPoints( + @Query(QUERY_PROJECT_ID) projectId: String, + ): ApiEnvelope + + /** + * 2.15 工点列表数据:[projectId]/[workPointId] 均非必填,传 null 时 Retrofit + * 省略参数(空 = 本人全部「进行中」项目工点)。 + */ + @GET(PATH_GET_WORK_POINT) + suspend fun getWorkPoints( + @Query(QUERY_PROJECT_ID) projectId: String? = null, + @Query(QUERY_WORK_POINT_ID) workPointId: String? = null, + ): ApiEnvelope + + companion object { + // ---- 接口管理规范 V4.5 · 章节 2.1 所有 API 目录 ---- + const val PATH_GET_POINT_BY_SURVEY_GROUP = "OutWebApi/api/GetPointBySurveyGroup" + const val PATH_GET_CONTROL_POINT = "OutWebApi/api/ControlPoint" + const val PATH_GET_WORK_POINT = "OutWebApi/api/GetWorkPoint" + + /** 查询参数名(2.14/2.15 复用 ProjectID;2.15 双参可空)。 */ + const val QUERY_GROUP_ID = "GroupID" + const val QUERY_PROJECT_ID = "ProjectID" + const val QUERY_WORK_POINT_ID = "WorkPointID" + } +} diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/PointModels.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/PointModels.kt new file mode 100644 index 0000000..136561f --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/PointModels.kt @@ -0,0 +1,102 @@ +package com.stec.cmd.core.network.api + +import kotlinx.serialization.Serializable + +/** + * 测点数据 DTO(接口管理规范 V4.5 · 2.9/2.14/2.15 返回值示例)。 + * + * 字段名与平台契约一致(PascalCase);文档未给类型,全 `String?` 容错, + * 响应侧配合 Json.ignoreUnknownKeys 容忍字段演进。 + */ + +/** + * 2.9 监测点条目(data.PointList)。 + * + * 文档字段拼写歧义:正文属性表列「LastMonitorDate 上次观值列表」, + * 2.9.6.2 属性说明与变更记录均为「LastMonitorData」——两键都声明, + * 由 [lastMonitorValues] 归一,响应侧任一拼写均可承接。 + */ +@Serializable +data class MonitorPointItem( + /** 监测点 ID(上传 MonitorValue.PointID 的取值来源)。 */ + val ID: String? = null, + /** 点名简称。 */ + val PointSimpleName: String? = null, + /** 点名。 */ + val PointName: String? = null, + /** 初值列表(2.9.6.1:测斜=孔深值、轴力=轴力、其他=高程)。 */ + val InitialValueData: List = emptyList(), + /** 速率报警值。 */ + val RateAlarm: String? = null, + /** 累计报警值。 */ + val TotalAlarm: String? = null, + /** 上次观值列表(文档 LastMonitorData/LastMonitorDate 双拼写,见类注释)。 */ + val LastMonitorData: List = emptyList(), + /** 同 [LastMonitorData],文档正文属性表拼写,双键兼容。 */ + val LastMonitorDate: List = emptyList(), + /** 上次观测时间。 */ + val MonitorDate: String? = null, + /** 支撑轴力监测类型(钢支撑/钢筋混凝土支撑 4 种),非轴力测点为空。 */ + val AxiaType: String? = null, +) { + /** 归一上次观值列表:优先 LastMonitorData(2.9.6.2 口径),空则回退 LastMonitorDate。 */ + val lastMonitorValues: List + get() = LastMonitorData.ifEmpty { LastMonitorDate } + + /** 展示点名:PointName 优先,空则回退点名简称。 */ + val displayName: String? + get() = PointName?.takeIf { it.isNotBlank() } ?: PointSimpleName?.takeIf { it.isNotBlank() } +} + +/** + * 2.9 初值/上次观值条目(InitialValueData 与 LastMonitorData 共用一模型): + * - InitialValueData:[Attribute]=孔深值/轴力/高程,[initialValue]=初值; + * - LastMonitorData:[Attribute]=孔深值/传感器编号/高程,[lastMonitorValue]=上次观测值。 + */ +@Serializable +data class PointValueItem( + /** 属性(含义随监测类型,见类注释)。 */ + val Attribute: String? = null, + /** 初值(InitialValueData)。 */ + val InitialValue: String? = null, + /** 上次观测值(LastMonitorData)。 */ + val LastMonitorValue: String? = null, +) { + /** 取本条数值文本:初值优先,其次上次观测值。 */ + val valueText: String? + get() = InitialValue?.takeIf { it.isNotBlank() } ?: LastMonitorValue?.takeIf { it.isNotBlank() } +} + +/** 2.14 基准点条目(data.ControlPointList):水平位移等计算的基准坐标。 */ +@Serializable +data class ControlPointItem( + /** 点名。 */ + val Point: String? = null, + /** X 值。 */ + val PointX: String? = null, + /** Y 值。 */ + val PointY: String? = null, + /** H 值。 */ + val PointZ: String? = null, +) + +/** 2.15 工点条目(data.WorkPointList):现场负责人本人的「进行中」工点。 */ +@Serializable +data class WorkPointItem( + /** 工点 ID。 */ + val ID: String? = null, + /** 工点名称。 */ + val Name: String? = null, + /** 项目 ID。 */ + val ProjectID: String? = null, + /** 项目全称。 */ + val ProjectName: String? = null, + /** 经度。 */ + val Lon: String? = null, + /** 纬度(文档字段名即为 Dim)。 */ + val Dim: String? = null, + /** 开工时间(yyyy-MM-dd)。 */ + val StartDate: String? = null, + /** 结束时间(yyyy-MM-dd)。 */ + val EndDate: String? = null, +)