feat(db): 支持早中晚三餐同一菜品独立保存及调料预填充优化

- 修复 getCookFoodById/deleteCookFood/markSubmittedAndDelete 查询条件缺少 dinnerType,
  导致同一 foodId 跨餐次保存时误判已存在、主记录和主辅材数据丢失的问题;
  查询键由三元组(canteenId+foodId+cookMode)升级为四元组(+dinnerType)
- SamplingModeActivity 构建 FoodRecord 时补充 dinnerType,确保删除操作能精确定位餐次
- SubmitFoodActivity 制作中模式下从数据库加载 goodsList 时预填充调料到 adapter,
  isCooking=false 时同样预填充前一页面带入的调料数据
- dm_cook_food 表新增 submitState 字段(DB v13),区分手动删除与接口提交删除
- 提交成功后改用 markSubmittedAndDelete 替代 deleteCookFoodAndGoods,正确置 submitState=1
- InitActivity onDestroy 不再停止秤服务,避免进程存活期间秤数据中断
- GoodsItem.isItemClicked 统一改为 isClicked,PrepareFoodActivity 食材选中逻辑同步更新
- AndroidManifest InitActivity 增加 singleTask 及 configChanges 配置

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 18:10:59 +08:00
co-authored by Claude Sonnet 4.6
parent 919e43afba
commit b6389be898
16 changed files with 150 additions and 66 deletions
+2
View File
@@ -45,6 +45,8 @@
<activity
android:name=".ui.InitActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode|locale|layoutDirection|fontScale|density"
tools:ignore="DiscouragedApi,LockedOrientationActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -69,7 +69,7 @@ class FoodMaterialAdapter(list: MutableList<GoodsItem>) :
else R.drawable.ic_dish_unselected
)
clBlock.setBackgroundResource(
if (data.isItemClicked) R.drawable.shape_item_cook_dish
if (data.isClicked) R.drawable.shape_item_cook_dish
else R.drawable.shape_white_fb_15_corners
)
}
@@ -25,7 +25,7 @@ import com.shuwei.dish.match.entity.SeasoningSlotEntity
CookFoodGoodsEntity::class,
SeasoningSlotEntity::class,
],
version = 12,
version = 13,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
@@ -248,6 +248,14 @@ val MIGRATION_11_12 = MigrationImpl(11, 12) { db ->
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN rawMaterialsType TEXT DEFAULT NULL")
}
/**
* 版本12→13:为 dm_cook_food 表新增 submitState(接口提交状态)字段
* 0-未提交(本地草稿/手动删除),1-接口提交成功后删除
*/
val MIGRATION_12_13 = MigrationImpl(12, 13) { db ->
db.execSQL("ALTER TABLE dm_cook_food ADD COLUMN submitState INTEGER NOT NULL DEFAULT 0")
}
class DatabaseProvider(private val context: Context) {
// 单例模式确保全局唯一实例‌
val instance: AppDatabase by lazy {
@@ -271,6 +279,7 @@ class DatabaseProvider(private val context: Context) {
.addMigrations(MIGRATION_9_10)
.addMigrations(MIGRATION_10_11)
.addMigrations(MIGRATION_11_12)
.addMigrations(MIGRATION_12_13)
.build()
}
}
@@ -21,8 +21,8 @@ class DbRepository {
db.cookFoodDao().updateCookFood(item)
}
suspend fun getCookFoodById(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) {
db.cookFoodDao().getCookFoodById(BaseApp.canteenId, foodId, cookMode)
suspend fun getCookFoodById(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().getCookFoodById(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
suspend fun getCookFoodList(cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
@@ -33,13 +33,23 @@ class DbRepository {
db.cookFoodDao().countCookFood(BaseApp.canteenId, cookMode)
}
suspend fun deleteCookFood(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) {
db.cookFoodDao().deleteCookFood(BaseApp.canteenId, foodId, cookMode)
suspend fun deleteCookFood(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().deleteCookFood(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
/** 接口提交成功后逻辑删除菜品,同时把 submitState 置 1 */
suspend fun markSubmittedAndDelete(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().markSubmittedAndDelete(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteCookFoodGoodsList(pid, foodId)
}
/** 仅逻辑删除调料行(materialType = 3),主辅材不动 */
suspend fun deleteSeasoningOnly(pid: Long, foodId: String) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteSeasoningOnly(pid, foodId)
}
//--------------------------------------------------------------------------------------------
suspend fun insertGoodsList(items: MutableList<CookFoodGoodsEntity>) =
@@ -28,9 +28,9 @@ class DbViewModel : ViewModel() {
private val _cookFoodDetailState = MutableStateFlow<CookFoodEntity?>(null)
val cookFoodDetailState: StateFlow<CookFoodEntity?> = _cookFoodDetailState.asStateFlow()
fun getCookFoodById(foodId: String, cookMode: Int) {
fun getCookFoodById(foodId: String, cookMode: Int, dinnerType: String) {
viewModelScope.launch {
_cookFoodDetailState.value = rep.getCookFoodById(foodId, cookMode)
_cookFoodDetailState.value = rep.getCookFoodById(foodId, cookMode, dinnerType)
}
}
@@ -68,28 +68,49 @@ class DbViewModel : ViewModel() {
entity: CookFoodEntity,
list: MutableList<CookFoodGoodsEntity>?
) {
val data = rep.getCookFoodById(entity.foodId, cookMode)
if (data != null) {
// 存在数据先删除
rep.deleteCookFood(entity.foodId, cookMode)
rep.deleteCookFoodGoodsList(data.id, entity.foodId)
}
// 重新保存数据
rep.insertCookFood(entity)
val newData = rep.getCookFoodById(entity.foodId, cookMode)
list?.let { it ->
it.forEach { goods ->
goods.id = 0
goods.pid = newData?.id ?: 0
val data = rep.getCookFoodById(entity.foodId, cookMode, entity.dinnerType ?: "0")
if (data == null) {
// 首次保存:主表 + 主辅材 + 调料 全量 insert
rep.insertCookFood(entity)
val newData = rep.getCookFoodById(entity.foodId, cookMode, entity.dinnerType ?: "0")
list?.let {
it.forEach { goods ->
goods.id = 0
goods.pid = newData?.id ?: 0
}
rep.insertGoodsList(it)
}
} else {
// 已存在:主表与主辅材保持不变,仅重建调料部分,避免主表/主辅材累积逻辑删除脏数据
rep.deleteSeasoningOnly(data.id, entity.foodId)
val seasoningList = list?.filter { it.materialType == 3 }?.toMutableList()
if (!seasoningList.isNullOrEmpty()) {
seasoningList.forEach { goods ->
goods.id = 0
goods.pid = data.id
goods.foodId = entity.foodId
}
rep.insertGoodsList(seasoningList)
}
rep.insertGoodsList(it)
}
}
suspend fun deleteCookFoodAndGoods(cookMode: Int, foodId: String) {
val data = rep.getCookFoodById(foodId, cookMode)
suspend fun deleteCookFoodAndGoods(cookMode: Int, foodId: String, dinnerType: String) {
val data = rep.getCookFoodById(foodId, cookMode, dinnerType)
if (data != null) {
rep.deleteCookFood(foodId = foodId, cookMode = cookMode)
rep.deleteCookFood(foodId = foodId, cookMode = cookMode, dinnerType = dinnerType)
rep.deleteCookFoodGoodsList(pid = data.id, foodId = foodId)
}
}
/**
* 接口提交成功后删除本地菜品草稿
* 与 [deleteCookFoodAndGoods] 的区别:主表 submitState 会被置 1,便于审计区分手动删除与接口删除
*/
suspend fun markSubmittedAndDelete(cookMode: Int, foodId: String, dinnerType: String) {
val data = rep.getCookFoodById(foodId, cookMode, dinnerType)
if (data != null) {
rep.markSubmittedAndDelete(foodId = foodId, cookMode = cookMode, dinnerType = dinnerType)
rep.deleteCookFoodGoodsList(pid = data.id, foodId = foodId)
}
}
@@ -98,9 +119,10 @@ class DbViewModel : ViewModel() {
suspend fun getCookFoodGoodsList(
foodId: String,
cookMode: Int
cookMode: Int,
dinnerType: String
): MutableList<CookFoodGoodsEntity> {
val cookFood = rep.getCookFoodById(foodId, cookMode)
val cookFood = rep.getCookFoodById(foodId, cookMode, dinnerType)
return if (cookFood != null) rep.getCookFoodGoodsList(pid = cookFood.id, foodId = cookFood.foodId)
else mutableListOf()
}
@@ -343,7 +365,8 @@ class DbViewModel : ViewModel() {
fields = listOf(
"id" to "${e.id}", "foodId" to "${e.foodId}", "foodName" to "${e.foodName}",
"canteenId" to "${e.canteenId}", "foodWeight" to "${e.foodWeight}", "cookMode" to "${e.cookMode}",
"dinnerType" to "${e.dinnerType}", "isDel" to "${e.isDel}", "createTime" to "${e.createTime}"
"dinnerType" to "${e.dinnerType}", "isDel" to "${e.isDel}", "submitState" to "${e.submitState}",
"createTime" to "${e.createTime}"
)
)
}
@@ -16,8 +16,8 @@ interface CookFoodDao {
@Update
suspend fun updateCookFood(item: CookFoodEntity)
@Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode")
suspend fun getCookFoodById(canteenId: String, foodId: String, cookMode: Int): CookFoodEntity?
@Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode AND dinnerType = :dinnerType")
suspend fun getCookFoodById(canteenId: String, foodId: String, cookMode: Int, dinnerType: String): CookFoodEntity?
@Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND cookMode = :cookMode AND dinnerType = :dinnerType ORDER BY createTime DESC")
suspend fun getCookFoodList(canteenId: String, cookMode: Int, dinnerType: String): MutableList<CookFoodEntity>?
@@ -26,8 +26,12 @@ interface CookFoodDao {
suspend fun countCookFood(canteenId: String, cookMode: Int): Int
// @Query("DELETE FROM dm_cook_food WHERE canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode")
@Query("UPDATE dm_cook_food SET isDel = 1 WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode")
suspend fun deleteCookFood(canteenId: String, foodId: String, cookMode: Int)
@Query("UPDATE dm_cook_food SET isDel = 1 WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode AND dinnerType = :dinnerType")
suspend fun deleteCookFood(canteenId: String, foodId: String, cookMode: Int, dinnerType: String)
/** 接口提交成功后逻辑删除菜品,同时把 submitState 置 1 以便在数据库审计中区分手动删除 */
@Query("UPDATE dm_cook_food SET isDel = 1, submitState = 1 WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode AND dinnerType = :dinnerType")
suspend fun markSubmittedAndDelete(canteenId: String, foodId: String, cookMode: Int, dinnerType: String)
/** 查询全部菜品记录(含已删除),用于数据库调试查看 */
@Query("SELECT * FROM dm_cook_food ORDER BY createTime DESC")
@@ -29,6 +29,10 @@ interface CookFoodGoodsDao {
@Query("UPDATE dm_cook_food_goods SET isDel = 1 WHERE isDel = 0 AND pid = :pid AND foodId = :foodId")
suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String)
/** 仅逻辑删除调料行(materialType = 3),主辅材不动,用于 cook 时只重建调料部分 */
@Query("UPDATE dm_cook_food_goods SET isDel = 1 WHERE isDel = 0 AND pid = :pid AND foodId = :foodId AND materialType = 3")
suspend fun deleteSeasoningOnly(pid: Long, foodId: String)
/** 查询全部食材记录(含已删除),用于数据库调试查看 */
@Query("SELECT * FROM dm_cook_food_goods ORDER BY createTime DESC")
suspend fun getAllCookFoodGoods(): MutableList<CookFoodGoodsEntity>
@@ -18,6 +18,8 @@ data class CookFoodEntity(
//0-制作模式,1-采样模式,2-品控模式
var cookMode:Int = 0,
var isDel: Int = 0,
/** 接口提交状态:0-未提交(本地草稿或手动删除),1-接口提交成功后删除 */
var submitState: Int = 0,
var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now()),
var dinnerType: String? = "0",
) : BaseEntity {
@@ -24,7 +24,7 @@ data class GoodsItem(
// 从 CookFoodGoodsEntity @Ignore 迁移的 UI 状态字段
var isSetFinished: Boolean = false,
var isItemClicked: Boolean = false,
// var isItemClicked: Boolean = false,
var isSamplingPage: Boolean = false,
var isNewDishType: Boolean = false,
var isClicked: Boolean = false,
@@ -140,9 +140,9 @@ class CookingModeActivity : BaseActivity() {
}
}
fun deleteCookFoodAndGoods(foodId: String, action: () -> Unit) {
fun deleteCookFoodAndGoods(foodId: String, dinnerType: String, action: () -> Unit) {
lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods(cookMode = 0, foodId = foodId)
appViewModel.deleteCookFoodAndGoods(cookMode = 0, foodId = foodId, dinnerType = dinnerType)
action()
}
}
@@ -143,7 +143,8 @@ class InitActivity : BaseActivity() {
override fun onDestroy() {
countdownHandler.removeCallbacksAndMessages(null)
WeightUtil.stopContinuousRead()
ScaleServiceManager.stop()
// 秤服务伴随进程生命周期,无需在 Activity 销毁时停止;进程退出时 OS 会自动回收所有资源
// ScaleServiceManager.stop()
super.onDestroy()
}
@@ -129,6 +129,16 @@ class PrepareFoodActivity : BaseActivity() {
WeightUtil.tareTwo(AddressUtil.ONE)
delayDismissLoading()
}
// TODO: 测试功能-------------------------------
binding.btnTestConfirm.clickWithDebounce {
if (currentWeight <= 0.0) return@clickWithDebounce
val (index, item) = list.withIndex().find { (_, it) -> it.isOriginalData && it.isClicked && (it.useWeight?:0.0) <= 0.0 } ?: return@clickWithDebounce
item.useWeight = currentWeight
item.isSetFinished = true
list.forEachIndexed { i, it -> it.isClicked = i == index }
materialAdapter.notifyDataSetChanged()
}
// TODO: 测试功能-------------------------------
//binding.root.setOnClickListener { hideKeyboard() }
WeightUtil.addWeightListener(
weightKey = TAG,
@@ -242,19 +252,25 @@ class PrepareFoodActivity : BaseActivity() {
* - list 中不含该 goodsId:直接新增,isNewDishType = true
* - list 中已含该 goodsId:累加 useWeight
*/
private val foodSelectCallback: (GoodsItem) -> Unit = { entity ->
val existing = list.firstOrNull { it.goodsId == entity.goodsId }
if (existing != null) {
existing.useWeight = (existing.useWeight ?: 0.0) + (entity.useWeight ?: 0.0)
existing.isSetFinished = true
materialAdapter.notifyItemChanged(list.indexOf(existing))
private val foodSelectCallback: (GoodsItem) -> Unit = foodSelectCallback@{ entity ->
val filterValue = list.withIndex().find { (_, it) -> it.goodsId == entity.goodsId }
if (filterValue != null) {
val index = filterValue.index
val item = filterValue.value
item.useWeight = (item.useWeight ?: 0.0) + (entity.useWeight ?: 0.0)
item.isSetFinished = true
list.forEachIndexed { i, it ->
item.isClicked = i == index
}
} else {
materialAdapter.add(entity.also {
list.forEach { it.isClicked = false }
list.add(entity.also {
it.isNewDishType = true
it.isSetFinished = true
// 标记为非接口数据,否则 updateMaterialTypes 不会为其计算 materialType
// 导致列表中不显示"主辅材:主材/辅材"
it.isOriginalData = false
it.isClicked = true
})
binding.rvMaterialList.smoothScrollToPosition(list.size - 1)
}
@@ -376,7 +392,7 @@ class PrepareFoodActivity : BaseActivity() {
FoodMaterialAdapter(list).apply {
onItemClick = { positon ->
list.forEachIndexed { index, entity ->
entity.isItemClicked = index == positon
entity.isClicked = index == positon
}
notifyDataSetChanged()
}
@@ -75,6 +75,7 @@ class SamplingModeActivity : BaseActivity() {
foodName = entity.foodName
cookMode = 1
isCooking = true
dinnerType = entity.dinnerType ?: "0"
})
}
list.clear()
@@ -146,9 +147,9 @@ class SamplingModeActivity : BaseActivity() {
}
}
fun deleteCookFoodAndGoods(foodId: String, action: () -> Unit) {
fun deleteCookFoodAndGoods(foodId: String, dinnerType: String, action: () -> Unit) {
lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods(cookMode = 1, foodId = foodId)
appViewModel.deleteCookFoodAndGoods(cookMode = 1, foodId = foodId, dinnerType = dinnerType)
action()
}
}
@@ -210,7 +211,7 @@ class SamplingModeActivity : BaseActivity() {
.setContent("确定要删除「${item.foodName}」吗?")
.setNegativeButton("取消")
.setPositiveButton("删除") {
deleteCookFoodAndGoods(foodId = item.foodId ?: "") {
deleteCookFoodAndGoods(foodId = item.foodId ?: "", dinnerType = item.dinnerType) {
if (list.size > 1) {
dishAdapter.removeAt(position)
} else {
@@ -162,7 +162,8 @@ class SubmitFoodActivity : BaseActivity() {
/**
* 从 Room 加载槽位配置后,开始订阅子设备秤数据
* isCooking=true 时从数据库查询 goodsList,筛选调料预填充 adapter,再开始观测
* isCooking=true 时从数据库查询 goodsList预填充调料 adapter
* isCooking=false 时从前一页面带入的 goodsList 中预填充调料 adapter,再开始观测
*/
private fun loadSlotsThenObserveScales() {
lifecycleScope.launch {
@@ -174,24 +175,33 @@ class SubmitFoodActivity : BaseActivity() {
// 制作中:从数据库查询 goodsList,筛选调料数据预填充 adapter
val foodId = food?.foodId ?: return@launch
val cookMode = food?.cookMode ?: return@launch
val list = appViewModel.getCookFoodGoodsList(foodId, cookMode)
val list = appViewModel.getCookFoodGoodsList(foodId, cookMode, food?.dinnerType ?: "0")
goodsList = list
list.filter { it.materialType == 3 }.forEach { item ->
val name = item.goodsName ?: ""
val weight = item.useWeight ?: 0.0
baseSeasoningMap[name] = (item.goodsId) to weight
seasoningAdapter.updateItem(
SeasoningWeightAdapter.Item(
goodsId = item.goodsId,
goodsName = name,
useWeight = weight
)
)
}
observeScaleData()
prefillSeasoningFromGoodsList(list)
} else {
observeScaleData()
// 非制作中:从前一页面带入的 goodsList 中筛选调料数据预填充 adapter
goodsList?.let { prefillSeasoningFromGoodsList(it) }
}
observeScaleData()
}
}
/**
* 从 goodsList 中筛选 materialType==3 的调料数据,
* 写入 baseSeasoningMap 作为历史用量基准,并预填充到 adapter
*/
private fun prefillSeasoningFromGoodsList(list: List<CookFoodGoodsEntity>) {
list.filter { it.materialType == 3 }.forEach { item ->
val name = item.goodsName ?: ""
val weight = item.useWeight ?: 0.0
baseSeasoningMap[name] = item.goodsId to weight
seasoningAdapter.updateItem(
SeasoningWeightAdapter.Item(
goodsId = item.goodsId,
goodsName = name,
useWeight = weight
)
)
}
}
@@ -399,9 +409,10 @@ class SubmitFoodActivity : BaseActivity() {
return
}
lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods(
appViewModel.markSubmittedAndDelete(
cookMode = food!!.cookMode,
foodId = food!!.foodId!!
foodId = food!!.foodId!!,
dinnerType = food!!.dinnerType
)
dismissLoading()
jumpPage(isSamplingData, false)
@@ -109,7 +109,7 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
.setContent("确定要删除「${item.foodName}」吗?")
.setNegativeButton("取消")
.setPositiveButton("删除") {
activity.deleteCookFoodAndGoods(foodId = item.foodId ?: "") {
activity.deleteCookFoodAndGoods(foodId = item.foodId ?: "", dinnerType = item.dinnerType) {
activity.toast("已删除")
pageNo = 1
getDishList()
@@ -63,6 +63,7 @@
android:gravity="center_vertical">
<TextView
android:id="@+id/btnTestConfirm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"