Compare commits

6 Commits

12 changed files with 133 additions and 48 deletions

View File

@ -27,6 +27,9 @@ UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
# 允许上传的文件后缀 # 允许上传的文件后缀
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx'}
# ★ 文件上传安全加固:限制最大文件大小 (10MB)
MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # 10MB
def allowed_file(filename): def allowed_file(filename):
return '.' in filename and \ return '.' in filename and \
@ -58,6 +61,16 @@ def upload_file():
if file.filename == '': if file.filename == '':
return jsonify({"code": 400, "msg": "未选择文件"}), 400 return jsonify({"code": 400, "msg": "未选择文件"}), 400
# ★ 文件上传安全加固:检查文件大小
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0) # 重置文件指针到开头
if file_size > MAX_CONTENT_LENGTH:
return jsonify({
"code": 400,
"msg": f"文件大小超过限制 ({MAX_CONTENT_LENGTH // (1024*1024)}MB)"
}), 400
if file and allowed_file(file.filename): if file and allowed_file(file.filename):
try: try:
# 获取后缀并生成唯一文件名 # 获取后缀并生成唯一文件名

View File

@ -3,6 +3,7 @@ from app.extensions import db, beijing_time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity
from app.utils.decorators import permission_required from app.utils.decorators import permission_required
from sqlalchemy.orm import joinedload
import uuid as uuid_module import uuid as uuid_module
import io import io
from openpyxl import Workbook from openpyxl import Workbook
@ -715,8 +716,12 @@ def export_stocktake():
1. 盘点差异明细 (diff_qty != 0) 1. 盘点差异明细 (diff_qty != 0)
2. 账实相符明细 (diff_qty == 0) 2. 账实相符明细 (diff_qty == 0)
3. 外借在用资产明细 (未归还的借出记录) 3. 外借在用资产明细 (未归还的借出记录)
4. 未盘点明细(疑似漏盘)
""" """
try: try:
# ★ 获取 session_id 参数,用于过滤当前会话的扫描记录
session_id = request.args.get('session_id', '', type=str)
# 创建工作簿 # 创建工作簿
wb = Workbook() wb = Workbook()
wb.remove(wb.active) wb.remove(wb.active)
@ -932,12 +937,17 @@ def export_stocktake():
# ===== Sheet 5: 未盘点明细(疑似漏盘) ===== # ===== Sheet 5: 未盘点明细(疑似漏盘) =====
# 逻辑:获取已盘点的集合,遍历库存表,找出未盘点且有库存的物资 # 逻辑:获取已盘点的集合,遍历库存表,找出未盘点且有库存的物资
ws5 = wb.create_sheet("未盘点明细(疑似漏盘)") ws5 = wb.create_sheet("未盘点明细(疑似漏盘)")
unscanned_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "状态"] unscanned_headers = ["物料名称", "SKU", "规格型号", "库位", "批号", "调整后账面数", "实盘数", "差异数", "状态"]
set_header_row(ws5, unscanned_headers) set_header_row(ws5, unscanned_headers)
# 获取已盘点的 (source_table, stock_id) 集合 # 获取已盘点的 (source_table, stock_id) 集合
all_drafts = StocktakeDraft.query.all() # ★ 修复:只查询当前 session_id 的扫描记录,避免历史记录干扰
scanned_set = {(d.source_table, d.stock_id) for d in all_drafts} if session_id:
session_drafts = StocktakeDraft.query.filter_by(session_id=session_id).all()
else:
# 如果没有传 session_id使用所有记录兼容旧行为
session_drafts = StocktakeDraft.query.all()
scanned_set = {(d.source_table, d.stock_id) for d in session_drafts}
def get_borrowed_qty(source_table, stock_id): def get_borrowed_qty(source_table, stock_id):
"""获取某库存的借出未还数量""" """获取某库存的借出未还数量"""
@ -954,24 +964,33 @@ def export_stocktake():
unscanned_items = [] unscanned_items = []
# 遍历 StockBuy # ★ 修复 N+1 查询:使用 joinedload 预加载 base 关系,同时过滤 stock_quantity > 0
for stock in StockBuy.query.all(): for stock in StockBuy.query.filter(StockBuy.stock_quantity > 0).options(joinedload(StockBuy.base)).all():
key = ('stock_buy', stock.id) key = ('stock_buy', stock.id)
if key in scanned_set: if key in scanned_set:
continue continue
stock_qty = float(stock.stock_quantity or 0)
if stock_qty <= 0:
continue
# 扣除外借数量 # 扣除外借数量
borrowed_qty = get_borrowed_qty('stock_buy', stock.id) borrowed_qty = get_borrowed_qty('stock_buy', stock.id)
stock_qty = float(stock.stock_quantity or 0)
expected_qty = stock_qty - borrowed_qty expected_qty = stock_qty - borrowed_qty
if expected_qty > 0: if expected_qty > 0:
mat_info = get_material_info('stock_buy', stock.id) # ★ 直接使用预加载的 base 关系,避免额外查询
material = stock.base
# ★ 安全提取批号/序列号:使用 getattr 降级链
batch_sn = getattr(stock, 'batch_number', None) or getattr(stock, 'sn', None) or getattr(stock, 'serial_number', None) or '-'
mat_info = {
'name': material.name if material else '-',
'sku': getattr(stock, 'sku', None) or '-',
'spec': getattr(material, 'spec_model', None) if material else '-',
'location': getattr(stock, 'warehouse_location', None) or '-',
'batch_no': batch_sn
}
unscanned_items.append({ unscanned_items.append({
'name': mat_info['name'], 'name': mat_info['name'],
'sku': mat_info['sku'], 'sku': mat_info['sku'],
'spec': mat_info['spec'], 'spec': mat_info['spec'],
'location': mat_info['location'], 'location': mat_info['location'],
'batch_no': mat_info['batch_no'],
'stock_qty': expected_qty, 'stock_qty': expected_qty,
'actual_qty': 0, 'actual_qty': 0,
'diff_qty': -expected_qty, 'diff_qty': -expected_qty,
@ -980,22 +999,31 @@ def export_stocktake():
# 遍历 StockSemi # 遍历 StockSemi
if StockSemi: if StockSemi:
for stock in StockSemi.query.all(): for stock in StockSemi.query.filter(StockSemi.stock_quantity > 0).options(joinedload(StockSemi.base)).all():
key = ('stock_semi', stock.id) key = ('stock_semi', stock.id)
if key in scanned_set: if key in scanned_set:
continue continue
stock_qty = float(stock.stock_quantity or 0)
if stock_qty <= 0:
continue
borrowed_qty = get_borrowed_qty('stock_semi', stock.id) borrowed_qty = get_borrowed_qty('stock_semi', stock.id)
stock_qty = float(stock.stock_quantity or 0)
expected_qty = stock_qty - borrowed_qty expected_qty = stock_qty - borrowed_qty
if expected_qty > 0: if expected_qty > 0:
mat_info = get_material_info('stock_semi', stock.id) # ★ 直接使用预加载的 base 关系,避免额外查询
material = stock.base
# ★ 安全提取批号/序列号:使用 getattr 降级链
batch_sn = getattr(stock, 'batch_number', None) or getattr(stock, 'sn', None) or getattr(stock, 'serial_number', None) or '-'
mat_info = {
'name': material.name if material else '-',
'sku': getattr(stock, 'sku', None) or '-',
'spec': getattr(material, 'spec_model', None) if material else '-',
'location': getattr(stock, 'warehouse_location', None) or '-',
'batch_no': batch_sn
}
unscanned_items.append({ unscanned_items.append({
'name': mat_info['name'], 'name': mat_info['name'],
'sku': mat_info['sku'], 'sku': mat_info['sku'],
'spec': mat_info['spec'], 'spec': mat_info['spec'],
'location': mat_info['location'], 'location': mat_info['location'],
'batch_no': mat_info['batch_no'],
'stock_qty': expected_qty, 'stock_qty': expected_qty,
'actual_qty': 0, 'actual_qty': 0,
'diff_qty': -expected_qty, 'diff_qty': -expected_qty,
@ -1004,7 +1032,7 @@ def export_stocktake():
# 遍历 StockProduct # 遍历 StockProduct
if StockProduct: if StockProduct:
for stock in StockProduct.query.all(): for stock in StockProduct.query.filter(StockProduct.stock_quantity > 0).options(joinedload(StockProduct.base)).all():
key = ('stock_product', stock.id) key = ('stock_product', stock.id)
if key in scanned_set: if key in scanned_set:
continue continue
@ -1014,12 +1042,23 @@ def export_stocktake():
borrowed_qty = get_borrowed_qty('stock_product', stock.id) borrowed_qty = get_borrowed_qty('stock_product', stock.id)
expected_qty = stock_qty - borrowed_qty expected_qty = stock_qty - borrowed_qty
if expected_qty > 0: if expected_qty > 0:
mat_info = get_material_info('stock_product', stock.id) # ★ 直接使用预加载的 base 关系,避免额外查询
material = stock.base
# ★ 安全提取批号/序列号:使用 getattr 降级链 (成品可能无此字段)
batch_sn = getattr(stock, 'batch_number', None) or getattr(stock, 'sn', None) or getattr(stock, 'serial_number', None) or '-'
mat_info = {
'name': material.name if material else '-',
'sku': getattr(stock, 'sku', None) or '-',
'spec': getattr(material, 'spec_model', None) if material else '-',
'location': getattr(stock, 'warehouse_location', None) or '-',
'batch_no': batch_sn
}
unscanned_items.append({ unscanned_items.append({
'name': mat_info['name'], 'name': mat_info['name'],
'sku': mat_info['sku'], 'sku': mat_info['sku'],
'spec': mat_info['spec'], 'spec': mat_info['spec'],
'location': mat_info['location'], 'location': mat_info['location'],
'batch_no': mat_info['batch_no'],
'stock_qty': expected_qty, 'stock_qty': expected_qty,
'actual_qty': 0, 'actual_qty': 0,
'diff_qty': -expected_qty, 'diff_qty': -expected_qty,
@ -1033,10 +1072,11 @@ def export_stocktake():
ws5.cell(row=row_idx, column=2, value=item['sku']).border = thin_border ws5.cell(row=row_idx, column=2, value=item['sku']).border = thin_border
ws5.cell(row=row_idx, column=3, value=item['spec']).border = thin_border ws5.cell(row=row_idx, column=3, value=item['spec']).border = thin_border
ws5.cell(row=row_idx, column=4, value=item['location']).border = thin_border ws5.cell(row=row_idx, column=4, value=item['location']).border = thin_border
ws5.cell(row=row_idx, column=5, value=float(item['stock_qty'])).border = thin_border ws5.cell(row=row_idx, column=5, value=item.get('batch_no', '-')).border = thin_border # ★ 批号
ws5.cell(row=row_idx, column=6, value=float(item['actual_qty'])).border = thin_border ws5.cell(row=row_idx, column=6, value=float(item['stock_qty'])).border = thin_border
ws5.cell(row=row_idx, column=7, value=float(item['diff_qty'])).border = thin_border ws5.cell(row=row_idx, column=7, value=float(item['actual_qty'])).border = thin_border
ws5.cell(row=row_idx, column=8, value=item['status']).border = thin_border ws5.cell(row=row_idx, column=8, value=float(item['diff_qty'])).border = thin_border
ws5.cell(row=row_idx, column=9, value=item['status']).border = thin_border
# 同时写入 Sheet 1 (汇总表) - 盘点人和时间留空 # 同时写入 Sheet 1 (汇总表) - 盘点人和时间留空
ws1.cell(row=master_row_idx, column=1, value=item['name']).border = thin_border ws1.cell(row=master_row_idx, column=1, value=item['name']).border = thin_border
ws1.cell(row=master_row_idx, column=2, value=item['sku']).border = thin_border ws1.cell(row=master_row_idx, column=2, value=item['sku']).border = thin_border
@ -1091,15 +1131,22 @@ def export_stocktake():
def generate_missing_stocktake(): def generate_missing_stocktake():
""" """
生成漏盘数据: 生成漏盘数据:
找出所有真实库存 > 0但未被盘点扫描到的物料 找出所有真实库存 > 0但未被当前会话盘点扫描到的物料,
自动生成盘点草稿,标记为盘亏(实盘=0差异=-库存数) 自动生成盘点草稿,标记为盘亏(实盘=0差异=-库存数)
""" """
try: try:
# 1. 获取所有已有盘点记录的 (source_table, stock_id) 集合 # 获取 session_id 参数,用于隔离当前会话
data = request.get_json() or {}
session_id = data.get('session_id', '')
if not session_id:
return jsonify({'code': 400, 'msg': '缺少 session_id 参数'}), 400
# 1. 获取当前会话已有盘点记录的 (source_table, stock_id) 集合
existing_records = db.session.query( existing_records = db.session.query(
StocktakeDraft.source_table, StocktakeDraft.source_table,
StocktakeDraft.stock_id StocktakeDraft.stock_id
).distinct().all() ).filter(StocktakeDraft.session_id == session_id).distinct().all()
scanned_keys = set() scanned_keys = set()
for src_table, stock_id in existing_records: for src_table, stock_id in existing_records:
@ -1205,6 +1252,8 @@ def get_all_stocktake_items():
'id': item.id, 'id': item.id,
'sku': item.sku or '', 'sku': item.sku or '',
'barcode': item.barcode or '', 'barcode': item.barcode or '',
# ★ 安全提取批号/序列号:使用 getattr 降级
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
'material_name': item.base.name if item.base else '', 'material_name': item.base.name if item.base else '',
'spec_model': item.base.spec_model if item.base else '', 'spec_model': item.base.spec_model if item.base else '',
'stock_qty': float(item.stock_quantity or 0), 'stock_qty': float(item.stock_quantity or 0),
@ -1229,6 +1278,8 @@ def get_all_stocktake_items():
'id': item.id, 'id': item.id,
'sku': item.sku or '', 'sku': item.sku or '',
'barcode': item.barcode or '', 'barcode': item.barcode or '',
# ★ 安全提取批号/序列号:使用 getattr 降级
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
'material_name': item.base.name if item.base else '', 'material_name': item.base.name if item.base else '',
'spec_model': item.base.spec_model if item.base else '', 'spec_model': item.base.spec_model if item.base else '',
'stock_qty': float(item.stock_quantity or 0), 'stock_qty': float(item.stock_quantity or 0),
@ -1253,6 +1304,8 @@ def get_all_stocktake_items():
'id': item.id, 'id': item.id,
'sku': item.sku or '', 'sku': item.sku or '',
'barcode': item.barcode or '', 'barcode': item.barcode or '',
# ★ 安全提取批号/序列号:使用 getattr 降级 (成品无此字段则为空)
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
'material_name': item.base.name if item.base else '', 'material_name': item.base.name if item.base else '',
'spec_model': item.base.spec_model if item.base else '', 'spec_model': item.base.spec_model if item.base else '',
'stock_qty': float(item.stock_quantity or 0), 'stock_qty': float(item.stock_quantity or 0),

View File

@ -132,7 +132,8 @@ def create():
if not StockModel: if not StockModel:
return jsonify({'code': 400, 'msg': '无效的库存类型'}), 400 return jsonify({'code': 400, 'msg': '无效的库存类型'}), 400
stock = StockModel.query.get(stock_id) # ★ 修复并发冲突:使用悲观锁锁定行,防止多人同时修改导致的数据覆盖
stock = StockModel.query.filter(StockModel.id == stock_id).with_for_update().first()
if not stock: if not stock:
return jsonify({'code': 404, 'msg': '库存记录不存在'}), 404 return jsonify({'code': 404, 'msg': '库存记录不存在'}), 404

View File

@ -205,18 +205,18 @@ def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target
username = claims.get('username', '') username = claims.get('username', '')
display_name = claims.get('display_name', '') display_name = claims.get('display_name', '')
# 兜底:如果 display_name 为空,查询数据库获取 # ★ 修复 DetachedInstanceError在 fn() 执行前预先获取用户完整信息
# 这样可以避免在 fn() 提交 session 后再访问 User 对象导致游离
if not display_name and user_id: if not display_name and user_id:
try: try:
from app.models.system import SysUser from app.models.system import SysUser
user = SysUser.query.get(user_id) user = SysUser.query.get(user_id)
if user: if user:
user_info = user.to_dict() display_name = user.display_name or username
display_name = user_info.get('display_name', username)
except Exception: except Exception:
pass pass
# 获取IP # 预先获取 IP(避免后续访问 request 对象异常)
ip_address = request.headers.get('X-Forwarded-For') or request.remote_addr or '' ip_address = request.headers.get('X-Forwarded-For') or request.remote_addr or ''
if ip_address and ',' in ip_address: if ip_address and ',' in ip_address:
ip_address = ip_address.split(',')[0].strip() ip_address = ip_address.split(',')[0].strip()
@ -235,7 +235,7 @@ def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target
raw_payload = _get_payload() raw_payload = _get_payload()
filtered_payload = _filter_payload(raw_payload) if raw_payload else None filtered_payload = _filter_payload(raw_payload) if raw_payload else None
# 执行原函数 # 执行原函数(此时 Session 可能被提交或回滚)
response = fn(*args, **kwargs) response = fn(*args, **kwargs)
# 只记录成功的请求(响应状态码 200/201 # 只记录成功的请求(响应状态码 200/201
@ -249,6 +249,9 @@ def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target
from app.extensions import db from app.extensions import db
from flask import current_app from flask import current_app
# ★ 已在上方预先获取 display_name此处无需再查询 User 对象
# 使用预先获取的字符串数据,避免 DetachedInstanceError
# 获取 target_id # 获取 target_id
target_id = None target_id = None
if get_target_id_fn: if get_target_id_fn:

View File

@ -3,6 +3,7 @@
v-model="visible" v-model="visible"
title="智能扫码" title="智能扫码"
width="600px" width="600px"
destroy-on-close
:close-on-click-modal="false" :close-on-click-modal="false"
@close="handleClose" @close="handleClose"
> >

View File

@ -45,7 +45,7 @@
</el-card> </el-card>
<!-- 打印机配置弹窗 --> <!-- 打印机配置弹窗 -->
<el-dialog v-model="printerDialogVisible" title="打印机 IP 配置" width="500px"> <el-dialog v-model="printerDialogVisible" title="打印机 IP 配置" width="500px" destroy-on-close>
<el-form :model="printerForm" label-width="120px"> <el-form :model="printerForm" label-width="120px">
<el-form-item label="标签打印机 IP"> <el-form-item label="标签打印机 IP">
<el-input v-model="printerForm.label_ip" placeholder="例如 192.168.9.221" /> <el-input v-model="printerForm.label_ip" placeholder="例如 192.168.9.221" />
@ -69,7 +69,7 @@
</el-dialog> </el-dialog>
<!-- 库位管理弹窗 --> <!-- 库位管理弹窗 -->
<el-dialog v-model="warehouseDialogVisible" title="库位管理" width="700px" :close-on-click-modal="false"> <el-dialog v-model="warehouseDialogVisible" title="库位管理" width="700px" destroy-on-close :close-on-click-modal="false">
<div class="warehouse-dialog"> <div class="warehouse-dialog">
<div class="warehouse-header"> <div class="warehouse-header">
<!-- 非批量模式 --> <!-- 非批量模式 -->
@ -145,7 +145,7 @@
</el-dialog> </el-dialog>
<!-- 新增/编辑库位弹窗 --> <!-- 新增/编辑库位弹窗 -->
<el-dialog v-model="locationFormVisible" :title="locationFormTitle" width="400px"> <el-dialog v-model="locationFormVisible" :title="locationFormTitle" width="400px" destroy-on-close>
<el-form :model="locationForm" label-width="80px"> <el-form :model="locationForm" label-width="80px">
<el-form-item label="上级库位"> <el-form-item label="上级库位">
<el-input :value="locationForm.parentName" disabled /> <el-input :value="locationForm.parentName" disabled />
@ -164,7 +164,7 @@
</el-dialog> </el-dialog>
<!-- 批量生成库位弹窗 --> <!-- 批量生成库位弹窗 -->
<el-dialog v-model="batchGenerateVisible" title="批量生成库位" width="800px"> <el-dialog v-model="batchGenerateVisible" title="批量生成库位" width="800px" destroy-on-close>
<el-form :model="batchGenerateForm" label-width="100px"> <el-form :model="batchGenerateForm" label-width="100px">
<el-form-item label="父级库位"> <el-form-item label="父级库位">
<el-input :value="batchGenerateForm.parentName" disabled /> <el-input :value="batchGenerateForm.parentName" disabled />

View File

@ -194,13 +194,14 @@
:data="tableData" :data="tableData"
border border
stripe stripe
row-key="id"
:size="tableSize" :size="tableSize"
:row-class-name="tableRowClassName" :row-class-name="tableRowClassName"
@sort-change="handleSortChange" @sort-change="handleSortChange"
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
style="width: 100%; margin-top: 15px" style="width: 100%; margin-top: 15px"
> >
<el-table-column v-if="isBatchMode" type="selection" width="55" /> <el-table-column v-if="isBatchMode" type="selection" width="55" :reserve-selection="true" />
<el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" /> <el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" />
<el-table-column v-if="columns.companyName.visible" prop="companyName" label="所属公司" min-width="100" align="center" show-overflow-tooltip sortable="custom"> <el-table-column v-if="columns.companyName.visible" prop="companyName" label="所属公司" min-width="100" align="center" show-overflow-tooltip sortable="custom">
@ -323,6 +324,7 @@
:title="dialog.title" :title="dialog.title"
width="700px" width="700px"
append-to-body append-to-body
destroy-on-close
@close="cancel" @close="cancel"
:close-on-click-modal="!isUploading" :close-on-click-modal="!isUploading"
:close-on-press-escape="!isUploading" :close-on-press-escape="!isUploading"

View File

@ -86,7 +86,7 @@
/> />
<!-- 新增调整单弹窗 --> <!-- 新增调整单弹窗 -->
<el-dialog v-model="showDialog" title="新增盘盈盘亏调整单" width="700px" :close-on-click-modal="false"> <el-dialog v-model="showDialog" title="新增盘盈盘亏调整单" width="700px" destroy-on-close :close-on-click-modal="false">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px"> <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="选择物料" prop="stock_id"> <el-form-item label="选择物料" prop="stock_id">
<el-select <el-select
@ -128,7 +128,7 @@
</el-dialog> </el-dialog>
<!-- 物料选择弹窗 --> <!-- 物料选择弹窗 -->
<el-dialog v-model="showStockDialog" title="选择物料" width="800px"> <el-dialog v-model="showStockDialog" title="选择物料" width="800px" destroy-on-close>
<div class="filter-container" style="margin-bottom: 15px"> <div class="filter-container" style="margin-bottom: 15px">
<el-input v-model="stockKeyword" placeholder="搜索SKU/条码" style="width: 200px" @keyup.enter="fetchStocks" clearable /> <el-input v-model="stockKeyword" placeholder="搜索SKU/条码" style="width: 200px" @keyup.enter="fetchStocks" clearable />
<el-button type="primary" @click="fetchStocks">搜索</el-button> <el-button type="primary" @click="fetchStocks">搜索</el-button>
@ -152,7 +152,7 @@
</el-dialog> </el-dialog>
<!-- 盘点差异审核弹窗 --> <!-- 盘点差异审核弹窗 -->
<el-dialog v-model="showReviewDialog" title="盘点差异审核" width="1200px" :close-on-click-modal="false"> <el-dialog v-model="showReviewDialog" title="盘点差异审核" width="1200px" destroy-on-close :close-on-click-modal="false">
<div v-loading="reviewLoading"> <div v-loading="reviewLoading">
<!-- 搜索栏 --> <!-- 搜索栏 -->
<div style="margin-bottom: 16px; display: flex; gap: 12px;"> <div style="margin-bottom: 16px; display: flex; gap: 12px;">
@ -171,8 +171,8 @@
<el-button type="primary" @click="handleReviewSearch">搜索</el-button> <el-button type="primary" @click="handleReviewSearch">搜索</el-button>
</div> </div>
<el-table :data="reviewList" border stripe ref="reviewTableRef" @selection-change="handleReviewSelectionChange"> <el-table :data="reviewList" border stripe ref="reviewTableRef" row-key="id" @selection-change="handleReviewSelectionChange">
<el-table-column type="selection" width="50" /> <el-table-column type="selection" width="50" :reserve-selection="true" />
<el-table-column prop="sku" label="SKU" width="140" /> <el-table-column prop="sku" label="SKU" width="140" />
<el-table-column prop="material_name" label="物料名称" min-width="150" show-overflow-tooltip /> <el-table-column prop="material_name" label="物料名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" width="120" show-overflow-tooltip /> <el-table-column prop="spec_model" label="规格" width="120" show-overflow-tooltip />
@ -226,7 +226,7 @@
</el-dialog> </el-dialog>
<!-- 处理调整单弹窗 --> <!-- 处理调整单弹窗 -->
<el-dialog v-model="showProcessDialog" title="处理调整单" width="500px" :close-on-click-modal="false"> <el-dialog v-model="showProcessDialog" title="处理调整单" width="500px" destroy-on-close :close-on-click-modal="false">
<el-form label-width="120px"> <el-form label-width="120px">
<el-form-item label="调整单号"> <el-form-item label="调整单号">
<el-input v-model="processForm.order_no" disabled /> <el-input v-model="processForm.order_no" disabled />

View File

@ -84,6 +84,7 @@
v-model="dialogVisible" v-model="dialogVisible"
:title="dialogTitle" :title="dialogTitle"
width="700px" width="700px"
destroy-on-close
@close="resetDialog" @close="resetDialog"
> >
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">

View File

@ -121,6 +121,7 @@
v-model="showConfirmDialog" v-model="showConfirmDialog"
title="⚠️ 确认清除盘点数据" title="⚠️ 确认清除盘点数据"
width="400" width="400"
destroy-on-close
:close-on-click-modal="false" :close-on-click-modal="false"
:close-on-press-escape="false" :close-on-press-escape="false"
show-close show-close
@ -234,10 +235,10 @@
<div class="table-container"> <div class="table-container">
<el-table <el-table
:data="filteredListData" :data="filteredListData"
height="100%" height="600"
stripe stripe
border border
row-key="id" row-key="uniqueKey"
style="width: 100%" style="width: 100%"
> >
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip /> <el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
@ -659,7 +660,7 @@ const resumeSession = async () => {
const res: any = await request({ const res: any = await request({
url: '/v1/inbound/stock/draft/list', url: '/v1/inbound/stock/draft/list',
method: 'get', method: 'get',
params: { page: 1, limit: 10000 } // 获取足够多的数据 params: { page: 1, limit: 500 } // ★ 限制单次加载数量,防止内存溢出
}) })
const drafts = res && res.items ? res.items : [] const drafts = res && res.items ? res.items : []
@ -916,9 +917,11 @@ const exportToExcel = async () => {
// ===== 调试结束 ===== // ===== 调试结束 =====
ElMessage.info('正在生成盘点报告,请稍候...'); ElMessage.info('正在生成盘点报告,请稍候...');
// ★ 传递 session_id 参数,用于导出当前会话的未盘点明细
const sessionParam = currentSessionId.value ? `?session_id=${encodeURIComponent(currentSessionId.value)}` : '';
// 使用项目封装的 request 发送请求,确保自动携带 JWT Token // 使用项目封装的 request 发送请求,确保自动携带 JWT Token
const res: any = await request({ const res: any = await request({
url: '/v1/inbound/stock/export-stocktake', url: '/v1/inbound/stock/export-stocktake' + sessionParam,
method: 'get', method: 'get',
responseType: 'blob' as any, // 核心:接收二进制文件流 responseType: 'blob' as any, // 核心:接收二进制文件流
headers: { headers: {
@ -982,12 +985,15 @@ const fetchInventoryList = async (silent = false) => {
method: 'get', method: 'get',
params: { params: {
page: 1, page: 1,
limit: 10000, // 获取全部已盘点记录 limit: 500, // ★ 限制单次加载数量,防止内存溢出
keyword: listKeyword.value keyword: listKeyword.value,
session_id: currentSessionId.value // ★ 必须传递 session_id 隔离会话
} }
}) })
const scannedDrafts = res?.items || [] const scannedDrafts = res?.items || []
// ★ 使用返回的 total 获取真实已盘数量,而不是受限的数组长度
const totalScanned = res?.total || scannedDrafts.length
// 保存全量草稿记录用于全局统计 // 保存全量草稿记录用于全局统计
allScannedDrafts.value = scannedDrafts allScannedDrafts.value = scannedDrafts
@ -1004,6 +1010,7 @@ const fetchInventoryList = async (silent = false) => {
id: draft?.id || null, id: draft?.id || null,
stock_id: item.id, stock_id: item.id,
source_table: item.source_table, source_table: item.source_table,
uniqueKey: `${item.source_table}_${item.id}`, // ★ 绝对唯一键解决row-key冲突
sku: item.sku, sku: item.sku,
material_name: item.material_name, material_name: item.material_name,
spec_model: item.spec_model, spec_model: item.spec_model,
@ -1129,7 +1136,10 @@ const handleGenerateMissing = async () => {
btnLoading.value = true btnLoading.value = true
const res = await request({ const res = await request({
url: '/v1/inbound/stock/stocktake/generate-missing', url: '/v1/inbound/stock/stocktake/generate-missing',
method: 'post' method: 'post',
data: {
session_id: currentSessionId.value // ★ 必须传递 session_id 隔离会话
}
}) })
if (res.code === 200) { if (res.code === 200) {

View File

@ -85,7 +85,7 @@
</el-card> </el-card>
<!-- 详情弹窗 --> <!-- 详情弹窗 -->
<el-dialog v-model="detailDialogVisible" title="操作详情" width="700px"> <el-dialog v-model="detailDialogVisible" title="操作详情" width="700px" destroy-on-close>
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item> <el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ currentLog.username }} ({{ currentLog.display_name }})</el-descriptions-item> <el-descriptions-item label="操作人">{{ currentLog.username }} ({{ currentLog.display_name }})</el-descriptions-item>

View File

@ -59,6 +59,7 @@
v-model="dialogVisible" v-model="dialogVisible"
:title="isEdit ? '编辑员工账号' : '新增员工账号'" :title="isEdit ? '编辑员工账号' : '新增员工账号'"
width="500px" width="500px"
destroy-on-close
@close="resetForm" @close="resetForm"
> >
<el-form <el-form