feat(app): 集成V3货柜管理功能

- 将应用包名从com.shuwei.intelligent.shelves迁移至com.sw.scalefusion.shelf
- 更新AndroidManifest.xml中的Activity声明路径以匹配新包名
- 在InitActivity中同步更新包名引用及依赖导入
- 修改GridLayoutTool工具类适配V3货柜布局排序逻辑
- 新增ApiResponse和BaseRepository统一网络响应处理
- 添加V3 API服务定义支持毛菜柜/净菜柜功能
- 新增HomeV3Activity和ShelfV3Activity实现新版货柜界面
- 集成API文档定义的货柜初始化、入柜、同步重量等核心功能
- 添加SlotModel等数据模型支持V3货柜操作
- 更新ProtocolConstants增加设备类型映射配置
- 重构网络管理层实现V2/V3接口实例分离管理
This commit is contained in:
2026-06-24 17:39:44 +08:00
parent 8aaba07cb6
commit a78eaf7233
27 changed files with 2028 additions and 324 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ android {
compileSdk = 34
defaultConfig {
applicationId = "com.shuwei.intelligent.shelves"
applicationId = "com.sw.scalefusion.shelf"
minSdk = 25
//noinspection EditedTargetSdkVersion,ExpiredTargetSdkVersion
targetSdk = 30
+5 -1
View File
@@ -26,7 +26,7 @@
android:theme="@style/Theme.IntelligentShelves"
android:usesCleartextTraffic="true">
<activity
android:name=".activity.InitActivity"
android:name="com.sw.scalefusion.shelf.InitActivity"
android:exported="true"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity">
@@ -46,6 +46,10 @@
<!-- <activity android:name="com.shuwei.intelligent.shelves.activity.SettingActivity" />-->
<activity android:name="com.shuwei.intelligent.shelves.activity.LogActivity" />
<activity android:name="com.shuwei.intelligent.shelves.activity.HomeV2Activity" />
<activity android:name="com.sw.scalefusion.shelf.HomeV3Activity" />
<activity android:name="com.sw.scalefusion.shelf.ShelfV3Activity"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name="com.shuwei.intelligent.shelves.activity.ShelfV2Activity" />
<receiver
@@ -1,321 +0,0 @@
package com.shuwei.intelligent.shelves.activity
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
import android.view.animation.RotateAnimation
import androidx.activity.viewModels
import androidx.core.graphics.toColorInt
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.GlobalData
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityInitBinding
import com.shuwei.intelligent.shelves.model.DeviceConfigInfo
import com.shuwei.intelligent.shelves.net.NetViewModel
import com.shuwei.intelligent.shelves.net.UiState
import com.shuwei.intelligent.shelves.net.UrlConfig
import com.shuwei.intelligent.shelves.utils.AppUtil
import com.shuwei.intelligent.shelves.utils.NetworkUtils
import com.shuwei.intelligent.shelves.utils.QRCodeUtil
import com.shuwei.intelligent.shelves.utils.SpTool
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.invisible
import com.shuwei.intelligent.shelves.utils.ext.startActivity
import com.shuwei.intelligent.shelves.utils.ext.toJsonString
import com.shuwei.intelligent.shelves.utils.ext.toObject
import com.shuwei.intelligent.shelves.utils.ext.toast
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlin.collections.forEach
import kotlin.getValue
import kotlin.ranges.downTo
import kotlin.text.isBlank
class InitActivity : BaseActivity() {
companion object {
const val TAG = "InitActivity"
}
private lateinit var binding: ActivityInitBinding
private val viewModel: NetViewModel by viewModels()
private val handler = Handler(Looper.getMainLooper())
private var startTime = 0L // 倒计时开始时间
private var lastNetworkCheckTime = 0L // 上次检测网络的时间
@SuppressLint("HardwareIds")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityInitBinding.inflate(layoutInflater)
setContentView(binding.root)
App.deviceId = AppUtil.getUDID( this)
// App.deviceId = "4787e213-90ab-3e32-88e0-ac271a937751"
Log.d(TAG, "onCreate: deviceId = ${App.deviceId}")
SpTool.put(SpTool.DEVICE_ID, App.deviceId)
val appBaseUrl = SpTool.baseUrl
if (!appBaseUrl.isBlank()) {
GlobalData.appBaseUrl = appBaseUrl
} else {
GlobalData.appBaseUrl = GlobalData.PROD_BASE_URL
}
App.canteenId = "0"
// var deviceId = AppUtil.getUDID(this)
// Log.d(TAG, "onCreate: deviceId=$deviceId")
//// deviceId = "39a7abdd06b3c7ab"
//// deviceId = "3ea47dc0-3cf0-3c2f-909c-265a9a65572e"
// App.deviceId = deviceId
//
// SpTool.put(SpTool.DEVICE_ID, deviceId)
// val deviceConfigCache = SpTool.getString(SpTool.DEVICE_CONFIG_CACHE)
// val checkResult = checkConfigData(deviceConfigCache)
// if (checkResult.not()) {
// binding.ivQrCode.visible()
// binding.btnInit.visible()
// //进行初始化操作
// initConfig()
// return
// }
binding.ivQrCode.invisible()
binding.btnInit.invisible()
// countDown()
initViews()
}
private fun countDown() {
lifecycleScope.launch {
flow {
(2 downTo 1).forEach {
delay(1000)
emit(it)
}
}.collect {
// 倒计时结束执行跳转
if (it == 1) {
startActivity<HomeActivity>()
finish()
}
}
}
}
// private fun initConfig() {
// binding.ivQrCode.setImageBitmap(
// QRCodeUtil.generateQRCode(
// content = App.deviceId,
// size = 200.dp
// )
// )
//// binding.btnInit.setOnClickListener {
//// viewModel.getDeviceToken(App.deviceId)
//// }
// lifecycleScope.launch {
// repeatOnLifecycle(Lifecycle.State.CREATED) {
//// launch {
//// viewModel.getDeviceTokenUiState.collect { state ->
//// when (state) {
//// is UiState.Loading -> {}
//// is UiState.Success<*> -> {
//// state.data.data?.let { deviceToken ->
//// Log.d(TAG, "initConfig: $deviceToken")
//// viewModel.getDeviceConfig(
//// deviceId = App.deviceId,
//// deviceToken = deviceToken.toString()
//// )
//// }
//// }
//// is UiState.Error -> toast(state.msg)
//// else -> {}
//// }
//// }
//// }
//// launch {
//// viewModel.getDeviceConfigUiState.collect { state ->
//// when (state) {
//// is UiState.Loading -> {}
//// is UiState.Success<*> -> {
//// state.data.data?.let {
//// if (it is DeviceConfigInfo) {
//// SpTool.put(SpTool.DEVICE_CONFIG_CACHE, it.toJsonString())
//// GlobalData.appBaseUrl = it.appPackageUrl?:""
//// App.canteenId = it.canteenId?:""
////
//// startActivity<HomeActivity>()
//// finish()
//// }
//// }
//// }
//// is UiState.Error -> toast(message = state.msg)
//// else -> {}
//// }
//// }
//// }
// }
// }
// }
private fun checkConfigData(data: String): Boolean {
if (data.isBlank()) {
return false
}
val config = data.toObject<DeviceConfigInfo?>()
if (config == null) {
return false
}
GlobalData.appBaseUrl = config.appPackageUrl?:""
App.canteenId = config.canteenId?:""
return true
}
/**
* 初始化控件事件
*/
private fun initViews() {
// 连接网络按钮点击事件
binding.btnConnectNetwork.setOnClickListener {
openNetworkSettings()
}
// 启动加载动画 - 持续旋转
startLoadingAnimation()
}
/**
* 启动加载动画 - ImageView 持续旋转
*/
private fun startLoadingAnimation() {
val rotateAnimation = RotateAnimation(
0f, 360f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f
).apply {
duration = 1200 // 旋转周期 1.2 秒
repeatCount = RotateAnimation.INFINITE // 无限循环
repeatMode = RotateAnimation.RESTART
}
binding.ivLoading.startAnimation(rotateAnimation)
}
override fun onResume() {
super.onResume()
if (NetworkUtils.isNetworkConnected(this)) {
navigateToHome()
return
}
startCountdown()
}
override fun onPause() {
super.onPause()
// 页面不可见时停止倒计时
handler.removeCallbacksAndMessages(null)
}
/**
* 启动 60 秒倒计时
*/
private fun startCountdown() {
startTime = SystemClock.elapsedRealtime()
lastNetworkCheckTime = 0
binding.llNetwork.setBackgroundColor(Color.TRANSPARENT)
binding.llLoading.visibility = android.view.View.VISIBLE
binding.llNetworkButton.visibility = android.view.View.GONE
binding.tvCountdown.text = "60秒"
scheduleCountdown()
}
/**
* 定时更新倒计时
* 使用 SystemClock.elapsedRealtime() 确保精确计时
*/
private fun scheduleCountdown() {
handler.postDelayed({
val elapsedTime = SystemClock.elapsedRealtime() - startTime
val remainingTime = 60 - (elapsedTime / 1000).toInt()
val currentNetworkCheckTime = (elapsedTime / 1000).toInt()
// 每 10 秒检测一次网络
if (currentNetworkCheckTime > 0 && currentNetworkCheckTime % 10 == 0 && currentNetworkCheckTime != lastNetworkCheckTime.toInt()) {
lastNetworkCheckTime = currentNetworkCheckTime.toLong()
checkNetworkConnection()
}
// 倒计时未结束,继续更新 UI
if (remainingTime > 0) {
binding.tvCountdown.text = "${remainingTime}"
scheduleCountdown()
} else {
// 倒计时结束,最后检测一次网络
if (NetworkUtils.isNetworkConnected(this)) {
navigateToHome()
} else {
// 网络未连接,显示"连接网络"按钮
showNetworkButton()
}
}
}, 100) // 每 100ms 检查一次,确保精确性
}
/**
* 检测网络连接
*/
private fun checkNetworkConnection() {
if (NetworkUtils.isNetworkConnected(this)) {
// 网络连接成功,停止倒计时并跳转到 HomeActivity
handler.removeCallbacksAndMessages(null)
navigateToHome()
}
}
/**
* 跳转到 HomeActivity
*/
private fun navigateToHome() {
// startActivity<HomeActivity>()
startActivity<HomeV2Activity>()
finish()
}
/**
* 显示"连接网络"按钮
*/
private fun showNetworkButton() {
binding.llNetwork.setBackgroundColor("#5C77F7".toColorInt())
binding.llLoading.visibility = android.view.View.GONE
binding.llNetworkButton.visibility = android.view.View.VISIBLE
}
/**
* 打开系统网络设置页面
*/
private fun openNetworkSettings() {
val intent = Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)
startActivity(intent)
}
/**
* 禁用返回键
*/
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return if (keyCode == KeyEvent.KEYCODE_BACK) {
// 禁用返回键,不执行任何操作
true
} else {
super.onKeyDown(keyCode, event)
}
}
}
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.util.Log
import com.shuwei.intelligent.shelves.BuildConfig
import com.shuwei.intelligent.shelves.GlobalData
import com.sw.scalefusion.shelf.net.ApiServiceV3
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
@@ -48,6 +49,14 @@ val apiServiceV2: ApiServiceV2 = Retrofit.Builder()
.build()
.create(ApiServiceV2::class.java)
/** V3 接口专用实例,与 V2 共用 cabinetClient(同款拦截器) */
val apiServiceV3: ApiServiceV3 = Retrofit.Builder()
.baseUrl(GlobalData.appBaseUrl)
.client(HttpManager.instance.cabinetClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiServiceV3::class.java)
class HttpManager private constructor() {
val client: OkHttpClient by lazy {
OkHttpClient.Builder()
@@ -168,6 +168,22 @@ object ProtocolConstants {
"505ce1b1-facc-3eb2-855a-3dadda5ba358"
)
/**
* 设备 cabinetType 映射,用于 V3 API
* 1=毛菜柜,2=净菜柜,默认 2
*/
val deviceCabinetType = mapOf(
// 原410货柜 → 毛菜柜
"7a991439-3a12-3ef7-809b-c0258b839473" to 1,
// 原1楼餐厅货柜 → 净菜柜
"4787e213-90ab-3e32-88e0-ac271a937751" to 2,
// 新设备 → 净菜柜
"d369f9b0-1c5b-3066-ba72-988e1449cecc" to 2,
"505ce1b1-facc-3eb2-855a-3dadda5ba358" to 2,
)
fun cabinetType(): Int = deviceCabinetType[App.deviceId] ?: 2
/** 激活指令mapkey为设备号,value为激活码 */
val ACTIVE_MAP = mutableMapOf(
//原410货柜
@@ -3,7 +3,7 @@ package com.shuwei.intelligent.shelves.utils
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.shuwei.intelligent.shelves.activity.InitActivity
import com.sw.scalefusion.shelf.InitActivity
import kotlin.jvm.java
class BootReceiver : BroadcastReceiver() {
@@ -7,6 +7,7 @@ import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.model.ShelfModel
import com.shuwei.intelligent.shelves.model.ShelfModelV2
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
import com.sw.scalefusion.shelf.model.SlotModel
object GridLayoutTool {
@@ -69,6 +70,20 @@ object GridLayoutTool {
return source.sortedBy { it.slotNo?.toIntOrNull()?:0 }
}
fun initListSortV3(rows: Int, columns: Int, source: List<SlotModel>): List<SlotModel> {
val map = source.associateBy { it.slotNo.toIntOrNull() ?: 0 }
if (rows != 5) return source
return when (columns) {
3 -> ProtocolConstants.device3ColumnsOrder.mapNotNull { map[it] }
2 -> ProtocolConstants.device2ColumnsOrder.mapNotNull { map[it] }
else -> source
}
}
fun submitListSortV3(source: List<SlotModel>): List<SlotModel> {
return source.sortedBy { it.slotNo.toIntOrNull() ?: 0 }
}
/**
* 提交接口list顺序
*/
@@ -0,0 +1,357 @@
package com.sw.scalefusion.shelf
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DefaultItemAnimator
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityHomeV3Binding
import com.shuwei.intelligent.shelves.dialog.CommonDialog
import com.shuwei.intelligent.shelves.model.ClearShelfEvent
import com.shuwei.intelligent.shelves.model.SendWeightEvent
import com.shuwei.intelligent.shelves.net.Loading
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.C_TEMP_CMD
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.START_TEMP_CTRL_CMD
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.utils.GridLayoutTool
import com.shuwei.intelligent.shelves.utils.IntervalExecutor
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.utils.ext.visible
import com.sw.scalefusion.shelf.adapter.ShelfV3Adapter
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.model.SlotRecord
import com.sw.scalefusion.shelf.model.SlotWeight
import com.sw.scalefusion.shelf.model.SyncWeightRequest
import com.sw.scalefusion.shelf.net.NetViewModelV3
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class HomeV3Activity : BaseActivity() {
companion object {}
override val enableRightStatusMenu: Boolean = true
private val list: MutableList<SlotModel> = mutableListOf()
private var shelfIndex = 0
private fun isNeedOpenLock() = App.deviceId == "4787e213-90ab-3e32-88e0-ac271a937751"
private var deviceName = ""
private var cabinetType: Int = 0
private var cabinetId: String = ""
private var canteenId: Long = 0
private lateinit var binding: ActivityHomeV3Binding
private val viewModel: NetViewModelV3 by viewModels()
private fun initScaleManager() {
lifecycleScope.launch {
val ok = ScaleManager
.init(activateCode = ProtocolConstants.ACTIVE_MAP[App.deviceId] ?: "")
.onHeartbeat { _, data ->
data.weights.forEach { weight ->
val shelfNo = weight.index
val pos = list.indexOfFirst {
it.slotNo.filter { c -> c.isDigit() }.toIntOrNull() == shelfNo
}
val model = list.getOrNull(pos) ?: return@forEach
model.weight = weight.grams / 1000.0
shelfAdapter.notifyItemChanged(pos)
log("getWeightInfo: slotNo=${model.slotNo}, realWeight=${weight.grams}, temp=${data.temperature}")
EventBus.getDefault().post(SendWeightEvent(shelfNo, weight.grams))
}
}
.onTempReport { frame ->
log("onTempReport: $frame")
updateLeftStatus(deviceName)
}
.onLog { message -> log(message) }
.start { data ->
lastDataReceivedTime = System.currentTimeMillis()
noDataWarningDialog?.takeIf { it.isShowing }?.dismiss()
log("receiveSerialPortData: $data")
}
log("onCreate: openState=$ok")
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityHomeV3Binding.inflate(layoutInflater)
EventBus.getDefault().register(this)
setBackground()
setContentView(binding.root)
onBackPressedDispatcher.addCallback(this) {}
updateLeftStatus("")
initScaleManager()
binding.root.postDelayed({
sendCmd(START_TEMP_CTRL_CMD)
sendCmd(C_TEMP_CMD)
}, 5000)
saveGoodsTask()
overdueTask()
viewModel.slots(
onLoading = { showProgress() },
onSuccess = { record -> updateUI(record) },
onError = { msg -> showError(msg) }
)
}
private fun showProgress() {
Loading.show(this)
}
@SuppressLint("NotifyDataSetChanged")
private fun updateUI(record: SlotRecord?) {
binding.include.root.gone()
window?.decorView?.postDelayed({ Loading.dismiss() }, 500)
if (record == null) {
loadEmptyView()
return
}
deviceName = record.deviceName
cabinetType = record.cabinetType
cabinetId = record.cabinetId
canteenId = record.canteenId
updateLeftStatus(deviceName)
val slots = record.slots ?: mutableListOf()
if (slots.isEmpty()) {
loadEmptyView()
return
}
// val columns = if (ProtocolConstants.device3Columns.contains(App.deviceId)) 3 else 2
// val rows = if (slots.size % columns == 0) slots.size / columns else 5
val columns = record.verticalCount
val rows = record.horizontalRows
log("updateUI: slots=${slots.size}, rows=$rows, columns=$columns")
list.clear()
list.addAll(GridLayoutTool.initListSortV3(rows = rows, columns = columns, source = slots))
if (binding.rvShelf.adapter == null) {
initRecyclerView(columns)
} else {
shelfAdapter.notifyDataSetChanged()
}
}
private fun showError(message: String) {
Loading.dismiss()
toast(message)
loadEmptyView()
}
private var tipDialog: CommonDialog? = null
private var lastDataReceivedTime = System.currentTimeMillis()
private var noDataWarningDialog: CommonDialog? = null
private val noDataWatchdogTask = object : Runnable {
override fun run() {
if (isFinishing || isDestroyed) return
val elapsed = System.currentTimeMillis() - lastDataReceivedTime
if (elapsed >= 5 * 60 * 1000L) {
if (noDataWarningDialog == null) {
noDataWarningDialog = CommonDialog(this@HomeV3Activity).apply {
dialogTitle = "设备提示"
dialogContent =
"长时间未收到数据,若无法开门或秤重量不更新情况,请考虑断电重启设备"
}
}
if (noDataWarningDialog?.isShowing == false) {
noDataWarningDialog?.show()
}
}
handler.postDelayed(this, 30 * 1000L)
}
}
override fun onUserInactive() {
switchLight(false)
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
tipDialog?.dismiss()
tipDialog = null
noDataWarningDialog?.dismiss()
noDataWarningDialog = null
runBlocking { ScaleManager.release() }
saveTaskJob?.cancel()
overdueTaskJob?.cancel()
EventBus.getDefault().unregister(this)
super.onDestroy()
}
private val shelfAdapter by lazy {
ShelfV3Adapter(list).apply {
setOnItemClickListener { _, _, position ->
onShelfItemClicked(position)
}
}
}
private fun onShelfItemClicked(position: Int) {
shelfIndex = position
lifecycleScope.launch {
if (isNeedOpenLock()) {
val slotNum = list[position].slotNo?.filter { it.isDigit() }?.toIntOrNull() ?: 0
val openCmd = if (slotNum in 1..5)
ScaleManager.buildOpenLock1Cmd()
else
ScaleManager.buildOpenLock2Cmd()
ScaleManager.sendCmd(openCmd)
}
val model = list[position]
launch(Intent(this@HomeV3Activity, ShelfV3Activity::class.java).also {
it.putExtra(ShelfV3Activity.EXTRA_CABINET_TYPE, model.cabinetType)
it.putExtra(ShelfV3Activity.EXTRA_CABINET_ID, model.cabinetId)
it.putExtra(ShelfV3Activity.EXTRA_SLOT_NO, model.slotNo)
it.putExtra(ShelfV3Activity.EXTRA_CANTEEN_ID, canteenId)
}) { result ->
result?.let { onShelfActivityResult(position, it) }
}
}
}
@Suppress("DEPRECATION")
private fun onShelfActivityResult(position: Int, intent: Intent) {
val model = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getSerializableExtra(ShelfV3Activity.SHELF_MODEL, SlotModel::class.java)
} else {
intent.getSerializableExtra(ShelfV3Activity.SHELF_MODEL) as? SlotModel
} ?: return
list[position] = model
shelfAdapter.notifyItemChanged(position)
}
private fun initRecyclerView(columns: Int) {
val layoutManager = GridLayoutTool.getLayoutManagerV2(this, columns = columns)
binding.rvShelf.let {
it.layoutManager = layoutManager
it.adapter = shelfAdapter
it.itemAnimator.let { animator ->
if (animator is DefaultItemAnimator) {
animator.supportsChangeAnimations = false
}
}
}
}
private val handler = Handler(Looper.getMainLooper())
override fun onResume() {
super.onResume()
hideStatusBar()
handler.postDelayed(noDataWatchdogTask, 30 * 1000L)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(noDataWatchdogTask)
}
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
list.clear()
shelfAdapter.notifyDataSetChanged()
binding.include?.let {
it.root.visible()
it.root.setOnClickListener {
viewModel.slots(
onLoading = { showProgress() },
onSuccess = { record -> updateUI(record) },
onError = { msg -> showError(msg) }
)
}
it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_white)
it.tvEmptyContent.setTextColor(Color.WHITE)
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun clearEmptyShelf(event: ClearShelfEvent) {
val index = list.indexOfFirst {
it.slotNo?.filter { c -> c.isDigit() }?.toIntOrNull() == event.shelfNo
}
if (index < 0) return
list[index] = list[index].copy(materName = "")
shelfAdapter.notifyItemChanged(index)
}
private val taskExecutor by lazy { IntervalExecutor() }
private var saveTaskJob: Job? = null
private fun saveGoodsTask() {
saveTaskJob =
taskExecutor.startIntervalTaskWithInitialDelay(10 * 1000L, 2 * 60 * 1000L) {
syncShelfGoodsToServer()
}
}
private var overdueTaskJob: Job? = null
private fun overdueTask() {
// overdueTaskJob = taskExecutor.startIntervalTaskWithInitialDelay(5 * 1000L, 5 * 60 * 1000L) {
// viewModel.slots()
// }
}
// private fun applyOverdueState(items: List<RecordItem>) {
// if (items.size != list.size) return
// items.forEachIndexed { index, newItem ->
// val oldItem = list[index]
// if (oldItem.slotStatus != newItem.slotStatus) {
// list[index] = newItem
// shelfAdapter.notifyItemChanged(index)
// log("更新格口 ${newItem.slotNo} 物料:${newItem.materName} 的状态:${newItem.slotStatus}")
// }
// }
// }
/** 全量同步格口重量 */
private fun syncShelfGoodsToServer() {
if (list.isEmpty()) return
val submitList = GridLayoutTool.submitListSortV3(list)
viewModel.syncWeight(
SyncWeightRequest(
cabinetType = cabinetType,
cabinetId = cabinetId,
slotList = submitList.map {
SlotWeight(slotNo = it.slotNo, weight = it.weight ?: 0.0)
}
)
)
}
}
@@ -0,0 +1,177 @@
package com.sw.scalefusion.shelf
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.animation.RotateAnimation
import androidx.activity.addCallback
import androidx.core.graphics.toColorInt
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.GlobalData
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityInitBinding
import com.shuwei.intelligent.shelves.utils.AppUtil
import com.shuwei.intelligent.shelves.utils.NetworkUtils
import com.shuwei.intelligent.shelves.utils.SpTool
import com.shuwei.intelligent.shelves.utils.ext.invisible
import com.shuwei.intelligent.shelves.utils.ext.startActivity
class InitActivity : BaseActivity() {
companion object {
const val TAG = "InitActivity"
}
private lateinit var binding: ActivityInitBinding
private val handler = Handler(Looper.getMainLooper())
private var startTime = 0L
private var lastNetworkCheckTime = 0L
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityInitBinding.inflate(layoutInflater)
setContentView(binding.root)
App.deviceId = AppUtil.getUDID(this)
// TODO: 测试设备号
App.deviceId = "d369f9b0-1c5b-3066-ba72-988e1449cecc"
// App.deviceId = "505ce1b1-facc-3eb2-855a-3dadda5ba358"
Log.d(TAG, "onCreate: deviceId = ${App.deviceId}")
SpTool.put(SpTool.DEVICE_ID, App.deviceId)
// val appBaseUrl = SpTool.baseUrl
// if (!appBaseUrl.isBlank()) {
// GlobalData.appBaseUrl = appBaseUrl
// } else {
// GlobalData.appBaseUrl = GlobalData.PROD_BASE_URL
// }
GlobalData.appBaseUrl = GlobalData.LOCAL_BASE_URL
App.canteenId = "0"
binding.ivQrCode.invisible()
binding.btnInit.invisible()
initViews()
// 禁用返回键
onBackPressedDispatcher.addCallback(this) {}
}
/**
* 初始化控件事件
*/
private fun initViews() {
binding.btnConnectNetwork.setOnClickListener {
openNetworkSettings()
}
startLoadingAnimation()
}
/**
* 启动加载动画 - ImageView 持续旋转
*/
private fun startLoadingAnimation() {
val rotateAnimation = RotateAnimation(
0f, 360f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f
).apply {
duration = 1200
repeatCount = RotateAnimation.INFINITE
repeatMode = RotateAnimation.RESTART
}
binding.ivLoading.startAnimation(rotateAnimation)
}
override fun onResume() {
super.onResume()
if (NetworkUtils.isNetworkConnected(this)) {
navigateToHome()
return
}
startCountdown()
}
override fun onPause() {
super.onPause()
handler.removeCallbacksAndMessages(null)
}
/**
* 启动 60 秒倒计时
*/
private fun startCountdown() {
startTime = SystemClock.elapsedRealtime()
lastNetworkCheckTime = 0
binding.llNetwork.setBackgroundColor(Color.TRANSPARENT)
binding.llLoading.visibility = android.view.View.VISIBLE
binding.llNetworkButton.visibility = android.view.View.GONE
binding.tvCountdown.text = "60秒"
scheduleCountdown()
}
/**
* 定时更新倒计时
*/
private fun scheduleCountdown() {
handler.postDelayed({
val elapsedTime = SystemClock.elapsedRealtime() - startTime
val remainingTime = 60 - (elapsedTime / 1000).toInt()
val currentNetworkCheckTime = (elapsedTime / 1000).toInt()
if (currentNetworkCheckTime > 0 && currentNetworkCheckTime % 10 == 0 && currentNetworkCheckTime != lastNetworkCheckTime.toInt()) {
lastNetworkCheckTime = currentNetworkCheckTime.toLong()
checkNetworkConnection()
}
if (remainingTime > 0) {
binding.tvCountdown.text = "${remainingTime}"
scheduleCountdown()
} else {
if (NetworkUtils.isNetworkConnected(this)) {
navigateToHome()
} else {
showNetworkButton()
}
}
}, 100)
}
/**
* 检测网络连接
*/
private fun checkNetworkConnection() {
if (NetworkUtils.isNetworkConnected(this)) {
handler.removeCallbacksAndMessages(null)
navigateToHome()
}
}
/**
* 跳转到 HomeV3Activity
*/
private fun navigateToHome() {
startActivity<HomeV3Activity>()
finish()
}
/**
* 显示"连接网络"按钮
*/
private fun showNetworkButton() {
binding.llNetwork.setBackgroundColor("#5C77F7".toColorInt())
binding.llLoading.visibility = android.view.View.GONE
binding.llNetworkButton.visibility = android.view.View.VISIBLE
}
/**
* 打开系统网络设置页面
*/
private fun openNetworkSettings() {
val intent = Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)
startActivity(intent)
}
}
@@ -0,0 +1,320 @@
package com.sw.scalefusion.shelf
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.widget.LinearLayout
import androidx.activity.viewModels
import androidx.core.view.updateLayoutParams
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.scwang.smart.refresh.layout.constant.RefreshState
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityShelfV3Binding
import com.shuwei.intelligent.shelves.model.SendWeightEvent
import com.shuwei.intelligent.shelves.net.Loading
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.utils.KeyboardUtil
import com.shuwei.intelligent.shelves.utils.ext.addOnActionSearchListener
import com.shuwei.intelligent.shelves.utils.ext.clickWithDebounce
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.utils.ext.visible
import com.sw.scalefusion.shelf.adapter.PendingInboundAdapter
import com.sw.scalefusion.shelf.model.PendingInboundItem
import com.sw.scalefusion.shelf.model.PendingInboundRequest
import com.sw.scalefusion.shelf.model.PutSlotRequest
import com.sw.scalefusion.shelf.model.SlotDetailRequest
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.net.NetViewModelV3
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@SuppressLint("NotifyDataSetChanged")
class ShelfV3Activity : BaseActivity() {
companion object {
const val SHELF_MODEL = "shelfModelV3"
const val EXTRA_CABINET_TYPE = "cabinetType"
const val EXTRA_CABINET_ID = "cabinetId"
const val EXTRA_CANTEEN_ID = "canteenId"
const val EXTRA_SLOT_NO = "slotNo"
const val YYYY_MM_DD__EEEE_HH_MM_SS = "yyyy年MM月dd日 EEEE***HH:mm:ss"
}
private lateinit var binding: ActivityShelfV3Binding
private var shelfModel: SlotModel? = null
private var realWeight: Int = 0
private var startTime = 0L
private val viewModel: NetViewModelV3 by viewModels()
private val optionList: MutableList<PendingInboundItem> = mutableListOf()
private val optionAdapter by lazy {
PendingInboundAdapter(optionList).apply {
setOnDebouncedItemClick { _, _, position ->
optionList.forEachIndexed { index, option -> option.isSelected = index == position }
notifyDataSetChanged()
binding.tvFoodName.text = optionList[position].materName
KeyboardUtil.hideKeyboard(this@ShelfV3Activity.window.decorView)
}
}
}
private var cabinetType: Int = 0
private var canteenId: Long = 0
private var cabinetId: String = ""
private var slotNo: String = ""
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityShelfV3Binding.inflate(layoutInflater)
setBackground()
setContentView(binding.root)
EventBus.getDefault().register(this)
cabinetType = intent.getIntExtra(EXTRA_CABINET_TYPE, 0)
canteenId = intent.getLongExtra(EXTRA_CANTEEN_ID, 0)
cabinetId = intent.getStringExtra(EXTRA_CABINET_ID) ?: ""
slotNo = intent.getStringExtra(EXTRA_SLOT_NO) ?: ""
binding.tvShelfName.text = slotNo
initRecyclerView()
initSearchBox()
initButtons()
sendCmd(ScaleManager.buildGetStatusCmd())
viewModel.slotDetail(
request = SlotDetailRequest(cabinetType, cabinetId, slotNo),
onLoading = { showProgress() },
onSuccess = onSuccess@{ model ->
if (model == null) return@onSuccess
shelfModel = model
updateUI(model)
loadList()
},
onError = { msg -> showError(msg) }
)
}
/** 从 slotDetail 响应更新 UI */
private fun updateUI(model: SlotModel) {
binding.tvFoodName.text = if (model.materName.isNullOrBlank()) "-" else model.materName
val weightG = model.weight ?: 0.0
realWeight = (weightG * 1000.0).toInt()
binding.tvFoodWeight.text = if (weightG >= 1.0) "%.3f千克".format(weightG)
else "${realWeight}"
startTime = System.currentTimeMillis()
}
private fun initRecyclerView() {
binding.rvSearch.run {
layoutManager = GridLayoutManager(this@ShelfV3Activity, 2)
adapter = optionAdapter
}
binding.refreshLayout.run {
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { loadList() }
}
}
private fun initSearchBox() {
binding.etInputFood.run {
addOnActionSearchListener {
loadList()
KeyboardUtil.hideKeyboard(context, this)
}
addTextChangedListener(afterTextChanged = { editable ->
if (editable.isNullOrBlank()) loadList()
})
}
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
}
private fun initButtons() {
binding.ivBack.setOnClickListener { finish() }
binding.btnClearZero.setOnClickListener {
Loading.show(this@ShelfV3Activity)
clearZero()
window.decorView.postDelayed({ Loading.dismiss() }, 10000)
}
binding.btnClearEmpty.setOnClickListener { v ->
clearZero()
binding.tvFoodName.text = "-"
Loading.show(this@ShelfV3Activity)
v.postDelayed({
Loading.dismiss()
val emptyModel = shelfModel?.copy(
materName = "",
materId = null,
weight = null,
traceCode = null,
storeTime = null,
inboundNo = null
)
val result = Intent().apply {
putExtra(SHELF_MODEL, emptyModel)
}
setResult(RESULT_OK, result)
finish()
}, 3000)
}
binding.btnConfirm.clickWithDebounce {
val item = optionList.firstOrNull { it.isSelected }
if (item == null) {
toast("请选择食材")
return@clickWithDebounce
}
val model = shelfModel ?: return@clickWithDebounce
viewModel.put(
request = PutSlotRequest(
cabinetType = cabinetType,
cabinetId = cabinetId,
slotNo = slotNo,
materId = item.materId ,
materName = item.materName,
traceCode = item.traceCode,
weight = shelfModel?.weight ?: 0.0
),
onLoading = { showProgress() },
onSuccess = onSuccess@{ inboundNo ->
Loading.dismiss()
// if (inboundNo.isNullOrBlank()) {
// toast("入库失败")
// return@onSuccess
// }
val resultModel = item.toSlotModel(model).also {
// it.inboundNo = inboundNo
}
val result = Intent().apply {
putExtra(SHELF_MODEL, resultModel)
}
setResult(RESULT_OK, result)
finish()
},
onError = { msg -> showError(msg) }
)
}
}
private fun loadList() {
viewModel.pendingInbound(
request = PendingInboundRequest(
cabinetType = shelfModel?.cabinetType ?: 0,
cabinetId = shelfModel?.cabinetId ?: "",
canteenId = canteenId
),
onLoading = { showProgress() },
onSuccess = { items -> updateOptionList(items) },
onError = { msg -> showError(msg) }
)
}
private fun showProgress() {
Loading.show(this)
}
@SuppressLint("NotifyDataSetChanged")
private fun updateOptionList(items: List<PendingInboundItem>?) {
finishRefresh()
binding.include?.root?.gone()
binding.root.postDelayed({ Loading.dismiss() }, 200)
if (items.isNullOrEmpty()) {
loadEmptyView(); return
}
optionList.clear()
optionList.addAll(items)
optionAdapter.notifyDataSetChanged()
}
private fun showError(message: String) {
finishRefresh()
Loading.dismiss()
toast(message)
loadEmptyView()
}
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
optionList.clear()
optionAdapter.notifyDataSetChanged()
binding.include?.let {
it.root.visible()
it.root.setOnClickListener { loadList() }
it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_gray)
it.ivEmptyIcon.updateLayoutParams<LinearLayout.LayoutParams> {
val swDpValue = resources?.configuration?.smallestScreenWidthDp ?: 0
width = if (swDpValue > 600) 200.dp else 120.dp
}
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
}
}
private fun finishRefresh() {
binding.refreshLayout.let {
if (it.state == RefreshState.Refreshing) it.finishRefresh(500)
else if (it.state == RefreshState.Loading) it.finishLoadMore(500)
}
}
private fun clearZero() {
lifecycleScope.launch {
val slotNum = shelfModel?.slotNo?.filter { it.isDigit() }?.toIntOrNull() ?: 0
val zeroClearingCmd = ScaleManager.buildScaleZeroCmd(slotNum)
log("格口${shelfModel?.slotNo}执行清零指令:$zeroClearingCmd")
sendCmd(zeroClearingCmd)
realWeight = 0
sendCmd(ScaleManager.buildGetStatusCmd())
startTime = System.currentTimeMillis()
}
KeyboardUtil.hideKeyboard(window.decorView)
}
override fun onClockTick() {
val sdf = SimpleDateFormat(YYYY_MM_DD__EEEE_HH_MM_SS, Locale.CHINA)
val arr = sdf.format(Date()).split("***")
updateLeftStatus(arr[0])
updateRightStatus(arr[1])
}
override fun onResume() {
super.onResume()
hideStatusBar()
}
override fun onDestroy() {
EventBus.getDefault().unregister(this)
super.onDestroy()
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun receiveWeightEvent(event: SendWeightEvent) {
val slotNum = shelfModel?.slotNo?.filter { it.isDigit() }?.toIntOrNull() ?: return
if (event.shelfNo != slotNum) return
val intervalTime = System.currentTimeMillis() - startTime
val weight = event.weight
log("格口${shelfModel?.slotNo}获取重量:${weight}克,间隔:${intervalTime}ms")
binding.tvFoodWeight.text = if (weight < 1000) "${weight}"
else "%.3f千克".format(weight / 1000.0)
startTime = System.currentTimeMillis()
window.decorView.postDelayed({ Loading.dismiss() }, 1000)
}
}
@@ -0,0 +1,39 @@
package com.sw.scalefusion.shelf.adapter
import android.content.Context
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemSearchBinding
import com.sw.scalefusion.shelf.model.PendingInboundItem
class PendingInboundAdapter(list: MutableList<PendingInboundItem>) :
BaseQuickAdapter<PendingInboundItem, PendingInboundAdapter.VH>(list) {
inner class VH(val binding: ListItemSearchBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemSearchBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: PendingInboundItem?) {
item ?: return
val isSelected = item.isSelected
holder.binding.btnFoodName.run {
text = item.materName
setTextColor(getColor(if (isSelected) R.color.shelf_name_blue else R.color.black999))
strokeColor = ColorStateList.valueOf(
(if (isSelected) "#4969F5" else "#E6E6E6").toColorInt()
)
}
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -0,0 +1,71 @@
package com.sw.scalefusion.shelf.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemShelfV3Binding
import com.sw.scalefusion.shelf.model.SlotModel
import kotlin.math.abs
class ShelfV3Adapter(list: MutableList<SlotModel>) :
BaseQuickAdapter<SlotModel, ShelfV3Adapter.VH>(list) {
inner class VH(val binding: ListItemShelfV3Binding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemShelfV3Binding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: SlotModel?) {
item ?: return
val binding = holder.binding
// 格口编号
binding.tvShelfName.text = item.slotNo.padStart(2, '0')
// // 临期预警(slotStatus=2)显示过期图标
// if (item.slotStatus.toInt() == 2) binding.ivStaleFood.visible()
// else binding.ivStaleFood.gone()
val weightG = item.weight ?: 0.0
binding.tvFoodWeight.run {
setTextColor(getColor(R.color.food_weight_orange))
text = if (abs(weightG) >= 1.0) "%.3f千克".format(weightG)
else "${(weightG * 1000.0).toInt()}"
}
val isBlank = item.materName.isNullOrBlank()
if (isBlank) {
binding.layoutCard.setCardBackgroundColor(getColor(R.color.bg_card_blue))
binding.tvShelfName.setTextColor(getColor(R.color.shelf_name_white))
binding.tvFoodName.run {
setTextColor(getColor(R.color.white))
text = ""
}
binding.tvStoreDate.run {
setTextColor(getColor(R.color.food_weight_blue))
text = "-"
}
return
}
binding.layoutCard.setCardBackgroundColor(getColor(R.color.bg_card_white))
binding.tvShelfName.setTextColor(getColor(R.color.shelf_name_blue))
binding.tvFoodName.run {
setTextColor(getColor(R.color.food_name_black))
text = item.materName
}
binding.tvStoreDate.run {
setTextColor(getColor(R.color.black999))
text = item.storeTime
}
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -0,0 +1,23 @@
package com.sw.scalefusion.shelf.model
data class PendingInboundRequest(
var cabinetType: Int,
var canteenId: Long,
var cabinetId: String
)
data class PutSlotRequest(
val cabinetType: Int,
val cabinetId: String,
val slotNo: String,
val materId: Long,
val materName: String,
val traceCode: String? = null,
val weight: Double
)
data class SlotDetailRequest(
var cabinetType: Int,
var cabinetId: String,
var slotNo: String
)
@@ -0,0 +1,32 @@
package com.sw.scalefusion.shelf.model
import com.shuwei.intelligent.shelves.utils.DateTimeUtil
import java.util.Date
/**
* /pending-inbound 接口响应 — 待入柜物料
*/
data class PendingInboundItem(
val id: Long = 0,
val materId: Long = 0,
val materName: String = "",
val traceCode: String? = null,
val weight: Double = 0.0,
val inboundNo: String = "",
val inboundTime: String? = null,
val vegTypeId:Int,
val vegTypeName:String,
val spec:String,
var isSelected: Boolean = false
) {
/** 转换为 SlotModel,保留原格口的 slotNo/cabinetId 等 */
fun toSlotModel(model: SlotModel) = model.copy(
materId = materId ,
materName = materName,
traceCode = traceCode,
inboundNo = inboundNo,
storeTime = DateTimeUtil.formatDateTime(dateTime = Date()),
weight = weight
)
}
@@ -0,0 +1,22 @@
package com.sw.scalefusion.shelf.model
import java.io.Serializable
/**
* /slots 接口响应模型 — 格口分组数据
*/
data class SlotModel(
val id: String = "",
val slotNo: String = "",
val cabinetId: String,
val cabinetType: Int,
val hasContent: Boolean = false,
var inboundNo: String? = null,
val materId: Long? = null,
val materName: String? = null,
val traceCode: String? = null,
val vegTypeId: String? = null,
//weight单位千克
var weight: Double? = 0.0,
var storeTime: String? = null,
): Serializable
@@ -0,0 +1,12 @@
package com.sw.scalefusion.shelf.model
data class SlotRecord(
val cabinetType: Int,
val cabinetId: String,
val deviceName: String,
val canteenId: Long,
val canteenName: String,
val horizontalRows: Int,
val verticalCount: Int,
val slots: List<SlotModel>? = null
)
@@ -0,0 +1,15 @@
package com.sw.scalefusion.shelf.model
/**
* /sync-weight 接口请求体
*/
data class SyncWeightRequest(
val cabinetType: Int,
val cabinetId: String,
val slotList: List<SlotWeight>
)
data class SlotWeight(
val slotNo: String,
val weight: Double
)
@@ -0,0 +1,9 @@
package com.sw.scalefusion.shelf.net
data class ApiResponse<T>(
val code: String,
val msg: String? = "",
val data: T? = null
) {
fun isSuccess(): Boolean = ("00000" == code)
}
@@ -0,0 +1,37 @@
package com.sw.scalefusion.shelf.net
import com.shuwei.intelligent.shelves.App
import com.sw.scalefusion.shelf.model.PendingInboundItem
import com.sw.scalefusion.shelf.model.PendingInboundRequest
import com.sw.scalefusion.shelf.model.PutSlotRequest
import com.sw.scalefusion.shelf.model.SlotDetailRequest
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.model.SlotRecord
import com.sw.scalefusion.shelf.model.SyncWeightRequest
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
interface ApiServiceV3 {
/** 格口列表 */
@GET("/nutrition/neglect/mater-cabinet/init")
suspend fun slots(@Query("cabinetId") cabinetId: String = App.deviceId): ApiResponse<SlotRecord?>
/** 待入柜列表 */
@POST("/nutrition/neglect/mater-cabinet/pending-inbound")
suspend fun pendingInbound(@Body request: PendingInboundRequest): ApiResponse<List<PendingInboundItem>?>
/** 全量同步重量 */
@POST("/nutrition/neglect/mater-cabinet/sync-weight")
suspend fun syncWeight(@Body request: SyncWeightRequest): ApiResponse<Any?>
/** 格口入柜 */
@POST("/nutrition/neglect/mater-cabinet/put")
suspend fun put(@Body request: PutSlotRequest): ApiResponse<String?>
/** 格口详情 */
@POST("/nutrition/neglect/mater-cabinet/slot-detail")
suspend fun slotDetail(@Body request: SlotDetailRequest): ApiResponse<SlotModel?>
}
@@ -0,0 +1,48 @@
package com.sw.scalefusion.shelf.net
import android.util.Log
import com.google.gson.JsonParseException
import retrofit2.HttpException
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import javax.net.ssl.SSLHandshakeException
abstract class BaseRepository {
suspend fun <T> safeApiCall(apiCall: suspend () -> ApiResponse<T>): ApiResponse<T> {
return try {
apiCall()
} catch (e: Exception) {
Log.e("BaseRepository","safeApiCall Exception: ${e.stackTraceToString()}")
when (e) {
is HttpException -> {
ApiResponse(code = "${e.code()}", msg = e.message())
}
is SocketTimeoutException -> {
ApiResponse(code = "-2", msg = "请求超时: ${e.message}")
}
is ConnectException -> {
ApiResponse(code = "-3", msg = "连接失败: ${e.message}")
}
is SSLHandshakeException -> {
ApiResponse(code = "-4", msg = "SSL握手失败: ${e.message}")
}
is JsonParseException -> {
ApiResponse(code = "-5", msg = "JSON解析错误: ${e.message}")
}
is IOException -> {
ApiResponse(code = "-6", msg = "网络IO错误: ${e.message}")
}
else -> {
ApiResponse(code = "-1", msg = "未知错误: ${e.message ?: "无错误信息"}")
}
}
}
}
}
@@ -0,0 +1,83 @@
package com.sw.scalefusion.shelf.net
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sw.scalefusion.shelf.model.PendingInboundItem
import com.sw.scalefusion.shelf.model.PendingInboundRequest
import com.sw.scalefusion.shelf.model.PutSlotRequest
import com.sw.scalefusion.shelf.model.SlotDetailRequest
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.model.SlotRecord
import com.sw.scalefusion.shelf.model.SyncWeightRequest
import kotlinx.coroutines.launch
class NetViewModelV3 : ViewModel() {
private val repository = ShelfRepository()
/** 格口列表 */
fun slots(
onLoading: () -> Unit = {},
onSuccess: (SlotRecord?) -> Unit,
onError: (String) -> Unit = {}
) {
viewModelScope.launch {
onLoading()
val response = repository.slots()
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
/** 待入柜列表 */
fun pendingInbound(
request: PendingInboundRequest,
onLoading: () -> Unit = {},
onSuccess: (List<PendingInboundItem>?) -> Unit,
onError: (String) -> Unit = {}
) {
viewModelScope.launch {
onLoading()
val response = repository.pendingInbound(request)
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
/** 格口详情 */
fun slotDetail(
request: SlotDetailRequest,
onLoading: () -> Unit = {},
onSuccess: (SlotModel?) -> Unit,
onError: (String) -> Unit = {}
) {
viewModelScope.launch {
onLoading()
val response = repository.slotDetail(request)
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
/** 全量同步重量(无回调,fire-and-forget */
fun syncWeight(request: SyncWeightRequest) {
viewModelScope.launch {
repository.syncWeight(request)
}
}
/** 格口入柜(无回调,fire-and-forget */
fun put(
request: PutSlotRequest,
onLoading: () -> Unit = {},
onSuccess: (String?) -> Unit,
onError: (String) -> Unit = {}
) {
viewModelScope.launch {
onLoading()
val response = repository.put(request)
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
}
@@ -0,0 +1,34 @@
package com.sw.scalefusion.shelf.net
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.net.apiServiceV3
import com.sw.scalefusion.shelf.model.PendingInboundItem
import com.sw.scalefusion.shelf.model.PendingInboundRequest
import com.sw.scalefusion.shelf.model.PutSlotRequest
import com.sw.scalefusion.shelf.model.SlotDetailRequest
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.model.SlotRecord
import com.sw.scalefusion.shelf.model.SyncWeightRequest
class ShelfRepository : BaseRepository() {
/** 格口列表 */
suspend fun slots(cabinetId: String = App.deviceId): ApiResponse<SlotRecord?> =
safeApiCall { apiServiceV3.slots(cabinetId) }
/** 待入柜列表 */
suspend fun pendingInbound(request: PendingInboundRequest): ApiResponse<List<PendingInboundItem>?> =
safeApiCall { apiServiceV3.pendingInbound(request) }
/** 全量同步重量 */
suspend fun syncWeight(request: SyncWeightRequest): ApiResponse<Any?> =
safeApiCall { apiServiceV3.syncWeight(request) }
/** 格口入柜 */
suspend fun put(request: PutSlotRequest): ApiResponse<String?> =
safeApiCall { apiServiceV3.put(request) }
/** 格口详情 */
suspend fun slotDetail(request: SlotDetailRequest): ApiResponse<SlotModel?> =
safeApiCall { apiServiceV3.slotDetail(request) }
}
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:background="@color/bg_page">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvShelf"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
android:overScrollMode="never"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
tools:listitem="@layout/list_item_shelf_v3"/>
<include
android:id="@+id/include"
android:layout_width="match_parent"
android:layout_height="match_parent"
layout="@layout/layout_empty_view"
android:visibility="gone"/>
</FrameLayout>
@@ -0,0 +1,272 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:background="@color/bg_page">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="376dp"
android:layout_marginStart="24dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="24dp"
app:cardBackgroundColor="@color/white"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/tvShelfName"
android:layout_width="wrap_content"
android:layout_height="60dp"
android:layout_marginTop="24dp"
android:fontFamily="sans-serif-medium"
android:gravity="center"
android:textColor="@color/bg_page"
android:textSize="30sp"
tools:text="货架 - 03" />
<Space
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/tvFoodWeight"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/food_weight_orange"
android:textSize="60sp"
android:textStyle="bold"
tools:text="1506克" />
<Space
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/tvFoodName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/food_name_black"
android:textSize="36sp"
android:textStyle="bold"
tools:text="金针菇" />
<Space
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnClearZero"
android:layout_width="120dp"
android:layout_height="60dp"
android:layout_gravity="top|end"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:backgroundTint="@color/bg_page"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:insetTop="0dp"
android:insetBottom="0dp"
android:maxLines="1"
android:text="清零"
android:textColor="@color/white"
android:textSize="30sp"
app:cornerRadius="12dp"
app:elevation="0dp"
tools:ignore="HardcodedText" />
<ImageView
android:id="@+id/ivBack"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_gravity="top|start"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:src="@drawable/ic_back_512"
tools:ignore="ContentDescription" />
</FrameLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="24dp"
app:cardBackgroundColor="@color/white"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- 搜索框 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_marginStart="48dp"
android:layout_marginTop="36dp"
android:layout_marginEnd="48dp"
android:background="@drawable/shape_search"
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:src="@drawable/ic_search_food"
tools:ignore="ContentDescription" />
<EditText
android:id="@+id/etInputFood"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginEnd="24dp"
android:background="@null"
android:fontFamily="sans-serif-medium"
android:hint="输入名称搜索"
android:imeOptions="actionSearch"
android:inputType="text"
android:maxLines="1"
android:paddingStart="1dp"
android:paddingEnd="1dp"
android:text=""
android:textColor="@color/food_name_black"
android:textColorHint="#B4BEC8"
android:textSize="30sp"
tools:ignore="Autofill,HardcodedText,TextFields" />
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="24dp"
android:layout_weight="1">
<com.scwang.smart.refresh.layout.SmartRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.scwang.smart.refresh.header.ClassicsHeader
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSearch"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingStart="36dp"
android:paddingTop="12dp"
android:paddingEnd="36dp"
android:paddingBottom="12dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
tools:itemCount="10"
tools:listitem="@layout/list_item_search" />
<com.scwang.smart.refresh.footer.ClassicsFooter
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.scwang.smart.refresh.layout.SmartRefreshLayout>
<include
android:id="@+id/include"
layout="@layout/layout_empty_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnClearEmpty"
android:layout_width="0dp"
android:layout_height="80dp"
android:layout_marginStart="48dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="24dp"
android:layout_weight="1"
android:backgroundTint="@color/bg_page"
android:ellipsize="end"
android:insetTop="0dp"
android:insetBottom="0dp"
android:maxLines="1"
android:text="清空"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold"
app:cornerRadius="12dp"
app:elevation="0dp"
tools:ignore="HardcodedText" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnConfirm"
android:layout_width="0dp"
android:layout_height="80dp"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="48dp"
android:layout_marginBottom="24dp"
android:layout_weight="1"
android:backgroundTint="@color/bg_page"
android:ellipsize="end"
android:insetTop="0dp"
android:insetBottom="0dp"
android:maxLines="1"
android:text="确定"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold"
app:cornerRadius="12dp"
app:elevation="0dp"
tools:ignore="HardcodedText" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layoutCard"
android:layout_width="match_parent"
android:layout_height="216dp"
android:layout_margin="12dp"
app:cardBackgroundColor="@color/bg_card_blue"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:rippleColor="#B0BEC5">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/tvShelfName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/shelf_name_white"
android:textSize="20sp"
android:textStyle="bold"
tools:text="货架-01" />
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/tvFoodName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold"
tools:text="空" />
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="0.833" />
<TextView
android:id="@+id/tvFoodWeight"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/food_weight_blue"
android:textSize="24sp"
android:textStyle="bold"
tools:text="0千克" />
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="0.933" />
<TextView
android:id="@+id/tvStoreDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/food_weight_blue"
android:textSize="18sp"
tools:text="-" />
</LinearLayout>
<ImageView
android:id="@+id/ivStaleFood"
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@drawable/ic_stale_food_512"
android:layout_gravity="top|end"
tools:ignore="ContentDescription"
android:visibility="gone"/>
</com.google.android.material.card.MaterialCardView>
+76
View File
@@ -0,0 +1,76 @@
// 测试列表转换逻辑
data class TestModel(var deviceId: String = "", val value: Int)
fun testTransform(tempList: List<TestModel>): List<TestModel> {
val list = mutableListOf<TestModel>()
val deviceId = "test-device"
val leftSize = (tempList.size + 1) / 2 // 左列长度(向上取整)
val rightSize = tempList.size / 2 // 右列长度(向下取整)
repeat(rightSize) { index ->
list.add(tempList[index].also { it.deviceId = deviceId })
list.add(tempList[leftSize + index].also { it.deviceId = deviceId })
}
// 处理奇数长度:左列比右列多一个
if (tempList.size % 2 != 0) {
list.add(tempList[rightSize].also { it.deviceId = deviceId })
}
return list
}
fun main() {
// 测试用例 1: 奇数 5 个元素
println("=== 测试 1: 奇数 [1,2,3,4,5] ===")
val test1 = listOf(1, 2, 3, 4, 5).map { TestModel(value = it) }
val result1 = testTransform(test1)
println("输入: ${test1.map { it.value }}")
println("输出: ${result1.map { it.value }}")
println("期望: [1, 4, 2, 5, 3]")
println("匹配: ${result1.map { it.value } == listOf(1, 4, 2, 5, 3)}\n")
// 测试用例 2: 偶数 6 个元素
println("=== 测试 2: 偶数 [1,2,3,4,5,6] ===")
val test2 = listOf(1, 2, 3, 4, 5, 6).map { TestModel(value = it) }
val result2 = testTransform(test2)
println("输入: ${test2.map { it.value }}")
println("输出: ${result2.map { it.value }}")
println("期望: [1, 4, 2, 5, 3, 6]")
println("匹配: ${result2.map { it.value } == listOf(1, 4, 2, 5, 3, 6)}\n")
// 测试用例 3: 偶数 10 个元素(实际业务场景)
println("=== 测试 3: 偶数 [1,2,3,4,5,6,7,8,9,10] ===")
val test3 = (1..10).map { TestModel(value = it) }
val result3 = testTransform(test3)
println("输入: ${test3.map { it.value }}")
println("输出: ${result3.map { it.value }}")
println("期望: [1, 6, 2, 7, 3, 8, 4, 9, 5, 10]")
println("匹配: ${result3.map { it.value } == listOf(1, 6, 2, 7, 3, 8, 4, 9, 5, 10)}\n")
// 测试用例 4: 奇数 7 个元素
println("=== 测试 4: 奇数 [1,2,3,4,5,6,7] ===")
val test4 = (1..7).map { TestModel(value = it) }
val result4 = testTransform(test4)
println("输入: ${test4.map { it.value }}")
println("输出: ${result4.map { it.value }}")
println("期望: [1, 5, 2, 6, 3, 7, 4]")
println("匹配: ${result4.map { it.value } == listOf(1, 5, 2, 6, 3, 7, 4)}\n")
// 测试用例 5: 边界情况 2 个元素
println("=== 测试 5: 边界 [1,2] ===")
val test5 = listOf(1, 2).map { TestModel(value = it) }
val result5 = testTransform(test5)
println("输入: ${test5.map { it.value }}")
println("输出: ${result5.map { it.value }}")
println("期望: [1, 2]")
println("匹配: ${result5.map { it.value } == listOf(1, 2)}\n")
// 测试用例 6: 边界情况 1 个元素
println("=== 测试 6: 边界 [1] ===")
val test6 = listOf(1).map { TestModel(value = it) }
val result6 = testTransform(test6)
println("输入: ${test6.map { it.value }}")
println("输出: ${result6.map { it.value }}")
println("期望: [1]")
println("匹配: ${result6.map { it.value } == listOf(1)}\n")
}
+241
View File
@@ -0,0 +1,241 @@
# 货柜设备端 — API 文档
> Controller: `NutCabinetController`(毛菜柜/净菜柜通用,通过 `cabinetType` 区分)
> 路径前缀: `/neglect/mater-cabinet`Nacos 白名单,无需 Sa-Token
> 日期: 2026-06-24
---
## 一、货柜初始化
设备端首次加载调用,后端从 `nut_terminal.device_type` 推导毛菜/净菜类型,懒创建空置格口,返回柜体元信息 + 全量格口。
```
GET /neglect/mater-cabinet/init?cabinetId=CAB-001
```
**参数**`cabinetId` — 必填,智能柜编号(即 `nut_terminal.device_code`
**响应**
```json
{
"cabinetType": 1, // 1=毛菜柜,2=净菜柜(后端推导)
"cabinetId": "CAB-001",
"deviceName": "毛菜柜-A区",
"canteenId": 100,
"canteenName": "第一食堂",
"horizontalRows": 4,
"verticalCount": 5,
"slots": [
{
"id": 1,
"slotNo": "1",
"cabinetId": "CAB-001",
"cabinetType": 1,
"hasContent": true,
"inboundNo": "IN20260623001",
"materId": 10001,
"materName": "菠菜",
"traceCode": "TC20260601",
"vegTypeId": 10,
"weight": 5.2,
"storeTime": "2026-06-23 10:30:00"
},
{
"id": null,
"slotNo": "2",
"cabinetId": "CAB-001",
"cabinetType": 1,
"hasContent": false,
"weight": null,
"storeTime": null
}
]
}
```
**说明**
- `cabinetType` 由后端根据 `nut_terminal.device_type` 推导(`raw_cabinet`→1 / `clean_cabinet`→2),前端无需传
- `hasContent=true` 时有存放数据,业务字段(inboundNo/materId 等)有值
- `hasContent=false` 时空置,业务字段均为 null
- 格口按 slotNo 数值升序排列
- 每次调用均幂等:格口数不足时自动补建,已足则跳过
---
## 二、格口入柜
```
POST /neglect/mater-cabinet/put
```
**请求体**
```json
{
"cabinetType": 1, // 必填 — 1=毛菜柜,2=净菜柜
"cabinetId": "CAB-001", // 必填 — 智能柜编号
"slotNo": "1", // 必填 — 格口编号
"materId": 10001, // 必填 — 食材id
"materName": "菠菜", // 必填 — 食材名称
"traceCode": "TC20260601", // 选填 — 溯源码(净菜柜用于精确匹配)
"weight": 5.2 // 必填 — 入柜重量(kg)
}
```
**说明**
- 后端按食材+重量匹配对应入库记录(选重量最接近的一条),将其标记为"已入柜"
- 传 traceCode 时先按溯源码精确过滤,再按重量匹配
- 格口必须已由 `/init` 预置,否则报错"格口未初始化"
- 格口已有物品时拒绝入柜
- 格口行 UPDATE,流水表 INSERT
**响应**
```json
{ "code": 200, "data": "IN20260623001", "msg": "操作成功" }
```
---
## 三、格口列表(全部格口)
```
POST /neglect/mater-cabinet/slots
```
**请求体**
```json
{
"cabinetType": 1, // 必填 — 1=毛菜柜,2=净菜柜
"cabinetId": "CAB-001" // 必填 — 智能柜编号
}
```
**响应** — 与 `/init` 返回的 `slots` 字段结构完全相同
```json
[{
"id": 1,
"slotNo": "1",
"cabinetId": "CAB-001",
"cabinetType": 1,
"hasContent": true,
"inboundNo": "IN20260623001",
"materId": 10001,
"materName": "菠菜",
"traceCode": "TC20260601",
"vegTypeId": 10,
"weight": 5.2,
"storeTime": "2026-06-23 10:30:00"
}]
```
---
## 四、待入柜列表
```
POST /neglect/mater-cabinet/pending-inbound
```
**请求体**
```json
{
"cabinetType": 1, // 必填 — 货柜类型
"cabinetId": "CAB-001", // 必填 — 智能柜编号(DTO 校验要求,实际查询用 canteenId)
"canteenId": 100 // 必填 — 食堂id
}
```
**响应**
```json
[{
"id": 1,
"materId": 10001,
"materName": "菠菜",
"traceCode": "TC20260601",
"weight": 10.0,
"inboundNo": "IN20260623001",
"inboundTime": "2026-06-23 09:00:00",
"vegTypeId": 10,
"vegTypeName": "净菜包",
"spec": "500g"
}]
```
---
## 五、格口详情
```
POST /neglect/mater-cabinet/slot-detail
```
**请求体**
```json
{
"cabinetType": 1, // 必填
"cabinetId": "CAB-001", // 必填
"slotNo": "1" // 必填
}
```
**响应** — 与 `/init` 返回的 `slots` 元素结构相同
```json
{
"id": 1,
"slotNo": "1",
"cabinetId": "CAB-001",
"cabinetType": 1,
"hasContent": true,
"inboundNo": "IN20260623001",
"materId": 10001,
"materName": "菠菜",
"traceCode": "TC20260601",
"vegTypeId": 10,
"weight": 5.2,
"storeTime": "2026-06-23 10:30:00"
}
```
---
## 六、全量同步格口重量
```
POST /neglect/mater-cabinet/sync-weight
```
**请求体**
```json
{
"cabinetType": 1, // 必填 — 货柜类型
"cabinetId": "CAB-001", // 必填 — 智能柜编号
"slotList": [
{ "slotNo": "1", "weight": 5.1 },
{ "slotNo": "2", "weight": 3.0 },
{ "slotNo": "3", "weight": 0 }
]
}
```
**说明**
- 终端定时上报整柜各格口当前重量,后端比对变化写流水
- `weight: 0` 表示空格口,空置格口不产生重量变化流水
- 建议上报所有格口(含空格口),不要只报有变化的格口
**响应**
```json
{ "code": 200, "msg": "操作成功" }
```
---
## 七、前端注意事项
1. **调用顺序**:设备端启动后先调 `/init` 获取柜体元信息(行列数、食堂名)+ 全量格口。后续定时刷新格口状态走 `/sync-weight`
2. **`cabinetType`**`/init` **无需传**,后端从 `nut_terminal.device_type` 推导;其余接口仍需传(毛菜柜=1,净菜柜=2)
3. **`deviceType` / `deviceCode`**:由后端自动从请求头 `X-DEVICE-CODE` 填充,前端无需传
4. **sync-weight 的 `slotList`**:上报所有格口(含空格口 weight=0),不要只报有变化的格口
5. **格口编号**:毛菜柜和净菜柜的 slotNo 均为数字字符串("1", "2", …),最大编号 = horizontalRows × verticalCount
6. **格口结构**:一个格口即一条数据库记录,响应为扁平对象,无嵌套