diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6b1fe70..6550f4d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -156,6 +156,9 @@ dependencies { implementation("org.greenrobot:eventbus:3.3.1") + // flexbox 布局,用于动态表单两列排列 + implementation("com.google.android.flexbox:flexbox:3.0.0") + } apply(plugin = "io.objectbox") diff --git a/app/src/main/java/com/sw/inbound/GlobalData.kt b/app/src/main/java/com/sw/inbound/GlobalData.kt index 5ffa047..d606692 100644 --- a/app/src/main/java/com/sw/inbound/GlobalData.kt +++ b/app/src/main/java/com/sw/inbound/GlobalData.kt @@ -2,7 +2,9 @@ package com.sw.inbound import android.net.Uri import com.sw.inbound.model.response.DictType +import com.sw.inbound.model.response.SupplierOption import com.sw.inbound.model.response.User +import com.sw.inbound.model.response.ZoneOption object GlobalData { /** @@ -40,6 +42,16 @@ object GlobalData { */ var unitTypeList: List = arrayListOf() + /** + * 入库供应商列表(入库秤使用) + */ + var inboundSupplierList: List = emptyList() + + /** + * 入库仓库区域列表(入库秤使用) + */ + var inboundZoneList: List = emptyList() + /** * 具体业务 BaseUrl */ diff --git a/app/src/main/java/com/sw/inbound/activity/GoodsListActivity.kt b/app/src/main/java/com/sw/inbound/activity/GoodsListActivity.kt index a7a88de..e5b3a5e 100644 --- a/app/src/main/java/com/sw/inbound/activity/GoodsListActivity.kt +++ b/app/src/main/java/com/sw/inbound/activity/GoodsListActivity.kt @@ -457,7 +457,7 @@ abstract class GoodsListActivity : BaseActivity() { goodsFile = file, goodsName = name ) { - GoodsSearchDialog(this).show() + onGoodsSearchRequested() }.apply { if (isFinishing.not() && isDestroyed.not()) { //isShowRecognizeDialog = false @@ -469,6 +469,11 @@ abstract class GoodsListActivity : BaseActivity() { var goodsRecognizeDialog: GoodsRecognizeDialog? = null + /** 子类可重写以替换物品检索弹窗 */ + protected open fun onGoodsSearchRequested() { + GoodsSearchDialog(this).show() + } + private fun replaceIncludeContent() { binding.flContainer.removeAllViews() //isReceiptPage diff --git a/app/src/main/java/com/sw/inbound/activity/HomeActivity.kt b/app/src/main/java/com/sw/inbound/activity/HomeActivity.kt index 7879b45..b032bbe 100644 --- a/app/src/main/java/com/sw/inbound/activity/HomeActivity.kt +++ b/app/src/main/java/com/sw/inbound/activity/HomeActivity.kt @@ -111,14 +111,14 @@ class HomeActivity : BaseActivity() { // 净材按钮点击提示 binding.btnOrderPurchase.setOnClickListener { startActivity { - //putExtra(GoodsListActivity.IS_RECEIPT_PAGE, false) + putExtra(StoreActivity.EXTRA_STORE_TYPE, 0) } } // 毛菜按钮点击提示 binding.btnOrderSelfProcurement.setOnClickListener { startActivity { - //putExtra(GoodsListActivity.IS_RECEIPT_PAGE, false) + putExtra(StoreActivity.EXTRA_STORE_TYPE, 1) } } } diff --git a/app/src/main/java/com/sw/inbound/activity/StoreActivity.kt b/app/src/main/java/com/sw/inbound/activity/StoreActivity.kt index 3cb7844..3a40c7d 100644 --- a/app/src/main/java/com/sw/inbound/activity/StoreActivity.kt +++ b/app/src/main/java/com/sw/inbound/activity/StoreActivity.kt @@ -1,24 +1,44 @@ package com.sw.inbound.activity import android.os.Bundle -import androidx.recyclerview.widget.RecyclerView +import androidx.activity.viewModels +import com.sw.inbound.GlobalData +import com.sw.inbound.dialog.InboundGoodsSearchDialog import com.sw.inbound.model.response.DictType import com.sw.inbound.utils.ext.gone +import com.sw.inbound.viewmodel.StoreViewModel import dagger.hilt.android.AndroidEntryPoint /** * 入库称重页面 * 继承 GoodsListActivity 以复用秤监听、相机拍照、菜品识别弹窗等核心能力, - * 同时通过隐藏不需要的 View 精简界面,避免大量重复代码。 + * 通过隐藏不需要的 View 精简界面,避免大量重复代码。 */ @AndroidEntryPoint class StoreActivity : GoodsListActivity() { + val storeViewModel: StoreViewModel by viewModels() + + /** 0=净材,1=毛菜,由 HomeActivity 通过 Intent 传入 */ + var storeType: Int = 0 + override fun onCreate(savedInstanceState: Bundle?) { + storeType = intent.getIntExtra(EXTRA_STORE_TYPE, 0) super.onCreate(savedInstanceState) hideUnusedViews() } + override fun onResume() { + super.onResume() + preloadInboundOptions() + } + + /** 预加载供应商和仓库区域列表到 GlobalData,供弹窗直接使用 */ + private fun preloadInboundOptions() { + storeViewModel.loadSupplierOptions(callback = { GlobalData.inboundSupplierList = it }) + storeViewModel.loadZoneOptions(callback = { GlobalData.inboundZoneList = it }) + } + /** 隐藏采购单列表、仓库下拉、提交按钮等本页面不需要的控件 */ private fun hideUnusedViews() { binding.flWarehouseDropdown.gone() @@ -31,13 +51,18 @@ class StoreActivity : GoodsListActivity() { binding.rvGoodsList.gone() } - override fun initRecyclerView() { - // 本页面无列表,空实现 - } + override fun initRecyclerView() {} - override fun initView() { - // 本页面无额外初始化逻辑,空实现 - } + override fun initView() {} override fun getWarehouseList(): List = emptyList() + + /** 使用入库专用检索弹窗替代原有 GoodsSearchDialog */ + override fun onGoodsSearchRequested() { + InboundGoodsSearchDialog(this).show() + } + + companion object { + const val EXTRA_STORE_TYPE = "storeType" + } } diff --git a/app/src/main/java/com/sw/inbound/adapter/FormFieldAdapter.kt b/app/src/main/java/com/sw/inbound/adapter/FormFieldAdapter.kt new file mode 100644 index 0000000..cc591d8 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/adapter/FormFieldAdapter.kt @@ -0,0 +1,189 @@ +package com.sw.inbound.adapter + +import android.app.DatePickerDialog +import android.content.Context +import android.text.Editable +import android.text.InputType +import android.text.SpannableStringBuilder +import android.text.TextWatcher +import android.text.style.ForegroundColorSpan +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.graphics.toColorInt +import com.chad.library.adapter4.BaseQuickAdapter +import com.chad.library.adapter4.viewholder.QuickViewHolder +import com.google.android.flexbox.FlexboxLayoutManager +import com.sw.inbound.R +import com.sw.inbound.databinding.ListItemFormFieldBinding +import com.sw.inbound.dialog.DropdownPopup +import com.sw.inbound.model.FieldType +import com.sw.inbound.model.FormField +import com.sw.inbound.utils.ext.dp +import com.sw.inbound.utils.ext.gone +import com.sw.inbound.utils.ext.hideKeyboard +import com.sw.inbound.utils.ext.visible +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import kotlin.math.min + +/** + * 动态表单字段 Adapter,配合 FlexboxLayoutManager 实现两列自动换行布局 + * 单一 item 布局,通过 visibility 控制 TEXT/NUMBER/FIXED/DROPDOWN/DATE_PICKER 五种类型渲染 + * + * @param activityContext Activity context,用于创建 DatePickerDialog + * @param fields 表单字段列表(hidden=false 的字段,由外部过滤后传入) + */ +class FormFieldAdapter( + private val activityContext: Context, + fields: MutableList +) : BaseQuickAdapter(fields) { + + private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()) + private val displayDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + + inner class VH(val binding: ListItemFormFieldBinding) : QuickViewHolder(binding.root) + + override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH { + val binding = ListItemFormFieldBinding.inflate(LayoutInflater.from(context), parent, false) + // 设置每个 item 宽度占 FlexboxLayout 的 50%,实现两列布局 + (binding.root.layoutParams as? FlexboxLayoutManager.LayoutParams)?.flexBasisPercent = 0.5f + return VH(binding) + } + + override fun onBindViewHolder(holder: VH, position: Int, item: FormField?) { + item ?: return + val b = holder.binding + + // 设置标签,必填项追加红色星号 + b.tvLabel.text = buildLabelSpannable(item.label, item.required) + + // 先全部隐藏,再按类型显示对应控件 + b.etInput.gone() + b.flDropdown.gone() + b.flDatePicker.gone() + + // 移除旧的 TextWatcher,防止 RecyclerView 复用时错误触发 + (b.etInput.tag as? TextWatcher)?.let { b.etInput.removeTextChangedListener(it) } + b.etInput.tag = null + + when (item.type) { + FieldType.TEXT -> bindEditText(b, item, InputType.TYPE_CLASS_TEXT) + FieldType.NUMBER -> bindEditText( + b, item, + InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED + ) + FieldType.FIXED -> bindFixed(b, item) + FieldType.DROPDOWN -> bindDropdown(b, item) + FieldType.DATE_PICKER -> bindDatePicker(b, item) + } + } + + /** 绑定可编辑文本框(TEXT / NUMBER) */ + private fun bindEditText(b: ListItemFormFieldBinding, item: FormField, inputType: Int) { + b.etInput.visible() + b.etInput.isEnabled = true + b.etInput.inputType = inputType + b.etInput.hint = item.hint.ifBlank { item.label } + b.etInput.setBackgroundResource(R.drawable.bg_white_radius10_stroke2) + b.etInput.setText(item.value) + b.etInput.setSelection(b.etInput.text.length) + + val watcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + item.value = s?.toString() ?: "" + } + } + b.etInput.addTextChangedListener(watcher) + b.etInput.tag = watcher + } + + /** 绑定固定值(不可编辑,灰色背景区分) */ + private fun bindFixed(b: ListItemFormFieldBinding, item: FormField) { + b.etInput.visible() + b.etInput.isEnabled = false + b.etInput.inputType = InputType.TYPE_CLASS_TEXT + b.etInput.hint = "" + b.etInput.setBackgroundResource(R.drawable.bg_gray_radius10) + b.etInput.setText(item.value) + } + + /** 绑定下拉选择框,点击时创建 DropdownPopup */ + private fun bindDropdown(b: ListItemFormFieldBinding, item: FormField) { + b.flDropdown.visible() + b.tvDropdownValue.hint = item.hint.ifBlank { "请选择${item.label}" } + b.tvDropdownValue.text = item.value.ifBlank { null } + + b.flDropdown.setOnClickListener { v -> + v.hideKeyboard() + val popHeight = min(item.options.size.coerceAtLeast(1), 4) * 71.dp + DropdownPopup( + context = activityContext, + list = item.options.toMutableList(), + popWidth = v.width, + popHeight = popHeight + ) { dictType -> + item.value = dictType.value ?: "" + item.valueId = dictType.id ?: "" + b.tvDropdownValue.text = item.value + }.also { + it.bgLayout = b.flDropdown + it.showAsDropDown(v) + } + } + } + + /** 绑定日期选择器,点击时弹出 DatePickerDialog */ + private fun bindDatePicker(b: ListItemFormFieldBinding, item: FormField) { + b.flDatePicker.visible() + b.tvDateValue.hint = item.hint.ifBlank { "请选择日期" } + b.tvDateValue.text = item.value.ifBlank { null } + + b.flDatePicker.setOnClickListener { v -> + v.hideKeyboard() + val cal = Calendar.getInstance() + DatePickerDialog( + activityContext, + { _, year, month, dayOfMonth -> + val selected = Calendar.getInstance().apply { + set(year, month, dayOfMonth, 0, 0, 0) + set(Calendar.MILLISECOND, 0) + } + // value 存显示格式,valueId 存 ISO 格式供提交使用 + item.value = displayDateFormat.format(selected.time) + item.valueId = isoFormat.format(selected.time) + b.tvDateValue.text = item.value + }, + cal.get(Calendar.YEAR), + cal.get(Calendar.MONTH), + cal.get(Calendar.DAY_OF_MONTH) + ).show() + } + } + + /** 构建带红色星号的必填标签 */ + private fun buildLabelSpannable(label: String, required: Boolean): SpannableStringBuilder { + return SpannableStringBuilder().apply { + append(label) + if (required) { + val start = length + append(" *") + setSpan( + ForegroundColorSpan("#FFCC0000".toColorInt()), + start, length, + android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + } + + /** RecyclerView 回收 ViewHolder 时移除 TextWatcher,防止复用错误 */ + override fun onViewRecycled(holder: VH) { + super.onViewRecycled(holder) + val watcher = holder.binding.etInput.tag as? TextWatcher + watcher?.let { holder.binding.etInput.removeTextChangedListener(it) } + holder.binding.etInput.tag = null + } +} diff --git a/app/src/main/java/com/sw/inbound/adapter/InboundSearchAdapter.kt b/app/src/main/java/com/sw/inbound/adapter/InboundSearchAdapter.kt new file mode 100644 index 0000000..c5f255d --- /dev/null +++ b/app/src/main/java/com/sw/inbound/adapter/InboundSearchAdapter.kt @@ -0,0 +1,56 @@ +package com.sw.inbound.adapter + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.graphics.toColorInt +import androidx.recyclerview.widget.DiffUtil +import com.chad.library.adapter4.BaseDifferAdapter +import com.chad.library.adapter4.viewholder.QuickViewHolder +import com.sw.inbound.databinding.ListItemGoodsSearchBinding +import com.sw.inbound.model.response.GoodsSearchModel +import com.sw.inbound.utils.ext.dp +import com.sw.inbound.utils.ext.setShapeDrawable + +class InboundSearchAdapter : + BaseDifferAdapter(DIFF_CALLBACK) { + + inner class VH(val binding: ListItemGoodsSearchBinding) : QuickViewHolder(binding.root) + + override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH { + val binding = ListItemGoodsSearchBinding.inflate(LayoutInflater.from(context), parent, false) + return VH(binding) + } + + override fun onBindViewHolder(holder: VH, position: Int, item: GoodsSearchModel?) { + holder.binding.tvGoodsName.run { + text = item?.materName + setTextColor( + if (item?.isSelected == true) "#FF0032C8".toColorInt() + else "#FF666666".toColorInt() + ) + } + holder.binding.root.run { + if (item?.isSelected == true) { + setShapeDrawable( + solidColor = "#FFEAF0FF", + strokeColor = "#FF0033CC", + strokeWidth = 2.dp, + radius = 10.dp + ) + } else { + setShapeDrawable(solidColor = "#FFF5F5F5", radius = 10.dp) + } + } + } + + companion object { + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(old: GoodsSearchModel, new: GoodsSearchModel) = + old.materId == new.materId + + override fun areContentsTheSame(old: GoodsSearchModel, new: GoodsSearchModel) = + old == new + } + } +} diff --git a/app/src/main/java/com/sw/inbound/di/InboundNetworkModule.kt b/app/src/main/java/com/sw/inbound/di/InboundNetworkModule.kt new file mode 100644 index 0000000..f388199 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/di/InboundNetworkModule.kt @@ -0,0 +1,62 @@ +package com.sw.inbound.di + +import com.google.gson.GsonBuilder +import com.sw.inbound.MyApp +import com.sw.inbound.network.api.InboundApiService +import com.sw.inbound.network.interceptor.InboundInterceptor +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object InboundNetworkModule { + + private const val INBOUND_BASE_URL = "http://192.168.10.101:24801/" + private const val TIME_OUT = 30L + + @Provides + @Singleton + @Named("inbound") + fun provideInboundHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(TIME_OUT, TimeUnit.SECONDS) + .readTimeout(TIME_OUT, TimeUnit.SECONDS) + .writeTimeout(TIME_OUT, TimeUnit.SECONDS) + .addNetworkInterceptor(HttpLoggingInterceptor(logger = { + Timber.d("inbound okhttp ==> $it") + }).apply { + level = if (MyApp.DEBUG) HttpLoggingInterceptor.Level.BODY + else HttpLoggingInterceptor.Level.NONE + }) + .addInterceptor(InboundInterceptor()) + .build() + } + + @Provides + @Singleton + @Named("inbound") + fun provideInboundRetrofit(@Named("inbound") okHttpClient: OkHttpClient): Retrofit { + val gson = GsonBuilder().setLenient().create() + return Retrofit.Builder() + .baseUrl(INBOUND_BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + } + + @Provides + @Singleton + fun provideInboundApiService(@Named("inbound") retrofit: Retrofit): InboundApiService { + return retrofit.create(InboundApiService::class.java) + } +} diff --git a/app/src/main/java/com/sw/inbound/dialog/GoodsRecognizeDialog.kt b/app/src/main/java/com/sw/inbound/dialog/GoodsRecognizeDialog.kt index 2471953..8132bee 100644 --- a/app/src/main/java/com/sw/inbound/dialog/GoodsRecognizeDialog.kt +++ b/app/src/main/java/com/sw/inbound/dialog/GoodsRecognizeDialog.kt @@ -6,8 +6,10 @@ import android.view.LayoutInflater import android.view.View import androidx.recyclerview.widget.LinearLayoutManager import com.sw.inbound.activity.GoodsListActivity +import com.sw.inbound.activity.StoreActivity import com.sw.inbound.adapter.RecognizeAdapter import com.sw.inbound.databinding.DialogGoodsRecognizeBinding +import com.sw.inbound.model.response.GoodsSearchModel import com.sw.inbound.model.response.SearchGoodsInfo import com.sw.inbound.utils.ext.clickWithDebounce import com.sw.inbound.utils.ext.gone @@ -57,6 +59,17 @@ class GoodsRecognizeDialog( } private fun showGoodsStoreDialog(position: Int) { + if (activity is StoreActivity) { + // 入库称重页面:识别结果无溯源码,直接用物品名称构造 GoodsSearchModel 打开入库录入弹窗 + val goods = list[position] + InboundGoodsStoreDialog( + context = context, + goods = GoodsSearchModel(materName = goods.goodsName ?: "") + ) { + dismiss() + }.show() + return + } GoodsStoreDialog( context = context, funcType = 0, diff --git a/app/src/main/java/com/sw/inbound/dialog/InboundGoodsSearchDialog.kt b/app/src/main/java/com/sw/inbound/dialog/InboundGoodsSearchDialog.kt new file mode 100644 index 0000000..612f4ee --- /dev/null +++ b/app/src/main/java/com/sw/inbound/dialog/InboundGoodsSearchDialog.kt @@ -0,0 +1,116 @@ +package com.sw.inbound.dialog + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import com.sw.inbound.activity.StoreActivity +import com.sw.inbound.adapter.InboundSearchAdapter +import com.sw.inbound.databinding.DialogInboundGoodsSearchBinding +import com.sw.inbound.databinding.LayoutEmptySearchBinding +import com.sw.inbound.model.response.GoodsSearchModel +import com.sw.inbound.utils.ext.addOnActionSearchListener +import com.sw.inbound.utils.ext.hideKeyboard +import com.sw.inbound.utils.ext.toast + +/** 入库物品检索弹窗,使用溯源码查询食材,供 StoreActivity 使用 */ +class InboundGoodsSearchDialog( + context: Context +) : BaseDialog(context) { + + private lateinit var inflater: LayoutInflater + private var emptyBinding: LayoutEmptySearchBinding? = null + private lateinit var binding: DialogInboundGoodsSearchBinding + + override fun getRootView(): View { + inflater = LayoutInflater.from(context) + binding = DialogInboundGoodsSearchBinding.inflate(inflater) + return binding.root + } + + private var storeActivity: StoreActivity? = null + + override fun initView() { + storeActivity = getReceiptActivity() as? StoreActivity + binding.ivClose.setOnClickListener { dismiss() } + binding.ivGoodsSearch.setOnClickListener { searchGoods() } + binding.etInputGoods.addOnActionSearchListener { searchGoods() } + binding.btnConfirm.setOnClickListener { + if (list.isEmpty()) { + context.toast("请搜索物品") + return@setOnClickListener + } + if (clickIndex == -1) { + context.toast("请选择物品") + return@setOnClickListener + } + InboundGoodsStoreDialog( + context = context, + goods = list[clickIndex] + ) { + storeActivity?.goodsRecognizeDialog?.dismiss() + dismiss() + }.show() + } + binding.rvSearch.let { + it.layoutManager = GridLayoutManager(context, 2, LinearLayoutManager.VERTICAL, false) + it.adapter = searchAdapter + } + binding.refreshLayout.let { + it.setEnableRefresh(false) + it.setEnableLoadMore(false) + } + loadEmptyView() + } + + private fun searchGoods() { + val traceCode = binding.etInputGoods.text.toString().trim() + if (traceCode.isBlank()) { + context.toast("请输入溯源码") + return + } + storeActivity?.storeViewModel?.getIngredientByTrace(traceCode) { items -> + binding.etInputGoods.hideKeyboard() + val models = items.map { GoodsSearchModel.from(it) } + if (models.isEmpty()) { + loadEmptyView() + searchAdapter.submitList(emptyList()) + return@getIngredientByTrace + } + list.clear() + list.addAll(models) + binding.rvSearch.layoutManager = + GridLayoutManager(context, 2, GridLayoutManager.VERTICAL, false) + searchAdapter.submitList(list.toList()) + } + } + + private fun loadEmptyView() { + binding.rvSearch.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) + if (emptyBinding == null) { + emptyBinding = LayoutEmptySearchBinding.inflate(inflater, binding.rvSearch, false) + } + emptyBinding?.root?.let { layout -> + layout.setOnClickListener { layout.hideKeyboard() } + searchAdapter.stateView = layout + } + } + + private var clickIndex = -1 + private val list: MutableList = mutableListOf() + + private val searchAdapter by lazy { + InboundSearchAdapter().apply { + isStateViewEnable = true + setOnItemClickListener { _, _, position -> + clickIndex = position + if (list[position].isSelected) return@setOnItemClickListener + list.forEach { it.isSelected = false } + list[position].isSelected = true + submitList(list.toList()) + } + } + } +} diff --git a/app/src/main/java/com/sw/inbound/dialog/InboundGoodsStoreDialog.kt b/app/src/main/java/com/sw/inbound/dialog/InboundGoodsStoreDialog.kt new file mode 100644 index 0000000..6266e7c --- /dev/null +++ b/app/src/main/java/com/sw/inbound/dialog/InboundGoodsStoreDialog.kt @@ -0,0 +1,276 @@ +package com.sw.inbound.dialog + +import android.content.Context +import android.graphics.Typeface +import android.text.SpannableStringBuilder +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.view.LayoutInflater +import android.view.View +import androidx.camera.view.PreviewView +import androidx.core.graphics.toColorInt +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexWrap +import com.google.android.flexbox.FlexboxLayoutManager +import com.sw.inbound.GlobalData +import com.sw.inbound.activity.StoreActivity +import com.sw.inbound.adapter.FormFieldAdapter +import com.sw.inbound.databinding.DialogInboundGoodsStoreBinding +import com.sw.inbound.databinding.LayoutTakePhotoBinding +import com.sw.inbound.model.FieldType +import com.sw.inbound.model.FormField +import com.sw.inbound.model.response.DictType +import com.sw.inbound.model.response.GoodsSearchModel +import com.sw.inbound.sdk.SensorScaleUtils +import com.sw.inbound.utils.CameraUtils +import com.sw.inbound.utils.ext.appendText +import com.sw.inbound.utils.ext.buildSpannableString +import com.sw.inbound.utils.ext.toast +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.abs + +/** 入库录入弹窗,使用 FlexboxLayoutManager 动态表单,根据 storeType 展示净材/毛菜对应字段 */ +class InboundGoodsStoreDialog( + context: Context, + private val goods: GoodsSearchModel, + private val confirmBlock: () -> Unit = {} +) : BaseDialog(context) { + + private lateinit var binding: DialogInboundGoodsStoreBinding + private lateinit var inflater: LayoutInflater + + private var storeActivity: StoreActivity? = null + + /** 0=净材,1=毛菜 */ + private var storeType: Int = 0 + + private var realWeight: Int = 0 + + private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()) + + /** 当前渲染的表单字段列表 */ + private lateinit var fields: MutableList + + private var previewView: PreviewView? = null + private var takePhotoBinding: LayoutTakePhotoBinding? = null + private val cameraUtils: CameraUtils by lazy { CameraUtils(storeActivity!!) } + + override fun getRootView(): View { + inflater = LayoutInflater.from(context) + binding = DialogInboundGoodsStoreBinding.inflate(inflater) + return binding.root + } + + override fun initView() { + storeActivity = getReceiptActivity() as? StoreActivity + storeType = storeActivity?.storeType ?: 0 + + buildFormFields() + setupRecyclerView() + setupButtons() + addChildToContainer() + addWeightListener() + } + + private fun buildFormFields() { + fields = if (storeType == 1) buildRawFields() else buildCleanFields() + } + + /** + * 毛菜字段列表 + * 接口字段:traceCode, supplierType(额外注入), materialName, supplierId, spec, quantity, + * weight(额外注入), inboundTime, zoneId, operator + * 注:来源类型无对应接口字段,apiKey="" 不提交 + */ + private fun buildRawFields(): MutableList { + val supplierOptions = GlobalData.inboundSupplierList.mapNotNull { opt -> + val id = opt.id?.toString() ?: return@mapNotNull null + DictType(id = id, value = opt.name) + } + val zoneOptions = GlobalData.inboundZoneList.mapNotNull { zone -> + val id = zone.id?.toString() ?: return@mapNotNull null + DictType(id = id, value = zone.zoneName) + } + return mutableListOf( + FormField("溯源码", FieldType.TEXT, apiKey = "traceCode", value = goods.traceCode), + FormField("来源类型", FieldType.DROPDOWN, apiKey = "", options = listOf( + DictType("0", "手动录入"), DictType("1", "系统识别") + )), + FormField("食材名称", FieldType.FIXED, apiKey = "materialName", required = true, value = goods.materName), + FormField("供应商", FieldType.DROPDOWN, apiKey = "supplierId", required = true, + hint = "请选择供应商", options = supplierOptions, submitValueId = true), + FormField("入库规格", FieldType.TEXT, apiKey = "spec", required = true, hint = "请录入入库规格"), + FormField("入库数量", FieldType.NUMBER, apiKey = "quantity", required = true, hint = "请录入入库数量"), + FormField("入库时间", FieldType.DATE_PICKER, apiKey = "inboundTime", required = true, + hint = "请选择入库时间", submitValueId = true), + FormField("存放区域", FieldType.DROPDOWN, apiKey = "zoneId", required = true, + hint = "请选择存放区域", options = zoneOptions, submitValueId = true), + FormField("操作人", FieldType.TEXT, apiKey = "operator", value = GlobalData.user?.name ?: "") + ) + } + + /** + * 净材字段列表 + * 接口字段:traceCode, ingredientName, cleanType, spec, quantity, + * weight(额外注入), inboundTime(额外注入当前时间), zoneId, operator, + * expiryDate, storageTemp + */ + private fun buildCleanFields(): MutableList { + val vegTypes = goods.vegTypeList() + val cleanTypeOptions = vegTypes.mapIndexed { index, type -> + DictType(id = index.toString(), value = type) + } + val zoneOptions = GlobalData.inboundZoneList.mapNotNull { zone -> + val id = zone.id?.toString() ?: return@mapNotNull null + DictType(id = id, value = zone.zoneName) + } + return mutableListOf( + FormField("溯源码", FieldType.TEXT, apiKey = "traceCode", value = goods.traceCode), + FormField("食材名称", FieldType.FIXED, apiKey = "ingredientName", required = true, value = goods.materName), + FormField("净材类型", FieldType.DROPDOWN, apiKey = "cleanType", + required = cleanTypeOptions.isNotEmpty(), hint = "请选择净材类型", options = cleanTypeOptions), + FormField("入库规格", FieldType.TEXT, apiKey = "spec", required = true, hint = "请录入入库规格"), + FormField("入库数量", FieldType.NUMBER, apiKey = "quantity", required = true, hint = "请录入入库数量"), + FormField("保质期至", FieldType.DATE_PICKER, apiKey = "expiryDate", + hint = "请选择保质期", submitValueId = true), + FormField("存放区域", FieldType.DROPDOWN, apiKey = "zoneId", required = true, + hint = "请选择存放区域", options = zoneOptions, submitValueId = true), + FormField("存放温度(℃)", FieldType.NUMBER, apiKey = "storageTemp"), + FormField("操作人", FieldType.TEXT, apiKey = "operator", value = GlobalData.user?.name ?: "") + ) + } + + private fun setupRecyclerView() { + val flexboxLM = FlexboxLayoutManager(context).apply { + flexDirection = FlexDirection.ROW + flexWrap = FlexWrap.WRAP + } + binding.rvFormFields.layoutManager = flexboxLM + binding.rvFormFields.adapter = FormFieldAdapter(storeActivity ?: context, fields) + } + + private fun setupButtons() { + binding.btnCancel.setOnClickListener { dismiss() } + binding.btnConfirm.setOnClickListener { confirmInputData() } + binding.tvTitle.setOnClickListener { SensorScaleUtils.startScale(autoScale = true) } + binding.btnClearZero.setOnClickListener { SensorScaleUtils.tare() } + } + + private fun addWeightListener() { + storeActivity?.readWeightInfo { weight -> + if (abs(this.realWeight - weight) < 10) return@readWeightInfo + this.realWeight = weight + binding.tvTotalWeight.text = buildWeightSpannable(weight) + } + } + + private fun buildWeightSpannable(num: Int): SpannableStringBuilder { + return buildSpannableString { + appendText(num.toString(), ForegroundColorSpan("#FF141428".toColorInt()), StyleSpan(Typeface.BOLD)) + appendText(" 克", ForegroundColorSpan("#FF000033".toColorInt()), StyleSpan(Typeface.BOLD)) + } + } + + /** 校验必填字段,通过后将 fields 转为 Map 提交 */ + private fun confirmInputData() { + val invalid = fields.firstOrNull { it.required && it.value.isBlank() } + if (invalid != null) { + context.toast(invalid.hint.ifBlank { "${invalid.label}不能为空" }) + return + } + if (realWeight <= 0) { + context.toast("物品重量需大于0") + return + } + if (storeType == 1) submitRawInbound() else submitCleanInbound() + } + + /** + * 将 fields 转为接口 Map,规则: + * - apiKey 为空的字段跳过 + * - submitValueId=true 时取 valueId,否则取 value + * - 空字符串跳过(可选字段无值不传) + * - quantity → Int,zoneId/supplierId → Long,storageTemp → Double,其余 → String + */ + private fun buildParamMap(): MutableMap { + val map = mutableMapOf() + for (field in fields) { + if (field.apiKey.isBlank()) continue + val raw = if (field.submitValueId) field.valueId else field.value + if (raw.isBlank()) continue + val converted: Any = when (field.apiKey) { + "quantity" -> raw.toIntOrNull() ?: continue + "zoneId", "supplierId" -> raw.toLongOrNull() ?: continue + "storageTemp" -> raw.toDoubleOrNull() ?: continue + else -> raw + } + map[field.apiKey] = converted + } + return map + } + + private fun submitRawInbound() { + val param = buildParamMap() + // weight 来自秤,不在表单中,额外注入 + param["weight"] = realWeight / 1000.0 + // supplierType 从 GlobalData 查找,不在表单中 + val supplierId = param["supplierId"]?.toString() + val supplierOpt = GlobalData.inboundSupplierList.find { it.id?.toString() == supplierId } + param["supplierType"] = (supplierOpt?.type ?: 1) + + Loading.show(storeActivity ?: return) + storeActivity?.storeViewModel?.submitRawInbound(param) { success, msg -> + Loading.dismiss() + if (success) { + context.toast("入库成功") + confirmBlock() + dismiss() + } else { + context.toast(msg ?: "入库失败") + } + } + } + + private fun submitCleanInbound() { + val param = buildParamMap() + // weight 来自秤,不在表单中,额外注入 + param["weight"] = realWeight / 1000.0 + // inboundTime 净菜取当前时间,不在表单中 + param["inboundTime"] = isoFormat.format(Date()) + + Loading.show(storeActivity ?: return) + storeActivity?.storeViewModel?.submitCleanInbound(param) { success, msg -> + Loading.dismiss() + if (success) { + context.toast("入库成功") + confirmBlock() + dismiss() + } else { + context.toast(msg ?: "入库失败") + } + } + } + + private fun addChildToContainer() { + takePhotoBinding = LayoutTakePhotoBinding.inflate(inflater, binding.flContainer, true) + storeActivity?.unBindCamera() + setOnDismissListener { cameraUtils.unbind() } + cameraUtils.initCamera() + takePhotoBinding?.let { + previewView = it.previewView + it.flCameraPreview.visibility = View.VISIBLE + } + } + + override fun show() { + super.show() + takePhotoBinding?.let { + cameraUtils.setPreviewController(previewView) + cameraUtils.bind() + it.llCameraFlag.postDelayed({ it.llCameraFlag.visibility = View.GONE }, 1000) + } + binding.root.postDelayed({ SensorScaleUtils.startScale() }, 2600) + } +} diff --git a/app/src/main/java/com/sw/inbound/model/FormField.kt b/app/src/main/java/com/sw/inbound/model/FormField.kt new file mode 100644 index 0000000..9a033af --- /dev/null +++ b/app/src/main/java/com/sw/inbound/model/FormField.kt @@ -0,0 +1,43 @@ +package com.sw.inbound.model + +import com.sw.inbound.model.response.DictType + +/** 表单字段类型枚举 */ +enum class FieldType { + /** 普通文本输入 */ + TEXT, + /** 数字输入(支持小数和负数) */ + NUMBER, + /** 下拉选择 */ + DROPDOWN, + /** 日期选择弹窗 */ + DATE_PICKER, + /** 固定值,不可编辑 */ + FIXED +} + +/** + * 动态表单字段数据模型 + * @param label 字段标签名称 + * @param type 字段类型,决定渲染哪种输入控件 + * @param apiKey 对应接口的字段名,空字符串表示该字段不提交(如来源类型) + * @param required 是否必填,提交时校验 + * @param hidden 是否隐藏,hidden=true 时不渲染该字段 + * @param hint 校验失败提示文案,同时作为输入框 hint + * @param options 下拉框选项列表(DROPDOWN 类型使用) + * @param submitValueId 提交时使用 valueId 而非 value;DROPDOWN 选 id、DATE_PICKER 选 ISO 格式时设为 true + * @param value 当前显示值/输入值;DATE_PICKER 存 yyyy-MM-dd 显示格式 + * @param valueId 下拉框选中项的 id;DATE_PICKER 存 ISO 格式 yyyy-MM-dd'T'HH:mm:ss + */ +data class FormField( + val label: String, + val type: FieldType, + val apiKey: String = "", + val required: Boolean = false, + val hidden: Boolean = false, + val hint: String = "", + val options: List = emptyList(), + val submitValueId: Boolean = false, + var value: String = "", + var valueId: String = "" +) diff --git a/app/src/main/java/com/sw/inbound/model/request/CleanInboundParam.kt b/app/src/main/java/com/sw/inbound/model/request/CleanInboundParam.kt new file mode 100644 index 0000000..c849aed --- /dev/null +++ b/app/src/main/java/com/sw/inbound/model/request/CleanInboundParam.kt @@ -0,0 +1,26 @@ +package com.sw.inbound.model.request + +import com.google.gson.annotations.SerializedName + +/** 净菜入库提交参数 */ +data class CleanInboundParam( + @SerializedName("traceCode") val traceCode: String, + @SerializedName("ingredientName") val ingredientName: String, + /** 净菜类型,如"切丁"、"切片",参考 IngredientByTrace.vegTypes */ + @SerializedName("cleanType") val cleanType: String, + /** 入库规格,如"袋装/500g"、"盒装/300g" */ + @SerializedName("spec") val spec: String, + @SerializedName("quantity") val quantity: Int, + /** 入库重量(kg) */ + @SerializedName("weight") val weight: Double, + /** 入库时间,格式:yyyy-MM-dd'T'HH:mm:ss */ + @SerializedName("inboundTime") val inboundTime: String, + @SerializedName("zoneId") val zoneId: Long, + @SerializedName("operator") val operator: String? = null, + /** 保质期至,格式同 inboundTime */ + @SerializedName("expiryDate") val expiryDate: String? = null, + @SerializedName("storageTemp") val storageTemp: Double? = null, + @SerializedName("cameraCode") val cameraCode: String? = null, + @SerializedName("videoUrl") val videoUrl: String? = null, + @SerializedName("remark") val remark: String? = null +) diff --git a/app/src/main/java/com/sw/inbound/model/request/RawInboundParam.kt b/app/src/main/java/com/sw/inbound/model/request/RawInboundParam.kt new file mode 100644 index 0000000..6372c62 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/model/request/RawInboundParam.kt @@ -0,0 +1,26 @@ +package com.sw.inbound.model.request + +import com.google.gson.annotations.SerializedName + +/** 毛菜入库提交参数 */ +data class RawInboundParam( + @SerializedName("traceCode") val traceCode: String, + /** 供应商类型:1-内部,2-外部 */ + @SerializedName("supplierType") val supplierType: Short, + @SerializedName("materialName") val materialName: String, + @SerializedName("supplierId") val supplierId: Long, + /** 入库规格,如"25kg/件"、"散装(kg)" */ + @SerializedName("spec") val spec: String, + @SerializedName("quantity") val quantity: Int, + /** 入库重量(kg) */ + @SerializedName("weight") val weight: Double, + /** 入库时间,格式:yyyy-MM-dd'T'HH:mm:ss */ + @SerializedName("inboundTime") val inboundTime: String, + @SerializedName("zoneId") val zoneId: Long, + @SerializedName("operator") val operator: String, + @SerializedName("storageArea") val storageArea: String? = null, + @SerializedName("reportNo") val reportNo: String? = null, + @SerializedName("cameraCode") val cameraCode: String? = null, + @SerializedName("videoUrl") val videoUrl: String? = null, + @SerializedName("remark") val remark: String? = null +) diff --git a/app/src/main/java/com/sw/inbound/model/response/GoodsSearchModel.kt b/app/src/main/java/com/sw/inbound/model/response/GoodsSearchModel.kt new file mode 100644 index 0000000..1b2dd82 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/model/response/GoodsSearchModel.kt @@ -0,0 +1,25 @@ +package com.sw.inbound.model.response + +/** 溯源码查食材结果,用于 InboundGoodsSearchDialog 列表展示 */ +data class GoodsSearchModel( + val materId: Long = 0L, + val materName: String = "", + /** 净菜类型,逗号分隔,如"切丁,切片" */ + val vegTypes: String = "", + val traceCode: String = "", + var isSelected: Boolean = false +) { + /** 将 vegTypes 拆分为列表,供净菜类型下拉使用 */ + fun vegTypeList(): List = + if (vegTypes.isBlank()) emptyList() + else vegTypes.split(",").map { it.trim() }.filter { it.isNotBlank() } + + companion object { + fun from(item: IngredientByTrace): GoodsSearchModel = GoodsSearchModel( + materId = item.materId ?: 0L, + materName = item.materName ?: "", + vegTypes = item.vegTypes ?: "", + traceCode = item.traceCode ?: "" + ) + } +} diff --git a/app/src/main/java/com/sw/inbound/model/response/InboundApiResponse.kt b/app/src/main/java/com/sw/inbound/model/response/InboundApiResponse.kt new file mode 100644 index 0000000..88b9488 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/model/response/InboundApiResponse.kt @@ -0,0 +1,15 @@ +package com.sw.inbound.model.response + +import com.google.gson.annotations.SerializedName + +/** + * 入库秤设备端接口统一响应包装 + * 与旧 ApiResponse 区别:code 为 Int,200 表示成功 + */ +data class InboundApiResponse( + @SerializedName("code") val code: Int = -1, + @SerializedName("msg") val msg: String? = null, + @SerializedName("data") val data: T? = null +) { + fun isSuccess(): Boolean = code == 200 +} diff --git a/app/src/main/java/com/sw/inbound/model/response/IngredientByTrace.kt b/app/src/main/java/com/sw/inbound/model/response/IngredientByTrace.kt new file mode 100644 index 0000000..ab97af3 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/model/response/IngredientByTrace.kt @@ -0,0 +1,12 @@ +package com.sw.inbound.model.response + +import com.google.gson.annotations.SerializedName + +/** 按溯源码查食材的响应条目 */ +data class IngredientByTrace( + @SerializedName("traceCode") val traceCode: String? = null, + @SerializedName("materId") val materId: Long? = null, + @SerializedName("materName") val materName: String? = null, + /** 净菜类型,逗号分隔,如"切丁,切片" */ + @SerializedName("vegTypes") val vegTypes: String? = null +) diff --git a/app/src/main/java/com/sw/inbound/model/response/SupplierOption.kt b/app/src/main/java/com/sw/inbound/model/response/SupplierOption.kt new file mode 100644 index 0000000..75106f2 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/model/response/SupplierOption.kt @@ -0,0 +1,11 @@ +package com.sw.inbound.model.response + +import com.google.gson.annotations.SerializedName + +/** 供应商下拉选项 */ +data class SupplierOption( + @SerializedName("id") val id: Long? = null, + @SerializedName("name") val name: String? = null, + /** 1-内部供应商,2-外部供应商 */ + @SerializedName("type") val type: Int? = null +) diff --git a/app/src/main/java/com/sw/inbound/model/response/ZoneOption.kt b/app/src/main/java/com/sw/inbound/model/response/ZoneOption.kt new file mode 100644 index 0000000..1a3212a --- /dev/null +++ b/app/src/main/java/com/sw/inbound/model/response/ZoneOption.kt @@ -0,0 +1,11 @@ +package com.sw.inbound.model.response + +import com.google.gson.annotations.SerializedName + +/** 仓库区域下拉选项 */ +data class ZoneOption( + @SerializedName("id") val id: Long? = null, + @SerializedName("zoneNo") val zoneNo: String? = null, + @SerializedName("zoneName") val zoneName: String? = null, + @SerializedName("warehouseId") val warehouseId: Long? = null +) diff --git a/app/src/main/java/com/sw/inbound/network/api/InboundApiService.kt b/app/src/main/java/com/sw/inbound/network/api/InboundApiService.kt new file mode 100644 index 0000000..10125e9 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/network/api/InboundApiService.kt @@ -0,0 +1,50 @@ +package com.sw.inbound.network.api + +import com.sw.inbound.model.response.IngredientByTrace +import com.sw.inbound.model.response.InboundApiResponse +import com.sw.inbound.model.response.SupplierOption +import com.sw.inbound.model.response.ZoneOption +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +/** + * 入库秤设备端接口 + * Base URL: http://192.168.10.101:24801/ + * 鉴权:authorization header 由 InboundInterceptor 统一注入 + */ +interface InboundApiService { + + /** 按溯源码查食材,用于毛菜/净菜入库时联动选择食材 */ + @GET("nutrition/neglect/inbound/ingredient-by-trace") + suspend fun getIngredientByTrace( + @Query("traceCode") traceCode: String + ): InboundApiResponse> + + /** 仓库区域下拉列表 */ + @GET("nutrition/neglect/inbound/zone-options") + suspend fun getZoneOptions( + @Query("warehouseId") warehouseId: Long? = null, + @Query("keyword") keyword: String? = null + ): InboundApiResponse> + + /** 供应商下拉列表,type: 1-内部,2-外部,不传返回全部 */ + @GET("nutrition/neglect/inbound/supplier-options") + suspend fun getSupplierOptions( + @Query("type") type: Int? = null, + @Query("keyword") keyword: String? = null + ): InboundApiResponse> + + /** 毛菜入库提交,入参为 Map 以便动态组装字段 */ + @POST("nutrition/neglect/inbound/raw-inbound") + suspend fun submitRawInbound( + @Body param: Map + ): InboundApiResponse + + /** 净菜入库提交,入参为 Map 以便动态组装字段 */ + @POST("nutrition/neglect/inbound/clean-inbound") + suspend fun submitCleanInbound( + @Body param: Map + ): InboundApiResponse +} diff --git a/app/src/main/java/com/sw/inbound/network/interceptor/InboundInterceptor.kt b/app/src/main/java/com/sw/inbound/network/interceptor/InboundInterceptor.kt new file mode 100644 index 0000000..ef4e469 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/network/interceptor/InboundInterceptor.kt @@ -0,0 +1,19 @@ +package com.sw.inbound.network.interceptor + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * 入库秤设备端接口拦截器 + * 统一注入 authorization 固定鉴权值及 Content-Type + */ +class InboundInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("authorization", "57ee87183f2a4fa59683ec9ef41c8f5d") + .build() + return chain.proceed(request) + } +} diff --git a/app/src/main/java/com/sw/inbound/repository/InboundRepository.kt b/app/src/main/java/com/sw/inbound/repository/InboundRepository.kt new file mode 100644 index 0000000..8fe0cef --- /dev/null +++ b/app/src/main/java/com/sw/inbound/repository/InboundRepository.kt @@ -0,0 +1,73 @@ +package com.sw.inbound.repository + +import com.google.gson.JsonParseException +import com.sw.inbound.model.response.IngredientByTrace +import com.sw.inbound.model.response.InboundApiResponse +import com.sw.inbound.model.response.SupplierOption +import com.sw.inbound.model.response.ZoneOption +import com.sw.inbound.network.api.InboundApiService +import com.sw.inbound.utils.LogSaveUtil +import retrofit2.HttpException +import timber.log.Timber +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import javax.inject.Inject + +/** + * 入库秤设备端接口数据层 + * 对应接口文档 Base URL: http://192.168.10.101:24801/nutrition/neglect/inbound + */ +class InboundRepository @Inject constructor( + private val apiService: InboundApiService +) { + + /** 按溯源码查食材 */ + suspend fun getIngredientByTrace(traceCode: String): InboundApiResponse> { + return safeCall { apiService.getIngredientByTrace(traceCode) } + } + + /** 仓库区域下拉列表 */ + suspend fun getZoneOptions( + warehouseId: Long? = null, + keyword: String? = null + ): InboundApiResponse> { + return safeCall { apiService.getZoneOptions(warehouseId, keyword) } + } + + /** 供应商下拉列表 */ + suspend fun getSupplierOptions( + type: Int? = null, + keyword: String? = null + ): InboundApiResponse> { + return safeCall { apiService.getSupplierOptions(type, keyword) } + } + + /** 毛菜入库提交 */ + suspend fun submitRawInbound(param: Map): InboundApiResponse { + return safeCall { apiService.submitRawInbound(param) } + } + + /** 净菜入库提交 */ + suspend fun submitCleanInbound(param: Map): InboundApiResponse { + return safeCall { apiService.submitCleanInbound(param) } + } + + private suspend fun safeCall(call: suspend () -> InboundApiResponse): InboundApiResponse { + return try { + call() + } catch (e: Exception) { + Timber.e("InboundRepository safeCall Exception: ${e.stackTraceToString()}") + LogSaveUtil.saveLogFile("InboundRepository safeCall Exception: ${e.stackTraceToString()}") + val msg = when (e) { + is HttpException -> "HTTP错误: ${e.code()} - ${e.message()}" + is SocketTimeoutException -> "请求超时: ${e.message}" + is ConnectException -> "连接失败: ${e.message}" + is JsonParseException -> "JSON解析错误: ${e.message}" + is IOException -> "网络IO错误: ${e.message}" + else -> "未知错误: ${e.message}" + } + InboundApiResponse(code = -1, msg = msg) + } + } +} diff --git a/app/src/main/java/com/sw/inbound/viewmodel/StoreViewModel.kt b/app/src/main/java/com/sw/inbound/viewmodel/StoreViewModel.kt new file mode 100644 index 0000000..ad057f1 --- /dev/null +++ b/app/src/main/java/com/sw/inbound/viewmodel/StoreViewModel.kt @@ -0,0 +1,96 @@ +package com.sw.inbound.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sw.inbound.model.response.IngredientByTrace +import com.sw.inbound.model.response.SupplierOption +import com.sw.inbound.model.response.ZoneOption +import com.sw.inbound.repository.InboundRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class StoreViewModel @Inject constructor( + private val repository: InboundRepository +) : ViewModel() { + + private val _zoneOptions = MutableStateFlow>(emptyList()) + val zoneOptions: StateFlow> = _zoneOptions + + private val _supplierOptions = MutableStateFlow>(emptyList()) + val supplierOptions: StateFlow> = _supplierOptions + + /** 按溯源码查食材,结果通过 callback 返回 */ + fun getIngredientByTrace( + traceCode: String, + callback: (List) -> Unit + ) { + viewModelScope.launch { + val response = repository.getIngredientByTrace(traceCode) + if (response.isSuccess()) { + callback(response.data ?: emptyList()) + } else { + Timber.e("getIngredientByTrace failed: ${response.msg}") + callback(emptyList()) + } + } + } + + /** 加载仓库区域列表,结果同时更新 StateFlow 并通过 callback 返回 */ + fun loadZoneOptions( + warehouseId: Long? = null, + keyword: String? = null, + callback: ((List) -> Unit)? = null + ) { + viewModelScope.launch { + val response = repository.getZoneOptions(warehouseId, keyword) + if (response.isSuccess()) { + val list = response.data ?: emptyList() + _zoneOptions.value = list + callback?.invoke(list) + } else { + Timber.e("loadZoneOptions failed: ${response.msg}") + callback?.invoke(emptyList()) + } + } + } + + /** 加载供应商列表,结果同时更新 StateFlow 并通过 callback 返回 */ + fun loadSupplierOptions( + type: Int? = null, + keyword: String? = null, + callback: ((List) -> Unit)? = null + ) { + viewModelScope.launch { + val response = repository.getSupplierOptions(type, keyword) + if (response.isSuccess()) { + val list = response.data ?: emptyList() + _supplierOptions.value = list + callback?.invoke(list) + } else { + Timber.e("loadSupplierOptions failed: ${response.msg}") + callback?.invoke(emptyList()) + } + } + } + + /** 毛菜入库提交 */ + fun submitRawInbound(param: Map, callback: (Boolean, String?) -> Unit) { + viewModelScope.launch { + val response = repository.submitRawInbound(param) + callback(response.isSuccess(), response.msg) + } + } + + /** 净菜入库提交 */ + fun submitCleanInbound(param: Map, callback: (Boolean, String?) -> Unit) { + viewModelScope.launch { + val response = repository.submitCleanInbound(param) + callback(response.isSuccess(), response.msg) + } + } +} diff --git a/app/src/main/res/layout/dialog_inbound_goods_search.xml b/app/src/main/res/layout/dialog_inbound_goods_search.xml new file mode 100644 index 0000000..d8c1621 --- /dev/null +++ b/app/src/main/res/layout/dialog_inbound_goods_search.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +