Compare commits

9 Commits

7 changed files with 525 additions and 122 deletions

View File

@ -1,14 +1,35 @@
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, request, send_file
from app.extensions import db
# ★★★ 修复点:必须引入 datetime否则下方更新时间时会报错 500 ★★★
from datetime import datetime
from datetime import datetime, timedelta
from app.utils.decorators import permission_required
import uuid as uuid_module
import io
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
# 导入模型
from app.models.inbound.buy import StockBuy
from app.models.inbound.stocktake import StocktakeDraft
from app.models.transaction import TransBorrow
from app.models.base import MaterialBase
# 尝试导入用户模型
try:
from app.models.sys.user import User
except ImportError:
User = None
# 尝试导入半成品和成品
try:
from app.models.inbound.semi import StockSemi
except ImportError:
StockSemi = None
try:
from app.models.inbound.product import StockProduct
except ImportError:
StockProduct = None
def _normalize_user_id(user_id):
@ -144,6 +165,10 @@ def add_draft():
"""
扫码同步 (支持更新数量)
如果 session_id 不存在则创建新的会话
差异计算逻辑调整:
- adjusted_stock_qty = 账面总库存 - 借出未还数量
- diff_qty = 实盘数量 - adjusted_stock_qty
"""
try:
data = request.json
@ -164,7 +189,21 @@ def add_draft():
if not item:
return jsonify({"message": "未找到对应的库存记录"}), 404
# 账面总库存
stock_qty = float(item.stock_quantity) if item.stock_quantity else 0
# 计算借出未还数量 (quantity - returned_quantity)
borrowed_result = db.session.query(
db.func.sum(TransBorrow.quantity - TransBorrow.returned_quantity)
).filter(
TransBorrow.source_table == source_table,
TransBorrow.stock_id == stock_id,
TransBorrow.is_returned == False
).scalar()
total_borrowed = float(borrowed_result) if borrowed_result else 0
# 调整后的账面可用库存 = 账面总库存 - 借出未还数量
adjusted_stock_qty = stock_qty - total_borrowed
# 查找是否已存在
draft = StocktakeDraft.query.filter_by(user_id=user_id, uuid=uuid, session_id=session_id).first()
@ -173,8 +212,8 @@ def add_draft():
# 如果已存在,更新数量和时间
draft.quantity = quantity
draft.scan_time = datetime.now()
draft.stock_qty = stock_qty
draft.diff_qty = quantity - stock_qty
draft.stock_qty = adjusted_stock_qty
draft.diff_qty = quantity - adjusted_stock_qty
draft.source_table = source_table
draft.stock_id = stock_id
else:
@ -184,8 +223,8 @@ def add_draft():
uuid=uuid,
quantity=quantity,
session_id=session_id,
stock_qty=stock_qty,
diff_qty=quantity - stock_qty,
stock_qty=adjusted_stock_qty,
diff_qty=quantity - adjusted_stock_qty,
source_table=source_table,
stock_id=stock_id
)
@ -195,7 +234,9 @@ def add_draft():
return jsonify({
"message": "Saved",
"session_id": session_id,
"draft_id": draft.id
"draft_id": draft.id,
"adjusted_stock_qty": adjusted_stock_qty,
"total_borrowed": total_borrowed
}), 200
except Exception as e:
print(f"Add Draft Error: {e}")
@ -329,32 +370,51 @@ def adjust_stock():
1. 根据 diff_qty 调整库存 (stock_quantity 和 available_quantity)
2. 生成流水账记录 (盘盈入库 / 盘亏出库)
3. 标记草稿为 is_processed=True
支持两种模式:
- 有草稿模式:通过 draft_id 或 stock_id+source_table 查找草稿
- 无草稿模式:直接传入 stock_id + diff_qty + source_table未扫码直接盘亏
"""
data = request.json
draft_id = data.get('draft_id')
stock_id = data.get('stock_id')
diff_qty = data.get('diff_qty')
source_table = data.get('source_table')
operator_name = data.get('operator_name', 'System')
remark = data.get('remark', '')
if not draft_id:
return jsonify({"message": "draft_id 不能为空"}), 400
if not draft_id and not stock_id:
return jsonify({"message": "draft_id 或 stock_id 不能同时为空"}), 400
try:
# 1. 获取草稿记录
draft = StocktakeDraft.query.get(draft_id)
if not draft:
return jsonify({"message": "草稿记录不存在"}), 404
# 1. 尝试获取草稿
draft = StocktakeDraft.query.get(draft_id) if draft_id else None
if not draft and stock_id and source_table:
draft = StocktakeDraft.query.filter_by(
stock_id=stock_id,
source_table=source_table
).first()
elif not draft and stock_id:
draft = StocktakeDraft.query.filter_by(stock_id=stock_id).first()
# 2. 获取库存记录
if not draft.stock_id or not draft.source_table:
return jsonify({"message": "草稿记录缺少库存关联信息"}), 400
# 2. 核心逻辑分支
if draft:
# 有草稿模式
stock_id = draft.stock_id
source_table = draft.source_table
diff_qty = float(draft.diff_qty)
else:
# 无草稿模式(未扫码直接盘亏)
if diff_qty is None or source_table is None or not stock_id:
return jsonify({"message": "未扫码物资平账缺失必要参数(需提供 diff_qty 和 source_table"}), 400
diff_qty = float(diff_qty)
stock = get_stock_record(draft.source_table, draft.stock_id)
# 3. 获取并校验真实的库存记录
stock = get_stock_record(source_table, stock_id)
if not stock:
return jsonify({"message": "库存记录不存在"}), 404
return jsonify({"message": "平账失败:物理库存记录不存在"}), 404
diff_qty = float(draft.diff_qty)
# 3. 计算调整
# 4. 计算调整
if diff_qty > 0:
# 盘盈:增加库存
new_stock_qty = float(stock.stock_quantity or 0) + diff_qty
@ -375,19 +435,20 @@ def adjust_stock():
else:
return jsonify({"message": "差异为0无需调整"}), 400
# 4. 执行库存调整
# 5. 执行库存调整
stock.stock_quantity = new_stock_qty
stock.available_quantity = new_avail_qty
# 5. 生成流水账记录
# 导入流水账模型
# 6. 生成流水账记录
from app.models.outbound import TransOutbound
# 生成唯一单号
adj_no_suffix = draft.id if draft else f"MANUAL-{datetime.now().strftime('%Y%m%d%H%M%S')}"
trans_record = TransOutbound(
outbound_no=f"STKADJ-{datetime.now().strftime('%Y%m%d%H%M%S')}-{draft.id:04d}",
outbound_no=f"STKADJ-{datetime.now().strftime('%Y%m%d%H%M%S')}-{adj_no_suffix}",
sku=stock.sku,
source_table=draft.source_table,
stock_id=draft.stock_id,
source_table=source_table,
stock_id=stock_id,
barcode=getattr(stock, 'barcode', ''),
quantity=abs(diff_qty),
unit_price=getattr(stock, 'pre_tax_unit_price', 0) or getattr(stock, 'manual_cost', 0) or 0,
@ -399,8 +460,9 @@ def adjust_stock():
)
db.session.add(trans_record)
# 6. 删除草稿记录(平账后从工作台移除
db.session.delete(draft)
# 7. 删除草稿记录(仅在有草稿时
if draft:
db.session.delete(draft)
db.session.commit()
@ -410,7 +472,7 @@ def adjust_stock():
"diff_qty": diff_qty,
"new_stock_qty": new_stock_qty,
"new_avail_qty": new_avail_qty,
"draft_id": draft_id
"draft_id": draft_id if draft_id else (draft.id if draft else None)
}), 200
except Exception as e:
@ -460,3 +522,206 @@ def print_stocktake():
return jsonify({"message": "盘点报告已发送" if success else msg}), 200 if success else 500
except Exception as e:
return jsonify({"message": str(e)}), 500
@bp.route('/export-stocktake', methods=['GET'])
@permission_required('inventory_stocktake:operation')
def export_stocktake():
"""
导出盘点报告 Excel
包含3个Sheet:
1. 盘点差异明细 (diff_qty != 0)
2. 账实相符明细 (diff_qty == 0)
3. 外借在用资产明细 (未归还的借出记录)
"""
try:
# 创建工作簿
wb = Workbook()
wb.remove(wb.active)
# 定义样式
header_font = Font(bold=True, size=11, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
thin_border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
def get_material_info(source_table, stock_id):
"""获取物料基本信息"""
if source_table == 'stock_buy':
stock = StockBuy.query.get(stock_id)
elif source_table == 'stock_semi':
stock = StockSemi.query.get(stock_id) if StockSemi else None
elif source_table == 'stock_product':
stock = StockProduct.query.get(stock_id) if StockProduct else None
else:
return {'name': '-', 'sku': '-', 'spec': '-', 'unit': '-', 'location': '-'}
if not stock:
return {'name': '-', 'sku': '-', 'spec': '-', 'unit': '-', 'location': '-'}
# 安全获取 sku
stock_sku = getattr(stock, 'sku', None) or getattr(stock, 'SKU', None) or '-'
# 使用 base_id 或 material_id 关联查询物料基础表(安全方式)
material = None
base_id = getattr(stock, 'base_id', None) or getattr(stock, 'material_id', None)
if base_id:
material = MaterialBase.query.get(base_id)
else:
# 如果没有 base_id尝试用 code 兜底查询
if hasattr(MaterialBase, 'code') and stock_sku != '-':
material = MaterialBase.query.filter_by(code=stock_sku).first()
# 规格型号:优先从 material 取,再 fallback 到 stock
spec = (
getattr(material, 'specification', None) or
getattr(material, 'spec', None) or
getattr(stock, 'spec_model', None) or
getattr(stock, 'standard', None) or
'-'
)
# 库位:获取真实物理库位
location = getattr(stock, 'location', None) or getattr(stock, 'position', None) or '-'
return {
'name': material.name if material else stock_sku,
'sku': stock_sku,
'spec': spec,
'unit': getattr(stock, 'unit', None) or '',
'location': location
}
def get_user_name(user_id):
"""获取用户真实姓名或昵称"""
if not User or not user_id:
return str(user_id) if user_id else '-'
try:
user = None
# 尝试通过ID或用户名查找
if str(user_id).isdigit():
user = User.query.get(int(user_id))
if not user:
user = User.query.filter_by(username=str(user_id)).first()
if not user:
user = User.query.filter_by(username=str(user_id).upper()).first()
if not user:
user = User.query.filter_by(username=str(user_id).lower()).first()
return getattr(user, 'real_name', None) or getattr(user, 'nickname', None) or str(user_id)
except:
return str(user_id)
def to_beijing_time(dt):
"""转换为北京时间(+8小时"""
if not dt:
return ''
try:
if isinstance(dt, str):
return dt[:19]
return (dt + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S')
except:
return str(dt)[:19]
def set_header_row(ws, headers):
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = thin_border
# ===== Sheet 1: 盘点差异明细 =====
ws1 = wb.create_sheet("盘点差异明细")
diff_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点人", "盘点时间"]
set_header_row(ws1, diff_headers)
diff_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty != 0).all()
for row_idx, draft in enumerate(diff_drafts, 2):
mat_info = get_material_info(draft.source_table, draft.stock_id)
ws1.cell(row=row_idx, column=1, value=mat_info['name']).border = thin_border
ws1.cell(row=row_idx, column=2, value=mat_info['sku']).border = thin_border
ws1.cell(row=row_idx, column=3, value=mat_info['spec']).border = thin_border
ws1.cell(row=row_idx, column=4, value=mat_info['location']).border = thin_border
ws1.cell(row=row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
ws1.cell(row=row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
ws1.cell(row=row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
ws1.cell(row=row_idx, column=8, value=get_user_name(draft.user_id)).border = thin_border
ws1.cell(row=row_idx, column=9, value=to_beijing_time(draft.scan_time)).border = thin_border
# ===== Sheet 2: 账实相符明细 =====
ws2 = wb.create_sheet("账实相符明细")
normal_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点人", "盘点时间"]
set_header_row(ws2, normal_headers)
normal_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty == 0).all()
for row_idx, draft in enumerate(normal_drafts, 2):
mat_info = get_material_info(draft.source_table, draft.stock_id)
ws2.cell(row=row_idx, column=1, value=mat_info['name']).border = thin_border
ws2.cell(row=row_idx, column=2, value=mat_info['sku']).border = thin_border
ws2.cell(row=row_idx, column=3, value=mat_info['spec']).border = thin_border
ws2.cell(row=row_idx, column=4, value=mat_info['location']).border = thin_border
ws2.cell(row=row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
ws2.cell(row=row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
ws2.cell(row=row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
ws2.cell(row=row_idx, column=8, value=get_user_name(draft.user_id)).border = thin_border
ws2.cell(row=row_idx, column=9, value=to_beijing_time(draft.scan_time)).border = thin_border
# ===== Sheet 3: 外借在用资产明细 =====
ws3 = wb.create_sheet("外借在用资产明细")
borrow_headers = ["借出单号", "借用人", "物料名称", "SKU", "规格型号", "借出总数", "已还数量", "待还数量", "借出时间", "预计归还时间"]
set_header_row(ws3, borrow_headers)
# 查询未归还的借出记录
unreturned_borrows = TransBorrow.query.filter(TransBorrow.is_returned == False).all()
for row_idx, borrow in enumerate(unreturned_borrows, 2):
mat_info = get_material_info(borrow.source_table, borrow.stock_id)
total_qty = float(borrow.quantity or 0)
returned_qty = float(borrow.returned_quantity or 0)
pending_qty = total_qty - returned_qty
ws3.cell(row=row_idx, column=1, value=borrow.borrow_no or '').border = thin_border
ws3.cell(row=row_idx, column=2, value=borrow.borrower_name or '').border = thin_border
ws3.cell(row=row_idx, column=3, value=mat_info['name']).border = thin_border
ws3.cell(row=row_idx, column=4, value=mat_info['sku']).border = thin_border
ws3.cell(row=row_idx, column=5, value=mat_info['spec']).border = thin_border
ws3.cell(row=row_idx, column=6, value=total_qty).border = thin_border
ws3.cell(row=row_idx, column=7, value=returned_qty).border = thin_border
ws3.cell(row=row_idx, column=8, value=pending_qty).border = thin_border
ws3.cell(row=row_idx, column=9, value=to_beijing_time(borrow.borrow_time)).border = thin_border
ws3.cell(row=row_idx, column=10, value='无限期' if not borrow.expected_return_time else to_beijing_time(borrow.expected_return_time)).border = thin_border
# 调整列宽
for ws in [ws1, ws2, ws3]:
for col in ws.columns:
max_length = 0
col_letter = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
ws.column_dimensions[col_letter].width = min(max_length + 2, 30)
# 生成文件
output = io.BytesIO()
wb.save(output)
output.seek(0)
filename = f"盘点报告_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
print(f"Export Stocktake Error: {e}")
import traceback
traceback.print_exc()
return jsonify({"message": f"导出失败: {str(e)}"}), 500

View File

@ -13,6 +13,7 @@ class TransBorrow(db.Model):
stock_id = db.Column(db.Integer)
barcode = db.Column(db.String(100))
quantity = db.Column(db.Numeric(19, 4))
returned_quantity = db.Column(db.Numeric(19, 4), default=0)
borrower_name = db.Column(db.String(100))
borrow_time = db.Column(db.DateTime, default=beijing_time)
borrow_signature = db.Column(db.Text)
@ -26,6 +27,9 @@ class TransBorrow(db.Model):
remark = db.Column(db.Text)
def to_dict(self):
returned_qty = float(self.returned_quantity) if self.returned_quantity is not None else 0
total_qty = float(self.quantity) if self.quantity is not None else 0
pending_qty = total_qty - returned_qty
return {
'id': self.id,
'borrow_no': self.borrow_no,
@ -33,7 +37,9 @@ class TransBorrow(db.Model):
'source_table': self.source_table,
'stock_id': self.stock_id,
'barcode': self.barcode,
'quantity': float(self.quantity) if self.quantity is not None else None,
'quantity': total_qty,
'returned_quantity': returned_qty,
'pending_quantity': pending_qty,
'borrower_name': self.borrower_name,
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else None,
'borrow_signature': self.borrow_signature,

View File

@ -113,10 +113,12 @@ class TransService:
@staticmethod
def process_return(data, operator_name):
"""
还库逻辑:
1. 恢复可用库存
2. 更新库位 (如果有变动)
3. 记录库管签字
还库逻辑(支持部分归还)
1. 校验本次归还数量不能大于待还数量
2. 恢复可用库存(按本次归还数量)
3. 更新库位 (如果有变动)
4. 记录库管签字
5. 更新归还数量和状态(部分归还/全部归还)
"""
items = data.get('items', [])
signature = data.get('signature_path') # 库管签字
@ -129,32 +131,55 @@ class TransService:
try:
for item in items:
borrow_id = item.get('id')
# 前端传入的本次归还数量
return_qty = float(item.get('return_qty', 0))
# 前端如果没有填 return_location应该在提交前处理好或者这里做 fallback
# 这里假设前端传来的 return_location 就是最终要保存的库位
final_location = item.get('return_location')
record = TransBorrow.query.with_for_update().get(borrow_id)
if not record or record.is_returned:
if not record:
continue
# 计算待还数量
returned_qty = float(record.returned_quantity) if record.returned_quantity else 0
total_qty = float(record.quantity) if record.quantity else 0
pending_qty = total_qty - returned_qty
# 校验归还数量
if return_qty <= 0:
raise ValueError(f"归还数量必须大于0")
if return_qty > pending_qty:
raise ValueError(f"本次归还数量({return_qty})不能大于待还数量({pending_qty})")
ModelClass = model_map.get(record.source_table)
if ModelClass:
stock = ModelClass.query.with_for_update().get(record.stock_id)
if stock:
# 1. 恢复可用库存
stock.available_quantity = float(stock.available_quantity) + float(record.quantity)
# 1. 恢复可用库存(按本次归还数量)
stock.available_quantity = float(stock.available_quantity) + return_qty
# 2. 更新库位 (如果提供了有效值)
if final_location:
stock.warehouse_location = final_location
# 3. 更新借用单状态
record.is_returned = True
record.status = 'returned'
# 3. 更新归还数量
new_returned_qty = returned_qty + return_qty
record.returned_quantity = new_returned_qty
# 4. 更新状态
if new_returned_qty >= total_qty:
record.is_returned = True
record.status = 'returned'
else:
record.is_returned = False
record.status = 'partial_returned'
record.return_time = datetime.now()
record.return_operator = operator_name
record.return_signature = signature
record.return_location = final_location
if final_location:
record.return_location = final_location
db.session.commit()
except Exception as e:

View File

@ -349,14 +349,13 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { getAllStock } from '@/api/inbound/stock'
import QrScanner from '@/components/QrScanner/index.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { useUserStore } from '@/stores/user'
import * as XLSX from 'xlsx'
const userStore = useUserStore()
const currentUser = userStore.username || 'admin'
@ -419,6 +418,51 @@ const currentItem = ref<StockItem | null>(null)
const inputQty = ref<number | undefined>(undefined)
const qtyInputRef = ref()
// ★ 新增: 多人协同心跳刷新定时器
let syncTimer: any = null
// ★ 新增: 静默刷新数据不弹loading
const syncData = async () => {
try {
// 仅刷新差异列表数据不显示loading
const res = await getAllStock()
if (!res) return
const processItem = (item: any, type: string) => {
const stock = parseFloat(item.stock_quantity || item.qty_stock || 0)
if (stock <= 0) return
const name = item.material_name || item.product_name || item.name || '未知物品'
const uuid = item.uuid || item.sku || ''
const isScanned = scannedMap.value.has(uuid)
return {
...item,
name: name,
standard: item.spec_model || item.standard || item.model || '',
sku: item.sku || '',
uuid: uuid,
bar_code: item.bar_code || item.barcode || '',
qty_stock: stock,
qty_actual: isScanned ? scannedMap.value.get(uuid)! : 0,
scanned: isScanned,
uniqueKey: `${type}_${item.id}`,
source_table: typeToSourceTable(type),
stock_id: item.id
}
}
const list: StockItem[] = []
if (res.materials) res.materials.forEach((i: any) => { const item = processItem(i, 'material'); if (item) list.push(item) })
if (res.semis) res.semis.forEach((i: any) => { const item = processItem(i, 'semi'); if (item) list.push(item) })
if (res.products) res.products.forEach((i: any) => { const item = processItem(i, 'product'); if (item) list.push(item) })
// 静默更新数据不触发loading
allData.value = list
await fetchBorrowedQuantities(list)
} catch (e) {
// 静默失败,不弹错误
}
}
const api = {
getDrafts: (sessionId?: string) => request({
url: '/v1/inbound/stock/draft/list',
@ -449,10 +493,17 @@ const api = {
params: {}
}),
// ★ 新增: 单条库存调整
adjustStock: (draftId: number, remark: string) => request({
adjustStock: (draftId: number, stockId: number, diffQty: number, sourceTable: string, remark: string) => request({
url: '/v1/inbound/stock/adjust',
method: 'post',
data: { draft_id: draftId, operator_name: currentUser, remark: remark }
data: {
draft_id: draftId,
stock_id: stockId, // 库存项ID
diff_qty: diffQty, // 差异数量(支持无草稿模式)
source_table: sourceTable, // 必须stock_buy / stock_semi / stock_product
operator_name: currentUser,
remark: remark
}
}),
// ★ 保留清除功能(用于兼容性)
clearDraft: () => request({
@ -492,6 +543,18 @@ async function fetchBorrowedQuantities(items: StockItem[]): Promise<void> {
onMounted(async () => {
await checkServerDraft()
// ★ 启动多人协同心跳轮询每5秒静默刷新
syncTimer = setInterval(() => {
syncData()
}, 5000)
})
// ★ 新增: 组件卸载时清除定时器
onUnmounted(() => {
if (syncTimer) {
clearInterval(syncTimer)
syncTimer = null
}
})
const checkServerDraft = async () => {
@ -748,74 +811,39 @@ const closeOverlays = () => {
showQtyDialog.value = false
}
// --- 导出 Excel 逻辑 ---
const exportToExcel = () => {
// --- 导出 Excel 逻辑 (调用后端API) ---
const exportToExcel = async () => {
try {
// 1. 已盘点 Sheet
const scannedData = allData.value.filter(i => i.scanned).map(item => {
const key = item.source_table && item.stock_id ? `${item.source_table}_${item.stock_id}` : ''
const borrowedQty = borrowedQuantities.value[key] || 0
const actualTotal = item.qty_actual + borrowedQty
const diff = actualTotal - item.qty_stock
const result = diff === 0 ? '正常' : diff < 0 ? '盘亏/差异' : '盘盈'
return {
'物品名称': item.name,
'类型': item.type || item.material_type || '-',
'类别': item.category || '-',
'规格型号': item.spec_model || item.standard || '-', // ★ 双重保险
'SKU': item.sku,
'批次/SN': item.serial_number || item.batch_no || '-',
'单位': item.unit || '个',
'单价': item.price || item.unit_price || 0,
'账面库存': parseFloat(item.qty_stock as any),
'实盘数量': item.qty_actual,
'借出未还数量': borrowedQty,
'盘点结果': result,
'差异数': diff
// ===== 调试代码 =====
console.warn('---- 触发了导出 Excel ----');
// ===== 调试结束 =====
ElMessage.info('正在生成盘点报告,请稍候...');
// 使用项目封装的 request 发送请求,确保自动携带 JWT Token
const res: any = await request({
url: '/v1/inbound/stock/export-stocktake',
method: 'get',
responseType: 'blob' as any, // 核心:接收二进制文件流
headers: {
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
})
});
// 2. 未盘点 Sheet
const missingData = allData.value.filter(i => !i.scanned).map(item => {
const key = item.source_table && item.stock_id ? `${item.source_table}_${item.stock_id}` : ''
const borrowedQty = borrowedQuantities.value[key] || 0
return {
'物品名称': item.name,
'类型': item.type || item.material_type || '-',
'类别': item.category || '-',
'规格型号': item.spec_model || item.standard || '-', // ★ 双重保险
'SKU': item.sku,
'批次/SN': item.serial_number || item.batch_no || '-',
'单位': item.unit || '个',
'单价': item.price || item.unit_price || 0,
'账面库存': parseFloat(item.qty_stock as any),
'借出未还数量': borrowedQty,
'状态': '未盘点'
}
})
const wb = XLSX.utils.book_new()
const ws1 = XLSX.utils.json_to_sheet(scannedData)
const ws2 = XLSX.utils.json_to_sheet(missingData)
const wscols = [
{wch: 20}, {wch: 10}, {wch: 10}, {wch: 15},
{wch: 15}, {wch: 15}, {wch: 5}, {wch: 8},
{wch: 8}, {wch: 8}, {wch: 8}, {wch: 8}, {wch: 8}
]
ws1['!cols'] = wscols
ws2['!cols'] = wscols
XLSX.utils.book_append_sheet(wb, ws1, "已盘点明细")
XLSX.utils.book_append_sheet(wb, ws2, "未盘点明细")
const fileName = `库存盘点报告_${new Date().toISOString().slice(0,10)}.xlsx`
XLSX.writeFile(wb, fileName)
ElMessage.success('Excel 报表已生成')
} catch (e) {
console.error(e)
ElMessage.error('导出失败,请检查 xlsx 插件是否安装')
// 触发静默下载
const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const dateStr = new Date().toISOString().split('T')[0];
link.download = `盘点差异报告_${dateStr}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
ElMessage.success('报告导出成功');
} catch (error) {
console.error('导出失败:', error);
ElMessage.error('导出失败,请重试');
}
}
@ -921,6 +949,12 @@ const openVarianceDialog = async () => {
// ★ 新增: 确认平账
const handleAdjust = async (row: any) => {
try {
// ===== 调试代码 =====
console.warn('---- 准备平账参数检查 ----');
console.warn('当前点击行的完整数据:', row);
console.warn(`将要发送的 draftId: ${row.id}, stockId: ${row.stock_id}, sourceTable: ${row.source_table}`);
// ===== 调试结束 =====
await ElMessageBox.confirm(
`确定要对 "${row.uuid}" 进行平账调整吗?\n\n差异: ${row.diff_qty > 0 ? '盘盈 +' : '盘亏 '}${row.diff_qty}`,
'确认平账',
@ -929,11 +963,12 @@ const handleAdjust = async (row: any) => {
const remark = `盘点差异调整 - ${row.diff_qty > 0 ? '盘盈入库' : '盘亏出库'}`
const res: any = await api.adjustStock(row.id, remark)
const res: any = await api.adjustStock(row.id, row.stock_id, row.diff_qty, row.source_table || 'stock_buy', remark)
ElMessage.success(res.message || '调整成功')
// 刷新差异列表
// 刷新数据并重新打开差异列表
await loadData()
await openVarianceDialog()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error(e?.message || '操作失败')

View File

@ -97,10 +97,17 @@
style="width: 100%"
size="large"
value-format="YYYY-MM-DD"
:disabled="isIndefinite"
:disabled-date="disabledDate"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-checkbox v-model="isIndefinite" @change="handleIndefiniteChange">
无限期/长期借用不设归还期限
</el-checkbox>
</el-col>
</el-row>
<el-form-item label="备注说明" prop="remark">
@ -192,7 +199,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onUnmounted } from 'vue'
import { ref, reactive, nextTick, onUnmounted, computed } from 'vue'
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'
@ -246,13 +253,21 @@ const form = reactive({
remark: ''
})
const rules = {
const rules = computed(() => ({
borrower_name: [
{ required: true, message: '请输入借用人姓名', trigger: 'blur' }
],
expected_return_time: [
{ required: true, message: '请选择预计归还日期', trigger: 'change' }
{ required: !isIndefinite.value, message: '请选择预计归还日期', trigger: 'change' }
]
}))
const isIndefinite = ref(false)
const handleIndefiniteChange = (val: boolean) => {
if (val) {
form.expected_return_time = ''
}
}
const disabledDate = (time: Date) => {
@ -352,6 +367,7 @@ const clearAll = () => {
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
isIndefinite.value = false
})
}
@ -362,7 +378,8 @@ const submitForm = async () => {
await formRef.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请填写完整的必填项(姓名、归还日期)')
const requiredMsg = isIndefinite.value ? '请填写完整的必填项(姓名)' : '请填写完整的必填项(姓名、归还日期)'
ElMessage.error(requiredMsg)
return
}
@ -378,12 +395,18 @@ 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
}
await request({
url: '/v1/transactions/borrow',
method: 'post',
data: {
items: cartItems.value,
...form,
...submitData,
signature_path: signatureUrl
}
})
@ -396,6 +419,7 @@ const submitForm = async () => {
signatureFile.value = null
signaturePreviewUrl.value = ''
showCamera.value = false
isIndefinite.value = false
} catch (error: any) {
console.error(error)

View File

@ -22,6 +22,22 @@
<el-table-column v-if="hasColumnPermission('borrow_no')" prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="100" />
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="借出数量" width="90" align="center">
<template #default="{row}">
<el-tag type="info">{{ row.quantity }}</el-tag>
</template>
</el-table-column>
<el-table-column label="已还数量" width="90" align="center">
<template #default="{row}">
<el-tag type="success">{{ row.returned_quantity || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="待还数量" width="90" align="center">
<template #default="{row}">
<el-tag v-if="row.pending_quantity > 0" type="warning">{{ row.pending_quantity }}</el-tag>
<el-tag v-else type="success">0</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('borrow_time')" prop="borrow_time" label="借出时间" width="160" sortable />
<el-table-column v-if="hasColumnPermission('expected_return_time') || hasColumnPermission('return_time')" label="归还时间 / 预计" min-width="200">
@ -30,6 +46,9 @@
<el-tag type="success" size="small">实际</el-tag>
{{ row.return_time || '-' }}
</div>
<div v-else-if="!row.expected_return_time">
<el-tag type="primary" size="small">无限期</el-tag>
</div>
<div v-else>
<el-tag type="info" size="small">预计</el-tag>
{{ formatExpectedTime(row.expected_return_time).text }}
@ -42,9 +61,9 @@
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{row}">
<el-tag :type="row.status==='returned'?'success':'warning'">
{{ row.status==='returned'?'已还':'借出中' }}
</el-tag>
<el-tag v-if="row.status === 'returned'" type="success">已还</el-tag>
<el-tag v-else-if="row.status === 'partial_returned'" type="warning">部分归还</el-tag>
<el-tag v-else type="danger">未还</el-tag>
</template>
</el-table-column>
@ -147,7 +166,7 @@ const handlePage = (val: number) => {
// ★ 新增:格式化预计归还时间及倒计时逻辑
const formatExpectedTime = (timeStr: string) => {
if (!timeStr) return { text: '-', diffText: '', cssClass: '' }
if (!timeStr) return { text: '无限期', diffText: '', cssClass: '' }
// 后端返回的可能是 YYYY-MM-DD HH:mm:ss我们只取日期部分比较
const expected = dayjs(timeStr).startOf('day')

View File

@ -47,6 +47,33 @@
<el-table :data="returnList" border stripe style="width: 100%">
<el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="90" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="借出数量" width="80" align="center">
<template #default="{row}">
<el-tag type="info">{{ row.quantity }}</el-tag>
</template>
</el-table-column>
<el-table-column label="已还数量" width="80" align="center">
<template #default="{row}">
<el-tag type="success">{{ row.returned_quantity || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="待还数量" width="80" align="center">
<template #default="{row}">
<el-tag type="warning">{{ row.pending_quantity }}</el-tag>
</template>
</el-table-column>
<el-table-column label="本次归还数" width="120" align="center">
<template #default="{row}">
<el-input-number
v-model="row.return_qty"
:min="1"
:max="row.pending_quantity"
size="small"
:disabled="!userStore.hasPermission('op_return:operation')"
/>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('return_location')" label="归还库位(可改)" min-width="160">
<template #default="{row}">
@ -253,6 +280,8 @@ const scanItem = async () => {
const item = res.data
// 默认将归还库位填为当前库位
item.return_location = item.current_location || ''
// 默认归还数量为待还数量
item.return_qty = item.pending_quantity
returnList.value.push(item)
barcode.value = ''