diff --git a/inventory-backend/app/models/borrow.py b/inventory-backend/app/models/borrow.py new file mode 100644 index 0000000..f9e5595 --- /dev/null +++ b/inventory-backend/app/models/borrow.py @@ -0,0 +1,96 @@ +from app.extensions import db, beijing_time +from app.models.system import SysUser +from datetime import datetime +import json + + +class BorrowApproval(db.Model): + """ + 借库审批单模型 + 用于管理借库申请的多级审批流程 + """ + __tablename__ = 'borrow_approval' + + id = db.Column(db.Integer, primary_key=True) + # 审批单号 + request_no = db.Column(db.String(100), unique=True, nullable=False, index=True) + # 申请人ID + applicant_id = db.Column(db.Integer, nullable=False, index=True) + # 申请说明 + remark = db.Column(db.Text) + # 状态: 0-待审批, 1-已通过, 2-已驳回, 3-已完成(已借出) + status = db.Column(db.Integer, default=0, nullable=False) + # 允许审批的人员列表 (JSON格式) + allowed_approvers = db.Column(db.Text) + # 实际审批人ID + actual_approver_id = db.Column(db.Integer, index=True) + # 审批时间 + approved_at = db.Column(db.DateTime) + # 驳回原因 + reject_reason = db.Column(db.Text) + # 借库人姓名(申请时填写,审批通过后流转至 TransBorrow) + borrower_name = db.Column(db.String(100)) + + # 明细快照 (存储借库物品的名称、规格、库位、数量等信息) + items_json = db.Column(db.Text, nullable=False) + + # 创建时间和更新时间 + created_at = db.Column(db.DateTime, default=beijing_time, nullable=False) + updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time, nullable=False) + + def _safe_parse_json(self, value): + if value is None: + return [] + if isinstance(value, (list, dict)): + return value + if isinstance(value, str): + val = value.strip() + if not val: + return [] + try: + parsed = json.loads(val) + return parsed if isinstance(parsed, list) else [] + except (json.JSONDecodeError, TypeError, ValueError): + return [] + return [] + + def get_items(self): + return self._safe_parse_json(self.items_json) + + def set_items(self, items): + self.items_json = json.dumps(items, ensure_ascii=False) if items else '[]' + + def get_allowed_approvers(self): + return self._safe_parse_json(self.allowed_approvers) + + def set_allowed_approvers(self, approvers): + self.allowed_approvers = json.dumps(approvers, ensure_ascii=False) if approvers else '[]' + + def to_dict(self): + return { + 'id': self.id, + 'request_no': self.request_no, + 'applicant_id': self.applicant_id, + 'applicant_name': self._get_user_name(self.applicant_id), + 'remark': self.remark, + 'status': self.status, + 'status_text': ['待审批', '已通过', '已驳回', '已完成'][self.status] if self.status in [0, 1, 2, 3] else '未知', + 'allowed_approvers': self.get_allowed_approvers(), + 'actual_approver_id': self.actual_approver_id, + 'approver_name': self._get_user_name(self.actual_approver_id) if self.actual_approver_id else None, + 'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None, + 'reject_reason': self.reject_reason, + 'borrower_name': self.borrower_name, + 'items': self.get_items(), + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None, + 'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None, + } + + def _get_user_name(self, user_id): + if not user_id: + return "" + try: + user = SysUser.query.get(user_id) + return user.username if user else f"未知用户({user_id})" + except Exception: + return f"用户({user_id})" \ No newline at end of file diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index 3e91f42..fdb533b 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -30,18 +30,65 @@ class TransService: return f"{prefix}{sequence:04d}" @staticmethod - def create_borrow(data, operator_name='System'): + def execute_dispatch(approval_id, items, operator_name='System', borrower_name=None, + signature=None, remark=None, expected_return_time=None): """ - 借库逻辑:减少可用库存,不减总库存 + 执行借库扣减(审批通过后调用) + 流程:锁审批单 → 超额校验 → 锁库存行 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成 """ - items = data.get('items', []) - borrower_name = data.get('borrower_name') - signature = data.get('signature_path') # 借用人签字 + from app.models.borrow import BorrowApproval if not items: raise ValueError("物品列表为空") - if not borrower_name: raise ValueError("请输入借用人") if not signature: raise ValueError("借用人必须签字") + # ============================================== + # ★ 防线1:并发防重复执行 - 用 SELECT FOR UPDATE 锁住审批单 + # ============================================== + approval = BorrowApproval.query.with_for_update().get(approval_id) + if not approval: + raise ValueError("审批单不存在") + if approval.status != 1: + status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'} + raise ValueError(f"审批单状态为【{status_map.get(approval.status, approval.status)}】,无法执行借库") + + # ★ borrower_name 兜底:优先用前端传参,其次从审批单读取(申请时填写的姓名) + if not borrower_name: + borrower_name = approval.borrower_name + if not borrower_name: + raise ValueError("审批单中未记录借库人姓名,请联系管理员补录") + + # ============================================== + # ★ 防线2:超额篡改校验 - 交叉比对前端传来的实际扣减量与审批单上限 + # ============================================== + approved_items = approval.get_items() + if not approved_items: + raise ValueError("审批单中无物料明细,请联系管理员检查") + + # 构建审批上限字典:key=(source_table, sku) → approved_total_qty + approved_limit = {} + for ai in approved_items: + key = (ai.get('source_table', ''), ai.get('sku', '')) + qty = float(ai.get('quantity', 0)) + approved_limit[key] = approved_limit.get(key, 0) + qty + + # 汇总前端传来的实际出库量(按 source_table+sku 聚合) + dispatch_qty = {} + for item in items: + key = (item.get('source_table', ''), item.get('sku', '')) + qty = float(item.get('out_quantity', 0)) + dispatch_qty[key] = dispatch_qty.get(key, 0) + qty + + # 逐条比对:任意一条实际出库量 > 审批上限 → 直接拒绝 + for key, actual_qty in dispatch_qty.items(): + limit_qty = approved_limit.get(key, 0) + if actual_qty > limit_qty: + source_table, sku = key + raise ValueError( + f"实际出库数量超出了审批单允许的上限: " + f"SKU={sku or '(无)'}({source_table}) " + f"审批上限={limit_qty}, 实际出库={actual_qty}" + ) + borrow_no = TransService.generate_borrow_no() model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct} @@ -54,6 +101,9 @@ class TransService: ModelClass = model_map.get(source_table) if not ModelClass: continue + # ============================================== + # ★ 防线3:并发超卖与负库存 - 锁行后再查可用库存 + # ============================================== stock = ModelClass.query.with_for_update().get(stock_id) if not stock: raise ValueError(f"库存不存在 ID:{stock_id}") @@ -63,7 +113,7 @@ class TransService: # 1. 冻结库存 (只减可用) stock.available_quantity = float(stock.available_quantity) - qty - # 2. 创建借用单 + # 2. 创建借用记录 record = TransBorrow( borrow_no=borrow_no, sku=stock.sku, @@ -73,19 +123,39 @@ class TransService: quantity=qty, borrower_name=borrower_name, borrow_signature=signature, - remark=data.get('remark'), - expected_return_time=data.get('expected_return_time'), + remark=remark, + expected_return_time=expected_return_time, status='borrowed', is_returned=False ) db.session.add(record) + # ★ 3. 标记审批单为已完成 + approval.status = 3 + db.session.commit() return borrow_no except Exception as e: db.session.rollback() raise e + # ★ 兼容旧入口(不走审批流的直接借库,保留以便平滑过渡) + @staticmethod + def create_borrow(data, operator_name='System'): + """ + 借库逻辑(兼容旧模式):减少可用库存,不减总库存 + @deprecated 请优先使用 execute_dispatch 走审批流 + """ + return TransService.execute_dispatch( + approval_id=0, + items=data.get('items', []), + operator_name=operator_name, + borrower_name=data.get('borrower_name'), + signature=data.get('signature_path'), + remark=data.get('remark'), + expected_return_time=data.get('expected_return_time') + ) + @staticmethod def scan_for_return(barcode): """ diff --git a/inventory-web/src/api/transaction.ts b/inventory-web/src/api/transaction.ts index e69de29..afb408c 100644 --- a/inventory-web/src/api/transaction.ts +++ b/inventory-web/src/api/transaction.ts @@ -0,0 +1,194 @@ +import request from '@/utils/request' + +// 购物车商品项接口 +export interface CartItem { + id: number + sku: string + name: string + spec_model: string + source_table: string + stock_quantity: number + available_quantity: number + barcode: string + price: number // 单价 + out_quantity: number // 本次出库数量 +} + +// 提交出库单的数据结构 +export interface OutboundSubmitData { + items: Array<{ + sku: string + source_table: string + stock_id: number + barcode: string + quantity: number + price: number + }> + outbound_type: string + consumer_name: string + operator_name: string + signature_path: string // 上传后返回的图片路径 + remark?: string +} + +export interface ScanResult { + id: number + sku: string + name: string + spec_model: string + source_table: string // 'stock_buy' | 'stock_product' ... + stock_quantity: number + available_quantity: number + batch_number?: string + warehouse_location?: string + barcode?: string + price?: number // 扫描返回的价格 +} + +/** + * 根据条码获取库存物品详情 + * @param barcode 扫描到的条码 + */ +export function getStockByBarcode(barcode: string) { + return request({ + url: '/v1/outbound/scan', + method: 'get', + params: { barcode } + }) +} + +/** + * 提交出库单 (批量) + */ +export function submitOutbound(data: OutboundSubmitData) { + return request({ + url: '/v1/outbound', + method: 'post', + data + }) +} + +/** + * 获取出库记录列表 + */ +export function getOutboundList(params: any) { + return request({ + url: '/v1/outbound', + method: 'get', + params + }) +} + +/** + * 提交出库申请单(申请人 → 审批流) + */ +export function submitOutboundRequest(data: { + items: Array<{ + material_type?: string + name: string + spec_model: string + warehouse_location?: string + quantity: number + }> + remark: string +}) { + return request({ + url: '/v1/outbound/request', + method: 'post', + data + }) +} + +/** + * 获取出库审批申请单列表 + * @param params 支持 status, page, limit + */ +export function getApprovalRequestList(params: { status?: number | ''; page?: number; limit?: number }) { + return request({ + url: '/v1/outbound/request', + method: 'get', + params + }) +} + +/** + * 审批(通过 / 驳回)出库申请单 + * @param id 审批单ID + * @param data action: 'approve' | 'reject',reject 时需传 reject_reason + */ +export function approveRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) { + return request({ + url: `/v1/outbound/request/${id}/approve`, + method: 'patch', + data + }) +} + +// ============================================================================== +// 借库审批流 API +// ============================================================================== + +/** + * 提交借库申请单(申请人 → 审批流) + */ +export function submitBorrowRequest(data: { + items: Array<{ + name: string + spec_model: string + warehouse_location?: string + quantity: number + }> + remark?: string + allowed_approvers?: Array<{ type: string; value: string }> + approver_id?: number +}) { + return request({ + url: '/v1/transactions/borrow/request', + method: 'post', + data + }) +} + +/** + * 获取借库审批申请单列表 + * @param params 支持 status, page, limit + */ +export function getBorrowApprovalList(params: { status?: number | ''; page?: number; limit?: number }) { + return request({ + url: '/v1/transactions/borrow/request', + method: 'get', + params + }) +} + +/** + * 审批(通过 / 驳回)借库申请单 + * @param id 审批单ID + * @param data action: 'approve' | 'reject',reject 时需传 reject_reason + */ +export function approveBorrowRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) { + return request({ + url: `/v1/transactions/borrow/request/${id}/approve`, + method: 'patch', + data + }) +} + +/** + * 执行借库扣减(审批通过后调用) + * @param data approval_id + 扫码选中的物品 + 借用人信息 + 签名 + */ +export function dispatchBorrow(data: { + approval_id: number + items: Array + borrower_name: string + signature_path: string + remark?: string + expected_return_time?: string | null +}) { + return request({ + url: '/v1/transactions/borrow/dispatch', + method: 'post', + data + }) +} \ No newline at end of file diff --git a/inventory-web/src/router/index.ts b/inventory-web/src/router/index.ts index e3d886b..8fd2830 100644 --- a/inventory-web/src/router/index.ts +++ b/inventory-web/src/router/index.ts @@ -215,6 +215,22 @@ const routes: Array = [ component: () => import('@/views/transaction/borrow.vue'), meta: { title: '借库' } }, + { + path: 'borrow_approval', + name: 'BorrowApproval', + component: () => import('@/views/borrow/approval/index.vue'), + meta: { + title: '借库审批', + icon: 'Stamp', + roles: ['SUPER_ADMIN', 'SUPERVISOR'] + } + }, + { + path: 'borrow_apply', + name: 'BorrowApply', + component: () => import('@/views/borrow/apply/index.vue'), + meta: { title: '借库申请' } + }, { path: 'repair', name: 'OpRepair', diff --git a/inventory-web/src/views/borrow/apply/index.vue b/inventory-web/src/views/borrow/apply/index.vue index a11722b..ca33b68 100644 --- a/inventory-web/src/views/borrow/apply/index.vue +++ b/inventory-web/src/views/borrow/apply/index.vue @@ -301,7 +301,7 @@ class="no-print-content" > - - - { // ★ 确认提交借库申请 const confirmSubmitRequest = async () => { - const borrower = borrowerName.value.trim() - if (!borrower) { - ElMessage.warning('请填写借库人姓名') - return - } - requestSubmitting.value = true try { const payload = { @@ -900,7 +886,6 @@ const confirmSubmitRequest = async () => { warehouse_location: item.warehouse_location || '', quantity: item.export_quantity || 0 })), - borrower_name: borrower, remark: requestRemark.value.trim(), approver_id: requestApproverId.value } diff --git a/inventory-web/src/views/borrow/approval/index.vue b/inventory-web/src/views/borrow/approval/index.vue new file mode 100644 index 0000000..2aa2c4b --- /dev/null +++ b/inventory-web/src/views/borrow/approval/index.vue @@ -0,0 +1,365 @@ + + + + + \ No newline at end of file diff --git a/inventory-web/src/views/transaction/borrow.vue b/inventory-web/src/views/transaction/borrow.vue index e88f693..857850c 100644 --- a/inventory-web/src/views/transaction/borrow.vue +++ b/inventory-web/src/views/transaction/borrow.vue @@ -12,6 +12,57 @@ + +
+ + + {{ req.request_no }} + + {{ req.borrower_name || '未知借库人' }} + + {{ req.remark || '无备注' }} + + +

仅显示已通过(status=1)的审批单

+
+ + +
+
+ 审批计划清单 + {{ plannedItems.length }} 种 +
+ + + + + + + + + + + + +
+
@@ -204,26 +255,22 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue' import QrScanner from '@/components/QrScanner/index.vue' import { getStockByBarcode } from '@/api/outbound' -import request from '@/utils/request' +import { dispatchBorrow, getBorrowApprovalList } from '@/api/transaction' import { uploadFile } from '@/api/common/upload' import { useUserStore } from '@/stores/user' const userStore = useUserStore() -// 列与权限Code的映射关系(数据库中的code) +// 列与权限Code的映射关系 const permissionMap: Record = { borrower_name: 'op_borrow:borrower_name', sku: 'op_borrow:sku', available_quantity: 'op_borrow:available_quantity', out_quantity: 'op_borrow:out_quantity', - // 其他字段可根据需要添加 } -// 检查列权限 const hasColumnPermission = (prop: string) => { - if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') { - return true - } + if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true const code = permissionMap[prop] return code ? userStore.hasPermission(code) : false } @@ -236,6 +283,76 @@ const showCamera = ref(false) const barcodeRef = ref() const formRef = ref() +// ★ 审批单选择 +const approvalRequests = ref([]) +const selectedApprovalId = ref(null) +const requestsLoading = ref(false) + +const selectedApproval = computed(() => + selectedApprovalId.value + ? approvalRequests.value.find(r => r.id === selectedApprovalId.value) ?? null + : null +) + +const plannedItems = computed(() => selectedApproval.value?.items ?? []) + +// ★ 加载已通过审批的借库申请单列表 +const loadApprovalRequests = async () => { + requestsLoading.value = true + try { + const res: any = await getBorrowApprovalList({ status: 1, page: 1, limit: 100 }) + approvalRequests.value = res.data?.items || [] + } catch (e) { + console.error('加载借库审批单列表失败', e) + } finally { + requestsLoading.value = false + } +} + +// ★ 切换审批单时:清空购物车和签名,防止跨单据污染 +const handleApprovalChange = (val: number | null) => { + if (!val) { + selectedApprovalId.value = null + } + cartItems.value = [] + signatureFile.value = null + signaturePreviewUrl.value = '' + barcodeInput.value = '' +} + +// ★ 扫码校验:比对扫描物料是否在审批计划清单内,且累计数量不超过审批上限 +const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => { + const normalizedName = scannedName.trim() + const normalizedSpec = (scannedSpec || '').trim() + + const matchedPlan = plannedItems.value.find(plan => { + const planName = (plan.name || '').trim() + const planSpec = (plan.spec_model || '').trim() + return planName === normalizedName && planSpec === normalizedSpec + }) + + if (!matchedPlan) { + return `该物料【${normalizedName} × ${normalizedSpec}】不在审批计划清单中,请检查` + } + + const planQty = matchedPlan.quantity ?? 0 + + // 购物车中已扫的同名同规格物料累计数量 + const alreadyScanned = cartItems.value + .filter(ci => { + const ciName = (ci.name || '').trim() + const ciSpec = (ci.spec_model || '').trim() + return ciName === normalizedName && ciSpec === normalizedSpec + }) + .reduce((sum, ci) => sum + (ci.out_quantity || 0), 0) + + if (alreadyScanned + scannedQty > planQty) { + return `【${normalizedName} × ${normalizedSpec}】超出审批数量(审批: ${planQty},已扫: ${alreadyScanned},本次: ${scannedQty})` + } + + return null +} + // 签名相关 const showSignatureDialog = ref(false) const signaturePreviewUrl = ref('') @@ -254,9 +371,7 @@ const form = reactive({ }) const rules = computed(() => ({ - borrower_name: [ - { required: true, message: '请输入借用人姓名', trigger: 'blur' } - ], + borrower_name: [{ required: true, message: '请输入借用人姓名', trigger: 'blur' }], expected_return_time: [ { required: !isIndefinite.value, message: '请选择预计归还日期', trigger: 'change' } ] @@ -265,9 +380,7 @@ const rules = computed(() => ({ const isIndefinite = ref(false) const handleIndefiniteChange = (val: boolean) => { - if (val) { - form.expected_return_time = '' - } + if (val) form.expected_return_time = '' } const disabledDate = (time: Date) => { @@ -278,35 +391,51 @@ const disabledDate = (time: Date) => { const onScanSuccess = (code: string) => { if (!code) return const trimCode = code.trim() - const validPattern = /^[A-Za-z0-9\-\.]+$/ if (!validPattern.test(trimCode)) { ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`) return } - if (trimCode.length < 3) { ElMessage.warning('扫描结果过短,请对准重试') return } - if (loading.value) return - barcodeInput.value = trimCode handleManualInput() } const handleManualInput = async () => { + if (!userStore.hasPermission('op_borrow:operation')) { + ElMessage.warning('无操作权限') + return + } const code = barcodeInput.value.trim() if (!code) return + // ★ 必须先选择审批单 + if (!selectedApproval.value) { + ElMessage.warning('请先选择要执行借库的审批申请单') + return + } + try { loading.value = true - // 查重 + // 查重:条码或 SKU 匹配已扫记录 const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code) if (existIndex > -1) { const item = cartItems.value[existIndex] + + // ★ 追加前仍需校验审批数量上限 + const err = validateAgainstPlan(item.name, item.spec_model, 1) + if (err) { + ElMessage.error(err) + if (navigator.vibrate) navigator.vibrate([200, 100, 200]) + barcodeInput.value = '' + return + } + const maxQty = parseFloat(item.available_quantity) if (item.out_quantity < maxQty) { item.out_quantity++ @@ -314,6 +443,7 @@ const handleManualInput = async () => { if (navigator.vibrate) navigator.vibrate(50) } else { ElMessage.warning(`库存不足 (余: ${maxQty})`) + if (navigator.vibrate) navigator.vibrate([100, 50, 100]) } barcodeInput.value = '' return @@ -326,16 +456,28 @@ const handleManualInput = async () => { const availQty = parseFloat(item.available_quantity || 0) if (availQty <= 0) { - ElMessage.warning(`库存不足 (余: ${availQty})`) - } else { - cartItems.value.push({ - ...item, - out_quantity: 1, - price: 0 - }) - ElMessage.success(`添加成功: ${item.name}`) - if (navigator.vibrate) navigator.vibrate(100) + ElMessage.warning(`库存不足或已借出 (余: ${availQty})`) + if (navigator.vibrate) navigator.vibrate([100, 50, 100]) + barcodeInput.value = '' + return } + + // ★ 扫码加入前强校验:不在清单内或超量直接阻断 + const err = validateAgainstPlan(item.name, item.spec_model, 1) + if (err) { + ElMessage.error(err) + if (navigator.vibrate) navigator.vibrate([200, 100, 200]) + barcodeInput.value = '' + return + } + + cartItems.value.push({ + ...item, + out_quantity: 1, + price: 0 + }) + ElMessage.success(`添加成功: ${item.name}`) + if (navigator.vibrate) navigator.vibrate(100) barcodeInput.value = '' } } catch (error: any) { @@ -344,9 +486,9 @@ const handleManualInput = async () => { } else { ElMessage.error('查询出错') } + if (navigator.vibrate) navigator.vibrate([200, 100, 200]) } finally { loading.value = false - // ★ 核心修改:只有当非全屏模式时,才自动聚焦输入框 if (!showCamera.value) { nextTick(() => { barcodeRef.value?.focus() }) } @@ -354,10 +496,18 @@ const handleManualInput = async () => { } const removeFromCart = (index: number) => { + if (!userStore.hasPermission('op_borrow:operation')) { + ElMessage.warning('无操作权限') + return + } cartItems.value.splice(index, 1) } const clearAll = () => { + if (!userStore.hasPermission('op_borrow:operation')) { + ElMessage.warning('无操作权限') + return + } ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' }) .then(() => { cartItems.value = [] @@ -368,13 +518,19 @@ const clearAll = () => { signaturePreviewUrl.value = '' barcodeInput.value = '' isIndefinite.value = false + // 仅清空购物车,保留审批单选择 }) } // --- 提交逻辑 --- const submitForm = async () => { + if (!userStore.hasPermission('op_borrow:operation')) { + ElMessage.warning('无操作权限') + return + } if (!formRef.value) return if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品') + if (!selectedApprovalId.value) return ElMessage.warning('请选择关联的审批申请单') await formRef.value.validate(async (valid: boolean) => { if (!valid) { @@ -382,7 +538,6 @@ const submitForm = async () => { ElMessage.error(requiredMsg) return } - if (!signatureFile.value) { ElMessage.error('请领用人进行电子签名') return @@ -395,20 +550,32 @@ const submitForm = async () => { const uploadRes = await uploadFile(signatureFile.value) const signatureUrl = uploadRes.data.url - // 处理无限期借用:如果选择了无限期,将预计归还时间置为空 - const submitData = { - ...form, - expected_return_time: isIndefinite.value ? null : form.expected_return_time + // ★ 规范 Payload:只包含后端需要的最小字段 + const itemsPayload = cartItems.value.map(item => { + let safeQty = Number(item.out_quantity) + if (isNaN(safeQty) || safeQty <= 0) safeQty = 1 + + return { + stock_id: item.id || 0, + source_table: item.source_table || '', + sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'), + barcode: item.barcode ? String(item.barcode) : '', + out_quantity: safeQty + } + }) + + if (itemsPayload.length === 0) { + ElMessage.warning('请至少扫描一件物料后再提交') + return } - await request({ - url: '/v1/transactions/borrow', - method: 'post', - data: { - items: cartItems.value, - ...submitData, - signature_path: signatureUrl - } + await dispatchBorrow({ + approval_id: selectedApprovalId.value, + items: itemsPayload, + borrower_name: form.borrower_name, + signature_path: signatureUrl, + remark: form.remark, + expected_return_time: isIndefinite.value ? null : form.expected_return_time }) ElMessage.success('借用成功') @@ -431,13 +598,18 @@ const submitForm = async () => { } // --- 签名逻辑 --- -const openSignatureDialog = () => { showSignatureDialog.value = true } +const openSignatureDialog = () => { + if (!userStore.hasPermission('op_borrow:operation')) { + ElMessage.warning('无签名权限') + return + } + showSignatureDialog.value = true +} const initCanvas = async () => { await nextTick() const canvas = nativeCanvasRef.value const container = canvasContainerRef.value - if (canvas && container) { canvas.width = container.clientWidth canvas.height = container.clientHeight @@ -500,6 +672,12 @@ const handleSignConfirm = () => { const handleSignCancel = () => { showSignatureDialog.value = false } +// --- 初始化 --- +import { onMounted } from 'vue' +onMounted(() => { + loadApprovalRequests() +}) + onUnmounted(() => { if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value) }) @@ -514,7 +692,16 @@ onUnmounted(() => { .card-header { display: flex; justify-content: space-between; align-items: center; } .title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; } -/* 扫码区(卡片内触发器) */ +/* 审批单选择 */ +.approval-request-select { margin-bottom: 16px; } +.select-tip { color: #909399; font-size: 12px; margin: 4px 0 0 0; } + +/* 计划清单 */ +.planned-items-section { margin-bottom: 16px; background: #f5f7fa; border-radius: 6px; padding: 12px; } +.planned-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } +.planned-title { font-size: 13px; font-weight: bold; color: #606266; } + +/* 扫码区 */ .scan-section { margin-bottom: 20px; } .camera-placeholder { height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px; @@ -525,59 +712,26 @@ onUnmounted(() => { .camera-placeholder:active { background: #e6e8eb; } .camera-placeholder .text { margin-top: 5px; font-size: 13px; } -/* ★ 全屏扫码层样式 */ +/* 全屏扫码层 */ .fullscreen-scanner-overlay { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: #000; - z-index: 9999; - display: flex; - flex-direction: column; + position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; + background: #000; z-index: 9999; display: flex; flex-direction: column; } - .scanner-header { - height: 60px; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 15px; - background: rgba(0,0,0,0.6); - color: #fff; - position: absolute; - top: 0; - width: 100%; - z-index: 10; + height: 60px; display: flex; align-items: center; justify-content: space-between; + padding: 0 15px; background: rgba(0,0,0,0.6); color: #fff; + position: absolute; top: 0; width: 100%; z-index: 10; } .scanner-title { font-size: 16px; font-weight: bold; } .close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; } - .scanner-body { - flex: 1; - width: 100%; - position: relative; - display: flex; - align-items: center; - justify-content: center; + flex: 1; width: 100%; position: relative; display: flex; + align-items: center; justify-content: center; } -/* 强制子组件(QrScanner)填满容器 */ -:deep(.qr-scanner-container) { - width: 100% !important; - height: 100% !important; - border-radius: 0 !important; -} - +:deep(.qr-scanner-container) { width: 100% !important; height: 100% !important; border-radius: 0 !important; } .scanner-footer { - position: absolute; - bottom: 0; - width: 100%; - padding: 20px; - background: rgba(0,0,0,0.6); - color: #fff; - text-align: center; - z-index: 10; + position: absolute; bottom: 0; width: 100%; padding: 20px; + background: rgba(0,0,0,0.6); color: #fff; text-align: center; z-index: 10; } .current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; } @@ -591,7 +745,6 @@ onUnmounted(() => { .unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; } .signed-img img { max-height: 90px; } .re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; } - .bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; } .bottom-actions .el-button { width: 48%; } @@ -621,4 +774,4 @@ onUnmounted(() => { .sidebar-actions { flex-direction: row; width: 100%; gap: 10px; } .sidebar-actions .el-button { flex: 1; height: 40px; } } - + \ No newline at end of file