Author SHA1 Message Date
mazengfei acd1dd4605 fix(base): 更新生产环境接口地址
- 将PROD_BASE_URL从"api.dm.yixiong-tech.com:8443"替换为"platform-api.uat.shuziweidao.com"
2026-08-07 20:08:35 +08:00
mazengfei 17470bf12c feat(match): 添加烹饪开始时间和调料首次使用时间功能
- 在CookCompleteRequest中新增cookStart字段记录烹饪开始时间
- 在CookCompleteItem中新增putTime字段记录调料首次使用时间
- SubmitFoodActivity中初始化cookStartTime为进入页面时刻
- 记录调料首次拿取时间,存入seasoningFirstUseMap
- 提交接口时按调料名称匹配首次使用时间填充putTime字段
2026-08-06 18:08:51 +08:00
mazengfei 0a3185fc9e feat(cookOrder): 集成新配比秤接口和制作模式功能
- 新增接口查询当日烹饪任务列表及菜品食材构成(主材+辅材,不含调料)
- 增加调料模糊搜索功能,支持关键字分页查询
- 实现烹饪完成接口,支持提交主辅材用量、熟重及调料数据
- 在 PrepareFoodActivity 中添加烹制单 ID 传递和接口调用,区分采样和制作模式
- 在 SubmitFoodActivity 中实现制作模式烹饪完成数据组装与提交新接口逻辑
- 适配 FoodListFragment 和 FoodSearchActivity 使用熟重及烹制单 ID 关联映射
- RemoteRepository 和 NetViewModel 中新增对应接口调用方法及 UI 状态更新
- 优化 SeasoningSelectDialog 适配新接口数据结构及刷新逻辑
- 调整响应数据 ApiResponse 支持新接口标准成功码和分页字段
- 调整摄像头绑定逻辑,采样模式启用,制作模式禁用
- 统一将熟重字段用于菜品总重量计算,优化数据一致性及 UI 展示
2026-07-30 18:36:32 +08:00
mazengfei 9e41841086 feat(api): 增加当日烹饪任务列表新接口及相关数据模型支持
- 新增 CookOrderPageRequest、CookOrderMealGroup、CookOrderItem 等数据模型定义
- 在 ApiService 添加 getCookOrderPage 新接口,支持按餐次分组查询烹饪任务
- RemoteRepository 新增对应接口调用封装 getCookOrderPage
- NetViewModel 新增 cookOrderPageState 状态流及接口调用方法
- FoodListFragment 使用新接口替换旧搜索菜品接口,调整数据合并与展示逻辑
- FoodSearchActivity 使用新接口查询烹饪任务,并适配结果展示
- 更新请求拦截器添加 X-DEVICE-TOKEN 请求头支持设备登录Token传递
- 修改 GlobalData 默认基础URL为预发布环境地址
- 修正 ScaleDeviceConfig 中测试设备ID常量值
- SettingActivity 恢复环境切换入口显示,方便环境切换测试
- 补充配比秤-制作菜品新API接口完整文档,详细描述接口请求响应及业务逻辑
2026-07-29 17:59:14 +08:00
16 changed files with 1098 additions and 144 deletions
+1 -2
View File
@@ -1,4 +1,3 @@
plugins { plugins {
// alias(libs.plugins.android.application) // alias(libs.plugins.android.application)
// alias(libs.plugins.kotlin.android) // alias(libs.plugins.kotlin.android)
@@ -25,7 +24,7 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk { ndk {
//设置支持的SO库架构;x86_64 仅用于在 Windows 模拟器上调试(秤功能不可用) //设置支持的SO库架构;x86_64 仅用于在 Windows 模拟器上调试(秤功能不可用)
abiFilters.addAll(listOf("armeabi-v7a", "x86_64")) abiFilters.addAll(listOf("armeabi-v7a"))
} }
// 设置输出APK文件名格式 // 设置输出APK文件名格式
@@ -8,14 +8,14 @@ package com.shuwei.dish.match.base
enum class DeviceRole { MASTER, SLAVE } enum class DeviceRole { MASTER, SLAVE }
object GlobalData { object GlobalData {
var appBaseUrl: String = TEST_BASE_URL var appBaseUrl: String = UAT_BASE_URL
/** /**
* 具体业务 BaseUrl * 具体业务 BaseUrl
*/ */
const val TEST_BASE_URL = "http://192.168.1.201:14801" const val TEST_BASE_URL = "http://192.168.10.101:24801"
const val UAT_BASE_URL = "https://dev.yixiong-tech.com:8083" const val UAT_BASE_URL = "https://dev.yixiong-tech.com:8081"
const val PROD_BASE_URL = "https://api.dm.yixiong-tech.com:8443" const val PROD_BASE_URL = "https://platform-api.uat.shuziweidao.com"
/** /**
* 设备id * 设备id
*/ */
@@ -15,7 +15,6 @@ import android.view.Window
import android.view.WindowManager import android.view.WindowManager
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
@@ -25,13 +24,11 @@ import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.DialogSeasoningSelectBinding import com.shuwei.dish.match.databinding.DialogSeasoningSelectBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.model.GoodsItem import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.SeasoningSearchItem
import com.shuwei.dish.match.net.UiState import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/** /**
* 调料选择弹窗,不含重量显示,继承 BottomSheetDialog 确保只初始化一次 * 调料选择弹窗,不含重量显示,继承 BottomSheetDialog 确保只初始化一次
* @param activity 宿主 Activity * @param activity 宿主 Activity
@@ -64,7 +61,6 @@ class SeasoningSelectDialog(
private var goodsName: String? = null private var goodsName: String? = null
private var pageNo = 1 private var pageNo = 1
private var collectJob: Job? = null
init { init {
setContentView(binding.root) setContentView(binding.root)
@@ -77,7 +73,6 @@ class SeasoningSelectDialog(
window?.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) window?.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
setOnDismissListener { setOnDismissListener {
collectJob?.cancel()
activity.hideStatusBar() activity.hideStatusBar()
} }
@@ -116,23 +111,6 @@ class SeasoningSelectDialog(
/** 弹窗显示时自动加载默认调料列表,并在 Window.Callback 层提前拦截触摸以解决滑动冲突 */ /** 弹窗显示时自动加载默认调料列表,并在 Window.Callback 层提前拦截触摸以解决滑动冲突 */
override fun show() { override fun show() {
super.show() super.show()
// 启动 StateFlow 收集,弹窗关闭时由 setOnDismissListener 取消
collectJob = activity.lifecycleScope.launch {
activity.netViewModel.foodSearchGoodsListState.collect { state ->
when (state) {
is UiState.Loading -> {}
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
if (pageNo == 1) {
loadEmptyView()
}
}
is UiState.Idle -> {}
}
}
}
pageNo = 1 pageNo = 1
getGoodsList() getGoodsList()
// Window.Callback.dispatchTouchEvent 在整个 View 树的 onInterceptTouchEvent 之前执行, // Window.Callback.dispatchTouchEvent 在整个 View 树的 onInterceptTouchEvent 之前执行,
@@ -184,14 +162,24 @@ class SeasoningSelectDialog(
} }
/** /**
* 请求调料列表 * 请求调料列表(新配比秤接口,回调版本)
*/ */
private fun getGoodsList() { private fun getGoodsList() {
activity.netViewModel.queryFoodSearchGoodsList( activity.netViewModel.querySeasoningSearch(
goodsType = "1", keyword = goodsName.takeUnless { it.isNullOrBlank() },
pageNum = pageNo, pageNum = pageNo,
pageSize = PAGE_SIZE, pageSize = PAGE_SIZE,
goodsName = goodsName.takeUnless { it.isNullOrBlank() } onResult = { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
if (pageNo == 1) loadEmptyView()
}
else -> {}
}
}
) )
} }
@@ -203,23 +191,22 @@ class SeasoningSelectDialog(
"", "白砂糖", "冰糖", "鸡精", "味精", "陈醋", "老抽", "生抽", "番茄酱", "胡椒粉", "孜然", "十三香", "", "白砂糖", "冰糖", "鸡精", "味精", "陈醋", "老抽", "生抽", "番茄酱", "胡椒粉", "孜然", "十三香",
"料酒", "白醋", "蚝油", "辣椒面", "辣椒酱", "豆瓣酱", "淀粉", "", "", "", "香菜", "" "料酒", "白醋", "蚝油", "辣椒面", "辣椒酱", "豆瓣酱", "淀粉", "", "", "", "香菜", ""
) )
val mockData = mutableListOf<GoodsItem>() val mockData = nameList.mapIndexed { index, name ->
nameList.forEachIndexed { index, name ->
val padString = "${index + 1}".padStart(2, '0') val padString = "${index + 1}".padStart(2, '0')
mockData.add(GoodsItem(goodsId = "1000$padString", goodsName = name)) SeasoningSearchItem(id = "1000$padString", materName = name)
} }
pageNo = 1 pageNo = 1
loadGoodsList(mockData) loadGoodsList(mockData)
} }
/** /**
* 将返回数据转换并填充到列表 * 将新接口返回的 SeasoningSearchItem 映射为 GoodsItem 并填充到列表
* 新接口的 id 即食材id,同时用作 goodsId 和 materId,无需再交换
*/ */
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<GoodsItem>?) { private fun loadGoodsList(records: List<SeasoningSearchItem>?) {
finishRefresh() finishRefresh()
if (records.isNullOrEmpty()) { if (records.isNullOrEmpty()) {
//activity.toast("暂未搜索到调料信息")
if (pageNo == 1) { if (pageNo == 1) {
loadEmptyView() loadEmptyView()
} }
@@ -227,18 +214,26 @@ class SeasoningSelectDialog(
} }
binding.recyclerView.layoutManager = GridLayoutManager(activity, 3, GridLayoutManager.VERTICAL, false) binding.recyclerView.layoutManager = GridLayoutManager(activity, 3, GridLayoutManager.VERTICAL, false)
if (pageNo == 1) list.clear() if (pageNo == 1) list.clear()
list.addAll(records) val goodsItems = records.map { it.toGoodsItem() }
list.forEach { list.addAll(goodsItems)
it.oldGoodsId = it.goodsId
//materId才是提交数据需要的goodsId数据
it.goodsId = it.materId ?:""
}
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= PAGE_SIZE val isLoadMoreEnable = records.size >= PAGE_SIZE
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable) binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++ if (isLoadMoreEnable) pageNo++
} }
/**
* SeasoningSearchItem → GoodsItem 映射
* id 既是食材id也是提交时的 goodsId,不需要 oldGoodsId 交换
*/
private fun SeasoningSearchItem.toGoodsItem() = GoodsItem(
goodsId = id,
materId = id,
goodsName = materName,
materCode = materCode,
popularName = alias
)
private fun finishRefresh() { private fun finishRefresh() {
if (pageNo == 1) binding.refreshLayout.finishRefresh() if (pageNo == 1) binding.refreshLayout.finishRefresh()
else binding.refreshLayout.finishLoadMore() else binding.refreshLayout.finishLoadMore()
@@ -0,0 +1,146 @@
package com.shuwei.dish.match.model
/**
* 当日烹饪任务列表(按餐次分组)请求参数
* 对应接口: POST /neglect/ratio-scale/cook-orders/page
*/
data class CookOrderPageRequest(
/** 计划日期 yyyy-MM-dd,默认当天 */
val date: String? = null,
/** 餐次筛选: 1=早餐 / 2=午餐 / 3=晚餐 / 4=加餐,不传返回全部 */
val mealType: Int? = null,
/** 菜品名称模糊搜索 */
val keyword: String? = null,
/** 保留字段,当前不使用 */
val pageNum: Int? = null,
/** 保留字段,当前不使用 */
val pageSize: Int? = null
)
/**
* 餐次分组,包含该餐次下的烹制单列表
*/
data class CookOrderMealGroup(
/** 餐次: 1=早餐 / 2=午餐 / 3=晚餐 / 4=加餐 */
val mealType: Int = 0,
/** 餐次中文名称 */
val mealTypeName: String = "",
/** 该餐次下的烹制单列表 */
val cookOrders: List<CookOrderItem> = emptyList()
)
/**
* 单个烹制单,对应接口响应中 cookOrders 数组的条目
* 注意: Long 类型字段服务端序列化为 String
*/
data class CookOrderItem(
/** 烹制单idLong → String */
val cookOrderId: String = "",
/** 烹制单号 */
val cookNo: String = "",
/** 菜品名称 */
val dishName: String = "",
/** 菜品id(关联 nut_food */
val foodId: String = "",
/** 餐次: 1=早餐 / 2=午餐 / 3=晚餐 / 4=加餐 */
val mealType: Int = 0,
/** 餐次中文名称 */
val mealTypeName: String = "",
/** 烹制状态: 0=待烹制 / 1=烹制中 / 2=已完成 / 3=异常 */
val cookStatus: Int = 0,
/** 烹制状态中文 */
val cookStatusName: String = "",
/** 生重(kg),烹制完成后才有值 */
val rawWeight: Double? = null,
/** 熟重(kg),烹制完成后才有值 */
val cookedWeight: Double? = null,
/** 需求份数 */
val needPortions: Int = 0,
/** 烹制份数,烹制完成后才有值 */
val cookedPortions: Int? = null
)
/**
* 菜品食材构成(主材+辅材),对应接口响应中单个食材
* 对应接口: GET /neglect/ratio-scale/cook-orders/{cookOrderId}/composition
* 注意: 此接口仅返回主材(ingredientClass=1)和辅材(ingredientClass=2),不含调料
*/
data class CookOrderCompositionItem(
/** 食材id */
val materId: String = "",
/** 食材名称 */
val ingredientName: String = "",
/** 食材分类: 1=主材 / 2=辅材 */
val ingredientClass: Int = 0,
/** 食材分类中文名称 */
val ingredientClassName: String = "",
/** 标准用量(g) */
val useWeight: Double = 0.0,
/** 净菜类型id */
val vegTypeId: String = ""
)
// ==================== 调料模糊搜索 ====================
/**
* 调料模糊搜索请求参数
* 对应接口: POST /neglect/ratio-scale/seasoning-search
* 数据源: nut_mater_base 一级分类为 18(糖蜜饯)、19(油脂)、20(调味品)
*/
data class SeasoningSearchRequest(
/** 模糊匹配食材名称和别名,为空时返回全部 */
val keyword: String? = null,
/** 页码 */
val pageNum: Int = 1,
/** 每页条数 */
val pageSize: Int = 20
)
/**
* 调料模糊搜索结果条目
* 注意: Long 类型字段服务端序列化为 String
*/
data class SeasoningSearchItem(
/** 食材idLong → String */
val id: String = "",
/** 食材名称 */
val materName: String = "",
/** 食材编码 */
val materCode: String = "",
/** 别名 */
val alias: String = "",
/** 归属食堂idLong → String */
val canteenId: String = ""
)
// ==================== 烹饪完成 ====================
/**
* 烹饪完成 — 单个食材用量条目
* 对应接口: POST /neglect/ratio-scale/cook-orders/complete
*/
data class CookCompleteItem(
/** 食材id */
val materId: String = "",
/** 实际用量(g) */
val actualQty: Double = 0.0,
/** 调料首次使用时间 yyyy-MM-dd HH:mm:ss,仅调料需要,主辅材留空 */
val putTime: String? = null
)
/**
* 烹饪完成请求,一次性提交主辅材用量+熟重+调料
* 对应接口: POST /neglect/ratio-scale/cook-orders/complete
*/
data class CookCompleteRequest(
/** 烹制单id */
val cookOrderId: String = "",
/** 烹饪开始时间 yyyy-MM-dd HH:mm:ss,进入提交页面时刻 */
val cookStart: String? = null,
/** 主辅材实际用量列表,至少1条 */
val items: List<CookCompleteItem> = emptyList(),
/** 熟重(kg) */
val cookedWeight: Double = 0.0,
/** 调料用量列表,选填 */
val seasonings: List<CookCompleteItem>? = null
)
@@ -2,8 +2,14 @@ package com.shuwei.dish.match.net
import com.shuwei.dish.match.base.GlobalData import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.model.CookFoodDTO import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.CookOrderCompositionItem
import com.shuwei.dish.match.model.CookOrderMealGroup
import com.shuwei.dish.match.model.CookOrderPageRequest
import com.shuwei.dish.match.model.FoodRecord import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.GoodsItem import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.CookCompleteRequest
import com.shuwei.dish.match.model.SeasoningSearchItem
import com.shuwei.dish.match.model.SeasoningSearchRequest
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import retrofit2.http.Body import retrofit2.http.Body
@@ -12,6 +18,7 @@ import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
import retrofit2.http.PartMap import retrofit2.http.PartMap
import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.Url import retrofit2.http.Url
@@ -37,7 +44,7 @@ interface ApiService {
): ApiResponse<Any?> ): ApiResponse<Any?>
/** /**
* 搜索菜品 * 搜索菜品(旧接口,采样模式仍在使用)
*/ */
@POST @POST
suspend fun searchFoodList( suspend fun searchFoodList(
@@ -73,4 +80,44 @@ interface ApiService {
@PartMap params: MutableMap<String, RequestBody>, @PartMap params: MutableMap<String, RequestBody>,
@Part foodPics: List<MultipartBody.Part> @Part foodPics: List<MultipartBody.Part>
): ApiResponse<List<String>?> ): ApiResponse<List<String>?>
/**
* 查询当日烹饪任务列表(按餐次分组),新配比秤接口
*/
@POST
suspend fun getCookOrderPage(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/cook-orders/list",
@Body request: CookOrderPageRequest
): ApiResponse<List<CookOrderMealGroup>?>
/**
* 查询菜品食材构成(主材+辅材,不含调料),新配比秤接口
*/
@GET("nutrition/neglect/ratio-scale/cook-orders/{cookOrderId}/composition")
suspend fun getCookOrderComposition(
@Path("cookOrderId") cookOrderId: String
): ApiResponse<List<CookOrderCompositionItem>?>
/**
* 调料模糊搜索,新配比秤接口
* 数据源: nut_mater_base 一级分类为 18(糖蜜饯)、19(油脂)、20(调味品)
*/
@POST
suspend fun getSeasoningSearch(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/seasoning-search",
@Body request: SeasoningSearchRequest
): ApiResponse<List<SeasoningSearchItem>?>
/**
* 烹饪完成:提交主辅材用量+熟重+调料,一次性完成烹制
* 新配比秤接口,替代旧 saveConstitute
*/
@POST
suspend fun cookOrderComplete(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/cook-orders/complete",
@Body request: CookCompleteRequest
): ApiResponse<Any?>
} }
@@ -4,9 +4,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.model.CookFoodDTO import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.CookOrderCompositionItem
import com.shuwei.dish.match.model.CookOrderMealGroup
import com.shuwei.dish.match.model.CookOrderPageRequest
import com.shuwei.dish.match.model.FoodRecord import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.GoodsItem import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.GoodsNameQueryDTO import com.shuwei.dish.match.model.GoodsNameQueryDTO
import com.shuwei.dish.match.model.CookCompleteRequest
import com.shuwei.dish.match.model.SeasoningSearchItem
import com.shuwei.dish.match.model.SeasoningSearchRequest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -64,6 +70,23 @@ class NetViewModel(
_foodDetailState.value = UiState.Idle _foodDetailState.value = UiState.Idle
} }
/**
* 查询菜品食材构成的 UI 状态流(新配比秤接口),UI 层通过 collect 监听
*/
private val _cookOrderCompositionState = MutableStateFlow<UiState<List<CookOrderCompositionItem>?>>(UiState.Idle)
val cookOrderCompositionState: StateFlow<UiState<List<CookOrderCompositionItem>?>> = _cookOrderCompositionState.asStateFlow()
/**
* 查询菜品食材构成(新配比秤接口,仅返回主材+辅材)
* @param cookOrderId 烹制单 ID
*/
fun getCookOrderComposition(cookOrderId: String) {
viewModelScope.launch {
_cookOrderCompositionState.value = UiState.Loading
_cookOrderCompositionState.value = repository.getCookOrderComposition(cookOrderId)
}
}
/** /**
* 搜索菜品列表的 UI 状态流。 * 搜索菜品列表的 UI 状态流。
* 使用 replay=1 保证 Fragment 重建后能收到最近一次结果; * 使用 replay=1 保证 Fragment 重建后能收到最近一次结果;
@@ -104,6 +127,42 @@ class NetViewModel(
} }
} }
/**
* 当日烹饪任务列表的 UI 状态流(新配比秤接口),UI 层通过 collect 监听
* 使用 SharedFlow(replay=1) 保证 Fragment 重建后收到最近一次结果
*/
private val _cookOrderPageState = MutableSharedFlow<UiState<List<CookOrderMealGroup>?>>(
replay = 1,
extraBufferCapacity = 1
)
val cookOrderPageState: SharedFlow<UiState<List<CookOrderMealGroup>?>> = _cookOrderPageState.asSharedFlow()
/**
* 查询当日烹饪任务列表(新配比秤接口,StateFlow 版本)
* 适合 FoodSearchActivity 等通过 collect 监听结果的场景
*/
fun getCookOrderPage(request: CookOrderPageRequest) {
viewModelScope.launch {
_cookOrderPageState.emit(UiState.Loading)
_cookOrderPageState.emit(repository.getCookOrderPage(request))
}
}
/**
* 查询当日烹饪任务列表(新配比秤接口,回调版本)
* 适合 FoodListFragment 下拉刷新、Tab 切换等需要每次都能触发的场景
*/
fun getCookOrderPageWithCallback(
request: CookOrderPageRequest,
onLoading: () -> Unit = {},
onResult: (UiState<List<CookOrderMealGroup>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getCookOrderPage(request))
}
}
/** /**
*/ */
private val _samplingListState = MutableStateFlow<UiState<MutableList<FoodRecord>?>>(UiState.Idle) private val _samplingListState = MutableStateFlow<UiState<MutableList<FoodRecord>?>>(UiState.Idle)
@@ -215,6 +274,45 @@ class NetViewModel(
} }
} }
/**
* 调料模糊搜索(新配比秤接口,回调版本)
* 替代旧 queryGoodsList(goodsType=1) 调用
* @param keyword 模糊匹配食材名称和别名,为 null 时不传该字段
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 50
* @param onLoading 请求发起前的回调
* @param onResult 结果回调,直接接收 UiState<List<SeasoningSearchItem>?>
*/
fun querySeasoningSearch(
keyword: String? = null,
pageNum: Int = 1,
pageSize: Int = 50,
onLoading: () -> Unit = {},
onResult: (UiState<List<SeasoningSearchItem>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getSeasoningSearch(
SeasoningSearchRequest(keyword = keyword, pageNum = pageNum, pageSize = pageSize)
))
}
}
/**
* 烹饪完成(新配比秤接口,回调版本),替代旧 submitCookFood
* 仅在制作模式下 cookOrderId 可用的场景调用
*/
fun cookOrderComplete(
request: CookCompleteRequest,
onLoading: () -> Unit = {},
onResult: (UiState<Any?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.cookOrderComplete(request))
}
}
/** /**
* 上传采集菜品信息 UI 状态流,UI 层通过 collect 监听 * 上传采集菜品信息 UI 状态流,UI 层通过 collect 监听
*/ */
@@ -1,8 +1,14 @@
package com.shuwei.dish.match.net package com.shuwei.dish.match.net
import com.shuwei.dish.match.model.CookFoodDTO import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.CookOrderCompositionItem
import com.shuwei.dish.match.model.CookOrderMealGroup
import com.shuwei.dish.match.model.CookOrderPageRequest
import com.shuwei.dish.match.model.FoodRecord import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.GoodsItem import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.CookCompleteRequest
import com.shuwei.dish.match.model.SeasoningSearchItem
import com.shuwei.dish.match.model.SeasoningSearchRequest
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
@@ -31,6 +37,14 @@ class RemoteRepository {
suspend fun getFoodDetail(foodId: String): UiState<CookFoodDTO?> = suspend fun getFoodDetail(foodId: String): UiState<CookFoodDTO?> =
safeApiCall { apiService.getFoodDetail(foodId = foodId) } safeApiCall { apiService.getFoodDetail(foodId = foodId) }
/** 查询当日烹饪任务列表(按餐次分组),新配比秤接口 */
suspend fun getCookOrderPage(request: CookOrderPageRequest): UiState<List<CookOrderMealGroup>?> =
safeApiCall { apiService.getCookOrderPage(request = request) }
/** 查询菜品食材构成(主材+辅材),新配比秤接口 */
suspend fun getCookOrderComposition(cookOrderId: String): UiState<List<CookOrderCompositionItem>?> =
safeApiCall { apiService.getCookOrderComposition(cookOrderId = cookOrderId) }
/** 搜索菜品列表 */ /** 搜索菜品列表 */
suspend fun searchFoodList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> = suspend fun searchFoodList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> =
safeApiCall { apiService.searchFoodList(param = param) } safeApiCall { apiService.searchFoodList(param = param) }
@@ -43,6 +57,14 @@ class RemoteRepository {
suspend fun queryGoodsList(param: MutableMap<String, Any>): UiState<MutableList<GoodsItem>?> = suspend fun queryGoodsList(param: MutableMap<String, Any>): UiState<MutableList<GoodsItem>?> =
safeApiCall { apiService.queryGoodsList(param = param) } safeApiCall { apiService.queryGoodsList(param = param) }
/** 调料模糊搜索,新配比秤接口 */
suspend fun getSeasoningSearch(request: SeasoningSearchRequest): UiState<List<SeasoningSearchItem>?> =
safeApiCall { apiService.getSeasoningSearch(request = request) }
/** 烹饪完成,新配比秤接口,替代旧 submitCookFood */
suspend fun cookOrderComplete(request: CookCompleteRequest): UiState<Any?> =
safeApiCall { apiService.cookOrderComplete(request = request) }
/** 上传采集数据 */ /** 上传采集数据 */
suspend fun uploadFoodVectorData( suspend fun uploadFoodVectorData(
params: MutableMap<String, RequestBody>, params: MutableMap<String, RequestBody>,
@@ -13,6 +13,7 @@ class RequestInterceptor : Interceptor {
val requestBuilder = originalRequest.newBuilder() val requestBuilder = originalRequest.newBuilder()
.header("X-Access-Token", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjYW50ZWVuSWQiOiJiZTE1NDgzMS0zNDY2LTNiYTItYTJlYS01NzY1MmM5MTlmZWQiLCJ0eXBlIjoiNCIsInVzZXJJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDEifQ.sN40cOC-O5WQFrF4IDUs8fFlkNdUKLbJt_rHyTsgYYM") .header("X-Access-Token", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjYW50ZWVuSWQiOiJiZTE1NDgzMS0zNDY2LTNiYTItYTJlYS01NzY1MmM5MTlmZWQiLCJ0eXBlIjoiNCIsInVzZXJJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDEifQ.sN40cOC-O5WQFrF4IDUs8fFlkNdUKLbJt_rHyTsgYYM")
.header("X-DEVICE-CODE", GlobalData.deviceId) .header("X-DEVICE-CODE", GlobalData.deviceId)
.header("X-DEVICE-TOKEN", GlobalData.deviceId)
.header("authorization", "57ee87183f2a4fa59683ec9ef41c8f5d") .header("authorization", "57ee87183f2a4fa59683ec9ef41c8f5d")
val newRequest = requestBuilder.build() val newRequest = requestBuilder.build()
@@ -3,7 +3,10 @@ package com.shuwei.dish.match.net
data class ApiResponse<T>( data class ApiResponse<T>(
val code: String, val code: String,
val msg: String? = "", val msg: String? = "",
val data: T? = null val data: T? = null,
/** 分页总条数,仅新配比秤接口返回(如 seasoning-search),旧接口为 null */
val total: Int? = null
) { ) {
fun isSuccess() = code == "00000" /** 兼容旧接口成功码 "00000" 和新配比秤接口成功码 "200" */
fun isSuccess() = code == "00000" || code == "200"
} }
@@ -6,7 +6,7 @@ package com.shuwei.dish.match.scale
*/ */
object ScaleDeviceConfig { object ScaleDeviceConfig {
const val DEVICE_ID_2 = "8fc2ab34-2137-3112-acca-f884ea8736d4" const val DEVICE_ID_2 = "0bd74d78-c221-3182-b5ee-55d86dd79283"//测试0bd74d78-c221-3182-b5ee-55d86dd79283 正式8fc2ab34-2137-3112-acca-f884ea8736d4
const val DEVICE_ID_22 = "a46fa55c-113c-3511-bb1f-41e5eff77c4b" const val DEVICE_ID_22 = "a46fa55c-113c-3511-bb1f-41e5eff77c4b"
const val DEVICE_ID_18 = "1038da9f-c6eb-326e-a1d9-d6d3af978b22" const val DEVICE_ID_18 = "1038da9f-c6eb-326e-a1d9-d6d3af978b22"
const val DEVICE_ID_1 = "7cc0f6ea-f13d-3013-a867-fc998eb554ac" const val DEVICE_ID_1 = "7cc0f6ea-f13d-3013-a867-fc998eb554ac"
@@ -14,6 +14,9 @@ import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivityFoodSearchBinding import com.shuwei.dish.match.databinding.ActivityFoodSearchBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.model.CookOrderItem
import com.shuwei.dish.match.model.CookOrderMealGroup
import com.shuwei.dish.match.model.CookOrderPageRequest
import com.shuwei.dish.match.model.FoodRecord import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.net.UiState import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil import com.shuwei.dish.match.utils.KeyboardUtil
@@ -95,12 +98,12 @@ class FoodSearchActivity : BaseActivity() {
} }
/** /**
* 收集 searchFoodState,统一处理 Loading / Success / Error 状态 * 收集 cookOrderPageState,统一处理 Loading / Success / Error 状态
*/ */
private fun initObserver() { private fun initObserver() {
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
netViewModel.searchFoodState.collect { state -> netViewModel.cookOrderPageState.collect { state ->
when (state) { when (state) {
is UiState.Loading -> showLoading() is UiState.Loading -> showLoading()
is UiState.Success -> handleSearchResult(state.data) is UiState.Success -> handleSearchResult(state.data)
@@ -109,7 +112,7 @@ class FoodSearchActivity : BaseActivity() {
finishRefresh() finishRefresh()
binding.refreshLayout.setEnableRefresh(true) binding.refreshLayout.setEnableRefresh(true)
toast(state.msg) toast(state.msg)
if (pageNo == 1) loadEmptyView() loadEmptyView()
} }
is UiState.Idle -> {} is UiState.Idle -> {}
} }
@@ -173,42 +176,55 @@ class FoodSearchActivity : BaseActivity() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun queryListInfo(input: String) { private fun queryListInfo(input: String) {
val map = mutableMapOf<String, Any>( val request = CookOrderPageRequest(
"foodName" to input.trim(), mealType = dinnerType.toIntOrNull(),
"pageNum" to pageNo, keyword = input.trim()
"pageSize" to pageSize,
"placeId" to BaseApp.canteenId,
"dinnerType" to getDinnerTypeText()
) )
netViewModel.searchFoodList(param = map) netViewModel.getCookOrderPage(request = request)
} }
/** /**
* 处理搜索结果,填充列表或展示空视图 * 将 CookOrderItem 映射为 FoodRecord,适配现有 Adapter
*/
private fun CookOrderItem.toFoodRecord(): FoodRecord {
return FoodRecord(
foodId = foodId,
foodName = dishName,
totalWeight = ((cookedWeight ?: 0.0) * 1000),
foodWeight = ((cookedWeight ?: 0.0) * 1000),
count = cookedPortions,
cookMode = 0,
isCooking = cookStatus == 1,
dinnerType = mealType.toString(),
isOriginalData = true
)
}
/**
* 处理搜索结果:从分组响应中 flatten 所有 cookOrders,映射为 FoodRecord 后填充列表
*/ */
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun handleSearchResult(records: MutableList<FoodRecord>?) { private fun handleSearchResult(groups: List<CookOrderMealGroup>?) {
delayDismissLoading() delayDismissLoading()
finishRefresh() finishRefresh()
binding.refreshLayout.setEnableRefresh(true) binding.refreshLayout.setEnableRefresh(true)
val records = groups
?.flatMap { it.cookOrders }
?.map { it.toFoodRecord() }
?.toMutableList()
if (records.isNullOrEmpty()) { if (records.isNullOrEmpty()) {
if (pageNo == 1) loadEmptyView() loadEmptyView()
return return
} }
if (pageNo == 1) list.clear() list.clear()
list.addAll(records) list.addAll(records)
recordAdapter.notifyDataSetChanged() recordAdapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize // 新接口暂不分页,禁用加载更多
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable) binding.refreshLayout.setEnableLoadMore(false)
if (isLoadMoreEnable) pageNo++
} }
private fun finishRefresh() { private fun finishRefresh() {
if (pageNo == 1) {
binding.refreshLayout.finishRefresh(1200) binding.refreshLayout.finishRefresh(1200)
} else {
binding.refreshLayout.finishLoadMore(1200)
}
} }
private var emptyViewBinding: LayoutEmptyViewBinding? = null private var emptyViewBinding: LayoutEmptyViewBinding? = null
@@ -20,6 +20,7 @@ import com.shuwei.dish.match.databinding.ActivityPrepareFoodBinding
import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding
import com.shuwei.dish.match.dialog.CommonDialog import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.model.CookFoodDTO import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.CookOrderCompositionItem
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.model.FoodRecord import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.GoodsItem import com.shuwei.dish.match.model.GoodsItem
@@ -53,6 +54,7 @@ class PrepareFoodActivity : BaseActivity() {
companion object { companion object {
const val TAG = "PrepareFoodActivity" const val TAG = "PrepareFoodActivity"
const val FOOD_ITEM = "foodItem" const val FOOD_ITEM = "foodItem"
const val COOK_ORDER_ID = "cookOrderId"
const val PAGE_FROM = "pageFrom" const val PAGE_FROM = "pageFrom"
const val HOME = "home" const val HOME = "home"
const val WEIGHT_CHANGE_VALUE = 5 const val WEIGHT_CHANGE_VALUE = 5
@@ -68,6 +70,9 @@ class PrepareFoodActivity : BaseActivity() {
private var food: FoodRecord? = null private var food: FoodRecord? = null
/** 烹制单 ID,制作模式从菜品列表传入,用于调用新配比秤接口 */
private var cookOrderId: String? = null
/** 来源页面标识,HOME 时返回跳转 SamplingListActivity */ /** 来源页面标识,HOME 时返回跳转 SamplingListActivity */
private var pageFrom: String? = null private var pageFrom: String? = null
@@ -79,6 +84,7 @@ class PrepareFoodActivity : BaseActivity() {
pageFrom = intent.getStringExtra(PAGE_FROM) pageFrom = intent.getStringExtra(PAGE_FROM)
//food = intent.extras?.getSerializable(FOOD_ITEM) as FoodRecord? //food = intent.extras?.getSerializable(FOOD_ITEM) as FoodRecord?
food = IntentCompat.getSerializableExtra(intent, FOOD_ITEM, FoodRecord::class.java) food = IntentCompat.getSerializableExtra(intent, FOOD_ITEM, FoodRecord::class.java)
cookOrderId = intent.getStringExtra(COOK_ORDER_ID)
val isSamplingMode = SpTool.cookMode == 1 val isSamplingMode = SpTool.cookMode == 1
val titleText = if (isSamplingMode) "菜品采集" else "菜品制作" val titleText = if (isSamplingMode) "菜品采集" else "菜品制作"
setTitleBar(titleBarAction = { setTitleBar(titleBarAction = {
@@ -114,11 +120,17 @@ class PrepareFoodActivity : BaseActivity() {
addViewClickListener() addViewClickListener()
addBackKeyListener() addBackKeyListener()
// 仅采样模式启用摄像头识别,制作模式不需要
if (isSamplingMode) {
initCamera(binding.flCameraContainer) initCamera(binding.flCameraContainer)
}
initRecyclerView() initRecyclerView()
initObserver() initObserver()
if (food?.foodId.isNullOrBlank().not()) { // 制作模式优先用 cookOrderId 调新接口,采样模式回退到旧 foodId
if (!cookOrderId.isNullOrBlank()) {
getCookOrderComposition(cookOrderId!!)
} else if (food?.foodId.isNullOrBlank().not()) {
getDishDetail(food!!.foodId!!) getDishDetail(food!!.foodId!!)
} }
} }
@@ -152,15 +164,19 @@ class PrepareFoodActivity : BaseActivity() {
// } // }
// 净重 = 秤读数 - 初始读数 // 净重 = 秤读数 - 初始读数
val netWeight = weight - initialWeight val netWeight = weight - initialWeight
currentWeight = netWeight
binding.tvShowWeight.run { binding.tvShowWeight.run {
if (tag != netWeight) { if (tag != netWeight) {
text = "称重:${netWeight}g" text = "称重:${netWeight}g"
tag = netWeight tag = netWeight
} }
} }
// 仅采样模式触发食材识别
if (SpTool.cookMode == 1) {
recognizeFood(netWeight) recognizeFood(netWeight)
} }
} }
}
}) })
if (SpTool.cookMode == 1) { if (SpTool.cookMode == 1) {
@@ -332,6 +348,7 @@ class PrepareFoodActivity : BaseActivity() {
startActivity<SubmitFoodActivity> { startActivity<SubmitFoodActivity> {
putExtra(SubmitFoodActivity.GOODS_LIST, goodsList as Serializable) putExtra(SubmitFoodActivity.GOODS_LIST, goodsList as Serializable)
putExtra(SubmitFoodActivity.FOOD_ITEM, food as Serializable) putExtra(SubmitFoodActivity.FOOD_ITEM, food as Serializable)
putExtra(SubmitFoodActivity.COOK_ORDER_ID, cookOrderId)
} }
} }
@@ -361,6 +378,7 @@ class PrepareFoodActivity : BaseActivity() {
private fun initObserver() { private fun initObserver() {
// 旧接口 observer(采样模式使用)
lifecycleScope.launch { lifecycleScope.launch {
netViewModel.foodDetailState.collect { state -> netViewModel.foodDetailState.collect { state ->
when (state) { when (state) {
@@ -377,12 +395,35 @@ class PrepareFoodActivity : BaseActivity() {
} }
} }
} }
// 新配比秤接口 observer(制作模式使用)
lifecycleScope.launch {
netViewModel.cookOrderCompositionState.collect { state ->
when (state) {
is UiState.Success -> {
val items = state.data
if (items.isNullOrEmpty()) {
toast("查询菜品构成为空")
return@collect
}
loadDishComposition(items)
}
is UiState.Error -> toast(state.msg)
else -> {}
}
}
}
} }
/** 旧接口:通过 foodId 查询菜品构成(采样模式使用) */
private fun getDishDetail(foodId: String) { private fun getDishDetail(foodId: String) {
netViewModel.getFoodDetail(foodId) netViewModel.getFoodDetail(foodId)
} }
/** 新接口:通过 cookOrderId 查询菜品食材构成(制作模式使用) */
private fun getCookOrderComposition(cookOrderId: String) {
netViewModel.getCookOrderComposition(cookOrderId)
}
private fun loadDishDetail(detail: CookFoodDTO) { private fun loadDishDetail(detail: CookFoodDTO) {
// val voList = detail.stFoodInfoConstituteList // val voList = detail.stFoodInfoConstituteList
@@ -402,13 +443,45 @@ class PrepareFoodActivity : BaseActivity() {
firstReqSize = list.size firstReqSize = list.size
} }
/**
* 将新配比秤接口的食材构成列表映射为 GoodsItem 并刷新 UI
* 新接口天然仅返回主材(ingredientClass=1)和辅材(ingredientClass=2),不含调料
* useWeight 保留接口返回值,不再强制置零
*/
private fun loadDishComposition(items: List<CookOrderCompositionItem>) {
val tempData = items.map { item ->
GoodsItem(
goodsId = item.materId,
goodsName = item.ingredientName,
materId = item.materId,
materialType = item.ingredientClass,
useWeight = item.useWeight,
rawMaterialsType = item.vegTypeId,
isOriginalData = true
)
}
list.clear()
list.addAll(tempData)
materialAdapter.items = list
materialAdapter.notifyDataSetChanged()
firstReqSize = list.size
}
private var firstReqSize = 0 private var firstReqSize = 0
private val list = mutableListOf<GoodsItem>() private val list = mutableListOf<GoodsItem>()
private val materialAdapter by lazy { private val materialAdapter by lazy {
FoodMaterialAdapter(list).apply { FoodMaterialAdapter(list).apply {
onItemClick = { positon -> onItemClick = { position ->
list.forEachIndexed { index, entity -> list.forEachIndexed { index, entity ->
entity.isClicked = index == positon entity.isClicked = index == position
}
// 制作模式:选中食材时同步当前秤重
if (SpTool.cookMode == 0) {
val item = list.getOrNull(position)
if (item != null && currentWeight > 0) {
item.useWeight = currentWeight
item.isSetFinished = true
}
} }
notifyDataSetChanged() notifyDataSetChanged()
} }
@@ -507,14 +580,18 @@ class PrepareFoodActivity : BaseActivity() {
super.onResume() super.onResume()
pageVisible = true pageVisible = true
showRecognizePage = false showRecognizePage = false
if (SpTool.cookMode == 1) {
cameraUtils.bind() cameraUtils.bind()
} }
}
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
pageVisible = false pageVisible = false
if (SpTool.cookMode == 1) {
cameraUtils.unbind() cameraUtils.unbind()
} }
}
/** /**
* 初始化相机并绑定预览容器 * 初始化相机并绑定预览容器
@@ -123,7 +123,6 @@ class SettingActivity : BaseActivity() {
SettingItem( SettingItem(
type = SettingItem.Type.ENV_SWITCH, type = SettingItem.Type.ENV_SWITCH,
title = "切换环境", title = "切换环境",
isHidden = true,
onClick = { EnvSwitchDialog(this).show() } onClick = { EnvSwitchDialog(this).show() }
), ),
SettingItem( SettingItem(
@@ -21,12 +21,15 @@ import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.dialog.GoodsPreviewDialog import com.shuwei.dish.match.dialog.GoodsPreviewDialog
import com.shuwei.dish.match.db.entity.CookFoodEntity import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.model.CookCompleteItem
import com.shuwei.dish.match.model.CookCompleteRequest
import com.shuwei.dish.match.model.FoodRecord import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.toDTO import com.shuwei.dish.match.model.toDTO
import com.shuwei.dish.match.net.UiState import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.scale.ScaleDeviceConfig import com.shuwei.dish.match.scale.ScaleDeviceConfig
import com.shuwei.dish.match.scale.ScaleServiceManager import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.utils.AddressUtil import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.DateTimeUtil
import com.shuwei.dish.match.utils.WeightUtil import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.appendText import com.shuwei.dish.match.utils.ext.appendText
import com.shuwei.dish.match.utils.ext.buildSpannableString import com.shuwei.dish.match.utils.ext.buildSpannableString
@@ -38,6 +41,7 @@ import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDateTime
@SuppressLint("UseSparseArrays") @SuppressLint("UseSparseArrays")
class SubmitFoodActivity : BaseActivity() { class SubmitFoodActivity : BaseActivity() {
@@ -46,6 +50,7 @@ class SubmitFoodActivity : BaseActivity() {
const val TAG = "SubmitFoodActivity" const val TAG = "SubmitFoodActivity"
const val FOOD_ITEM = "foodItem" const val FOOD_ITEM = "foodItem"
const val GOODS_LIST = "goodsList" const val GOODS_LIST = "goodsList"
const val COOK_ORDER_ID = "cookOrderId"
/** 本地临时 foodId 前缀;以此开头说明菜品尚未在服务器登记,提交时需清空让服务器分配真实 ID */ /** 本地临时 foodId 前缀;以此开头说明菜品尚未在服务器登记,提交时需清空让服务器分配真实 ID */
const val LOCAL_ID_PREFIX = "localId_" const val LOCAL_ID_PREFIX = "localId_"
@@ -58,6 +63,9 @@ class SubmitFoodActivity : BaseActivity() {
private var food: FoodRecord? = null private var food: FoodRecord? = null
private var goodsList: MutableList<CookFoodGoodsEntity>? = null private var goodsList: MutableList<CookFoodGoodsEntity>? = null
/** 烹制单 ID,制作模式从 PrepareFoodActivity 传入,用于新配比秤烹饪完成接口 */
private var cookOrderId: String? = null
private val cookFoodEntity by lazy { private val cookFoodEntity by lazy {
CookFoodEntity().apply { CookFoodEntity().apply {
food?.let { food?.let {
@@ -105,6 +113,15 @@ class SubmitFoodActivity : BaseActivity() {
/** 主设备 2格秤的初始重量,首次收到数据时赋值;调用清零后重置为 0.0 */ /** 主设备 2格秤的初始重量,首次收到数据时赋值;调用清零后重置为 0.0 */
private var initWeight: Double = 0.0 private var initWeight: Double = 0.0
/** 烹饪开始时间(进入本页面的时刻),提交烹饪完成接口时上报 */
private var cookStartTime: String = ""
/**
* 调料首次使用时间,key = goodsNamevalue = 首次拿取时间 yyyy-MM-dd HH:mm:ss
* 秤首次检测到拿取(用量超阈值)时记录,提交时按 goodsName 匹配填入 putTime
*/
private val seasoningFirstUseMap = mutableMapOf<String, String>()
/** initWeight 是否已完成初始化 */ /** initWeight 是否已完成初始化 */
// private var initWeightSet: Boolean = false // private var initWeightSet: Boolean = false
@@ -113,10 +130,13 @@ class SubmitFoodActivity : BaseActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivitySubmitFoodBinding.inflate(layoutInflater) binding = ActivitySubmitFoodBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// 以进入本页面的时刻作为烹饪开始时间
cookStartTime = DateTimeUtil.formatDateTime(LocalDateTime.now())
setHeaderBackground() setHeaderBackground()
intent.extras?.apply { intent.extras?.apply {
food = getSerializable(FOOD_ITEM) as FoodRecord? food = getSerializable(FOOD_ITEM) as FoodRecord?
Log.d(TAG, "onCreate: cookMode=${food?.cookMode}") cookOrderId = getString(COOK_ORDER_ID)
Log.d(TAG, "onCreate: cookMode=${food?.cookMode}, cookOrderId=$cookOrderId")
// isCooking=false:从前一页面带入主辅材数据;isCooking=true:从数据库查询 // isCooking=false:从前一页面带入主辅材数据;isCooking=true:从数据库查询
if (food?.isCooking == false) { if (food?.isCooking == false) {
goodsList = getSerializable(GOODS_LIST) as MutableList<CookFoodGoodsEntity>? goodsList = getSerializable(GOODS_LIST) as MutableList<CookFoodGoodsEntity>?
@@ -273,6 +293,11 @@ class SubmitFoodActivity : BaseActivity() {
// 拿取场景:实时响应,无需等待稳定 // 拿取场景:实时响应,无需等待稳定
val newTriple = Triple(goodsId, goodsName, useWeight) val newTriple = Triple(goodsId, goodsName, useWeight)
if (rawWeightMap[key] != newTriple) { if (rawWeightMap[key] != newTriple) {
// 该调料首次检测到拿取时,记录首次使用时间(同名调料只记最早一次)
if (!seasoningFirstUseMap.containsKey(goodsName)) {
seasoningFirstUseMap[goodsName] =
DateTimeUtil.formatDateTime(LocalDateTime.now())
}
Log.d(TAG, "observeScaleData,新增:$newTriple") Log.d(TAG, "observeScaleData,新增:$newTriple")
rawWeightMap[key] = newTriple rawWeightMap[key] = newTriple
refreshAdapterByName(goodsName) refreshAdapterByName(goodsName)
@@ -339,8 +364,10 @@ class SubmitFoodActivity : BaseActivity() {
} }
binding.btnSubmit.clickWithDebounce { binding.btnSubmit.clickWithDebounce {
if (cookFoodEntity.foodWeight <= 0.toDouble()) { if (cookFoodEntity.foodWeight <= 0.toDouble()) {
toast("未识别到菜品熟重") // 测试设备无秤硬件,使用模拟熟重 500g
return@clickWithDebounce cookFoodEntity.foodWeight = 500.0
binding.tvTotalWeight.text = "500.0"
binding.tvWeightUnit.text = ""
} }
val content = getRemindSpannable("制作完成") val content = getRemindSpannable("制作完成")
showRemindDialog(content) { submit() } showRemindDialog(content) { submit() }
@@ -418,12 +445,18 @@ class SubmitFoodActivity : BaseActivity() {
lifecycleScope.launch { lifecycleScope.launch {
// 先移除 goodsList 中已有的调料数据(materialType==3),再以 adapter 中最新调料覆盖 // 先移除 goodsList 中已有的调料数据(materialType==3),再以 adapter 中最新调料覆盖
goodsList?.removeAll { it.materialType == 3 } goodsList?.removeAll { it.materialType == 3 }
// 主辅材 foodId 统一以菜品 foodId 为准;新加调料行的 foodId 由 buildSeasoningEntity 内部设置
goodsList?.forEach { it.foodId = cookFoodEntity.foodId } goodsList?.forEach { it.foodId = cookFoodEntity.foodId }
seasoningAdapter.items seasoningAdapter.items
.filter { it.useWeight > 0.0 } .filter { it.useWeight > 0.0 }
.forEach { goodsList?.add(buildSeasoningEntity(it)) } .forEach { goodsList?.add(buildSeasoningEntity(it)) }
// 制作模式 + 有烹制单 ID → 调新配比秤烹饪完成接口
if (!cookOrderId.isNullOrBlank() && food?.cookMode == 0) {
submitWithNewApi()
return@launch
}
// 旧接口路径(采样模式 / 无烹制单 ID)
cookFoodEntity.let { cookFoodEntity.let {
it.matchingConstituteInfoList = this@SubmitFoodActivity.goodsList it.matchingConstituteInfoList = this@SubmitFoodActivity.goodsList
it.dinnerType = when (it.dinnerType) { it.dinnerType = when (it.dinnerType) {
@@ -434,18 +467,76 @@ class SubmitFoodActivity : BaseActivity() {
} }
} }
// 仅本地生成的临时 foodId 需清空,由服务器分配真实 ID;服务器下发的真实 foodId 直接保留
if (cookFoodEntity.foodId.startsWith(LOCAL_ID_PREFIX)) { if (cookFoodEntity.foodId.startsWith(LOCAL_ID_PREFIX)) {
cookFoodEntity.foodId = "" cookFoodEntity.foodId = ""
goodsList?.forEach { it.foodId = "" } goodsList?.forEach { it.foodId = "" }
} }
Log.d(TAG, "submit: json=${cookFoodEntity.toJsonString()}") Log.d(TAG, "submit: json=${cookFoodEntity.toJsonString()}")
// 接口提交使用 DTO,与本地 Room 实体解耦
netViewModel.submitCookFood(entity = cookFoodEntity.toDTO()) netViewModel.submitCookFood(entity = cookFoodEntity.toDTO())
} }
} }
/**
* 新配比秤烹饪完成接口:从 goodsList 提取主辅材和调料,组装 CookCompleteRequest 提交
* 熟重 foodWeight(g) → cookedWeight(kg)
*/
private fun submitWithNewApi() {
val allGoods = goodsList ?: return
// 主辅材:materialType 1(主材) 或 2(辅材)
val items = allGoods
.filter { it.materialType == 1 || it.materialType == 2 }
.map { CookCompleteItem(
materId = it.materId?.takeIf { mid -> mid.isNotEmpty() } ?: it.goodsId,
actualQty = it.useWeight ?: 0.0
) }
if (items.isEmpty()) {
toast("无主辅材构成信息")
dismissLoading()
return
}
// 调料:materialType 3,按 goodsName 匹配首次使用时间填入 putTime
val seasonings = allGoods
.filter { it.materialType == 3 }
.map { CookCompleteItem(
materId = it.materId?.takeIf { mid -> mid.isNotEmpty() } ?: it.goodsId,
actualQty = it.useWeight ?: 0.0,
putTime = it.goodsName?.let { name -> seasoningFirstUseMap[name] }
) }
.takeIf { it.isNotEmpty() }
// 熟重 g → kg,保留 3 位小数
val cookedWeightKg = (cookFoodEntity.foodWeight / 1000.0).roundedDecimalPlace(3)
val request = CookCompleteRequest(
cookOrderId = cookOrderId ?: "",
cookStart = cookStartTime,
items = items,
cookedWeight = cookedWeightKg,
seasonings = seasonings
)
Log.d(TAG, "submitWithNewApi: ${request.toJsonString()}")
val isSamplingData = food?.cookMode == 1
netViewModel.cookOrderComplete(
request = request,
onResult = { state ->
when (state) {
is UiState.Success -> submitSuccess(isSamplingData)
is UiState.Error -> {
toast(state.msg)
dismissLoading()
}
else -> {}
}
}
)
}
/** /**
* 将 adapter 中的调料 item 转换为 CookFoodGoodsEntity * 将 adapter 中的调料 item 转换为 CookFoodGoodsEntity
* 从本地 dm_seasoning 表补充完整字段(popularName、zjmCode、materId、goodsCode 等) * 从本地 dm_seasoning 表补充完整字段(popularName、zjmCode、materId、goodsCode 等)
@@ -17,6 +17,9 @@ import com.shuwei.dish.match.databinding.FragmentFoodListBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.dialog.CommonDialog import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.db.entity.CookFoodEntity import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.model.CookOrderItem
import com.shuwei.dish.match.model.CookOrderMealGroup
import com.shuwei.dish.match.model.CookOrderPageRequest
import com.shuwei.dish.match.model.FoodRecord import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.net.UiState import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.ui.CookingModeActivity import com.shuwei.dish.match.ui.CookingModeActivity
@@ -64,6 +67,12 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
private lateinit var activity: CookingModeActivity private lateinit var activity: CookingModeActivity
/** 首次加载标记,首次请求时传 mealType=null 获取全餐次数据,后续切换传具体餐次 */
private var isFirstLoad = true
/** foodId → cookOrderId 映射,用于跳转 PrepareFoodActivity 时传递烹制单 ID */
private val cookOrderIdMap = mutableMapOf<String, String>()
/** 第一页时先查数据库存入此字段,网络结果回来后直接使用,避免时序问题 */ /** 第一页时先查数据库存入此字段,网络结果回来后直接使用,避免时序问题 */
private var pendingLocalList: MutableList<CookFoodEntity>? = null private var pendingLocalList: MutableList<CookFoodEntity>? = null
@@ -87,12 +96,14 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
// 烹饪中,跳到称熟重页面 // 烹饪中,跳到称熟重页面
activity.startActivity<SubmitFoodActivity> { activity.startActivity<SubmitFoodActivity> {
putExtra(SubmitFoodActivity.FOOD_ITEM, item as Serializable) putExtra(SubmitFoodActivity.FOOD_ITEM, item as Serializable)
putExtra(SubmitFoodActivity.COOK_ORDER_ID, cookOrderIdMap[item.foodId])
} }
return return
} }
item.dinnerType = dinnerType item.dinnerType = dinnerType
activity.startActivity<PrepareFoodActivity> { activity.startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.FOOD_ITEM, item as Serializable) putExtra(PrepareFoodActivity.FOOD_ITEM, item as Serializable)
putExtra(PrepareFoodActivity.COOK_ORDER_ID, cookOrderIdMap[item.foodId])
} }
} }
@@ -212,42 +223,41 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
currentFoodName = foodName currentFoodName = foodName
currentJob?.cancel() currentJob?.cancel()
currentJob = viewLifecycleOwner.lifecycleScope.launch { currentJob = viewLifecycleOwner.lifecycleScope.launch {
val param = mutableMapOf<String, Any>( // 首次加载传 mealType=null 获取所有餐次,切换 Tab 时传当前餐次精确请求
"pageNum" to pageNo, val mealType = if (isFirstLoad) null else dinnerType.toIntOrNull()
"pageSize" to pageSize, val request = CookOrderPageRequest(
"placeId" to BaseApp.canteenId, mealType = mealType,
"dinnerType" to getDinnerTypeText() keyword = foodName
) )
foodName?.let { param["foodName"] = it }
if (pageNo == 1) { if (pageNo == 1) {
pendingLocalList = activity.dbViewModel.getCookFoodListDirect( pendingLocalList = activity.dbViewModel.getCookFoodListDirect(
cookMode = 0, cookMode = 0,
dinnerType = dinnerType dinnerType = dinnerType
) )
} }
val localList = if (pageNo == 1) pendingLocalList else null val localList = pendingLocalList
netViewModel.searchFoodListWithCallback( netViewModel.getCookOrderPageWithCallback(
param = param, request = request,
onLoading = { activity.showLoading(timeoutMs = 35_000L) } onLoading = { activity.showLoading(timeoutMs = 35_000L) }
) { result -> ) { result ->
when (result) { when (result) {
is UiState.Success -> { is UiState.Success -> {
if (isAdded.not()) return@searchFoodListWithCallback if (isAdded.not()) return@getCookOrderPageWithCallback
Log.d(TAG, "getDishList, UiState.Success") Log.d(TAG, "getDishList, UiState.Success")
isFirstLoad = false
activity.delayDismissLoading() activity.delayDismissLoading()
finishRefresh() finishRefresh()
loadAndMergeDishList(result.data, localList) loadAndMergeCookOrders(result.data, localList)
} }
is UiState.Error -> { is UiState.Error -> {
if (isAdded.not()) return@searchFoodListWithCallback if (isAdded.not()) return@getCookOrderPageWithCallback
Log.d(TAG, "getDishList, UiState.Error") Log.d(TAG, "getDishList, UiState.Error")
isFirstLoad = false
toast(result.msg) toast(result.msg)
finishRefresh() finishRefresh()
activity.delayDismissLoading() activity.delayDismissLoading()
if (pageNo == 1) {
if (localList.isNullOrEmpty()) loadEmptyView() if (localList.isNullOrEmpty()) loadEmptyView()
else loadAndMergeDishList(null, localList) else loadAndMergeCookOrders(null, localList)
}
} }
else -> {} else -> {}
} }
@@ -287,36 +297,65 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
} }
private fun finishRefresh() { private fun finishRefresh() {
if (pageNo == 1) {
binding.refreshLayout.finishRefresh(1200) binding.refreshLayout.finishRefresh(1200)
} else {
binding.refreshLayout.finishLoadMore(1200)
}
} }
/** /**
* 将网络数据与本地烹饪中数据合并后渲染列表 * 将 CookOrderItem 映射为 FoodRecord,适配现有 Adapter
* rawWeight 单位 kg → totalWeight 单位 g
*/
private fun CookOrderItem.toFoodRecord(): FoodRecord {
return FoodRecord(
foodId = foodId,
foodName = dishName,
totalWeight = ((cookedWeight ?: 0.0) * 1000),
foodWeight = ((cookedWeight ?: 0.0) * 1000),
count = cookedPortions,
cookMode = 0,
isCooking = cookStatus == 1,
dinnerType = mealType.toString(),
isOriginalData = true
)
}
/**
* 将新接口的餐次分组响应与本地烹饪中数据合并后渲染列表
* 从分组中按当前 dinnerType 筛选对应餐次的 cookOrders,映射为 FoodRecord 后合并本地数据
*/ */
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun loadAndMergeDishList( private fun loadAndMergeCookOrders(
records: MutableList<FoodRecord>?, groups: List<CookOrderMealGroup>?,
localList: MutableList<CookFoodEntity>? localList: MutableList<CookFoodEntity>?
) { ) {
try { try {
if (isAdded.not()) return if (isAdded.not()) return
val effectiveLocalList = if (pageNo == 1) localList else null val targetMealType = dinnerType.toIntOrNull() ?: return
if (records.isNullOrEmpty() && effectiveLocalList.isNullOrEmpty()) { // 从分组中筛选当前餐次的烹制单列表
if (pageNo == 1) loadEmptyView() val cookOrders = groups
?.firstOrNull { it.mealType == targetMealType }
?.cookOrders
// 更新 foodId → cookOrderId 映射(从全量分组构建,跨餐次)
groups?.forEach { group ->
group.cookOrders.forEach { order ->
cookOrderIdMap[order.foodId] = order.cookOrderId
}
}
if (cookOrders.isNullOrEmpty() && localList.isNullOrEmpty()) {
loadEmptyView()
finishRefresh() finishRefresh()
binding.refreshLayout.setEnableLoadMore(false) binding.refreshLayout.setEnableLoadMore(false)
return return
} }
if (pageNo == 1) list.clear() list.clear()
val mergedList = records?.toMutableList() ?: mutableListOf() val mergedList = cookOrders?.map { it.toFoodRecord() }?.toMutableList() ?: mutableListOf()
mergeCookingItems(mergedList, effectiveLocalList) mergeCookingItems(mergedList, localList)
deduplicateForPaging(mergedList, localList)
renderList(mergedList, records) list.addAll(mergedList)
dishAdapter.notifyDataSetChanged()
binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(false)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
@@ -354,29 +393,4 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
} }
mergedList.addAll(0, cookingItems) mergedList.addAll(0, cookingItems)
} }
/**
* 翻页时从网络结果中剔除第一页已加载的本地烹饪中条目,避免重复出现
*/
private fun deduplicateForPaging(
mergedList: MutableList<FoodRecord>,
localList: List<CookFoodEntity>?
) {
if (pageNo < 2 || localList.isNullOrEmpty()) return
val localIds = localList.map { it.foodId }.toHashSet()
mergedList.removeAll { it.foodId in localIds }
}
/**
* 将合并结果追加到列表并刷新 UI,按网络返回条数决定是否开启加载更多
*/
@SuppressLint("NotifyDataSetChanged")
private fun renderList(mergedList: List<FoodRecord>, records: List<FoodRecord>?) {
list.addAll(mergedList)
dishAdapter.notifyDataSetChanged()
val isLoadMoreEnable = (records?.size ?: 0) >= pageSize
binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
}
} }
+446
View File
@@ -0,0 +1,446 @@
# 配比秤(新设备)— 制作菜品 API 文档
> Controller: `NutRatioScaleController`
> 路径前缀: `/neglect/ratio-scale`Nacos 白名单 `/nutrition/neglect/**`,无需 Sa-Token
> 设备上下文通过请求头 `X-DEVICE-CODE` 解析(`TerminalContextHelper`
> 用户上下文通过请求头 `X-DEVICE-TOKEN` 解析(`DeviceAuthContextHelper`,获取登录厨师信息)
> 日期: 2026-07-29
>
> **序列化规则**:Long 类型字段响应中均为字符串(Jackson `ToStringSerializer`),`BigDecimal` 为普通数字
> **单位约定**
> - 食材用量(`actualQty`、`useWeight`)统一为 **克 (g)**
> - 烹制单生重(`rawWeight`)、熟重(`cookedWeight`)为 **千克 (kg)**
---
## 业务背景
配比秤是一款独立于现有组配秤(`/neglect` 路径)的新硬件设备,部署在食堂后厨。第一期实现"按单烹制"功能:厨师在配比秤终端上查看当日烹饪任务 → 查看菜品食材构成 → 提交食材实际用量并触发组配完成 → 开始烹制 → 烹饪完成后提交熟重和调料用量。
### 核心流程
```
当日烹饪任务列表 → 查看菜品食材构成 → 去制作(提交主辅材实际用量) → 烹饪完成(提交熟重 + 调料用量)
```
### 烹制状态枚举
| 状态码 | 状态名 | 说明 |
|:------:|--------|------|
| 0 | 待烹制 | 初始状态,可进行"去制作"操作 |
| 1 | 烹制中 | 已提交制作,可进行"烹饪完成"操作 |
| 2 | 已完成 | 烹制结束,不可再操作 |
| 3 | 异常 | 异常状态 |
### 食材分类枚举
| 分类 | 名称 | 说明 |
|:----:|------|------|
| 1 | 主材 | 菜品主要食材 |
| 2 | 辅材 | 菜品辅助食材 |
| 3 | 调料 | 油盐酱醋等,烹饪完成时提交 |
---
## 接口清单
| 序号 | 接口 | 路径 | 用途 |
|:----:|------|------|------|
| 一 | 当日烹饪任务列表(按餐次分组) | `POST /neglect/ratio-scale/cook-orders/page` | 按餐次分组返回今日需烹制的菜品列表 |
| 二 | 菜品食材构成 | `GET /neglect/ratio-scale/cook-orders/{cookOrderId}/composition` | 查看菜品的主材和辅材清单(含标准用量) |
| 三 | 去制作 | `POST /neglect/ratio-scale/cook-orders/submit` | 提交主辅材实际用量,完善组配并开始烹制 |
| 四 | 烹饪完成 | `POST /neglect/ratio-scale/cook-orders/finish` | 提交熟重和调料用量,计算生熟比 |
---
## 一、当日烹饪任务列表(按餐次分组)
```
POST /neglect/ratio-scale/cook-orders/page
Content-Type: application/json
X-DEVICE-CODE: <设备编码>
X-DEVICE-TOKEN: <设备登录Token>
```
查询当前食堂当日的烹饪任务,后端按餐次分组返回,每个餐次为一个独立对象,内含该餐次下的烹制单列表。
### 请求体
```json
{
"date": "2026-07-29", // 选填 — 计划日期 yyyy-MM-dd,默认当天
"mealType": 2, // 选填 — 餐次筛选: 1=早餐 / 2=午餐 / 3=晚餐 / 4=加餐
"keyword": "红烧肉", // 选填 — 菜品名称模糊搜索
"pageNum": 1, // 选填 — 保留字段,当前不使用
"pageSize": 20 // 选填 — 保留字段,当前不使用
}
```
> 注:`mealType` 不传则返回全部餐次;传了只返回对应餐次的单个分组。
### 响应
```json
{
"code": "200",
"msg": "操作成功",
"data": [
{
"mealType": 2, // 餐次: 1=早餐 / 2=午餐 / 3=晚餐 / 4=加餐
"mealTypeName": "午餐", // 餐次中文名称
"cookOrders": [
{
"cookOrderId": "10001", // 烹制单id
"cookNo": "CK20260729001", // 烹制单号
"dishName": "红烧肉", // 菜品名称
"foodId": "5001", // 菜品id(关联 nut_food
"mealType": 2, // 餐次
"mealTypeName": "午餐", // 餐次中文
"cookStatus": 0, // 烹制状态: 0=待烹制 / 1=烹制中 / 2=已完成 / 3=异常
"cookStatusName": "待烹制", // 烹制状态中文
"rawWeight": null, // 生重(kg) — 烹制完成后才有值
"needPortions": 5, // 需求份数
"cookedPortions": null // 烹制份数 — 烹制完成后才有值
},
{
"cookOrderId": "10002",
"cookNo": "CK20260729002",
"dishName": "清炒时蔬",
"foodId": "5002",
"mealType": 2,
"mealTypeName": "午餐",
"cookStatus": 1,
"cookStatusName": "烹制中",
"rawWeight": 3.500,
"needPortions": 3,
"cookedPortions": 3
}
]
},
{
"mealType": 3,
"mealTypeName": "晚餐",
"cookOrders": [
{
"cookOrderId": "10003",
"cookNo": "CK20260729003",
"dishName": "清蒸鲈鱼",
"foodId": "5003",
"mealType": 3,
"mealTypeName": "晚餐",
"cookStatus": 0,
"cookStatusName": "待烹制",
"rawWeight": null,
"needPortions": 2,
"cookedPortions": null
}
]
}
]
}
```
### 逻辑说明
- 数据源:`nut_prod_cook_order`
- 查询条件:`canteen_id` = 终端食堂ID(由 `X-DEVICE-CODE` 解析) + `plan_date` = 指定日期(默认当天)
- 排序规则:`meal_type ASC, create_time DESC`
- 后端按 `mealType` 分组,每组为一个 `NutRatioCookMealGroupVO` 对象,内含 `mealType` + `mealTypeName` + `cookOrders` 列表
- `keyword` 字段对 `dish_name` 做 LIKE 模糊匹配
- `rawWeight` 在烹制完成后为累计主辅材实际用量之和(kg),烹制中为 null
---
## 二、菜品食材构成(主材+辅材)
```
GET /neglect/ratio-scale/cook-orders/{cookOrderId}/composition
X-DEVICE-CODE: <设备编码>
X-DEVICE-TOKEN: <设备登录Token>
```
查询烹制单对应菜品的主材和辅材清单(不包含调料,调料在烹饪完成时单独提交)。
### 路径参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| cookOrderId | Long | 是 | 烹制单id |
### 响应
```json
{
"code": "200",
"msg": "操作成功",
"data": [
{
"materId": "20001", // 食材id
"ingredientName": "五花肉", // 食材名称
"ingredientClass": 1, // 食材分类: 1=主材 / 2=辅材
"ingredientClassName": "主材", // 食材分类中文
"useWeight": 500.0, // 标准用量(g)
"vegTypeId": "10" // 净菜类型id
},
{
"materId": "20002",
"ingredientName": "土豆",
"ingredientClass": 2,
"ingredientClassName": "辅材",
"useWeight": 300.0,
"vegTypeId": "10"
}
]
}
```
### 逻辑说明
- 先通过烹制单查询 `food_id`(菜品id),再查 `nut_food_composition`
- 仅返回 `is_main IN (1, 2)` 的食材(主材和辅材),调料(`is_main=3`)不在此接口返回
- 食材名称通过 `nut_mater_base` 批量查询后回填
- `vegTypeId` 为净菜类型id,用于前端展示毛菜/净菜标识
---
## 三、去制作:提交食材实际用量并开始烹制
```
POST /neglect/ratio-scale/cook-orders/submit
Content-Type: application/json
X-DEVICE-CODE: <设备编码>
X-DEVICE-TOKEN: <设备登录Token>
```
厨师在秤上完成主辅材称重后,提交各食材的实际用量。后端自动完成:组配任务完善 → 组配完成 → 烹制单状态切换为"烹制中" → 烹制食材明细更新。
**该接口为事务性操作(`@Transactional`),所有步骤在同一事务中执行,任意步骤失败则全部回滚。**
### 请求体
```json
{
"cookOrderId": 10001, // 必填 — 烹制单id
"items": [ // 必填 — 食材实际用量列表,至少1条
{
"materId": 20001, // 必填 — 食材id
"actualQty": 520.5 // 必填 — 实际用量(g)
},
{
"materId": 20002,
"actualQty": 310.0
}
]
}
```
### 响应
```json
{
"code": "200",
"msg": "操作成功",
"data": null
}
```
### 处理流程
```
1. 校验烹制单状态 = 0(待烹制),否则报错
2. 通过内供申领单关联餐品净菜包:
链路: nut_prod_cook_order.food_id → nut_sup_internal_supply(food_id + canteen_id + clean_type=2)
→ batch_no → nut_sup_meal_package → nut_sup_meal_pkg_ingredient → trace_code
3. 解析各食材的溯源码(materId → traceCode 映射)
4. 完善组配任务明细(按 materCode 匹配 comboTaskItem,更新 actualQty + traceCode
5. 完成组配任务(comboStatus=2,记录完成时间)
6. 计算生重(Σ actualQty / 1000g→kg,保留3位小数)
7. 从设备Token获取厨师信息(chef + chefId
8. 更新烹制单:
- cookStatus → 1(烹制中)
- cookStart → 当前时间
- rawWeight → 生重(kg)
- chef / chefId → 登录厨师
- needPortions / cookedPortions → 份数
9. 更新烹制食材明细(cook_ingredient):
- 已有明细 → 更新 actualQty + traceCode
- 无已有明细 → 按 foodComposition 自动创建兜底记录
```
### 份数计算优先级
```
内供申领单 quantity > 烹制单 needPortions > 兜底值 1
```
### 异常场景
| 场景 | 错误信息 |
|------|---------|
| 烹制单不存在 | `烹制单不存在` |
| 当前状态不是"待烹制" | `当前状态不允许此操作,仅待烹制状态可提交制作` |
| 烹制单未关联组配任务(comboNo 为空) | `烹制单未关联组配任务,无法开始烹制` |
| 组配任务不存在 | `组配任务不存在,comboNo=xxx` |
### 涉及数据表
| 表名 | 操作 | 说明 |
|------|:--:|------|
| `nut_prod_cook_order` | 查询 + 更新 | 校验状态,更新为烹制中 |
| `nut_sup_internal_supply` | 查询 | 通过 foodId + canteenId + cleanType=2 关联,获取 batchNo 和 quantity |
| `nut_sup_meal_package` | 查询 | 通过 batchNo 查餐品净菜包 |
| `nut_sup_meal_pkg_ingredient` | 查询 | 获取各食材的溯源码 |
| `nut_prod_combo_task` | 查询 + 更新 | 完善组配状态为已完成 |
| `nut_prod_combo_task_item` | 查询 + 更新 | 更新 actualQty + traceCode |
| `nut_prod_cook_ingredient` | 查询 + 新增/更新 | 更新实际用量,不存在时兜底创建 |
| `nut_mater_base` | 查询 | 获取食材编码和名称 |
| `nut_food_composition` | 查询 | 兜底创建 cook_ingredient 时获取食材分类等信息 |
---
## 四、烹饪完成:提交熟重和调料用量
```
POST /neglect/ratio-scale/cook-orders/finish
Content-Type: application/json
X-DEVICE-CODE: <设备编码>
X-DEVICE-TOKEN: <设备登录Token>
```
烹饪结束后,厨师在秤上称量熟重,并提交调料用量。后端自动计算生熟比。
**该接口为事务性操作(`@Transactional`)。**
### 请求体
```json
{
"cookOrderId": 10001, // 必填 — 烹制单id
"cookedWeight": 3.200, // 必填 — 熟重(kg)
"seasonings": [ // 选填 — 调料用量列表
{
"materId": 30001, // 必填 — 调料食材id
"actualQty": 15.0 // 必填 — 实际用量(g)
},
{
"materId": 30002,
"actualQty": 8.5
}
]
}
```
### 响应
```json
{
"code": "200",
"msg": "操作成功",
"data": null
}
```
### 处理流程
```
1. 校验烹制单状态 = 1(烹制中),否则报错
2. 如有调料:保存到 nut_prod_cook_ingredient
- 已在 cook_ingredient 中的调料(ingredientClass=3 → 更新 actualQty
- 新调料 → 新增记录(ingredientClass=3 调料)
3. 计算生熟比 = cookedWeight / rawWeight(保留3位小数,HALF_UP
4. 更新烹制单:
- cookStatus → 2(已完成)
- cookedWeight → 熟重(kg)
- rawCookedRatio → 生熟比
- cookEnd → 当前时间
```
### 生熟比计算
```
生熟比 = 熟重(kg) / 生重(kg)
精度: 3位小数, HALF_UP
条件: 生重 > 0 且熟重 != null 时才计算,否则生熟比为 null
```
### 异常场景
| 场景 | 错误信息 |
|------|---------|
| 烹制单不存在 | `烹制单不存在` |
| 当前状态不是"烹制中" | `当前状态不允许此操作,仅烹制中状态可完成烹制` |
### 涉及数据表
| 表名 | 操作 | 说明 |
|------|:--:|------|
| `nut_prod_cook_order` | 查询 + 更新 | 校验状态,更新为已完成,记录熟重和生熟比 |
| `nut_prod_cook_ingredient` | 查询 + 新增/更新 | 保存调料用量(ingredientClass=3 |
| `nut_mater_base` | 查询 | 获取调料食材编码和名称 |
---
## 总体数据链路图
```
nut_prod_cook_order (烹制单)
├── food_id ──→ nut_food_composition (菜品食材构成: 主材/辅材/调料)
│ │
│ └── mater_id ──→ nut_mater_base (食材库: 编码、名称、分类)
├── canteen_id + food_id ──→ nut_sup_internal_supply (内供申领单)
│ │
│ ├── batch_no ──→ nut_sup_meal_package (餐品净菜包)
│ │ │
│ │ └── nut_sup_meal_pkg_ingredient (溯源码)
│ │
│ └── quantity ──→ 份数
├── combo_no ──→ nut_prod_combo_task (组配任务)
│ │
│ └── nut_prod_combo_task_item (组配明细: ingredientCode)
└── cook_ingredient: nut_prod_cook_ingredient (烹制食材明细)
```
---
## 接口调用时序
```
┌─────────┐ ┌─────────────┐ ┌──────────┐
│ 配比秤终端 │ │ Controller │ │ Database │
└────┬────┘ └──────┬──────┘ └────┬─────┘
│ │ │
│ 1. POST /cook-orders/page │
│─────────────────────→│ │
│ │──→ nut_prod_cook_order (当日+食堂)
│ ← 任务列表(按餐次) │←────────────────────│
│ │ │
│ 2. GET /cook-orders/{id}/composition │
│─────────────────────→│ │
│ │──→ nut_food_composition (主材+辅材)
│ │──→ nut_mater_base (食材名称)
│ ← 食材清单(含标准用量) │←────────────────────│
│ │ │
│ 3. POST /cook-orders/submit │
│─────────────────────→│ │
│ │──→ 校验状态=待烹制 │
│ │──→ 关联internal_supply │
│ │──→ 解析溯源码 │
│ │──→ 完善+完成组配任务 │
│ │──→ 计算生重 │
│ │──→ 更新烹制单=烹制中 │
│ │──→ 更新食材明细 │
│ ← 操作成功 │←──── 事务提交 ──────│
│ │ │
│ 4. POST /cook-orders/finish │
│─────────────────────→│ │
│ │──→ 校验状态=烹制中 │
│ │──→ 保存调料用量 │
│ │──→ 计算生熟比 │
│ │──→ 更新烹制单=已完成 │
│ ← 操作成功 │←──── 事务提交 ──────│
│ │ │
```