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 }}
-
+
-
将条码对准取景框
+
请将条码横向填满红框
+
+
+
@@ -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 @@
-
+
+
+
+
+
库存盘点系统
+
请确保已连接扫描枪或摄像头
+
+
+
+ 开始新盘点
+
+
+
+ 继续上次盘点
+ (云端已存: {{ serverDraftCount }} 项)
+
+
+
+
+
+ 数据实时同步至服务器,防止意外丢失
+
+
+
+
+
@@ -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