feat(food-prepare): 增加食材信息录入卡及相关接口支持

- 新增手动录入食材信息的UI布局,包含食材名称搜索、主辅材选择及用量输入
- 整合采样模式下的食材搜索弹窗,支持食材模糊查询与净材种类选择
- 新增相关网络接口调用,实现食材搜索、菜品搜索、菜品构成查询及采样历史等功能
- 优化食材列表展示,支持采样历史审核状态和熟重显示,增加颜色区分
- 修改PrepareFoodActivity,采样模式启用手动新增食材功能,包含添加、重置操作
- 调整Adapter和ViewModel,兼容新接口数据模型,支持分页加载和状态回调
- 修正接口路径和网络请求地址,统一使用nutrition/neglect路径前缀
- 删除冗余代码和未使用的旧接口代码,简化搜索逻辑及界面响应机制
This commit is contained in:
mazengfei
2026-09-10 13:41:35 +08:00
parent 45543295e3
commit 6d3c5490a4
20 changed files with 1755 additions and 299 deletions
@@ -88,6 +88,14 @@ class FoodListAdapter(
// 非烹饪中,采样模式 // 非烹饪中,采样模式
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.dish_green)) tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.dish_green))
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners) clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
// 新采样历史(sample/history):展示审核状态 + 熟重(g)
val auditName = item.auditStatusName
if (!auditName.isNullOrBlank()) {
tvDishCount.text = "熟重:${formatGram(foodWeight)}"
tvShowState.text = auditName
tvShowState.setTextColor(auditStatusColor(holder, item.auditStatus))
return@run
}
val realTotalWeight = if (totalWeight > 0.0) totalWeight else foodWeight val realTotalWeight = if (totalWeight > 0.0) totalWeight else foodWeight
val showTotalWeight = if (realTotalWeight == 0.0) "-" else df.format(realTotalWeight / 1000.0F) val showTotalWeight = if (realTotalWeight == 0.0) "-" else df.format(realTotalWeight / 1000.0F)
tvDishCount.text = "累计统计:${showTotalWeight}kg(${item.count ?: "-"}次)" tvDishCount.text = "累计统计:${showTotalWeight}kg(${item.count ?: "-"}次)"
@@ -96,5 +104,21 @@ class FoodListAdapter(
} }
} }
/** 熟重(g)格式化,整数不带小数,小数保留 2 位 */
private fun formatGram(weight: Double): String {
val gramDf = DecimalFormat("0.##")
return "${gramDf.format(weight)}g"
}
/** 审核状态着色:1=待审核(灰) / 2=已通过(绿) / 3=已驳回(红) */
private fun auditStatusColor(holder: VH, status: Int?): Int {
val context = holder.itemView.context
return when (status) {
2 -> ContextCompat.getColor(context, R.color.dish_green)
3 -> ContextCompat.getColor(context, R.color.red_ff4444)
else -> ContextCompat.getColor(context, R.color.black999)
}
}
private val df = DecimalFormat("0.000") private val df = DecimalFormat("0.000")
} }
@@ -11,9 +11,6 @@ import android.view.View
import android.view.Window import android.view.Window
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick import com.chad.library.adapter4.util.setOnDebouncedItemClick
@@ -24,6 +21,7 @@ import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.DialogFoodSearchBinding import com.shuwei.dish.match.databinding.DialogFoodSearchBinding
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.MaterSearchItem
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.showRawMaterialsDialog import com.shuwei.dish.match.utils.showRawMaterialsDialog
@@ -54,15 +52,23 @@ class FoodSearchDialog(
isStateViewEnable = true isStateViewEnable = true
setOnDebouncedItemClick { _, _, position -> setOnDebouncedItemClick { _, _, position ->
val item = list.getOrNull(position) ?: return@setOnDebouncedItemClick val item = list.getOrNull(position) ?: return@setOnDebouncedItemClick
// 弹出净材种类单选弹窗,确认后再回调并关闭弹窗 item.isClicked = true
notifyItemChanged(position)
// mater-search 不返回净材种类,rawMaterialsTypes 为空时直接选中返回,避免卡在净材弹窗
if (item.rawMaterialsTypes.isNullOrBlank()) {
binding.root.postDelayed({
onItemSelected(item)
dismiss()
}, 300)
return@setOnDebouncedItemClick
}
// 有净材种类时弹出单选弹窗,确认后再回调并关闭弹窗
showRawMaterialsDialog( showRawMaterialsDialog(
context = activity, context = activity,
types = item.rawMaterialsTypes, types = item.rawMaterialsTypes,
checkedType = item.rawMaterialsType checkedType = item.rawMaterialsType
) { selectedType -> ) { selectedType ->
item.rawMaterialsType = selectedType item.rawMaterialsType = selectedType
item.isClicked = true
notifyItemChanged(position)
binding.root.postDelayed({ binding.root.postDelayed({
onItemSelected(item) onItemSelected(item)
dismiss() dismiss()
@@ -121,8 +127,6 @@ class FoodSearchDialog(
binding.ivSearch.setOnClickListener { searchGoods(it) } binding.ivSearch.setOnClickListener { searchGoods(it) }
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) } binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
initObserver()
// 若有默认食材名称,自动填充并触发搜索 // 若有默认食材名称,自动填充并触发搜索
//if (!defGoodsName.isNullOrBlank()) { //if (!defGoodsName.isNullOrBlank()) {
//binding.etSheetInput.setText(defGoodsName) //binding.etSheetInput.setText(defGoodsName)
@@ -170,27 +174,6 @@ class FoodSearchDialog(
} }
} }
/**
* 收集 foodSearchGoodsListState,统一处理 Loading / Success / Error 状态
* 使用独立的 State,避免与 FoodRecognizeActivity 等页面共用 goodsListState 产生干扰
*/
private fun initObserver() {
activity.lifecycleScope.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
activity.netViewModel.foodSearchGoodsListState.collect { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
}
else -> {}
}
}
}
}
}
/** /**
* 触发搜索:校验输入、隐藏键盘、发起请求 * 触发搜索:校验输入、隐藏键盘、发起请求
*/ */
@@ -205,14 +188,24 @@ class FoodSearchDialog(
} }
/** /**
* 请求食材列表,使用弹窗专用接口,结果写入 foodSearchGoodsListState * 请求食材列表(采样模式新配比秤接口 mater-search,回调版本)
*/ */
private fun getGoodsList() { private fun getGoodsList() {
activity.netViewModel.queryFoodSearchGoodsList( activity.netViewModel.materSearch(
goodsType = "0", 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 -> {}
}
}
) )
} }
@@ -220,7 +213,7 @@ class FoodSearchDialog(
* 将返回数据填充到列表 * 将返回数据填充到列表
*/ */
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<GoodsItem>?) { private fun loadGoodsList(records: List<MaterSearchItem>?) {
finishRefresh() finishRefresh()
if (records.isNullOrEmpty()) { if (records.isNullOrEmpty()) {
if (pageNo == 1) { if (pageNo == 1) {
@@ -235,20 +228,27 @@ class FoodSearchDialog(
} }
} }
if (pageNo == 1) list.clear() if (pageNo == 1) list.clear()
// 将接口返回的 rawMaterialsType(全部种类)移存到 rawMaterialsTypes,供弹窗使用 // mater-search 不返回净材种类,rawMaterialsTypes 保持 null,点击时直接选中返回
records.forEach { val goodsItems = records.map { it.toGoodsItem() }
it.rawMaterialsTypes = it.rawMaterialsType list.addAll(goodsItems)
it.rawMaterialsType = null
it.oldGoodsId = it.goodsId
it.goodsId = it.materId ?: ""
}
list.addAll(records)
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++
} }
/**
* MaterSearchItem → GoodsItem 映射
* id 即食材id,同时作为 goodsId 和 materId(提交 sample/save 时使用)
*/
private fun MaterSearchItem.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()
@@ -15,5 +15,14 @@ data class FoodRecord(
var dinnerType: String = "0", var dinnerType: String = "0",
//true-原始数据,false-非原始数据 //true-原始数据,false-非原始数据
var isOriginalData: Boolean = true, var isOriginalData: Boolean = true,
var sort: Int = 0 var sort: Int = 0,
// ===== 采样历史(sample/history)扩展字段,制作模式不涉及 =====
/** 菜品编号 */
var foodCode: String? = null,
/** 审核状态: 1=待审核 / 2=已通过 / 3=已驳回 */
var auditStatus: Int? = null,
/** 审核状态中文 */
var auditStatusName: String? = null,
/** 审核记录id */
var auditRecordId: String? = null
) : Serializable ) : Serializable
@@ -32,6 +32,8 @@ data class GoodsItem(
var isClicked: Boolean = false, var isClicked: Boolean = false,
// true-原始数据,false-非原始数据(识图新增) // true-原始数据,false-非原始数据(识图新增)
var isOriginalData: Boolean = true, var isOriginalData: Boolean = true,
// true-用户手动指定了主辅材,updateMaterialTypes 自动分配时跳过
var isMaterialTypeManual: Boolean = false,
// 识图匹配分数,-1 表示不显示 // 识图匹配分数,-1 表示不显示
var foodScore: Int = -1, var foodScore: Int = -1,
// 净材种类(当前选中值) // 净材种类(当前选中值)
@@ -0,0 +1,271 @@
package com.shuwei.dish.match.model
/**
* 采样与制作模式相关模型,对应配比秤接口(Controller: NutRatioScaleController
* 路径前缀: /neglect/ratio-scale
* 序列化规则: Long 字段服务端返回字符串;请求体字段按后端约定(id 类字段用 String 与现有代码一致)
*/
/** 提交构成接口 cookMode 取值(与本地 cookMode 0=制作/1=采样 不同,注意映射) */
object ConstituteCookMode {
/** 制作模式(基于已有菜品,foodId 必填) */
const val MAKE = 1
/** 采样模式(从零建菜,foodName 必填) */
const val SAMPLE = 2
}
/** 采样菜品审核状态 */
object SampleAuditStatus {
/** 待审核 */
const val PENDING = 1
/** 已通过 */
const val APPROVED = 2
/** 已驳回 */
const val REJECTED = 3
}
// ==================== 一、食材模糊搜索 ====================
/**
* 食材模糊搜索请求参数
* 对应接口: POST /neglect/ratio-scale/mater-search
* 数据源: nut_mater_base,限定设备对应食堂
*/
data class MaterSearchRequest(
/** 模糊匹配食材名称和别名,为空时返回全部 */
val keyword: String? = null,
/** 页码 */
val pageNum: Int = 1,
/** 每页条数 */
val pageSize: Int = 20
)
/**
* 食材模糊搜索结果条目
* 注意: id / canteenId 为 Long 类型,服务端序列化为 String
*/
data class MaterSearchItem(
/** 食材idLong → String,提交构成 items.materId 使用) */
val id: String = "",
/** 食材名称 */
val materName: String = "",
/** 食材编码(一级+二级+自编码,全局唯一) */
val materCode: String = "",
/** 别名,可能为 null */
val alias: String? = null,
/** 食材一级分类 */
val materFirstClass: String? = null,
/** 食材二级分类 */
val materSecondClass: String? = null,
/** 食材图片url */
val materUrl: String? = null,
/** 归属食堂idLong → String */
val canteenId: String = ""
)
// ==================== 二、搜菜品 ====================
/**
* 搜菜品请求参数(制作模式选已有菜品)
* 对应接口: POST /neglect/ratio-scale/food-search
* 数据源: nut_foodstatus=1 上架)
*/
data class FoodSearchRequest(
/** 菜品名称或编号模糊搜索 */
val keyword: String? = null,
/** 页码 */
val pageNum: Int = 1,
/** 每页条数 */
val pageSize: Int = 20
)
/**
* 搜菜品结果条目
* 注意: foodId 为 Long 类型,服务端序列化为 String
*/
data class FoodSearchItem(
/** 菜品idLong → String */
val foodId: String = "",
/** 菜品编号 */
val foodCode: String = "",
/** 菜品名称 */
val foodName: String = ""
)
// ==================== 三、查菜品构成 ====================
/**
* 查菜品构成响应 — 单个食材
* 对应接口: GET /neglect/ratio-scale/food/{foodId}/composition
*/
data class FoodCompositionIngredient(
/** 食材idLong → String */
val materId: String = "",
/** 食材名称 */
val materName: String = "",
/** 用量(g) */
val useWeight: Double = 0.0,
/** 1=主材 / 2=辅材 / 3=调料 */
val isMain: Int = 0,
/** 分类中文名 */
val isMainName: String = ""
)
/**
* 查菜品构成响应(主辅料+调料 + 熟重)
* 对应接口: GET /neglect/ratio-scale/food/{foodId}/composition
*/
data class FoodCompositionResponse(
/** 菜品idLong → String */
val foodId: String = "",
/** 菜品名称 */
val foodName: String = "",
/** 熟重(g),从 nut_food_cook.output_weight 反查 */
val foodWeight: Double = 0.0,
/** 食材构成列表 */
val ingredients: List<FoodCompositionIngredient> = emptyList()
)
// ==================== 四、提交构成 ====================
/**
* 提交构成 — 单个食材构成条目
* 对应接口: POST /neglect/ratio-scale/constitute/save
*/
data class ConstituteSaveItem(
/** 食材idnut_mater_base.id */
val materId: String = "",
/** 食材称量用量(g),必须 > 0 */
val useWeight: Double = 0.0,
/** 食材分类: 1=主材 / 2=辅材 / 3=调料 */
val isMain: Int = 0
)
/**
* 提交构成请求,cookMode 区分采样/制作
* 对应接口: POST /neglect/ratio-scale/constitute/save
*/
data class ConstituteSaveRequest(
/** 1=制作模式 / 2=采样模式 */
val cookMode: Int = ConstituteCookMode.SAMPLE,
/** 制作模式必填 — 已有菜品id */
val foodId: String? = null,
/** 采样模式必填 — 菜品名称 */
val foodName: String? = null,
/** 熟重(g),必须 > 0 */
val foodWeight: Double = 0.0,
/** 餐次: 1=早餐/2=午餐/3=晚餐/4=加餐,选填 */
val mealType: Int? = null,
/** 食材构成列表,至少 1 条 */
val items: List<ConstituteSaveItem> = emptyList()
)
// ==================== 五、采样历史 ====================
/**
* 采样历史查询请求参数
* 对应接口: POST /neglect/ratio-scale/sample/history
*/
data class SampleHistoryRequest(
/** 页码 */
val pageNum: Int = 1,
/** 每页条数 */
val pageSize: Int = 10,
/** 菜品名称模糊搜索 */
val keyword: String? = null,
/** 审核状态筛选: 1=待审核 / 2=已通过 / 3=已驳回,不传查全部 */
val auditStatus: Int? = null,
/** 采样日期 yyyy-MM-dd,不传默认当天 */
val sampleDate: String? = null
)
/**
* 采样历史结果条目
* 注意: foodId / auditRecordId 为 Long 类型,服务端序列化为 String
*/
data class SampleHistoryItem(
/** 菜品idLong → String */
val foodId: String = "",
/** 菜品编号 */
val foodCode: String = "",
/** 菜品名称 */
val foodName: String = "",
/** 熟重(g) */
val foodWeight: Double = 0.0,
/** 审核状态: 1=待审核 / 2=已通过 / 3=已驳回 */
val auditStatus: Int = 0,
/** 审核状态中文 */
val auditStatusName: String = "",
/** 审核记录idLong → String */
val auditRecordId: String = "",
/** 提交时间 */
val createTime: String = ""
)
// ==================== 六、制作历史 ====================
/**
* 制作历史查询请求参数
* 对应接口: POST /neglect/ratio-scale/make/history
* 数据源: nut_food_matching_use,按设备过滤
*/
data class MakeHistoryRequest(
/** 页码 */
val pageNum: Int = 1,
/** 每页条数 */
val pageSize: Int = 10,
/** 制作日期 yyyy-MM-dd,不传查全部 */
val day: String? = null
)
/**
* 制作历史结果条目
* 注意: id / foodId 为 Long 类型,服务端序列化为 String
*/
data class MakeHistoryItem(
/** 制作记录idLong → String */
val id: String = "",
/** 菜品idLong → String */
val foodId: String = "",
/** 菜品名称 */
val foodName: String = "",
/** 熟重(g) */
val foodWeight: Double = 0.0,
/** 餐次: 1=早餐/2=午餐/3=晚餐/4=加餐 */
val mealType: Int = 0,
/** 餐次中文 */
val mealTypeName: String = "",
/** 制作日期 */
val day: String = "",
/** 制作时间 */
val createTime: String = ""
)
// ==================== 七、制作历史图表 ====================
/**
* 制作历史图表 — 横轴项
* 对应接口: GET /neglect/ratio-scale/make/history/{foodId}
*/
data class MakeHistoryChartAxis(
/** 食材id(时间/熟重项为空字符串) */
val goodId: String = "",
/** 项名称(时间/熟重/食材名称) */
val goodName: String = ""
)
/**
* 制作历史图表 — 纵轴值
* value 为 String(时间项) 或 Double(数值项)
*/
data class MakeHistoryChartValue(
val value: Any? = null
)
/**
* 制作历史图表 — 单条记录(xAxes 与 yAxes 一一对应)
*/
data class MakeHistoryChartRow(
val xAxes: List<MakeHistoryChartAxis> = emptyList(),
val yAxes: List<MakeHistoryChartValue> = emptyList()
)
@@ -7,7 +7,18 @@ import com.shuwei.dish.match.model.CookOrderMealGroup
import com.shuwei.dish.match.model.CookOrderPageRequest 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.ConstituteSaveRequest
import com.shuwei.dish.match.model.CookCompleteRequest import com.shuwei.dish.match.model.CookCompleteRequest
import com.shuwei.dish.match.model.FoodCompositionResponse
import com.shuwei.dish.match.model.FoodSearchItem
import com.shuwei.dish.match.model.FoodSearchRequest
import com.shuwei.dish.match.model.MakeHistoryChartRow
import com.shuwei.dish.match.model.MakeHistoryItem
import com.shuwei.dish.match.model.MakeHistoryRequest
import com.shuwei.dish.match.model.MaterSearchItem
import com.shuwei.dish.match.model.MaterSearchRequest
import com.shuwei.dish.match.model.SampleHistoryItem
import com.shuwei.dish.match.model.SampleHistoryRequest
import com.shuwei.dish.match.model.SeasoningSearchItem import com.shuwei.dish.match.model.SeasoningSearchItem
import com.shuwei.dish.match.model.SeasoningSearchRequest import com.shuwei.dish.match.model.SeasoningSearchRequest
import okhttp3.MultipartBody import okhttp3.MultipartBody
@@ -29,7 +40,7 @@ interface ApiService {
*/ */
@GET @GET
suspend fun getFoodDetail( suspend fun getFoodDetail(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/getConstituteByFoodId", @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/matching/app/getConstituteByFoodId",
@Query("foodId") foodId: String @Query("foodId") foodId: String
): ApiResponse<CookFoodDTO?> ): ApiResponse<CookFoodDTO?>
@@ -39,7 +50,7 @@ interface ApiService {
*/ */
@POST @POST
suspend fun submitCookFood( suspend fun submitCookFood(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/saveConstitute", @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/matching/app/saveConstitute",
@Body param: CookFoodDTO @Body param: CookFoodDTO
): ApiResponse<Any?> ): ApiResponse<Any?>
@@ -48,7 +59,7 @@ interface ApiService {
*/ */
@POST @POST
suspend fun searchFoodList( suspend fun searchFoodList(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryPageList", @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/matching/app/queryPageList",
@Body param: MutableMap<String, Any> @Body param: MutableMap<String, Any>
): ApiResponse<MutableList<FoodRecord>?> ): ApiResponse<MutableList<FoodRecord>?>
@@ -57,7 +68,7 @@ interface ApiService {
*/ */
@POST @POST
suspend fun getSamplingList( suspend fun getSamplingList(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryHistoryGoodsInfoList", @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/matching/app/queryHistoryGoodsInfoList",
@Body param: MutableMap<String, Any> @Body param: MutableMap<String, Any>
): ApiResponse<MutableList<FoodRecord>?> ): ApiResponse<MutableList<FoodRecord>?>
@@ -66,7 +77,7 @@ interface ApiService {
*/ */
@POST @POST
suspend fun queryGoodsList( suspend fun queryGoodsList(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryGoodsInfoList", @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/matching/app/queryGoodsInfoList",
@Body param: MutableMap<String, Any> @Body param: MutableMap<String, Any>
): ApiResponse<MutableList<GoodsItem>?> ): ApiResponse<MutableList<GoodsItem>?>
@@ -76,7 +87,7 @@ interface ApiService {
@Multipart @Multipart
@POST @POST
suspend fun uploadFoodVectorData( suspend fun uploadFoodVectorData(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/addWeighingCollectionData", @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/matching/app/addWeighingCollectionData",
@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>?>
@@ -120,4 +131,66 @@ interface ApiService {
@Body request: CookCompleteRequest @Body request: CookCompleteRequest
): ApiResponse<Any?> ): ApiResponse<Any?>
/**
* 食材模糊搜索(采样/制作通用,称量前检索食材)
* 数据源: nut_mater_base,限定设备对应食堂,模糊匹配名称和别名
*/
@POST
suspend fun materSearch(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/mater-search",
@Body request: MaterSearchRequest
): ApiResponse<MutableList<MaterSearchItem>?>
/**
* 搜菜品(制作模式选已有上架菜品)
*/
@POST
suspend fun foodSearch(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/food-search",
@Body request: FoodSearchRequest
): ApiResponse<MutableList<FoodSearchItem>?>
/**
* 查菜品构成(制作模式查看已有菜品主辅料+调料 + 熟重)
*/
@GET("nutrition/neglect/ratio-scale/food/{foodId}/composition")
suspend fun getFoodComposition(
@Path("foodId") foodId: String
): ApiResponse<FoodCompositionResponse?>
/**
* 提交构成(cookMode 区分采样/制作)
*/
@POST
suspend fun constituteSave(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/constitute/save",
@Body request: ConstituteSaveRequest
): ApiResponse<Any?>
/**
* 采样历史(本食堂,默认当天)
*/
@POST
suspend fun sampleHistory(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/sample/history",
@Body request: SampleHistoryRequest
): ApiResponse<MutableList<SampleHistoryItem>?>
/**
* 制作历史(本设备,按日期可选过滤)
*/
@POST
suspend fun makeHistory(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/make/history",
@Body request: MakeHistoryRequest
): ApiResponse<MutableList<MakeHistoryItem>?>
/**
* 制作历史图表(按菜品id)
*/
@GET("nutrition/neglect/ratio-scale/make/history/{foodId}")
suspend fun makeHistoryChart(
@Path("foodId") foodId: String
): ApiResponse<MutableList<MakeHistoryChartRow>?>
} }
@@ -10,7 +10,18 @@ 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.ConstituteSaveRequest
import com.shuwei.dish.match.model.CookCompleteRequest import com.shuwei.dish.match.model.CookCompleteRequest
import com.shuwei.dish.match.model.FoodCompositionResponse
import com.shuwei.dish.match.model.FoodSearchItem
import com.shuwei.dish.match.model.FoodSearchRequest
import com.shuwei.dish.match.model.MakeHistoryChartRow
import com.shuwei.dish.match.model.MakeHistoryItem
import com.shuwei.dish.match.model.MakeHistoryRequest
import com.shuwei.dish.match.model.MaterSearchItem
import com.shuwei.dish.match.model.MaterSearchRequest
import com.shuwei.dish.match.model.SampleHistoryItem
import com.shuwei.dish.match.model.SampleHistoryRequest
import com.shuwei.dish.match.model.SeasoningSearchItem import com.shuwei.dish.match.model.SeasoningSearchItem
import com.shuwei.dish.match.model.SeasoningSearchRequest import com.shuwei.dish.match.model.SeasoningSearchRequest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -313,6 +324,134 @@ class NetViewModel(
} }
} }
/**
* 食材模糊搜索(回调版本)
* @param keyword 模糊匹配食材名称和别名,为 null 时不传该字段
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 20
*/
fun materSearch(
keyword: String? = null,
pageNum: Int = 1,
pageSize: Int = 20,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<MaterSearchItem>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.materSearch(
MaterSearchRequest(keyword = keyword, pageNum = pageNum, pageSize = pageSize)
))
}
}
/**
* 搜菜品(回调版本,制作模式选已有上架菜品)
*/
fun foodSearch(
keyword: String? = null,
pageNum: Int = 1,
pageSize: Int = 20,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<FoodSearchItem>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.foodSearch(
FoodSearchRequest(keyword = keyword, pageNum = pageNum, pageSize = pageSize)
))
}
}
/**
* 查菜品构成(回调版本,制作模式)
*/
fun getFoodComposition(
foodId: String,
onLoading: () -> Unit = {},
onResult: (UiState<FoodCompositionResponse?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getFoodComposition(foodId))
}
}
/**
* 提交构成(回调版本,cookMode 区分采样/制作)
*/
fun constituteSave(
request: ConstituteSaveRequest,
onLoading: () -> Unit = {},
onResult: (UiState<Any?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.constituteSave(request))
}
}
/**
* 采样历史(回调版本)
* @param keyword 菜品名称模糊搜索,为 null 时不传该字段
* @param auditStatus 审核状态筛选,为 null 时不传该字段
* @param sampleDate 采样日期 yyyy-MM-dd,为 null 时不传该字段
*/
fun sampleHistory(
pageNum: Int = 1,
pageSize: Int = 10,
keyword: String? = null,
auditStatus: Int? = null,
sampleDate: String? = null,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<SampleHistoryItem>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.sampleHistory(
SampleHistoryRequest(
pageNum = pageNum,
pageSize = pageSize,
keyword = keyword,
auditStatus = auditStatus,
sampleDate = sampleDate
)
))
}
}
/**
* 制作历史(回调版本,本设备按日期可选过滤)
*/
fun makeHistory(
pageNum: Int = 1,
pageSize: Int = 10,
day: String? = null,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<MakeHistoryItem>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.makeHistory(
MakeHistoryRequest(pageNum = pageNum, pageSize = pageSize, day = day)
))
}
}
/**
* 制作历史图表(回调版本,按菜品id)
*/
fun makeHistoryChart(
foodId: String,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<MakeHistoryChartRow>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.makeHistoryChart(foodId))
}
}
/** /**
* 上传采集菜品信息 UI 状态流,UI 层通过 collect 监听 * 上传采集菜品信息 UI 状态流,UI 层通过 collect 监听
*/ */
@@ -6,7 +6,18 @@ import com.shuwei.dish.match.model.CookOrderMealGroup
import com.shuwei.dish.match.model.CookOrderPageRequest 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.ConstituteSaveRequest
import com.shuwei.dish.match.model.CookCompleteRequest import com.shuwei.dish.match.model.CookCompleteRequest
import com.shuwei.dish.match.model.FoodCompositionResponse
import com.shuwei.dish.match.model.FoodSearchItem
import com.shuwei.dish.match.model.FoodSearchRequest
import com.shuwei.dish.match.model.MakeHistoryChartRow
import com.shuwei.dish.match.model.MakeHistoryItem
import com.shuwei.dish.match.model.MakeHistoryRequest
import com.shuwei.dish.match.model.MaterSearchItem
import com.shuwei.dish.match.model.MaterSearchRequest
import com.shuwei.dish.match.model.SampleHistoryItem
import com.shuwei.dish.match.model.SampleHistoryRequest
import com.shuwei.dish.match.model.SeasoningSearchItem import com.shuwei.dish.match.model.SeasoningSearchItem
import com.shuwei.dish.match.model.SeasoningSearchRequest import com.shuwei.dish.match.model.SeasoningSearchRequest
import okhttp3.MultipartBody import okhttp3.MultipartBody
@@ -65,6 +76,34 @@ class RemoteRepository {
suspend fun cookOrderComplete(request: CookCompleteRequest): UiState<Any?> = suspend fun cookOrderComplete(request: CookCompleteRequest): UiState<Any?> =
safeApiCall { apiService.cookOrderComplete(request = request) } safeApiCall { apiService.cookOrderComplete(request = request) }
/** 食材模糊搜索 */
suspend fun materSearch(request: MaterSearchRequest): UiState<MutableList<MaterSearchItem>?> =
safeApiCall { apiService.materSearch(request = request) }
/** 搜菜品(制作模式选已有上架菜品) */
suspend fun foodSearch(request: FoodSearchRequest): UiState<MutableList<FoodSearchItem>?> =
safeApiCall { apiService.foodSearch(request = request) }
/** 查菜品构成(制作模式) */
suspend fun getFoodComposition(foodId: String): UiState<FoodCompositionResponse?> =
safeApiCall { apiService.getFoodComposition(foodId = foodId) }
/** 提交构成(cookMode 区分采样/制作) */
suspend fun constituteSave(request: ConstituteSaveRequest): UiState<Any?> =
safeApiCall { apiService.constituteSave(request = request) }
/** 采样历史(本食堂,默认当天) */
suspend fun sampleHistory(request: SampleHistoryRequest): UiState<MutableList<SampleHistoryItem>?> =
safeApiCall { apiService.sampleHistory(request = request) }
/** 制作历史(本设备,按日期可选过滤) */
suspend fun makeHistory(request: MakeHistoryRequest): UiState<MutableList<MakeHistoryItem>?> =
safeApiCall { apiService.makeHistory(request = request) }
/** 制作历史图表(按菜品id) */
suspend fun makeHistoryChart(foodId: String): UiState<MutableList<MakeHistoryChartRow>?> =
safeApiCall { apiService.makeHistoryChart(foodId = foodId) }
/** 上传采集数据 */ /** 上传采集数据 */
suspend fun uploadFoodVectorData( suspend fun uploadFoodVectorData(
params: MutableMap<String, RequestBody>, params: MutableMap<String, RequestBody>,
@@ -6,7 +6,7 @@ package com.shuwei.dish.match.scale
*/ */
object ScaleDeviceConfig { object ScaleDeviceConfig {
const val DEVICE_ID_2 = "8fc2ab34-2137-3112-acca-f884ea8736d4"//测试0bd74d78-c221-3182-b5ee-55d86dd79283 正式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"
@@ -4,34 +4,26 @@ import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.Food2Adapter import com.shuwei.dish.match.adapter.Food2Adapter
import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.base.BaseActivity
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.model.FoodSearchItem
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.SpTool
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.gone import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity
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 java.io.Serializable import java.io.Serializable
import kotlinx.coroutines.launch
import kotlin.collections.getOrNull import kotlin.collections.getOrNull
/** /**
* 菜品搜索页面 * 菜品搜索页面(采样模式:搜索已有上架菜品作为称量模板)
* 数据源: POST /neglect/ratio-scale/food-search
*/ */
class FoodSearchActivity : BaseActivity() { class FoodSearchActivity : BaseActivity() {
@@ -40,25 +32,16 @@ class FoodSearchActivity : BaseActivity() {
const val FOOD_ITEM = "foodItem" const val FOOD_ITEM = "foodItem"
const val FOOD_ID = "foodId" const val FOOD_ID = "foodId"
const val FOOD_NAME = "foodName" const val FOOD_NAME = "foodName"
const val DINNER_TYPE = "dinnerType"
} }
private lateinit var binding: ActivityFoodSearchBinding private lateinit var binding: ActivityFoodSearchBinding
private var list: MutableList<FoodRecord> = mutableListOf() private var list: MutableList<FoodRecord> = mutableListOf()
private var dinnerType = "1"
private val recordAdapter by lazy { private val recordAdapter by lazy {
Food2Adapter(list).apply { Food2Adapter(list).apply {
isStateViewEnable = true isStateViewEnable = true
setOnItemClickListener { adapter, view, position -> setOnItemClickListener { adapter, view, position ->
var jumpItem = list.getOrNull(position) ?: return@setOnItemClickListener val jumpItem = list.getOrNull(position) ?: return@setOnItemClickListener
if (SpTool.cookMode == 0) {
jumpItem.dinnerType = dinnerType
startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.FOOD_ITEM, jumpItem as Serializable)
}
return@setOnItemClickListener
}
setResult(RESULT_OK, Intent().apply { setResult(RESULT_OK, Intent().apply {
putExtra(FOOD_ITEM, jumpItem as Serializable) putExtra(FOOD_ITEM, jumpItem as Serializable)
}) })
@@ -73,7 +56,6 @@ class FoodSearchActivity : BaseActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityFoodSearchBinding.inflate(layoutInflater) binding = ActivityFoodSearchBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
dinnerType = intent.getStringExtra(DINNER_TYPE) ?: "0"
setHeaderBackground() setHeaderBackground()
foodName = intent.getStringExtra(FOOD_NAME) foodName = intent.getStringExtra(FOOD_NAME)
binding.etInputDish.setText(foodName) binding.etInputDish.setText(foodName)
@@ -94,31 +76,6 @@ class FoodSearchActivity : BaseActivity() {
adapter = recordAdapter adapter = recordAdapter
} }
addViewListener() addViewListener()
initObserver()
}
/**
* 收集 cookOrderPageState,统一处理 Loading / Success / Error 状态
*/
private fun initObserver() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
netViewModel.cookOrderPageState.collect { state ->
when (state) {
is UiState.Loading -> showLoading()
is UiState.Success -> handleSearchResult(state.data)
is UiState.Error -> {
delayDismissLoading()
finishRefresh()
binding.refreshLayout.setEnableRefresh(true)
toast(state.msg)
loadEmptyView()
}
is UiState.Idle -> {}
}
}
}
}
} }
private fun addViewListener() { private fun addViewListener() {
@@ -164,63 +121,62 @@ class FoodSearchActivity : BaseActivity() {
} }
private var pageNo = 1 private var pageNo = 1
private var pageSize = 10 private var pageSize = 20
fun getDinnerTypeText(): String {
return when (dinnerType) {
"1" -> "早餐"
"2" -> "午餐"
"3" -> "晚餐"
else -> "早餐"
}
}
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun queryListInfo(input: String) { private fun queryListInfo(input: String) {
val request = CookOrderPageRequest( netViewModel.foodSearch(
mealType = dinnerType.toIntOrNull(), keyword = input.trim(),
keyword = input.trim() pageNum = pageNo,
) pageSize = pageSize,
netViewModel.getCookOrderPage(request = request) onLoading = { showLoading() },
} onResult = { state ->
when (state) {
/** is UiState.Success -> handleSearchResult(state.data)
* 将 CookOrderItem 映射为 FoodRecord,适配现有 Adapter is UiState.Error -> {
*/
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")
private fun handleSearchResult(groups: List<CookOrderMealGroup>?) {
delayDismissLoading() delayDismissLoading()
finishRefresh() finishRefresh()
binding.refreshLayout.setEnableRefresh(true) binding.refreshLayout.setEnableRefresh(true)
val records = groups toast(state.msg)
?.flatMap { it.cookOrders } if (pageNo == 1) loadEmptyView()
?.map { it.toFoodRecord() } }
?.toMutableList() else -> {}
if (records.isNullOrEmpty()) { }
}
)
}
/**
* FoodSearchItem → FoodRecord 映射,供 Food2Adapter 展示
* 采样模式下返回给 PrepareFoodActivity 作为称量模板
*/
private fun FoodSearchItem.toFoodRecord(): FoodRecord = FoodRecord(
foodId = foodId,
foodName = foodName,
foodCode = foodCode,
cookMode = 1
)
/**
* 处理搜索结果:FoodSearchItem → FoodRecord 填充列表
*/
@SuppressLint("NotifyDataSetChanged")
private fun handleSearchResult(items: List<FoodSearchItem>?) {
delayDismissLoading()
finishRefresh()
binding.refreshLayout.setEnableRefresh(true)
if (items.isNullOrEmpty()) {
if (pageNo == 1) {
loadEmptyView() loadEmptyView()
}
return return
} }
list.clear() if (pageNo == 1) list.clear()
list.addAll(records) list.addAll(items.map { it.toFoodRecord() })
recordAdapter.notifyDataSetChanged() recordAdapter.notifyDataSetChanged()
// 新接口暂不分页,禁用加载更多 val isLoadMoreEnable = items.size >= pageSize
binding.refreshLayout.setEnableLoadMore(false) binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
} }
private fun finishRefresh() { private fun finishRefresh() {
@@ -19,8 +19,9 @@ import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityPrepareFoodBinding 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.dialog.FoodSearchDialog
import com.shuwei.dish.match.model.CookOrderCompositionItem import com.shuwei.dish.match.model.CookOrderCompositionItem
import com.shuwei.dish.match.model.FoodCompositionResponse
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
@@ -35,6 +36,7 @@ import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.clickWithDebounce import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.dp import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.gone import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.startActivity import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toJsonString import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
@@ -77,6 +79,12 @@ class PrepareFoodActivity : BaseActivity() {
private var pageFrom: String? = null private var pageFrom: String? = null
private var goodsList: MutableList<CookFoodGoodsEntity>? = null private var goodsList: MutableList<CookFoodGoodsEntity>? = null
/** 食材检索弹窗,手动新增构成时复用 */
private var foodSearchDialog: FoodSearchDialog? = null
/** 手动录入卡当前选中的食材(含 materId),点击搜索后回填 */
private var selectedIngredient: GoodsItem? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
@@ -111,19 +119,23 @@ class PrepareFoodActivity : BaseActivity() {
binding.etInputDish.let { binding.etInputDish.let {
it.setSelection(it.length()) it.setSelection(it.length())
} }
// 采集模式:启用食材信息录入卡(手动新增构成)
initIngredientInput()
} else { } else {
// 制作模式:不可编辑,仅展示菜名 // 制作模式:不可编辑,仅展示菜名
binding.etInputDish.isFocusable = false binding.etInputDish.isFocusable = false
binding.etInputDish.isFocusableInTouchMode = false binding.etInputDish.isFocusableInTouchMode = false
binding.ivDishSearch.gone() binding.ivDishSearch.gone()
// 制作模式:不展示手动新增卡
binding.llIngredientInput.gone()
} }
addViewClickListener() addViewClickListener()
addBackKeyListener() addBackKeyListener()
// 仅采样模式启用摄像头识别,制作模式不需要 // 仅采样模式启用摄像头识别,制作模式不需要
if (isSamplingMode) { // if (isSamplingMode) {
initCamera(binding.flCameraContainer) // initCamera(binding.flCameraContainer)
} // }
initRecyclerView() initRecyclerView()
initObserver() initObserver()
@@ -136,11 +148,11 @@ class PrepareFoodActivity : BaseActivity() {
} }
private fun addViewClickListener() { private fun addViewClickListener() {
// binding.tvShowWeight.setOnClickListener { binding.tvShowWeight.setOnClickListener {
// showLoading("正在清零……") showLoading("正在清零……")
// WeightUtil.tareTwo(AddressUtil.ONE) WeightUtil.tareTwo(AddressUtil.ONE)
// delayDismissLoading() delayDismissLoading()
// } }
// // TODO: 测试功能------------------------------- // // TODO: 测试功能-------------------------------
// binding.btnTestConfirm.clickWithDebounce { // binding.btnTestConfirm.clickWithDebounce {
// if (currentWeight <= 0.0) return@clickWithDebounce // if (currentWeight <= 0.0) return@clickWithDebounce
@@ -171,8 +183,11 @@ class PrepareFoodActivity : BaseActivity() {
tag = netWeight tag = netWeight
} }
} }
// 仅采样模式触发食材识别 // 仅采样模式:用量自动回填秤重 + 触发食材识别
if (SpTool.cookMode == 1) { if (SpTool.cookMode == 1) {
val displayWeight =
if (netWeight > 0) netWeight.roundedOneDecimalPlace().toString() else ""
binding.etAmount.setText(displayWeight)
recognizeFood(netWeight) recognizeFood(netWeight)
} }
} }
@@ -320,8 +335,8 @@ class PrepareFoodActivity : BaseActivity() {
val hasOriginalData = list.any { it.isOriginalData } val hasOriginalData = list.any { it.isOriginalData }
// 接口数据中主材数量 // 接口数据中主材数量
val n = list.count { it.isOriginalData && it.materialType == 1 } val n = list.count { it.isOriginalData && it.materialType == 1 }
// 待分配的非接口数据 // 待分配的非接口数据(跳过手动指定过主辅材的项)
val nonOriginalList = list.filter { !it.isOriginalData } val nonOriginalList = list.filter { !it.isOriginalData && !it.isMaterialTypeManual }
if (hasOriginalData && MATERIAL_COUNT - n <= 0) { if (hasOriginalData && MATERIAL_COUNT - n <= 0) {
// 主材已满,所有非接口数据均为辅材 // 主材已满,所有非接口数据均为辅材
@@ -338,6 +353,79 @@ class PrepareFoodActivity : BaseActivity() {
materialAdapter.notifyDataSetChanged() materialAdapter.notifyDataSetChanged()
} }
/**
* 食材信息录入卡初始化(仅采样模式):
* 搜索食材 → 选中回填名称并暂存 GoodsItem(含 materId)→ 选择主辅材 → 输入用量 → 添加
*/
private fun initIngredientInput() {
binding.ivIngredientSearch.setOnClickListener { openIngredientSearch() }
binding.etIngredientName.setOnClickListener { openIngredientSearch() }
binding.btnReset.setOnClickListener {
binding.etAmount.setText("")
}
binding.btnAdd.clickWithDebounce {
addManualIngredient()
}
}
/** 打开食材检索弹窗(食材名称不可手输,点击即搜索) */
private fun openIngredientSearch() {
if (foodSearchDialog?.isShowing == true) return
foodSearchDialog = FoodSearchDialog(
activity = this,
onItemSelected = { item -> onIngredientSelected(item) }
).also { it.show() }
}
/**
* 食材检索选中回调:回填名称,暂存 GoodsItemmaterId 用于提交)
*/
private fun onIngredientSelected(item: GoodsItem) {
selectedIngredient = item
binding.etIngredientName.setText(item.goodsName)
}
/**
* 手动添加食材到构成列表:名称(materId)+ 主辅材 + 用量(克,取自秤重)
*/
private fun addManualIngredient() {
val ingredient = selectedIngredient
if (ingredient == null || ingredient.materId.isNullOrBlank()) {
toast("请先搜索选择食材")
return
}
val amount = currentWeight.roundedOneDecimalPlace()
if (amount <= 0) {
toast("请先称重获取用量")
return
}
val type = if (binding.rbAux.isChecked) 2 else 1
val existing = list.firstOrNull { it.goodsId == ingredient.goodsId }
if (existing != null) {
// 已存在同食材:累加用量并同步主辅材
existing.useWeight = (existing.useWeight ?: 0.0) + amount
existing.materialType = type
existing.isMaterialTypeManual = true
existing.isSetFinished = true
existing.isNewDishType = true
} else {
ingredient.materialType = type
ingredient.useWeight = amount
ingredient.isMaterialTypeManual = true
ingredient.isNewDishType = true
ingredient.isOriginalData = false
ingredient.isSetFinished = true
ingredient.isClicked = false
list.add(ingredient)
binding.rvMaterialList.smoothScrollToPosition(list.size - 1)
}
// 清空录入区
selectedIngredient = null
binding.etIngredientName.setText("")
binding.etAmount.setText("")
updateMaterialTypes()
}
private fun openSubmitPage() { private fun openSubmitPage() {
getGoodsList() getGoodsList()
if (goodsList.isNullOrEmpty()) { if (goodsList.isNullOrEmpty()) {
@@ -378,23 +466,6 @@ class PrepareFoodActivity : BaseActivity() {
private fun initObserver() { private fun initObserver() {
// 旧接口 observer(采样模式使用)
lifecycleScope.launch {
netViewModel.foodDetailState.collect { state ->
when (state) {
is UiState.Success -> {
val detail = state.data
if (detail == null) {
toast("查询菜品信息为空")
return@collect
}
loadDishDetail(detail)
}
is UiState.Error -> toast(state.msg)
else -> {}
}
}
}
// 新配比秤接口 observer(制作模式使用) // 新配比秤接口 observer(制作模式使用)
lifecycleScope.launch { lifecycleScope.launch {
netViewModel.cookOrderCompositionState.collect { state -> netViewModel.cookOrderCompositionState.collect { state ->
@@ -414,9 +485,25 @@ class PrepareFoodActivity : BaseActivity() {
} }
} }
/** 接口:通过 foodId 查询菜品构成(采样模式使用 */ /** 接口:通过 foodId 查询菜品构成(采样模式称量模板 */
private fun getDishDetail(foodId: String) { private fun getDishDetail(foodId: String) {
netViewModel.getFoodDetail(foodId) netViewModel.getFoodComposition(
foodId = foodId,
onResult = { state ->
when (state) {
is UiState.Success -> {
val response = state.data
if (response == null) {
toast("查询菜品构成为空")
} else {
loadFoodComposition(response)
}
}
is UiState.Error -> toast(state.msg)
else -> {}
}
}
)
} }
/** 新接口:通过 cookOrderId 查询菜品食材构成(制作模式使用) */ /** 新接口:通过 cookOrderId 查询菜品食材构成(制作模式使用) */
@@ -425,17 +512,23 @@ class PrepareFoodActivity : BaseActivity() {
} }
private fun loadDishDetail(detail: CookFoodDTO) { /**
// val voList = detail.stFoodInfoConstituteList * 采样模式:将 food/{foodId}/composition 返回的构成映射为 GoodsItem 并刷新 UI
val voList = detail.foodConstituteList * 筛选主材(1)/辅材(2),调料(3)仍走槽位秤;useWeight 置零待重新称量
if (voList.isNullOrEmpty()) { */
return private fun loadFoodComposition(response: FoodCompositionResponse) {
val tempData = response.ingredients
.filter { it.isMain == 1 || it.isMain == 2 }
.map {
GoodsItem(
goodsId = it.materId,
materId = it.materId,
goodsName = it.materName,
materialType = it.isMain,
useWeight = 0.0,
isOriginalData = true
)
} }
// 筛选出主材和辅材,转换为 GoodsItemuseWeight 置零)
val tempData = voList.filter { it.materialType == 1 || it.materialType == 2 }
.map { it.toGoodsItem().also { item -> item.useWeight = 0.0 } }
// 直接替换 adapter.items,确保 adapter 数据源被完全替换
// 同时同步外部 list,保证后续 updateMaterialTypes/removeFood 等操作正确
list.clear() list.clear()
list.addAll(tempData) list.addAll(tempData)
materialAdapter.items = list materialAdapter.items = list
@@ -475,14 +568,12 @@ class PrepareFoodActivity : BaseActivity() {
list.forEachIndexed { index, entity -> list.forEachIndexed { index, entity ->
entity.isClicked = index == position entity.isClicked = index == position
} }
// 制作模式:选中食材时同步当前秤重 // 选中食材时同步当前秤重(制作/采样均支持)
if (SpTool.cookMode == 0) {
val item = list.getOrNull(position) val item = list.getOrNull(position)
if (item != null && currentWeight > 0) { if (item != null && currentWeight > 0) {
item.useWeight = currentWeight item.useWeight = currentWeight
item.isSetFinished = true item.isSetFinished = true
} }
}
notifyDataSetChanged() notifyDataSetChanged()
} }
addOnItemChildClickListener(R.id.ivClearIcon) { _, _, position -> addOnItemChildClickListener(R.id.ivClearIcon) { _, _, position ->
@@ -519,9 +610,9 @@ class PrepareFoodActivity : BaseActivity() {
private fun initRecyclerView() { private fun initRecyclerView() {
binding.rvMaterialList.let { binding.rvMaterialList.let {
it.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) it.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
// 仅 isNewDishType=true 的 item 显示侧滑删除菜单(必须在 setAdapter 之前调用) // 采样模式下所有食材均可侧滑删除(必须在 setAdapter 之前调用)
it.setSwipeMenuCreator { _, rightMenu, position -> it.setSwipeMenuCreator { _, rightMenu, position ->
if (list.getOrNull(position)?.isNewDishType == true) { if (SpTool.cookMode == 1) {
rightMenu.addMenuItem(buildDeleteMenuItem()) rightMenu.addMenuItem(buildDeleteMenuItem())
} }
} }
@@ -13,11 +13,11 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.FoodListAdapter import com.shuwei.dish.match.adapter.FoodListAdapter
import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivitySamplingModeBinding import com.shuwei.dish.match.databinding.ActivitySamplingModeBinding
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.model.FoodRecord import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.SampleHistoryItem
import com.shuwei.dish.match.net.UiState import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.DateTimeUtil import com.shuwei.dish.match.utils.DateTimeUtil
import com.shuwei.dish.match.utils.ext.dp import com.shuwei.dish.match.utils.ext.dp
@@ -251,12 +251,9 @@ class SamplingModeActivity : BaseActivity() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun getSamplingList() { fun getSamplingList() {
netViewModel.getSamplingListWithCallback( netViewModel.sampleHistory(
param = mutableMapOf( pageNum = pageNo,
"pageNum" to pageNo, pageSize = pageSize,
"pageSize" to pageSize,
"placeId" to BaseApp.canteenId
),
onLoading = { showLoading() } onLoading = { showLoading() }
) { state -> ) { state ->
when (state) { when (state) {
@@ -264,14 +261,14 @@ class SamplingModeActivity : BaseActivity() {
// 回调期间用户可能已切换 Tab,需再次校验 // 回调期间用户可能已切换 Tab,需再次校验
if (binding.rbSamplingFinished.isChecked.not()) { if (binding.rbSamplingFinished.isChecked.not()) {
delayDismissLoading() delayDismissLoading()
return@getSamplingListWithCallback return@sampleHistory
} }
loadDishList(state.data) loadDishList(state.data?.map { it.toFoodRecord() }?.toMutableList())
} }
is UiState.Error -> { is UiState.Error -> {
if (binding.rbSamplingFinished.isChecked.not()) { if (binding.rbSamplingFinished.isChecked.not()) {
delayDismissLoading() delayDismissLoading()
return@getSamplingListWithCallback return@sampleHistory
} }
binding.refreshLayout.setEnableRefresh(true) binding.refreshLayout.setEnableRefresh(true)
toast(state.msg) toast(state.msg)
@@ -284,6 +281,18 @@ class SamplingModeActivity : BaseActivity() {
} }
} }
/** 采样历史条目 → FoodRecord 映射,供 FoodListAdapter(SAMPLING_MODE) 展示 */
private fun SampleHistoryItem.toFoodRecord(): FoodRecord = FoodRecord(
foodId = foodId,
foodName = foodName,
foodWeight = foodWeight,
cookMode = 1,
foodCode = foodCode,
auditStatus = auditStatus,
auditStatusName = auditStatusName,
auditRecordId = auditRecordId
)
private fun finishRefresh() { private fun finishRefresh() {
if (pageNo == 1) { if (pageNo == 1) {
binding.refreshLayout.finishRefresh(1200) binding.refreshLayout.finishRefresh(1200)
@@ -21,6 +21,9 @@ 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.ConstituteCookMode
import com.shuwei.dish.match.model.ConstituteSaveItem
import com.shuwei.dish.match.model.ConstituteSaveRequest
import com.shuwei.dish.match.model.CookCompleteItem import com.shuwei.dish.match.model.CookCompleteItem
import com.shuwei.dish.match.model.CookCompleteRequest import com.shuwei.dish.match.model.CookCompleteRequest
import com.shuwei.dish.match.model.FoodRecord import com.shuwei.dish.match.model.FoodRecord
@@ -375,7 +378,7 @@ class SubmitFoodActivity : BaseActivity() {
// 监听主设备 2格秤的熟重 // 监听主设备 2格秤的熟重
WeightUtil.addWeightListener(TAG) { address, _, weight -> WeightUtil.addWeightListener(TAG) { address, _, weight ->
if (address == AddressUtil.TWO) { if (address == AddressUtil.ONE) {
// 首次收到数据时记录初始重量 // 首次收到数据时记录初始重量
// if (!initWeightSet) { // if (!initWeightSet) {
// initWeight = weight // initWeight = weight
@@ -456,7 +459,13 @@ class SubmitFoodActivity : BaseActivity() {
return@launch return@launch
} }
// 旧接口路径(采样模式 / 无烹制单 ID // 采样模式 → 调新配比秤提交构成接口(cookMode=2,从零建菜,进入待审核
if (food?.cookMode == 1) {
submitConstitute()
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) {
@@ -537,6 +546,73 @@ class SubmitFoodActivity : BaseActivity() {
) )
} }
/**
* 采样提交:从 goodsList 提取全部构成(主材/辅材/调料),组装 ConstituteSaveRequest 提交
* cookMode=2(采样),熟重 foodWeight 单位为 gisMain 直接取 materialType(1主/2辅/3调)
*/
private fun submitConstitute() {
val allGoods = goodsList ?: run {
toast("未获取到菜品或构成信息")
dismissLoading()
return
}
val foodName = cookFoodEntity.foodName ?: food?.foodName ?: ""
if (foodName.isBlank()) {
toast("菜品名称为空")
dismissLoading()
return
}
// 仅保留用量 > 0 的食材;materId 优先取 materId,为空时回退 goodsId
val items = allGoods
.filter { (it.useWeight ?: 0.0) > 0.0 }
.map {
ConstituteSaveItem(
materId = it.materId?.takeIf { mid -> mid.isNotEmpty() } ?: it.goodsId,
useWeight = it.useWeight ?: 0.0,
isMain = it.materialType
)
}
if (items.isEmpty()) {
toast("请完善菜品构成信息")
dismissLoading()
return
}
if (items.any { it.materId.isBlank() }) {
toast("菜品食材不能为空")
dismissLoading()
return
}
val foodWeight = cookFoodEntity.foodWeight
if (foodWeight <= 0.0) {
toast("熟重必须大于0")
dismissLoading()
return
}
val request = ConstituteSaveRequest(
cookMode = ConstituteCookMode.SAMPLE,
foodName = foodName,
foodWeight = foodWeight,
items = items
)
Log.d(TAG, "submitConstitute: ${request.toJsonString()}")
netViewModel.constituteSave(
request = request,
onResult = { state ->
when (state) {
is UiState.Success -> submitSuccess(isSamplingData = true)
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 等)
@@ -224,7 +224,10 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
currentJob?.cancel() currentJob?.cancel()
currentJob = viewLifecycleOwner.lifecycleScope.launch { currentJob = viewLifecycleOwner.lifecycleScope.launch {
// 首次加载传 mealType=null 获取所有餐次,切换 Tab 时传当前餐次精确请求 // 首次加载传 mealType=null 获取所有餐次,切换 Tab 时传当前餐次精确请求
val mealType = if (isFirstLoad) null else dinnerType.toIntOrNull() val mealType = if (isFirstLoad) null else when (dinnerType) {
"1", "2", "3", "4" -> dinnerType.toInt()
else -> null // "0" 或非法值不传,由后端返回全部餐次
}
val request = CookOrderPageRequest( val request = CookOrderPageRequest(
mealType = mealType, mealType = mealType,
keyword = foodName keyword = foodName
@@ -51,6 +51,178 @@
</LinearLayout> </LinearLayout>
<!-- 食材信息录入卡:手动新增菜品构成 -->
<LinearLayout
android:id="@+id/llIngredientInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="30dp"
android:layout_marginBottom="30dp"
android:background="@drawable/shape_white_30_corners"
android:orientation="vertical"
android:padding="20dp">
<!-- 食材名称 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dish_name"
android:textColor="@color/black666"
android:textSize="28sp" />
<EditText
android:id="@+id/etIngredientName"
android:layout_width="0dp"
android:layout_height="80dp"
android:layout_marginStart="20dp"
android:layout_weight="1"
android:autofillHints=""
android:background="@drawable/shape_white_f6_10_corners"
android:clickable="true"
android:ellipsize="end"
android:focusable="false"
android:focusableInTouchMode="false"
android:gravity="center_vertical"
android:hint="@string/click_search_ingredient"
android:maxLines="1"
android:paddingHorizontal="20dp"
android:textColor="@color/black333"
android:textColorHint="@color/gray_c8"
android:textSize="30sp"
tools:ignore="TextFields" />
<ImageView
android:id="@+id/ivIngredientSearch"
android:layout_width="70dp"
android:layout_height="70dp"
android:layout_marginStart="16dp"
android:padding="20dp"
android:src="@drawable/ic_search_green"
tools:ignore="ContentDescription" />
</LinearLayout>
<!-- 主辅材 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dish_from_type"
android:textColor="@color/black666"
android:textSize="28sp" />
<RadioGroup
android:id="@+id/rgMainAux"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<RadioButton
android:id="@+id/rbMain"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/white"
android:button="@null"
android:checked="true"
android:drawablePadding="20dp"
android:drawableStart="@drawable/selector_dish_type"
android:gravity="center"
android:paddingHorizontal="30dp"
android:text="@string/dish_type_first"
android:textColor="@color/dish_type_font"
android:textSize="30sp"
android:textStyle="bold" />
<RadioButton
android:id="@+id/rbAux"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/white"
android:button="@null"
android:drawablePadding="20dp"
android:drawableStart="@drawable/selector_dish_type"
android:gravity="center"
android:paddingHorizontal="30dp"
android:text="@string/dish_type_second"
android:textColor="@color/dish_type_font"
android:textSize="30sp"
android:textStyle="bold" />
</RadioGroup>
</LinearLayout>
<!-- 用量(克) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dish_part_weight_remind"
android:textColor="@color/black666"
android:textSize="28sp" />
<EditText
android:id="@+id/etAmount"
android:layout_width="0dp"
android:layout_height="80dp"
android:layout_marginStart="20dp"
android:layout_weight="1"
android:autofillHints=""
android:background="@drawable/shape_white_f6_10_corners"
android:focusable="false"
android:focusableInTouchMode="false"
android:gravity="center_vertical"
android:hint="@string/weight_from_scale"
android:maxLines="1"
android:paddingHorizontal="20dp"
android:textColor="@color/black333"
android:textColorHint="@color/gray_c8"
android:textSize="30sp"
tools:ignore="TextFields" />
<TextView
android:id="@+id/btnReset"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:padding="10dp"
android:text="@string/clear_zero"
android:textColor="@color/red_ff4444"
android:textSize="28sp"
android:visibility="gone" />
<TextView
android:id="@+id/btnAdd"
android:layout_width="wrap_content"
android:layout_height="70dp"
android:layout_marginStart="16dp"
android:background="@drawable/shape_green_bg"
android:gravity="center"
android:paddingHorizontal="40dp"
android:text="@string/add"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
+4
View File
@@ -35,4 +35,8 @@
<string name="dish_type_first">主材</string> <string name="dish_type_first">主材</string>
<string name="add_sampling">新增采样</string> <string name="add_sampling">新增采样</string>
<string name="add_food">添加食材</string> <string name="add_food">添加食材</string>
<string name="clear_zero">清零</string>
<string name="add">添加</string>
<string name="click_search_ingredient">点击搜索食材</string>
<string name="weight_from_scale">称重获取</string>
</resources> </resources>
+66 -89
View File
@@ -15,20 +15,19 @@
## 业务背景 ## 业务背景
配比秤是一款独立于现有组配秤(`/neglect` 路径)的新硬件设备,部署在食堂后厨。第一期实现"按单烹制"功能:厨师在配比秤终端上查看当日烹饪任务 → 查看菜品食材构成 → 提交材实际用量并触发组配完成 → 开始烹制 → 烹饪完成后提交熟重调料用量。 配比秤是一款独立于现有组配秤(`/neglect` 路径)的新硬件设备,部署在食堂后厨。第一期实现"按单烹制"功能:厨师在配比秤终端上查看当日烹饪任务 → 查看菜品食材构成 → 提交主辅材实际用量+熟重+调料用量,一次性完成烹制
### 核心流程 ### 核心流程
``` ```
当日烹饪任务列表 → 查看菜品食材构成 → 去制作(提交主辅材实际用量) → 烹饪完成(提交熟重 + 调料用量 当日烹饪任务列表 → 查看菜品食材构成 → 烹饪完成(提交主辅材用量 + 熟重 + 调料)
``` ```
### 烹制状态枚举 ### 烹制状态枚举
| 状态码 | 状态名 | 说明 | | 状态码 | 状态名 | 说明 |
|:------:|--------|------| |:------:|--------|------|
| 0 | 待烹制 | 初始状态,可进行"去制作"操作 | | 0 | 待烹制 | 初始状态,可进行"烹饪完成"操作 |
| 1 | 烹制中 | 已提交制作,可进行"烹饪完成"操作 |
| 2 | 已完成 | 烹制结束,不可再操作 | | 2 | 已完成 | 烹制结束,不可再操作 |
| 3 | 异常 | 异常状态 | | 3 | 异常 | 异常状态 |
@@ -46,17 +45,17 @@
| 序号 | 接口 | 路径 | 用途 | | 序号 | 接口 | 路径 | 用途 |
|:----:|------|------|------| |:----:|------|------|------|
| 一 | 当日烹饪任务列表(按餐次分组) | `POST /neglect/ratio-scale/cook-orders/page` | 按餐次分组返回今日需烹制的菜品列表 | | 一 | 当日烹饪任务列表(按餐次分组) | `POST /neglect/ratio-scale/cook-orders/list` | 按餐次分组返回今日需烹制的菜品列表 |
| 二 | 菜品食材构成 | `GET /neglect/ratio-scale/cook-orders/{cookOrderId}/composition` | 查看菜品的主材和辅材清单(含标准用量) | | 二 | 菜品食材构成 | `GET /neglect/ratio-scale/cook-orders/{cookOrderId}/composition` | 查看菜品的主材和辅材清单(含标准用量) |
| 三 | 去制作 | `POST /neglect/ratio-scale/cook-orders/submit` | 提交主辅材实际用量,完善组配并开始烹制 | | 三 | 烹饪完成 | `POST /neglect/ratio-scale/cook-orders/complete` | 提交主辅材用量+熟重+调料,一次完成烹制 |
| 四 | 烹饪完成 | `POST /neglect/ratio-scale/cook-orders/finish` | 提交熟重和调料用量,计算生熟比 | | 四 | 调料模糊搜索 | `POST /neglect/ratio-scale/seasoning-search` | 模糊搜索调料(糖蜜饯+油脂+调味品三大类) |
--- ---
## 一、当日烹饪任务列表(按餐次分组) ## 一、当日烹饪任务列表(按餐次分组)
``` ```
POST /neglect/ratio-scale/cook-orders/page POST /neglect/ratio-scale/cook-orders/list
Content-Type: application/json Content-Type: application/json
X-DEVICE-CODE: <设备编码> X-DEVICE-CODE: <设备编码>
X-DEVICE-TOKEN: <设备登录Token> X-DEVICE-TOKEN: <设备登录Token>
@@ -70,9 +69,7 @@ X-DEVICE-TOKEN: <设备登录Token>
{ {
"date": "2026-07-29", // 选填 — 计划日期 yyyy-MM-dd,默认当天 "date": "2026-07-29", // 选填 — 计划日期 yyyy-MM-dd,默认当天
"mealType": 2, // 选填 — 餐次筛选: 1=早餐 / 2=午餐 / 3=晚餐 / 4=加餐 "mealType": 2, // 选填 — 餐次筛选: 1=早餐 / 2=午餐 / 3=晚餐 / 4=加餐
"keyword": "红烧肉", // 选填 — 菜品名称模糊搜索 "keyword": "红烧肉" // 选填 — 菜品名称模糊搜索
"pageNum": 1, // 选填 — 保留字段,当前不使用
"pageSize": 20 // 选填 — 保留字段,当前不使用
} }
``` ```
@@ -203,16 +200,16 @@ X-DEVICE-TOKEN: <设备登录Token>
--- ---
## 三、去制作:提交食材实际用量并开始烹制 ## 三、烹饪完成:提交主辅材用量+熟重+调料
``` ```
POST /neglect/ratio-scale/cook-orders/submit POST /neglect/ratio-scale/cook-orders/complete
Content-Type: application/json Content-Type: application/json
X-DEVICE-CODE: <设备编码> X-DEVICE-CODE: <设备编码>
X-DEVICE-TOKEN: <设备登录Token> X-DEVICE-TOKEN: <设备登录Token>
``` ```
厨师在秤上完成主辅材称重后,提交各食材的实际用量。后端自动完成:组配任务完善 → 组配完成 → 烹制单状态切换为"烹制中" → 烹制食材明细更新 厨师在秤上完成称重后一次性提交全部数据,后端自动完成:组配任务完善 → 组配完成 → 计算生重生熟比 → 保存调料 → 烹制单置为已完成
**该接口为事务性操作(`@Transactional`),所有步骤在同一事务中执行,任意步骤失败则全部回滚。** **该接口为事务性操作(`@Transactional`),所有步骤在同一事务中执行,任意步骤失败则全部回滚。**
@@ -221,7 +218,7 @@ X-DEVICE-TOKEN: <设备登录Token>
```json ```json
{ {
"cookOrderId": 10001, // 必填 — 烹制单id "cookOrderId": 10001, // 必填 — 烹制单id
"items": [ // 必填 — 材实际用量列表,至少1条 "items": [ // 必填 — 主辅材实际用量列表,至少1条
{ {
"materId": 20001, // 必填 — 食材id "materId": 20001, // 必填 — 食材id
"actualQty": 520.5 // 必填 — 实际用量(g) "actualQty": 520.5 // 必填 — 实际用量(g)
@@ -230,6 +227,13 @@ X-DEVICE-TOKEN: <设备登录Token>
"materId": 20002, "materId": 20002,
"actualQty": 310.0 "actualQty": 310.0
} }
],
"cookedWeight": 3.200, // 必填 — 熟重(kg)
"seasonings": [ // 选填 — 调料用量列表
{
"materId": 30001, // 必填 — 调料食材id
"actualQty": 15.0 // 必填 — 实际用量(g)
}
] ]
} }
``` ```
@@ -249,22 +253,26 @@ X-DEVICE-TOKEN: <设备登录Token>
``` ```
1. 校验烹制单状态 = 0(待烹制),否则报错 1. 校验烹制单状态 = 0(待烹制),否则报错
2. 通过内供申领单关联餐品净菜包: 2. 通过内供申领单关联餐品净菜包:
链路: nut_prod_cook_order.food_id → nut_sup_internal_supply(food_id + canteen_id + clean_type=2) 链路: nut_prod_cook_order(food_id + canteen_id + meal_type) → nut_prod_clean_order(food_id + canteen_id + meal_type + clean_type=2)
→ clean_order_id → nut_sup_internal_supply(clean_order_id + clean_type=2)
→ batch_no → nut_sup_meal_package → nut_sup_meal_pkg_ingredient → trace_code → batch_no → nut_sup_meal_package → nut_sup_meal_pkg_ingredient → trace_code
餐次匹配:通过 nut_prod_clean_order.meal_type 过滤,确保同菜不同餐次不会串单
3. 解析各食材的溯源码(materId → traceCode 映射) 3. 解析各食材的溯源码(materId → traceCode 映射)
4. 完善组配任务明细(按 materCode 匹配 comboTaskItem,更新 actualQty + traceCode 4. 完善组配任务明细(按 materCode 匹配 comboTaskItem,更新 actualQty + traceCode
5. 完成组配任务(comboStatus=2,记录完成时间) 5. 完成组配任务(comboStatus=2,记录完成时间)
6. 计算生重(Σ actualQty / 1000g→kg,保留3位小数) 6. 计算生重(Σ actualQty / 1000g→kg,保留3位小数)
7. 从设备Token获取厨师信息(chef + chefId 7. 从设备Token获取厨师信息(chef + chefId
8. 更新烹制单: 8. 更新烹制食材明细(主辅材 actualQty + traceCode,无已有明细时兜底创建)
- cookStatus → 1(烹制中 9. 保存调料用量(ingredientClass=3
- cookStart → 当前时间 10. 计算生熟比 = 熟重 / 生重(保留3位小数,HALF_UP,生重>0且熟重≠null时才计算)
11. 更新烹制单:
- cookStatus → 2(已完成)
- cookStart / cookEnd → 当前时间
- rawWeight → 生重(kg) - rawWeight → 生重(kg)
- cookedWeight → 熟重(kg)
- rawCookedRatio → 生熟比
- chef / chefId → 登录厨师 - chef / chefId → 登录厨师
- needPortions / cookedPortions → 份数 - needPortions / cookedPortions → 份数
9. 更新烹制食材明细(cook_ingredient):
- 已有明细 → 更新 actualQty + traceCode
- 无已有明细 → 按 foodComposition 自动创建兜底记录
``` ```
### 份数计算优先级 ### 份数计算优先级
@@ -278,7 +286,7 @@ X-DEVICE-TOKEN: <设备登录Token>
| 场景 | 错误信息 | | 场景 | 错误信息 |
|------|---------| |------|---------|
| 烹制单不存在 | `烹制单不存在` | | 烹制单不存在 | `烹制单不存在` |
| 当前状态不是"待烹制" | `当前状态不允许此操作,仅待烹制状态可提交制作` | | 当前状态不是"待烹制" | `当前状态不允许此操作,仅待烹制状态可提交` |
| 烹制单未关联组配任务(comboNo 为空) | `烹制单未关联组配任务,无法开始烹制` | | 烹制单未关联组配任务(comboNo 为空) | `烹制单未关联组配任务,无法开始烹制` |
| 组配任务不存在 | `组配任务不存在,comboNo=xxx` | | 组配任务不存在 | `组配任务不存在,comboNo=xxx` |
@@ -286,47 +294,36 @@ X-DEVICE-TOKEN: <设备登录Token>
| 表名 | 操作 | 说明 | | 表名 | 操作 | 说明 |
|------|:--:|------| |------|:--:|------|
| `nut_prod_cook_order` | 查询 + 更新 | 校验状态,更新为烹制中 | | `nut_prod_cook_order` | 查询 + 更新 | 校验状态,更新为已完成 |
| `nut_sup_internal_supply` | 查询 | 通过 foodId + canteenId + cleanType=2 关联,获取 batchNo 和 quantity | | `nut_prod_clean_order` | 查询 | 通过 foodId + canteenId + mealType + cleanType=2 匹配净菜订单,获取 clean_order_id 用于内供申领单的餐次过滤 |
| `nut_sup_internal_supply` | 查询 | 通过 clean_order_id + cleanType=2 关联,获取 batchNo 和 quantity |
| `nut_sup_meal_package` | 查询 | 通过 batchNo 查餐品净菜包 | | `nut_sup_meal_package` | 查询 | 通过 batchNo 查餐品净菜包 |
| `nut_sup_meal_pkg_ingredient` | 查询 | 获取各食材的溯源码 | | `nut_sup_meal_pkg_ingredient` | 查询 | 获取各食材的溯源码 |
| `nut_prod_combo_task` | 查询 + 更新 | 完善组配状态为已完成 | | `nut_prod_combo_task` | 查询 + 更新 | 完善组配状态为已完成 |
| `nut_prod_combo_task_item` | 查询 + 更新 | 更新 actualQty + traceCode | | `nut_prod_combo_task_item` | 查询 + 更新 | 更新 actualQty + traceCode |
| `nut_prod_cook_ingredient` | 查询 + 新增/更新 | 更新实际用量,不存在时兜底创建 | | `nut_prod_cook_ingredient` | 查询 + 新增/更新 | 更新主辅材实际用量 + 保存调料用量 |
| `nut_mater_base` | 查询 | 获取食材编码和名称 | | `nut_mater_base` | 查询 | 获取食材编码和名称 |
| `nut_food_composition` | 查询 | 兜底创建 cook_ingredient 时获取食材分类等信息 | | `nut_food_composition` | 查询 | 兜底创建 cook_ingredient 时获取食材分类等信息 |
--- ---
## 四、烹饪完成:提交熟重和调料用量 ## 四、调料模糊搜索
``` ```
POST /neglect/ratio-scale/cook-orders/finish POST /neglect/ratio-scale/seasoning-search
Content-Type: application/json Content-Type: application/json
X-DEVICE-CODE: <设备编码> X-DEVICE-CODE: <设备编码>
X-DEVICE-TOKEN: <设备登录Token>
``` ```
烹饪结束后,厨师在秤上称量熟重,并提交调料用量。后端自动计算生熟比 搜索调料食材库,数据源 `nut_mater_base` 一级分类为 18(糖蜜饯)、19(油脂)、20(调味品) 的食材
**该接口为事务性操作(`@Transactional`)。**
### 请求体 ### 请求体
```json ```json
{ {
"cookOrderId": 10001, // 填 — 烹制单id "keyword": "酱油", // 填 — 模糊匹配食材名称和别名
"cookedWeight": 3.200, // 必填 — 熟重(kg) "pageNum": 1, // 必填 — 页码
"seasonings": [ // 填 — 调料用量列表 "pageSize": 20 // 填 — 每页条数
{
"materId": 30001, // 必填 — 调料食材id
"actualQty": 15.0 // 必填 — 实际用量(g)
},
{
"materId": 30002,
"actualQty": 8.5
}
]
} }
``` ```
@@ -336,47 +333,28 @@ X-DEVICE-TOKEN: <设备登录Token>
{ {
"code": "200", "code": "200",
"msg": "操作成功", "msg": "操作成功",
"data": null "data": [
{
"id": "30001",
"materName": "酱油",
"materCode": "19192004",
"alias": "生抽",
"canteenId": "2"
}
],
"total": 1
} }
``` ```
### 处理流程 ### 字段说明
``` | 字段 | 类型 | 说明 |
1. 校验烹制单状态 = 1(烹制中),否则报错 |------|------|------|
2. 如有调料:保存到 nut_prod_cook_ingredient | id | Long → String | 食材id |
- 已在 cook_ingredient 中的调料(ingredientClass=3 → 更新 actualQty | materName | String | 食材名称 |
- 新调料 → 新增记录(ingredientClass=3 调料) | materCode | String | 食材编码 |
3. 计算生熟比 = cookedWeight / rawWeight(保留3位小数,HALF_UP | alias | String | 别名 |
4. 更新烹制单: | canteenId | Long → String | 归属食堂id |
- 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` | 查询 | 获取调料食材编码和名称 |
--- ---
@@ -389,7 +367,11 @@ nut_prod_cook_order (烹制单)
│ │ │ │
│ └── mater_id ──→ nut_mater_base (食材库: 编码、名称、分类) │ └── mater_id ──→ nut_mater_base (食材库: 编码、名称、分类)
├── canteen_id + food_id ──→ nut_sup_internal_supply (内供申领单) ├── food_id + canteen_id + meal_type ──→ nut_prod_clean_order (净菜订单, clean_type=2)
│ │
│ └── id = clean_order_id
├── clean_order_id ──→ nut_sup_internal_supply (内供申领单, 按 clean_order_id + clean_type=2 匹配)
│ │ │ │
│ ├── batch_no ──→ nut_sup_meal_package (餐品净菜包) │ ├── batch_no ──→ nut_sup_meal_package (餐品净菜包)
│ │ │ │ │ │
@@ -424,20 +406,15 @@ nut_prod_cook_order (烹制单)
│ │──→ nut_mater_base (食材名称) │ │──→ nut_mater_base (食材名称)
│ ← 食材清单(含标准用量) │←────────────────────│ │ ← 食材清单(含标准用量) │←────────────────────│
│ │ │ │ │ │
│ 3. POST /cook-orders/submit │ 3. POST /cook-orders/complete
│─────────────────────→│ │ │─────────────────────→│ │
│ │──→ 校验状态=待烹制 │ │ │──→ 校验状态=待烹制 │
│ │──→ 匹配净菜订单(餐次过滤) │
│ │──→ 关联internal_supply │ │ │──→ 关联internal_supply │
│ │──→ 解析溯源码 │ │ │──→ 解析溯源码 │
│ │──→ 完善+完成组配任务 │ │ │──→ 完善+完成组配任务 │
│ │──→ 计算生重 │ │ │──→ 计算生重 │
│ │──→ 更新烹制单=烹制中 │
│ │──→ 更新食材明细 │ │ │──→ 更新食材明细 │
│ ← 操作成功 │←──── 事务提交 ──────│
│ │ │
│ 4. POST /cook-orders/finish │
│─────────────────────→│ │
│ │──→ 校验状态=烹制中 │
│ │──→ 保存调料用量 │ │ │──→ 保存调料用量 │
│ │──→ 计算生熟比 │ │ │──→ 计算生熟比 │
│ │──→ 更新烹制单=已完成 │ │ │──→ 更新烹制单=已完成 │
+611
View File
@@ -0,0 +1,611 @@
# 配比秤(新设备)— 采样与制作模式 API 文档
> Controller: `NutRatioScaleController`
> 路径前缀: `/neglect/ratio-scale`Nacos 白名单 `/nutrition/neglect/**`,无需 Sa-Token
> 设备上下文通过请求头 `X-DEVICE-CODE` 解析(`TerminalContextHelper`,据此拿到食堂 id
> 日期: 2026-09-09
>
> **序列化规则**:Long 类型字段响应中均为字符串(Jackson `ToStringSerializer`),`BigDecimal` 为普通数字
> **单位约定**
> - 提交构成的熟重 `foodWeight` 单位为 **克 (g)**(与 `nut_food_cook.output_weight` 口径一致)
> - 食材称量用量 `useWeight` 单位为 **克 (g)**
> - 烹制单生重 `rawWeight`、熟重 `cookedWeight` 单位为 **千克 (kg)**
> - 食材实际用量 `actualQty` 单位为 **克 (g)**
---
## 业务背景
配比秤新增「采样模式」与「制作模式」,两者通过**同一个提交接口** `POST /constitute/save``cookMode` 字段区分:
| cookMode | 模式 | 说明 |
|:--------:|------|------|
| 2 | 采样模式 | **从零建菜**:称量食材 → 新增菜品(生成构成/营养/宝塔)→ 进入「待审核」→ 后台审核通过后上架 |
| 1 | 制作模式 | **基于已有菜品**:称量食材 → 记录熟重+构成快照(历史参考,不覆盖正式构成)→ 标记菜品为「配比秤制作」 |
### 制作方式枚举
| 值 | 名称 | 菜品对象 | 数据来源 data_source |
|:--:|------|---------|---------------------|
| 1 | 制作模式 | 已有菜品(foodId 必填) | 3(配比秤制作) |
| 2 | 采样模式 | 新增菜品(foodName 必填) | 2(配比秤采样) |
### 采样菜品审核状态枚举
| 状态码 | 状态名 | 说明 |
|:------:|--------|------|
| 1 | 待审核 | 采样提交后的初始状态,后台可审核 |
| 2 | 已通过 | 审核通过,菜品上架生效 |
| 3 | 已驳回 | 审核驳回,菜品保持下架,可重新采样同名菜品 |
### 食材分类枚举
| 分类 | 名称 | 说明 |
|:----:|------|------|
| 1 | 主材 | 菜品主要食材 |
| 2 | 辅材 | 菜品辅助食材 |
| 3 | 调料 | 油盐酱醋等 |
---
## 接口清单
| 序号 | 接口 | 路径 | 用途 |
|:----:|------|------|------|
| 一 | 食材模糊搜索 | `POST /neglect/ratio-scale/mater-search` | 称量前检索食材(设备对应食堂食材库) |
| 二 | 搜菜品 | `POST /neglect/ratio-scale/food-search` | 制作模式选已有菜品(按名称模糊查上架菜品) |
| 三 | 查菜品构成 | `GET /neglect/ratio-scale/food/{foodId}/composition` | 制作模式查看已有菜品的主辅料+调料构成 |
| 四 | 提交构成 | `POST /neglect/ratio-scale/constitute/save` | 统一提交,cookMode 区分制作/采样 |
| 五 | 采样历史 | `POST /neglect/ratio-scale/sample/history` | 查询本食堂采样提交历史(默认当天) |
| 六 | 制作历史 | `POST /neglect/ratio-scale/make/history` | 按设备+日期查询制作记录分页 |
| 七 | 制作历史图表 | `GET /neglect/ratio-scale/make/history/{foodId}` | 按菜品id查制作历史图表数据 |
| 八 | 烹饪完成 | `POST /neglect/ratio-scale/cook-orders/complete` | 按单烹制,任意状态提交均累加 |
---
## 一、食材模糊搜索
```
POST /neglect/ratio-scale/mater-search
Content-Type: application/json
X-DEVICE-CODE: <设备编码>
```
称量食材前,从食材库检索食材(设备对应食堂食材)。
### 请求体
```json
{
"keyword": "萝卜", // 选填 — 模糊匹配食材名称和别名
"pageNum": 1, // 必填 — 页码
"pageSize": 20 // 必填 — 每页条数
}
```
### 响应
```json
{
"code": "00000",
"msg": "操作成功",
"data": [
{
"id": "1979825467306471454", // 食材id
"materName": "樱桃萝卜", // 食材名称
"materCode": "04041012", // 食材编码
"alias": null, // 别名
"materFirstClass": "04", // 食材一级分类
"materSecondClass": "043", // 食材二级分类
"materUrl": null, // 食材图片url
"canteenId": "2" // 归属食堂id
}
],
"total": 1
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Long → String | 食材id(提交构成 items.materId 使用) |
| materName | String | 食材名称 |
| materCode | String | 食材编码(一级+二级+自编码,全局唯一) |
| alias | String | 别名 |
| materFirstClass | String | 食材一级分类 |
| materSecondClass | String | 食材二级分类 |
| materUrl | String | 食材图片url |
| canteenId | Long → String | 归属食堂id |
### 逻辑说明
- 数据源:`nut_mater_base`
- 过滤:`canteen_id` = 当前食堂(由 `X-DEVICE-CODE` 解析)
- 关键词模糊匹配 `mater_name``alias`
- 排序:`mater_name ASC`
---
## 二、搜菜品
```
POST /neglect/ratio-scale/food-search
Content-Type: application/json
X-DEVICE-CODE: <设备编码>
```
制作模式下,检索本食堂已上架的菜品,供后厨选择目标菜品。
### 请求体
```json
{
"keyword": "西兰花", // 选填 — 菜品名称或编号模糊搜索
"pageNum": 1, // 必填 — 页码
"pageSize": 20 // 必填 — 每页条数
}
```
### 响应
```json
{
"code": "00000",
"msg": "操作成功",
"data": [
{
"foodId": "2091839370283118593", // 菜品id
"foodCode": "FOOD00012", // 菜品编号
"foodName": "清炒西兰花" // 菜品名称
}
],
"total": 1
}
```
### 逻辑说明
- 数据源:`nut_food``status=1` 上架)
- 过滤:`canteen_id` = 当前食堂
- 关键词模糊匹配 `food_name``food_code`
- 排序:`id DESC`
---
## 三、查菜品构成
```
GET /neglect/ratio-scale/food/{foodId}/composition
X-DEVICE-CODE: <设备编码>
```
制作模式下,根据菜品 id 查询已有菜品的主辅料+调料构成及熟重。
### 响应
```json
{
"code": "00000",
"msg": "操作成功",
"data": {
"foodId": "2091839370283118593", // 菜品id
"foodName": "清炒西兰花", // 菜品名称
"foodWeight": 450.0, // 熟重(g),从 nut_food_cook.output_weight 反查
"ingredients": [
{
"materId": "1979825467306471454", // 食材id
"materName": "樱桃萝卜", // 食材名称
"useWeight": 300.0, // 用量(g)
"isMain": 1, // 1=主材 / 2=辅材 / 3=调料
"isMainName": "主材"
}
]
}
}
```
### 逻辑说明
- 数据源:`nut_food_composition`(按 `food_id` 查全部构成)
- 食材名称从 `nut_mater_base` 批量反查
---
## 四、提交构成(cookMode 区分采样/制作)
```
POST /neglect/ratio-scale/constitute/save
Content-Type: application/json
X-DEVICE-CODE: <设备编码>
```
**统一提交接口**,通过 `cookMode` 区分采样/制作。
### 请求体
```json
{
"cookMode": 2, // 必填 — 1=制作模式 / 2=采样模式
"foodId": 2091839370283118593, // 制作模式必填 — 已有菜品id
"foodName": "清炒西兰花", // 采样模式必填 — 菜品名称
"foodWeight": 500, // 必填 — 熟重(g),必须 > 0
"mealType": 2, // 选填 — 餐次: 1=早餐/2=午餐/3=晚餐/4=加餐
"items": [ // 必填 — 食材构成列表,至少1条
{
"materId": 20001, // 必填 — 食材idnut_mater_base.id
"useWeight": 300, // 必填 — 食材称量用量(g),必须 > 0
"isMain": 1 // 必填 — 食材分类: 1=主材 / 2=辅材 / 3=调料
},
{
"materId": 30001,
"useWeight": 50,
"isMain": 3
}
]
}
```
### 响应
```json
{
"code": "00000",
"msg": "操作成功",
"data": null
}
```
### 处理流程(采样模式 cookMode=2
```
1. 校验食材构成非空 + materId 非空且唯一
2. 校验菜品名称在同食堂下唯一(排除已驳回菜品,允许驳回后重新采样同名)
3. 生成菜品编号(FOOD + 5位序号)
4. 保存菜品 nut_fooddata_source=2(配比秤采样) / audit_status=1(待审核) / status=0(下架)
5. 保存熟重 nut_food_cookoutput_weight = foodWeight
6. 保存食材构成 nut_food_compositionmater_id + use_weight + is_main
7. 根据构成自动计算营养指标 nut_food_nutri + 膳食宝塔 nut_food_pagoda
8. 发起审核记录 nut_food_audit_recordaudit_status=1(待审核)
```
### 处理流程(制作模式 cookMode=1
```
1. 校验食材构成非空 + materId 非空且唯一
2. 校验菜品存在(foodId 必填)
3. 标记菜品 nut_food.data_source = 3(配比秤制作)
4. 保存制作记录 nut_food_matching_usedevice_code + food_id + food_name + meal_type + day + food_weight(熟重)
5. 保存制作明细 nut_food_matching_use_itemmater_id + mater_name + use_weight + is_main(构成快照)
6. 不覆盖菜品的正式构成/营养/宝塔(历史参考)
```
### 异常场景
| 场景 | 错误信息 |
|------|---------|
| 食材构成列表为空 | `请完善菜品构成信息` |
| 食材id为空 | `菜品食材不能为空` |
| 食材id重复 | `菜品食材不能重复,重复食材id:xxx` |
| 制作方式非法 | `不支持的制作方式:xxx` |
| 采样:同食堂已存在同名菜品(非驳回) | `该食堂下已存在同名餐品` |
| 采样:食材id无效 | `存在无效的食材id` |
| 制作:菜品不存在 | `菜品不存在` |
| 熟重/用量 ≤ 0 | 参数校验:`熟重必须大于0` / `食材用量必须大于0` |
### 涉及数据表
**采样模式(cookMode=2**
| 表名 | 操作 | 说明 |
|------|:--:|------|
| `nut_food` | 新增 | 采样菜品主表(采样来源 + 待审核 + 下架) |
| `nut_food_cook` | 新增 | 保存熟重 |
| `nut_food_composition` | 新增 | 食材构成 |
| `nut_food_nutri` | 新增 | 每100g营养指标(自动计算) |
| `nut_food_pagoda` | 新增 | 膳食宝塔(自动计算) |
| `nut_food_audit_record` | 新增 | 待审核记录 |
**制作模式(cookMode=1**
| 表名 | 操作 | 说明 |
|------|:--:|------|
| `nut_food` | 更新 | 标记 data_source=3(配比秤制作) |
| `nut_food_matching_use` | 新增 | 制作记录(熟重记录) |
| `nut_food_matching_use_item` | 新增 | 制作明细(构成快照) |
---
## 五、采样历史
```
POST /neglect/ratio-scale/sample/history
Content-Type: application/json
X-DEVICE-CODE: <设备编码>
```
查询本食堂(由 `X-DEVICE-CODE` 解析)的采样提交历史,默认查询当天。
### 请求体
```json
{
"pageNum": 1, // 必填 — 页码
"pageSize": 10, // 必填 — 每页条数
"keyword": "西兰花", // 选填 — 菜品名称模糊搜索
"auditStatus": 1, // 选填 — 审核状态筛选: 1=待审核 / 2=已通过 / 3=已驳回
"sampleDate": "2026-09-09" // 选填 — 采样日期,不传默认当天
}
```
### 响应
```json
{
"code": "00000",
"msg": "操作成功",
"data": [
{
"foodId": "2097486806812893186", // 菜品id
"foodCode": "FOOD00030", // 菜品编号
"foodName": "清炒西兰花", // 菜品名称
"foodWeight": 500.0, // 熟重(g)
"auditStatus": 1, // 审核状态: 1=待审核 / 2=已通过 / 3=已驳回
"auditStatusName": "待审核", // 审核状态中文
"auditRecordId": "2097486809375612929", // 审核记录id
"createTime": "2026-09-09 08:46:39" // 提交时间
}
],
"total": 1
}
```
### 逻辑说明
- 数据源:`nut_food``data_source=2` 配比秤采样)
- `canteenId``X-DEVICE-CODE` 解析终端后自动限定
- 排序:`create_time DESC`
- `foodWeight``nut_food_cook.output_weight` 反查
- `auditRecordId``nut_food_audit_record` 反查
---
## 六、制作历史
```
POST /neglect/ratio-scale/make/history
Content-Type: application/json
X-DEVICE-CODE: <设备编码>
```
查询本设备(由 `X-DEVICE-CODE` 解析)的制作记录。
### 请求体
```json
{
"pageNum": 1, // 必填 — 页码
"pageSize": 10, // 必填 — 每页条数
"day": "2026-09-09" // 选填 — 制作日期,不传查全部
}
```
### 响应
```json
{
"code": "00000",
"msg": "操作成功",
"data": [
{
"id": "2097499999999999999", // 制作记录id
"foodId": "2091839370283118593", // 菜品id
"foodName": "清炒西兰花", // 菜品名称
"foodWeight": 500.0, // 熟重(g)
"mealType": 2, // 餐次: 1=早餐/2=午餐/3=晚餐/4=加餐
"mealTypeName": "午餐",
"day": "2026-09-09", // 制作日期
"createTime": "2026-09-09 08:46:39"
}
],
"total": 1
}
```
### 逻辑说明
- 数据源:`nut_food_matching_use`
- 过滤:`device_code` = 当前设备(由 `X-DEVICE-CODE` 解析)
- 排序:`create_time DESC`
---
## 七、制作历史图表(按菜品id
```
GET /neglect/ratio-scale/make/history/{foodId}
X-DEVICE-CODE: <设备编码>
```
根据菜品 id 查询该菜品的所有制作记录,返回图表数据(对齐借鉴项目 `queryGoodsInfoByFoodIdMatchingList`),用于前端渲染制作趋势图。
### 响应
```json
{
"code": "00000",
"msg": "操作成功",
"data": [
{
"xAxes": [
{"goodId": "", "goodName": "时间"},
{"goodId": "", "goodName": "熟重"},
{"goodId": "20001", "goodName": "樱桃萝卜"},
{"goodId": "30001", "goodName": "酱油"}
],
"yAxes": [
{"value": "2026-09-09 08:46:39"},
{"value": 500.0},
{"value": 300.0},
{"value": 50.0}
]
}
]
}
```
### 字段说明
| 字段 | 说明 |
|------|------|
| xAxes | 横轴模板:时间、熟重、该菜品所有出现过的食材(去重保序),所有记录共用 |
| xAxes[].goodId | 食材id(时间/熟重项为空字符串) |
| xAxes[].goodName | 项名称(时间/熟重/食材名称) |
| yAxes | 纵轴值,与 xAxes 一一对应 |
| yAxes[].value | 值:时间项为时间戳,其余为数值(缺失食材补 0) |
### 逻辑说明
- 数据源:`nut_food_matching_use`(主表)+ `nut_food_matching_use_item`(子表)
- 主表按 `food_id` 过滤,`create_time DESC` 排序
- 子表按 `matching_use_id` 批量查询并分组(消除 N+1
- 时间项取 `create_time`,熟重项取 `food_weight`,食材项取 `use_weight`(缺失补 0
---
## 八、烹饪完成(支持再次烹制)
```
POST /neglect/ratio-scale/cook-orders/complete
Content-Type: application/json
X-DEVICE-CODE: <设备编码>
```
按单烹制提交。**改造点**:原逻辑一次性完成(`待烹制 → 已完成`),现支持**任意状态下再次烹制**——每次提交累加份数/生重/熟重并留存一条流水,不校验烹制状态,需求达标后仍可继续烹制(应对菜不够卖接着做的场景)。
### 请求体
```json
{
"cookOrderId": 10001, // 必填 — 烹制单id
"portions": 10, // 选填 — 本次烹制份数,不传则按申领单/需求份数兜底
"cookStart": "2026-09-09 10:00:00", // 选填 — 本次烹制开始时间
"cookedWeight": 3.5, // 必填 — 本次熟重(kg)
"items": [ // 必填 — 主辅材实际用量列表,至少1条
{
"materId": 20001, // 必填 — 食材id
"actualQty": 5000 // 必填 — 实际用量(g)
}
],
"seasonings": [ // 选填 — 调料用量列表
{
"materId": 30001,
"actualQty": 15.0
}
]
}
```
### 响应
```json
{
"code": "00000",
"msg": "操作成功",
"data": null
}
```
### 状态流转规则(改造核心)
```
每次提交后(不校验状态,任何状态下提交均累加):
cooked_portions 累加 += 本次份数
raw_weight 累加 += 本次生重
cooked_weight 累加 += 本次熟重
cook_status → 2(已完成,每次提交都置此状态,允许继续烹制累加)
生熟比 = 累计熟重 / 累计生重(用累加后的值)
时间语义:
cook_start = 首次烹制开始时间(后续烹制不覆盖)
cook_end = 最后一次烹制结束时间(每次更新)
```
### 处理流程
```
1. 校验烹制单存在 + 食堂权限(不校验烹制状态,任何状态下提交均累加数据)
2. 本次烹制份数 = 入参 portions(优先)→ 申领单 quantity → 需求份数 → 1
3. 首次烹制(cooked_portions=0)才完善组配任务并完成
4. 计算本次生重(Σ actualQty / 1000g→kg
5. 计算生熟比 = 累计熟重 / 累计生重(用累加后的值)
6. 写烹制流水 nut_prod_cook_flow(本次生重/熟重/份数/起止时间)
7. 保存食材明细 + 调料(首次更新预生成明细,再次插入新明细,均关联 flow_id)
8. 累加主表:份数/生重/熟重累加 + 状态置已完成 + 起止时间
```
### 异常场景
| 场景 | 错误信息 |
|------|---------|
| 烹制单不存在 | `烹制单不存在` |
| 首次烹制未关联组配任务 | `烹制单未关联组配任务,无法开始烹制` |
| 组配任务不存在 | `组配任务不存在,comboNo=xxx` |
### 涉及数据表
| 表名 | 操作 | 说明 |
|------|:--:|------|
| `nut_prod_cook_order` | 查询 + 更新 | 累加份数/生重/熟重 + 状态置已完成 |
| `nut_prod_cook_flow` | 新增 | **烹制流水**,每次烹制一条 |
| `nut_prod_cook_ingredient` | 查询 + 更新/新增 | 首次更新预生成明细,再次插入新明细,均带 `flow_id` |
| `nut_prod_combo_task` / `_item` | 查询 + 更新 | 仅首次烹制时完善组配 |
| `nut_sup_internal_supply` / `nut_sup_meal_package` / `_ingredient` | 查询 | 解析溯源码与份数 |
| `nut_mater_base` / `nut_food_composition` | 查询 | 食材编码/名称/分类 |
---
## 数据链路图(采样模式 cookMode=2
```
nut_mater_base (食材库)
│ 采样称量 mater_id + use_weight(g)
POST /constitute/save (cookMode=2)
├── 生成 nut_food (采样来源 + 待审核 + 下架)
├── 生成 nut_food_cook (熟重 = foodWeight)
├── 生成 nut_food_composition (食材构成)
├── 自动计算 nut_food_nutri (每100g营养) + nut_food_pagoda (膳食宝塔)
└── 生成 nut_food_audit_record (待审核)
```
## 数据链路图(制作模式 cookMode=1
```
nut_food (已有菜品)
│ 制作称量 mater_id + use_weight(g) + 熟重
POST /constitute/save (cookMode=1)
├── 标记 nut_food.data_source = 3 (配比秤制作)
├── 生成 nut_food_matching_use (制作记录:熟重/餐次/日期/设备)
└── 生成 nut_food_matching_use_item (构成快照,历史参考)
(不覆盖 nut_food_composition / nut_food_nutri / nut_food_pagoda
```
## 数据链路图(再次烹制)
```
nut_prod_cook_order (烹制单,累加数据)
├── POST /cook-orders/complete (每次烹制)
│ ├── 生成 nut_prod_cook_flow (流水,每次一条)
│ └── 生成/更新 nut_prod_cook_ingredient (明细,带 flow_id)
└── 主表累加: cooked_portions / raw_weight / cooked_weight
cook_start = 首次开始 / cook_end = 末次结束
cook_status = 已完成(每次提交都置此状态,允许继续烹制累加)
```