diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index b190e5e..a1141ff 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -1,9 +1,10 @@ from flask import Blueprint, jsonify, request -# ★ [核心修复] 导入正确的模型类名 StockBuy (替换原来的 InboundBuy) +from app.extensions import db +# 导入模型 from app.models.inbound.buy import StockBuy +from app.models.inbound.stocktake import StocktakeDraft # 新增 -# 尝试导入半成品和成品模型 (根据你的命名习惯修正为 StockSemi/StockProduct) -# 使用 try-except 防止如果其他文件还没改名导致再次报错 +# 尝试导入半成品和成品 try: from app.models.inbound.semi import StockSemi except ImportError: @@ -22,30 +23,27 @@ bp = Blueprint('stock_ops', __name__) @bp.route('/all', methods=['GET']) def get_all_stock(): """ - 获取所有在库物品(采购件+半成品+成品) - 用于:盘点初始化、出库选单列表 + 获取所有库存 > 0 的物品(无论状态是 在库 还是 部分出库) """ try: - # 1. 获取采购件 - # ★ [核心修复] 使用 StockBuy 查询,并将状态条件改为 '在库' (匹配你的 Model 定义) + # 1. 采购件 (核心修改:只看库存数量 > 0) materials = [] if StockBuy: - materials = StockBuy.query.filter(StockBuy.status == '在库').all() + materials = StockBuy.query.filter(StockBuy.stock_quantity > 0).all() - # 2. 获取半成品 + # 2. 半成品 semis = [] if StockSemi: try: - # 假设半成品也使用 '在库' 状态 - semis = StockSemi.query.filter(StockSemi.status == '在库').all() + semis = StockSemi.query.filter(StockSemi.stock_quantity > 0).all() except Exception: semis = [] - # 3. 获取成品 + # 3. 成品 products = [] if StockProduct: try: - products = StockProduct.query.filter(StockProduct.status == '在库').all() + products = StockProduct.query.filter(StockProduct.stock_quantity > 0).all() except Exception: products = [] @@ -55,44 +53,69 @@ def get_all_stock(): "products": [item.to_dict() for item in products] }), 200 except Exception as e: - print(f"Error in get_all_stock: {e}") # 输出错误日志以便调试 + print(f"Error: {e}") return jsonify({"message": f"查询库存失败: {str(e)}"}), 500 +# --- 草稿箱接口 (断点续传) --- + +@bp.route('/draft/list', methods=['GET']) +def get_drafts(): + """获取当前用户的盘点进度""" + user_id = request.args.get('user_id', 'admin') + drafts = StocktakeDraft.query.filter_by(user_id=user_id).all() + return jsonify([d.to_dict() for d in drafts]), 200 + + +@bp.route('/draft/add', methods=['POST']) +def add_draft(): + """扫码同步""" + data = request.json + user_id = data.get('user_id', 'admin') + uuid = data.get('uuid') + + # 避免重复插入 + exists = StocktakeDraft.query.filter_by(user_id=user_id, uuid=uuid).first() + if not exists: + draft = StocktakeDraft(user_id=user_id, uuid=uuid) + db.session.add(draft) + db.session.commit() + + return jsonify({"message": "Saved"}), 200 + + +@bp.route('/draft/clear', methods=['POST']) +def clear_draft(): + """清空进度 (开始新盘点或结束后)""" + data = request.json + user_id = data.get('user_id', 'admin') + + StocktakeDraft.query.filter_by(user_id=user_id).delete() + db.session.commit() + return jsonify({"message": "Cleared"}), 200 + + +# --- 打印接口 (保持不变) --- + @bp.route('/print/selection', methods=['POST']) def print_selection(): - """打印出库选单""" try: data = request.json items = data.get('items', []) - - if not items: - return jsonify({"message": "未选择任何物品"}), 400 - - printer = NetworkPrintService() # 默认连接 192.168.9.205 + if not items: return jsonify({"message": "未选择任何物品"}), 400 + printer = NetworkPrintService() success, msg = printer.print_outbound_selection(items) - - if success: - return jsonify({"message": "打印指令已发送"}), 200 - else: - return jsonify({"message": msg}), 500 + return jsonify({"message": "打印指令已发送" if success else msg}), 200 if success else 500 except Exception as e: - return jsonify({"message": f"打印服务错误: {str(e)}"}), 500 + return jsonify({"message": str(e)}), 500 @bp.route('/print/stocktake', methods=['POST']) def print_stocktake(): - """打印盘点报告""" try: data = request.json - # data 结构: { total, scanned, missing, missing_items: [] } - printer = NetworkPrintService() success, msg = printer.print_stocktake_report(data) - - if success: - return jsonify({"message": "盘点报告已发送"}), 200 - else: - return jsonify({"message": msg}), 500 + return jsonify({"message": "盘点报告已发送" if success else msg}), 200 if success else 500 except Exception as e: - return jsonify({"message": f"打印服务错误: {str(e)}"}), 500 \ No newline at end of file + return jsonify({"message": str(e)}), 500 \ No newline at end of file diff --git a/inventory-backend/app/models/inbound/stocktake.py b/inventory-backend/app/models/inbound/stocktake.py new file mode 100644 index 0000000..8433f5e --- /dev/null +++ b/inventory-backend/app/models/inbound/stocktake.py @@ -0,0 +1,19 @@ +from app.extensions import db +from datetime import datetime + +class StocktakeDraft(db.Model): + """盘点草稿表""" + __tablename__ = 'stocktake_draft' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.String(100), default='admin') + uuid = db.Column(db.String(100)) + scan_time = db.Column(db.DateTime, default=datetime.now) + + def to_dict(self): + return { + 'id': self.id, + 'user_id': self.user_id, + 'uuid': self.uuid, + 'scan_time': self.scan_time.strftime('%Y-%m-%d %H:%M:%S') + } \ No newline at end of file diff --git a/inventory-backend/app/services/print/label_service.py b/inventory-backend/app/services/print/label_service.py index 315c471..f08476a 100644 --- a/inventory-backend/app/services/print/label_service.py +++ b/inventory-backend/app/services/print/label_service.py @@ -139,8 +139,8 @@ class LabelPrintService: CURRENT_Y = LabelPrintService.TOP_MARGIN_PX # --- A. 绘制条形码 (居中) --- - bc_w = int(35 * LabelPrintService.DOTS_PER_MM) - bc_h = int(7 * LabelPrintService.DOTS_PER_MM) # 高度 + bc_w = int(37 * LabelPrintService.DOTS_PER_MM) + bc_h = int(8 * LabelPrintService.DOTS_PER_MM) # 高度 bc_img = LabelPrintService._generate_barcode_image(sku_code, bc_w, bc_h) diff --git a/inventory-web/package.json b/inventory-web/package.json index 556813c..6004a42 100644 --- a/inventory-web/package.json +++ b/inventory-web/package.json @@ -13,6 +13,8 @@ "axios": "^1.13.3", "element-plus": "^2.13.1", "html5-qrcode": "^2.3.8", + "jspdf": "^2.5.1", + "jspdf-autotable": "^3.8.2", "pinia": "^3.0.4", "sass": "^1.97.3", "vue": "^3.5.24", diff --git a/inventory-web/src/components/QrScanner/index.vue b/inventory-web/src/components/QrScanner/index.vue index 263dbf4..49460f0 100644 --- a/inventory-web/src/components/QrScanner/index.vue +++ b/inventory-web/src/components/QrScanner/index.vue @@ -4,9 +4,16 @@
{{ errorMsg }}
-
+
-
将条码对准取景框
+
请将条码横向填满红框
+
+ +
+
+ + 扫描成功,3秒后继续... +
@@ -14,9 +21,13 @@ \ No newline at end of file diff --git a/inventory-web/src/views/stock/stocktake/index.vue b/inventory-web/src/views/stock/stocktake/index.vue index af780d4..64e29e1 100644 --- a/inventory-web/src/views/stock/stocktake/index.vue +++ b/inventory-web/src/views/stock/stocktake/index.vue @@ -1,15 +1,49 @@ @@ -119,10 +210,17 @@ import { ref, computed, onMounted } from 'vue' import { getAllStock, printStocktakeReport } from '@/api/inbound/stock' import QrScanner from '@/components/QrScanner/index.vue' -import { ElMessage, ElNotification, ElMessageBox } from 'element-plus' -import { Search, List, Printer, VideoPause } from '@element-plus/icons-vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import { Search, VideoPlay, VideoPause, List, Printer, Checked, Download, ArrowRight, Cloudy } from '@element-plus/icons-vue' +import jsPDF from 'jspdf' +import 'jspdf-autotable' +import request from '@/utils/request' +import { useUserStore } from '@/stores/user' // 引入UserStore获取真实用户 -// --- 类型定义 --- +const userStore = useUserStore() +const currentUser = userStore.username || 'admin' // 优先使用登录名 + +// --- 数据接口 --- interface StockItem { id: number name: string @@ -130,71 +228,192 @@ interface StockItem { batch_no: string uuid: string bar_code: string + qty_stock: number scanned: boolean - category_type: 'material' | 'semi' | 'product' - category_label: string uniqueKey: string [key: string]: any } -// --- 状态管理 --- +// --- 状态变量 --- +const loading = ref(false) +const btnLoading = ref(false) const printing = ref(false) -const showList = ref(false) // 控制抽屉显示 +const isSessionActive = ref(false) // 会话是否进行中 +const serverDraftCount = ref(0) // 服务器上的草稿数量 +const showList = ref(false) // 是否显示抽屉清单 +const showFinishDialog = ref(false) // 是否显示结算弹窗 +const syncStatus = ref<'success' | 'syncing' | 'failed'>('success') + +const allData = ref([]) +const scannedSet = ref>(new Set()) + const filterType = ref('all') const searchKeyword = ref('') -const allData = ref([]) + +// --- API 封装 --- +const api = { + getDrafts: () => request({ url: '/v1/inbound/stock/draft/list', method: 'get', params: { user_id: currentUser } }), + addDraft: (data: any) => request({ url: '/v1/inbound/stock/draft/add', method: 'post', data: { ...data, user_id: currentUser } }), + clearDraft: () => request({ url: '/v1/inbound/stock/draft/clear', method: 'post', data: { user_id: currentUser } }) +} // --- 初始化 --- -const init = async () => { +onMounted(async () => { + await checkServerDraft() +}) + +const checkServerDraft = async () => { + try { + const res: any = await api.getDrafts() + if (res && res.length > 0) { + serverDraftCount.value = res.length + } else { + serverDraftCount.value = 0 + } + } catch (e) { + console.error('检查草稿失败', e) + } +} + +// --- 核心业务逻辑 --- + +// 1. 开始新会话 (清空服务器草稿) +const startNewSession = async () => { + try { + if (serverDraftCount.value > 0) { + await ElMessageBox.confirm('服务器存在未完成的盘点记录,开始新盘点将清除它们,确定吗?', '新盘点', { type: 'warning' }) + } + btnLoading.value = true + await api.clearDraft() // 清空后端 + scannedSet.value.clear() + await loadData() // 拉取库存 + isSessionActive.value = true + } catch (e) { + // cancel + } finally { + btnLoading.value = false + } +} + +// 2. 恢复旧会话 (从服务器拉取草稿) +const resumeSession = async () => { + btnLoading.value = true + try { + // 先拉草稿 + const drafts: any = await api.getDrafts() + const draftUUIDs = new Set(drafts.map((d: any) => d.uuid)) + + scannedSet.value = draftUUIDs + await loadData() // 再拉库存,并匹配状态 + isSessionActive.value = true + } catch (e) { + ElMessage.error('恢复进度失败') + } finally { + btnLoading.value = false + } +} + +// 3. 暂停 (无需做任何事,因为数据是实时同步的) +const pauseSession = () => { + isSessionActive.value = false + checkServerDraft() // 更新一下待机界面的数量显示 + ElMessage.success('已退出,进度已安全保存在云端') +} + +// 4. 加载库存数据 +const loadData = async () => { + loading.value = true try { const res = await getAllStock() const list: StockItem[] = [] - const processItem = (item: any, type: 'material' | 'semi' | 'product', label: string) => { + const processItem = (item: any, type: string) => { + // 逻辑:只要 qty_stock > 0 就算在库 + const stock = parseFloat(item.stock_quantity || item.qty_stock || 0) + if (stock <= 0) return + const name = item.material_name || item.product_name || item.name || '未知物品' - return { + const uuid = item.uuid || item.sku || '' + + list.push({ ...item, name: name, standard: item.standard || '', batch_no: item.batch_no || item.batch_number || '', - uuid: item.uuid || item.sku || '', + uuid: uuid, bar_code: item.bar_code || item.barcode || '', - scanned: false, - category_type: type, - category_label: label, + qty_stock: stock, + scanned: scannedSet.value.has(uuid), // 匹配草稿状态 uniqueKey: `${type}_${item.id}` - } + }) } - if (res.materials) res.materials.forEach((i: any) => list.push(processItem(i, 'material', '采购件'))) - if (res.semis) res.semis.forEach((i: any) => list.push(processItem(i, 'semi', '半成品'))) - if (res.products) res.products.forEach((i: any) => list.push(processItem(i, 'product', '成品'))) + if (res.materials) res.materials.forEach((i: any) => processItem(i, 'material')) + if (res.semis) res.semis.forEach((i: any) => processItem(i, 'semi')) + if (res.products) res.products.forEach((i: any) => processItem(i, 'product')) allData.value = list - ElMessage.success(`加载成功,共 ${list.length} 件物品`) } catch (e) { - ElMessage.error('库存加载失败,请重试') + ElMessage.error('库存数据加载失败') + } finally { + loading.value = false } } -// --- 打开清单 (同时会自动触发 v-if 销毁摄像头) --- -const openInventoryList = () => { - showList.value = true +// 5. 扫码成功处理 (实时同步到服务器) +const onScanSuccess = async (code: string) => { + if (!code) return + const trimCode = code.trim() + + const item = allData.value.find(i => i.uuid === trimCode || i.bar_code === trimCode) + + if (item) { + if (item.scanned) { + ElMessage.warning('重复扫描') + return + } + + // 前端先响应,不阻塞 UI + item.scanned = true + scannedSet.value.add(item.uuid) + + if (navigator.vibrate) navigator.vibrate(100); + ElMessage.success(`已确认:${item.name}`) + + // 后台静默同步 + syncStatus.value = 'syncing' + try { + await api.addDraft({ + id: item.id, + uuid: item.uuid, + name: item.name, + standard: item.standard, + batch_no: item.batch_no, + qty_stock: item.qty_stock + }) + syncStatus.value = 'success' + } catch (e) { + console.error('同步失败', e) + syncStatus.value = 'failed' + } + + } else { + ElMessage.error(`未知条码: ${trimCode}`) + if (navigator.vibrate) navigator.vibrate([200, 50, 200]); + } } -// --- 过滤逻辑 --- +// --- 计算属性 --- const filteredList = computed(() => { let result = allData.value - if (filterType.value !== 'all') { - result = result.filter(item => item.category_type === filterType.value) - } + if (filterType.value === 'scanned') result = result.filter(i => i.scanned) + if (filterType.value === 'missing') result = result.filter(i => !i.scanned) if (searchKeyword.value) { - const kw = searchKeyword.value.toLowerCase().trim() - result = result.filter(item => - (item.name && item.name.toLowerCase().includes(kw)) || - (item.standard && item.standard.toLowerCase().includes(kw)) || - (item.uuid && item.uuid.toLowerCase().includes(kw)) || - (item.bar_code && item.bar_code.toLowerCase().includes(kw)) + const kw = searchKeyword.value.toLowerCase() + result = result.filter(i => + i.name.toLowerCase().includes(kw) || + i.uuid.includes(kw) || + (i.standard && i.standard.toLowerCase().includes(kw)) // 增加对规格的搜索 ) } return result @@ -206,91 +425,99 @@ const stats = computed(() => { return { total, scanned, missing: total - scanned } }) -// --- 扫码逻辑 --- -const onScanSuccess = (code: string) => { - if (!code) return - const trimCode = code.trim() +const missingList = computed(() => { + return allData.value.filter(i => !i.scanned) +}) - const item = allData.value.find(i => i.uuid === trimCode || i.bar_code === trimCode) - - if (item) { - if (item.scanned) { - ElMessage.warning(`${item.name} 已扫过`) - return - } - item.scanned = true - - // 震动反馈 (如果设备支持) - if (navigator.vibrate) navigator.vibrate(200); - - ElNotification({ - title: '✅ 匹配成功', - message: `${item.name}\n${item.standard}`, - type: 'success', - duration: 2000, - position: 'top-left' // 移动端通常顶部提示更明显 - }) - } else { - ElMessage.error(`未找到: ${trimCode}`) - if (navigator.vibrate) navigator.vibrate([100, 50, 100]); - } +// --- 界面交互 --- +const openInventoryList = () => { showList.value = true } +const openFinishDialog = () => { + if (stats.value.total === 0) return + showFinishDialog.value = true } -// --- 结束打印 --- -const handleFinish = async () => { - if (stats.value.total === 0) return +// 生成 PDF (包含中文支持提示) +const generatePDF = () => { + const doc = new jsPDF() + // 提示:默认 jspdf 不支持中文,需要 addFont。 + // 这里使用英文表头以确保基础可用性,实际生产需自行引入中文字体文件 + doc.setFontSize(18) + doc.text("Inventory Stocktake Report", 14, 22) + + doc.setFontSize(11) + doc.text(`Time: ${new Date().toLocaleString()}`, 14, 30) + doc.text(`Total: ${stats.value.total} | Scanned: ${stats.value.scanned} | Missing: ${stats.value.missing}`, 14, 38) + + const tableData = missingList.value.map(item => [ + item.uuid, + item.name, + item.standard, + item.qty_stock.toString() + ]) + + ;(doc as any).autoTable({ + head: [['UUID', 'Name', 'Spec', 'Stock']], + body: tableData, + startY: 45, + theme: 'grid' + }) + doc.save(`inventory_report_${Date.now()}.pdf`) +} + +// 打印并清除服务器草稿 +const confirmPrint = async () => { try { - await ElMessageBox.confirm( - `应盘: ${stats.value.total} | 差异: ${stats.value.missing}\n确认结束并打印?`, - '结束盘点', - { confirmButtonText: '打印报告', cancelButtonText: '取消', type: 'warning', center: true } - ) - - const missingItems = allData.value.filter(i => !i.scanned) const payload = { total: stats.value.total, scanned: stats.value.scanned, missing: stats.value.missing, - missing_items: missingItems + missing_items: missingList.value } - printing.value = true await printStocktakeReport(payload) - ElMessage.success('打印指令已发送') + ElMessage.success('已发送至打印机') + + // 任务完成,清空服务器草稿 + await api.clearDraft() + + scannedSet.value.clear() + isSessionActive.value = false + showFinishDialog.value = false + checkServerDraft() // 刷新计数 } catch (e) { - if (e !== 'cancel') ElMessage.error('打印失败') + ElMessage.error('打印失败') } finally { printing.value = false } } - -onMounted(() => { - init() -}) \ No newline at end of file