From 1a76c4853ec5a78e358fb45506f0f899df7e4757 Mon Sep 17 00:00:00 2001 From: DXC Date: Tue, 12 May 2026 17:48:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(purchase):=20=E7=89=A9=E6=96=99=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=88=86=E9=A1=B5+=E4=BB=B7=E6=A0=BC=E5=8D=8A?= =?UTF-8?q?=E8=81=94=E5=8A=A8+=E5=9B=BE=E7=89=87=E5=BF=85=E5=A1=AB?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- inventory-backend/app/api/v1/purchase.py | 31 +++++ .../app/services/purchase_service.py | 43 +++++++ inventory-web/src/api/purchase.ts | 9 ++ inventory-web/src/views/purchase/index.vue | 108 +++++++++++------- 4 files changed, 152 insertions(+), 39 deletions(-) diff --git a/inventory-backend/app/api/v1/purchase.py b/inventory-backend/app/api/v1/purchase.py index aa68fc7..d52ba4e 100644 --- a/inventory-backend/app/api/v1/purchase.py +++ b/inventory-backend/app/api/v1/purchase.py @@ -74,6 +74,11 @@ def create_purchase_request(): if field not in data or str(data.get(field, '')).strip() == '': return jsonify({'code': 400, 'msg': f'缺少必填字段: {field}'}), 400 + # 图片必填强校验 + images = data.get('images') + if not images or (isinstance(images, list) and len(images) == 0): + return jsonify({'code': 400, 'msg': '请上传采购凭证/物品图片'}), 400 + purchase = PurchaseService.create_purchase_request(data, requester_id=user_id) return jsonify({ @@ -200,3 +205,29 @@ def auto_fill_purchase(): return jsonify({'code': 200, 'msg': 'ok', 'data': result}), 200 except Exception as e: return jsonify({'code': 500, 'msg': str(e)}), 500 + + +# -------------------------------------------------------- +# 7. 物料基础信息搜索(分页) +# GET /api/v1/purchase/search-material?keyword=xxx&page=1 +# -------------------------------------------------------- +@purchase_bp.route('/search-material', methods=['GET']) +@jwt_required() +def search_material_for_purchase(): + """物料基础信息搜索接口,支持分页,用于采购申请弹窗""" + try: + keyword = request.args.get('keyword', '') + page = request.args.get('page', 1, type=int) + limit = 20 + + result = PurchaseService.search_base_material(keyword, page, limit) + return jsonify({ + 'code': 200, + 'msg': 'success', + 'data': result['items'], + 'total': result['total'], + 'has_next': result['has_next'] + }), 200 + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': str(e)}), 500 diff --git a/inventory-backend/app/services/purchase_service.py b/inventory-backend/app/services/purchase_service.py index b9f7a28..41befd1 100644 --- a/inventory-backend/app/services/purchase_service.py +++ b/inventory-backend/app/services/purchase_service.py @@ -138,6 +138,49 @@ class PurchaseService: purchase = db.session.get(PurchaseRequest, purchase_id) return purchase.to_dict() if purchase else None + @staticmethod + def search_base_material(keyword: str, page: int = 1, limit: int = 20): + """ + 物料基础信息搜索,支持 name/spec_model/company_name 模糊匹配,返回分页结果 + 用于采购申请弹窗的物料远程搜索 + """ + from sqlalchemy import and_, or_ + + query = MaterialBase.query.filter(MaterialBase.is_enabled == True) + + if keyword: + k = keyword.strip() + k_str = f'%{k}%' + query = query.filter(or_( + MaterialBase.name.ilike(k_str), + MaterialBase.spec_model.ilike(k_str), + MaterialBase.company_name.ilike(k_str) + )) + + query = query.order_by(MaterialBase.id.desc()) + pagination = query.paginate(page=page, per_page=limit, error_out=False) + + items = [] + for item in pagination.items: + items.append({ + 'id': item.id, + 'company_name': item.company_name, + 'name': item.name, + 'spec_model': item.spec_model, + 'category': item.category, + 'unit': item.unit, + 'type': item.material_type, + 'pinyin': getattr(item, 'pinyin', ''), + 'status': '启用' + }) + + return { + 'items': items, + 'total': pagination.total, + 'page': page, + 'has_next': pagination.has_next + } + @staticmethod def _notify_new_request(purchase): """发送新申请邮件给审批人""" diff --git a/inventory-web/src/api/purchase.ts b/inventory-web/src/api/purchase.ts index 610577f..f16b957 100644 --- a/inventory-web/src/api/purchase.ts +++ b/inventory-web/src/api/purchase.ts @@ -89,3 +89,12 @@ export function autoFillPurchase(keyword: string) { params: { keyword } }) } + +// 物料基础信息搜索(分页),用于采购申请弹窗 +export function searchMaterialPurchase(keyword: string, page: number = 1) { + return request({ + url: '/purchase/search-material', + method: 'get', + params: { keyword, page } + }) +} diff --git a/inventory-web/src/views/purchase/index.vue b/inventory-web/src/views/purchase/index.vue index a7b9cec..bb8df41 100644 --- a/inventory-web/src/views/purchase/index.vue +++ b/inventory-web/src/views/purchase/index.vue @@ -83,6 +83,8 @@ @change="onMaterialSelected" default-first-option popper-class="long-dropdown" + v-loadmore="handleLoadMoreMaterials" + @visible-change="onMaterialDropdownVisibleChange" > {{ item.spec_model || '-' }} +
  • + 加载中... +
  • {{ autoFillHint }}
    @@ -142,7 +147,7 @@ - + ([]) const materialOptions = ref([]) const searchLoading = ref(false) const materialBaseId = ref(null) +const searchPage = ref(1) +const searchKeyword = ref('') +const hasNextPage = ref(true) +const loadingMore = ref(false) + +// v-loadmore 指令:监听 el-select 下拉滚动,滚动到底部时触发回调 +const vLoadmore = { + mounted(el: any, binding: any) { + const dropdown = el.querySelector?.('.el-select-dropdown__wrap') + if (!dropdown) return + dropdown.addEventListener('scroll', function (this: any) { + const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 2 + if (condition) binding.value() + }) + } +} // 表单 const form = ref({ @@ -362,10 +383,11 @@ const openCreateDialog = () => { materialOptions.value = [] autoFillHint.value = '' fileList.value = [] + totalPriceManuallyEdited.value = false formDialogVisible.value = true } -// --- 物料搜索 --- +// --- 物料搜索(分页) --- let searchTimer: any = null const handleSearchMaterialDebounced = (query: string) => { clearTimeout(searchTimer) @@ -373,19 +395,46 @@ const handleSearchMaterialDebounced = (query: string) => { } const handleSearchMaterial = async (query: string) => { - if (!query.trim()) { - materialOptions.value = [] - return - } searchLoading.value = true + searchKeyword.value = query + searchPage.value = 1 + materialOptions.value = [] + hasNextPage.value = true + try { - const res: any = await searchMaterialBase(query, 1) + const res: any = await searchMaterialPurchase(query, 1) materialOptions.value = res.data || [] + hasNextPage.value = res.has_next !== false } finally { searchLoading.value = false } } +const handleLoadMoreMaterials = async () => { + if (searchLoading.value || loadingMore.value || !hasNextPage.value) return + loadingMore.value = true + searchPage.value += 1 + try { + const res: any = await searchMaterialPurchase(searchKeyword.value, searchPage.value) + if (res.data && res.data.length > 0) { + materialOptions.value = [...materialOptions.value, ...res.data] + hasNextPage.value = res.has_next !== false + } else { + hasNextPage.value = false + } + } catch { + searchPage.value -= 1 + } finally { + loadingMore.value = false + } +} + +const onMaterialDropdownVisibleChange = (visible: boolean) => { + if (visible && materialOptions.value.length === 0) { + handleSearchMaterial('') + } +} + const onMaterialSelected = (id: number | null) => { if (!id) { materialBaseId.value = null @@ -400,40 +449,21 @@ const onMaterialSelected = (id: number | null) => { } } -// --- 价格自动计算(需确认)--- -let priceConfirmTimer: any = null +// --- 价格半联动逻辑 --- +// totalPriceManuallyEdited:标记总价是否被用户手动修改过,修改后不再自动覆盖 +const totalPriceManuallyEdited = ref(false) + const onUnitPriceChange = (val: number | undefined) => { - if (priceConfirmTimer) clearTimeout(priceConfirmTimer) - priceConfirmTimer = setTimeout(() => { - if (val !== undefined && val > 0 && form.value.quantity > 0 && !form.value.total_price) { - ElMessageBox.confirm( - `即将自动计算总价:${val} × ${form.value.quantity} = ${+(val * form.value.quantity).toFixed(2)},是否继续?`, - '自动计算确认', - { confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' } - ).then(() => { - form.value.total_price = +(val * form.value.quantity).toFixed(2) - }).catch(() => {}) - } else if (val !== undefined && val > 0 && form.value.quantity > 0) { - form.value.total_price = +(val * form.value.quantity).toFixed(2) - } - }, 300) + // 用户修改单价时:如果总价尚未被手动修改,则自动计算总价 + if (val !== undefined && val > 0 && form.value.quantity > 0 && !totalPriceManuallyEdited.value) { + form.value.total_price = +(val * form.value.quantity).toFixed(2) + } } -const onTotalPriceChange = (val: number | undefined) => { - if (priceConfirmTimer) clearTimeout(priceConfirmTimer) - priceConfirmTimer = setTimeout(() => { - if (val !== undefined && val > 0 && form.value.quantity > 0 && !form.value.unit_price) { - ElMessageBox.confirm( - `即将自动计算单价:${val} ÷ ${form.value.quantity} = ${+(val / form.value.quantity).toFixed(4)},是否继续?`, - '自动计算确认', - { confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' } - ).then(() => { - form.value.unit_price = +(val / form.value.quantity).toFixed(4) - }).catch(() => {}) - } else if (val !== undefined && val > 0 && form.value.quantity > 0) { - form.value.unit_price = +(val / form.value.quantity).toFixed(4) - } - }, 300) +const onTotalPriceChange = (_val: number | undefined) => { + // 用户手动修改总价时:标记为已手动修改,不再反向强制覆盖单价 + // 允许用户输入打包一口价(如单价3元,买2个算5元),保留用户填入的单价和总价 + totalPriceManuallyEdited.value = true } // --- 上传 ---