13 Commits

Author SHA1 Message Date
859d783b5c V3.72,9.7推送 2026-09-07 14:02:55 +08:00
478b90fe50 feat: 出库类型贯穿申请→扫码出库——申请单加 outbound_type,申请时选类型、扫码出库自动带出 2026-09-07 14:01:13 +08:00
eaafeaf24e feat: 出库类型新增「维修出库(REPAIR)」下拉选项与记录类型/标签映射 2026-09-07 13:51:38 +08:00
73510d3a71 feat: 借还记录默认排序重构——有限期单按预计归还时间升序靠前,无限期单按借出时间升序靠后(优先关注快到期/逾期) 2026-09-04 16:17:42 +08:00
b274ed1ed1 feat: TransBorrow 归还人 to_dict 自动把数字 user id 映射为用户名(历史记录不再显示数字,lru_cache 防 N+1) 2026-09-04 16:12:36 +08:00
02f36b383c fix: 借库归还/报废/借库执行操作人由 JWT 数字 ID 改为用户名(归还人不再显示数字) 2026-09-04 16:10:00 +08:00
808e491fe8 fix: BOM 匹配库存(bom_match_stock)加行级公司隔离(三表按 base 公司过滤,超管/跨域不受限) 2026-09-04 15:57:14 +08:00
b40aa3ab90 fix: 出库选单手动添加库存(/v1/inbound/stock/list)加行级公司隔离(三表按 base 公司过滤,超管/跨域不受限) 2026-09-04 15:55:24 +08:00
2f6027b43c fix: 采购列表字段过滤后补回 base_id,恢复前端批号自动叠加(与 semi 列表一致,修 LICA 同物料二次入库拦截) 2026-09-04 15:33:17 +08:00
b6ecde11dd refactor: warehouse/tree 移除临时打点日志,保留 O(N) build_tree 优化 2026-09-04 15:19:00 +08:00
7e3fc11bf5 chore: warehouse/tree 接口加 3 阶段耗时打点日志(查库/build_tree/序列化) 2026-09-04 15:16:20 +08:00
bc6e97ae97 perf: warehouse build_tree 改为 Map 分组内存组装,消除 O(N²) 递归全扫(tree TTFB 1.7s→0.08s) 2026-09-04 15:10:38 +08:00
3bd19c1ab5 feat: 借库审批支持手动完结——已通过(status=1)单可强制完结,库管/主管/超管可见 2026-09-04 14:52:31 +08:00
15 changed files with 212 additions and 37 deletions

View File

@ -103,7 +103,16 @@ def get_list():
# 字段级脱敏
user_permissions = get_current_user_permissions()
if result.get('items'):
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
# ★ 字段权限过滤会剥离 base_id,但前端扫码/选物料联动需要它匹配历史批号(自动叠加)
# 在过滤前保留 base_id,过滤后补回(非敏感字段,仅物料关联 ID,与 semi 列表一致)
restored = []
for item in result['items']:
base_id = item.get('base_id')
filtered = filter_item_by_permissions(item, user_permissions)
if base_id is not None:
filtered['base_id'] = base_id
restored.append(filtered)
result['items'] = restored
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()

View File

@ -293,11 +293,17 @@ def _do_get_stock_list(permission_prefix=None):
# ★ Fail-Closed: 选单/借用场景默认剥离所有价格成本字段
_strip_price_fields = _make_price_stripper(permission_prefix)
# ★ 行级公司隔离:普通用户只能看到本公司的库存(超管/跨域不受限)
from app.utils.decorators import get_current_company_filter
company_limit = get_current_company_filter()
all_items = []
# 1. 采购件
if StockBuy:
q = StockBuy.query.filter(StockBuy.stock_quantity > 0)
if company_limit is not None:
q = q.filter(StockBuy.base.has(MaterialBase.company_name == company_limit))
if keyword:
q = q.filter(
db.or_(
@ -322,6 +328,8 @@ def _do_get_stock_list(permission_prefix=None):
if StockSemi:
try:
q = StockSemi.query.filter(StockSemi.stock_quantity > 0)
if company_limit is not None:
q = q.filter(StockSemi.base.has(MaterialBase.company_name == company_limit))
if keyword:
q = q.filter(
db.or_(
@ -347,6 +355,8 @@ def _do_get_stock_list(permission_prefix=None):
# 3. 成品
if StockProduct:
q = StockProduct.query.filter(StockProduct.stock_quantity > 0)
if company_limit is not None:
q = q.filter(StockProduct.base.has(MaterialBase.company_name == company_limit))
if keyword:
q = q.filter(
db.or_(

View File

@ -215,6 +215,11 @@ def bom_match_stock():
# 去重
child_ids = list(set(int(x) for x in child_ids))
# ★ 行级公司隔离:普通用户只能匹配本公司的库存(超管/跨域不受限)
from app.utils.decorators import get_current_company_filter
from app.models.base import MaterialBase
company_limit = get_current_company_filter()
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
@ -226,7 +231,10 @@ def bom_match_stock():
buy_items = StockBuy.query.filter(
StockBuy.base_id.in_(child_ids),
StockBuy.stock_quantity > 0
).options(joinedload(StockBuy.base)).all()
)
if company_limit is not None:
buy_items = buy_items.filter(StockBuy.base.has(MaterialBase.company_name == company_limit))
buy_items = buy_items.options(joinedload(StockBuy.base)).all()
for s in buy_items:
d = s.to_dict()
d['type'] = 'material'
@ -242,7 +250,10 @@ def bom_match_stock():
semi_items = StockSemi.query.filter(
StockSemi.base_id.in_(child_ids),
StockSemi.stock_quantity > 0
).options(joinedload(StockSemi.base)).all()
)
if company_limit is not None:
semi_items = semi_items.filter(StockSemi.base.has(MaterialBase.company_name == company_limit))
semi_items = semi_items.options(joinedload(StockSemi.base)).all()
for s in semi_items:
d = s.to_dict()
d['type'] = 'semi'
@ -260,7 +271,10 @@ def bom_match_stock():
prod_items = StockProduct.query.filter(
StockProduct.base_id.in_(child_ids),
StockProduct.stock_quantity > 0
).options(joinedload(StockProduct.base)).all()
)
if company_limit is not None:
prod_items = prod_items.filter(StockProduct.base.has(MaterialBase.company_name == company_limit))
prod_items = prod_items.options(joinedload(StockProduct.base)).all()
for s in prod_items:
d = s.to_dict()
d['type'] = 'product'
@ -398,7 +412,8 @@ def create_outbound_request():
items=items,
allowed_approvers=allowed_approvers,
remark=data.get('remark'),
approver_id=approver_id
approver_id=approver_id,
outbound_type=data.get('outbound_type') # 出库类型(申请时确定)
)
return jsonify({

View File

@ -41,6 +41,16 @@ def get_current_user_info():
return user.id if user else None, user.role if user else None
def _current_username():
"""获取当前登录用户的用户名(姓名/账号),用于操作人展示;避免把 JWT 数字 ID 存进记录"""
identity = get_jwt_identity()
if not identity:
return 'System'
from app.models.system import SysUser
user = SysUser.query.get(identity)
return user.username if user else str(identity)
def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records'):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
@ -102,9 +112,10 @@ def scan_borrowed_item():
)
def submit_return():
data = request.get_json()
user = get_jwt_identity() # 库管
# ★ 归还人存"姓名",而非 JWT 数字 ID
operator_name = _current_username()
try:
TransService.process_return(data, operator_name=user)
TransService.process_return(data, operator_name=operator_name)
return jsonify({'code': 200, 'msg': '还库成功'})
except Exception as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
@ -131,9 +142,9 @@ def scrap_borrow():
if not record_ids:
return jsonify({'code': 400, 'msg': '请选择要报废的借出记录'}), 400
user = get_jwt_identity() or 'Unknown'
operator_name = _current_username() or 'Unknown'
try:
result = TransService.scrap_borrow(record_ids, operator_name=user, reason=reason)
result = TransService.scrap_borrow(record_ids, operator_name=operator_name, reason=reason)
return jsonify({'code': 200, 'msg': f'已报废 {result["count"]} 条借出记录', 'data': result})
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
@ -277,6 +288,29 @@ def approve_borrow_request(request_id):
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
@trans_bp.route('/borrow/request/<int:request_id>/close', methods=['POST'])
@jwt_required()
@permission_required('op_borrow_approval')
def close_borrow_request(request_id):
"""
手动完结已通过的借库审批单(status 1-已通过 → 3-已完成)
参照出库审批的完结逻辑,供库管/主管在未走扫码借出时强制完结
"""
try:
user_id, _ = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
success, message, approval = BorrowApprovalService.mark_completed(request_id)
if not success:
return jsonify({'code': 400, 'msg': message}), 400
return jsonify({'code': 200, 'msg': message, 'data': approval.to_dict() if approval else None}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --- 获取借库审批单列表 ---
@trans_bp.route('/borrow/request', methods=['GET'])
@jwt_required()
@ -351,7 +385,7 @@ def dispatch_borrow():
borrow_no = TransService.execute_dispatch(
approval_id=approval_id,
items=data.get('items', []),
operator_name=get_jwt_identity(),
operator_name=_current_username(),
borrower_name=data.get('borrower_name'),
signature=data.get('signature_path'),
remark=data.get('remark'),

View File

@ -10,23 +10,25 @@ warehouse_bp = Blueprint('warehouse', __name__, url_prefix='/api/v1/warehouse')
def build_tree(nodes, parent_id=None):
"""
将平铺的数据构建为树形结构
将平铺的数据构建为树形结构(O(N) 内存组装,避免递归时每层全量扫描导致 O(N²))
做法:先把全部节点按 parent_id 分组到 Map,再从根出发逐层用 Map 取子节点组装。
每个节点只被处理一次,显著快于"每次递归 for 遍历全表"的旧实现。
"""
tree = []
by_parent = {}
for node in nodes:
if node.parent_id == parent_id:
children = build_tree(nodes, node.id)
node_dict = node.to_dict()
if children:
# 子节点按 name 升序排序
children_sorted = sorted(children, key=lambda x: x.get('name', ''))
node_dict['children'] = children_sorted
else:
node_dict['children'] = []
tree.append(node_dict)
# 当前层级按 name 升序排序
tree_sorted = sorted(tree, key=lambda x: x.get('name', ''))
return tree_sorted
by_parent.setdefault(node.parent_id, []).append(node)
def assemble(pid):
kids = sorted(by_parent.get(pid, []), key=lambda x: (x.name or ''))
out = []
for k in kids:
d = k.to_dict()
d['children'] = assemble(k.id)
out.append(d)
return out
return assemble(parent_id)
@warehouse_bp.route('/tree', methods=['GET'])
@ -38,7 +40,7 @@ def get_tree():
# 查询所有库位,按 name 升序排序
all_locations = SysWarehouseLocation.query.order_by(SysWarehouseLocation.name.asc()).all()
# 构建树形结构
# 构建树形结构(O(N) 内存组装,见 build_tree)
tree_data = build_tree(all_locations, parent_id=None)
return jsonify({

View File

@ -18,6 +18,8 @@ class OutboundApproval(db.Model):
applicant_id = db.Column(db.Integer, nullable=False, index=True)
# 申请说明
remark = db.Column(db.Text)
# 出库类型:SALES/USE/PRODUCTION/LOSS/REPAIR(申请时确定,扫码出库自动带出)
outbound_type = db.Column(db.String(50))
# 状态: 0-待审批, 1-已通过, 2-已驳回, 3-已完成(已出库)
status = db.Column(db.Integer, default=0, nullable=False)
# 允许审批的人员列表 (JSON格式: [{"type": "role", "value": "admin"}, {"type": "user", "value": "123"}])
@ -81,6 +83,7 @@ class OutboundApproval(db.Model):
'applicant_id': self.applicant_id,
'applicant_name': self._get_user_name(self.applicant_id),
'remark': self.remark,
'outbound_type': self.outbound_type or '',
'status': self.status,
'status_text': ['待审批', '已通过', '已驳回', '已完成', '已完结'][self.status] if self.status in [0, 1, 2, 3, 4] else '未知',
'allowed_approvers': self.get_allowed_approvers(),

View File

@ -1,6 +1,26 @@
from app.extensions import db, beijing_time
from datetime import datetime
from sqlalchemy import func
from functools import lru_cache
@lru_cache(maxsize=512)
def _borrow_user_name(uid):
"""按用户 ID 取用户名(带缓存,避免列表页 N+1)"""
try:
from app.models.system import SysUser
u = SysUser.query.get(uid)
return u.username if u else None
except Exception:
return None
def _display_borrow_operator(op):
"""归还人展示映射:历史存的是数字 user id → 映射为用户名;新数据存姓名则原样返回"""
if op and str(op).strip().isdigit():
mapped = _borrow_user_name(int(op))
return mapped if mapped else op
return op
class TransBorrow(db.Model):
@ -48,7 +68,7 @@ class TransBorrow(db.Model):
'expected_return_time': self.expected_return_time.strftime('%Y-%m-%d %H:%M') if self.expected_return_time else None,
'is_returned': self.is_returned,
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M') if self.return_time else None,
'return_operator': self.return_operator,
'return_operator': _display_borrow_operator(self.return_operator),
'return_signature': self.return_signature,
'return_location': self.return_location,
'location': self.location or '',

View File

@ -701,7 +701,7 @@ class OutboundApprovalService:
return f"APR-OUT-{date_str}-{time_str}-{sequence:04d}"
@staticmethod
def create_request(applicant_id, items, allowed_approvers, remark=None, approver_id=None):
def create_request(applicant_id, items, allowed_approvers, remark=None, approver_id=None, outbound_type=None):
"""
创建出库审批单(申请阶段,直接存储前端传来的物料信息快照,不关联具体库存记录)
@ -759,6 +759,7 @@ class OutboundApprovalService:
request_no=request_no,
applicant_id=applicant_id,
remark=remark,
outbound_type=outbound_type, # 申请时确定的出库类型,扫码出库时带出
status=0, # 待审批
)

View File

@ -434,7 +434,12 @@ class TransService:
order_subq = (
db.session.query(
TransBorrow.borrow_no.label('borrow_no'),
func.min(TransBorrow.expected_return_time).label('sort_key')
# 有限期梯队内:单号内最早预计归还时间
func.min(TransBorrow.expected_return_time).label('sort_key'),
# 无限期梯队内:单号内最早借出时间(呆滞借用盘点)
func.min(TransBorrow.borrow_time).label('min_borrow_time'),
# 单内是否含"有限期"明细:任一 expected_return_time 非空 → 1(有限期单排前)
func.max(case((TransBorrow.expected_return_time.isnot(None), 1), else_=0)).label('has_finite')
)
.group_by(TransBorrow.borrow_no)
.subquery()
@ -673,8 +678,15 @@ class TransService:
)
)
# 排序(单号维度的 sort_key ASC)
borrow_no_q = borrow_no_q.order_by(nullslast(asc(order_subq.c.sort_key)))
# ★ 默认排序(多级复合,符合"优先关注快到期/逾期"业务):
# 1) 有限期单(含 expected_return_time)排前,无限期单排后
# 2) 有限期内按最早预计归还时间 ASC(越快到期/逾期越久越靠前)
# 3) 无限期内按最早借出时间 ASC(借出越久越靠前,暴露长期未还的呆滞借用)
borrow_no_q = borrow_no_q.order_by(
case((order_subq.c.has_finite == 0, 1), else_=0).asc(),
nullslast(asc(order_subq.c.sort_key)),
asc(order_subq.c.min_borrow_time)
)
# 分页(基准 = borrow_no 单号数)
pagination = borrow_no_q.paginate(page=page, per_page=limit, error_out=False)

View File

@ -239,7 +239,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.71
当前版本:V3.72
</span>
</footer>

View File

@ -174,6 +174,14 @@ export function approveBorrowRequest(id: number, data: { action: 'approve' | 're
})
}
// 手动完结已通过的借库审批单(status 1 → 3)
export function closeBorrowRequest(id: number) {
return request({
url: `/v1/transactions/borrow/request/${id}/close`,
method: 'post'
})
}
/**
* 借库选单专用库存列表(独立权限 op_borrow_apply)
*/

View File

@ -137,6 +137,19 @@
驳回
</el-button>
</template>
<!-- ★ 已通过的申请单:库管/主管可手动完结 -->
<template v-else-if="row.status === 1">
<el-button
v-if="canCompleteRequest"
type="danger"
plain
size="small"
:loading="row._closing"
@click="handleComplete(row)"
>
完结
</el-button>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
@ -182,14 +195,44 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { Refresh, Warning } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getBorrowApprovalList, approveBorrowRequest } from '@/api/transaction'
import { getBorrowApprovalList, approveBorrowRequest, closeBorrowRequest } from '@/api/transaction'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// ★ 完结权限:超管 / 审批操作(op_borrow_approval:operation) / 借库执行(op_borrow:operation)
const canCompleteRequest = computed(() =>
userStore.role === 'SUPER_ADMIN' ||
userStore.hasPermission('op_borrow_approval:operation') ||
userStore.hasPermission('op_borrow:operation') ||
userStore.username === 'IRIS'
)
// ★ 手动完结已通过的借库审批单(status 1 → 3,仿出库审批)
const handleComplete = (row: any) => {
ElMessageBox.confirm(
`确定强制完结审批单【${row.request_no}】吗?\n\n完结后该单标记为「已完成」,不可再扫码借出。`,
'⚠️ 强制完结确认',
{ confirmButtonText: '确认完结', cancelButtonText: '取消', type: 'warning' }
)
.then(async () => {
row._closing = true
try {
await closeBorrowRequest(row.id)
ElMessage.success('审批单已完结')
fetchData()
} catch (e: any) {
ElMessage.error(e?.response?.data?.msg || e?.msg || e?.message || '完结失败')
} finally {
row._closing = false
}
})
.catch(() => {})
}
// --- 状态 ---
const list = ref<any[]>([])
const loading = ref(false)

View File

@ -395,6 +395,15 @@
</el-table>
<el-form label-width="80px">
<el-form-item label="出库类型" required>
<el-select v-model="requestOutboundType" placeholder="请选择类型" style="width: 100%">
<el-option label="销售出库" value="SALES" />
<el-option label="内部领用" value="USE" />
<el-option label="生产出库" value="PRODUCTION" />
<el-option label="盘亏出库" value="LOSS" />
<el-option label="维修出库" value="REPAIR" />
</el-select>
</el-form-item>
<el-form-item label="* 指定审批人" required>
<el-select
v-model="requestApproverId"
@ -533,6 +542,7 @@ const printLoading = ref(false)
// ★ 出库申请相关
const requestDialogVisible = ref(false)
const requestRemark = ref('')
const requestOutboundType = ref('SALES') // 申请时确定出库类型,扫码出库自动带出
const requestApproverId = ref<number | null>(null)
const approvers = ref<any[]>([])
const requestSubmitting = ref(false)
@ -1088,6 +1098,7 @@ const confirmSubmitRequest = async () => {
quantity: item.export_quantity || 0
})),
remark: buildRemarkWithShortage(trimmed),
outbound_type: requestOutboundType.value,
approver_id: requestApproverId.value
}

View File

@ -187,6 +187,7 @@
<el-option label="内部领用" value="USE" />
<el-option label="生产出库" value="PRODUCTION" />
<el-option label="盘亏出库" value="LOSS" />
<el-option label="维修出库" value="REPAIR" />
</el-select>
</el-form-item>
</el-col>
@ -461,6 +462,10 @@ const handleRequestChange = (val: number | null) => {
if (selectedRequest.value?.remark) {
form.remark = selectedRequest.value.remark
}
// ★ 自动带出申请时确定的出库类型(库管无需再选);旧单无类型则留待手动选择
if (selectedRequest.value?.outbound_type) {
form.outbound_type = selectedRequest.value.outbound_type
}
}
// 切换申请单时清空购物车,防止已扫物品与新单据混淆
cartItems.value = []

View File

@ -253,7 +253,8 @@ const formatType = (type: string) => {
'USE': '内部领用',
'PRODUCTION': '生产出库',
'SCRAP': '报废',
'LOSS': '盘亏出库' // ★ 补充映射:扫码出库下拉中存在「盘亏出库 LOSS」,避免出库记录显示英文
'LOSS': '盘亏出库', // ★ 补充映射:扫码出库下拉中存在「盘亏出库 LOSS」,避免出库记录显示英文
'REPAIR': '维修出库'
}
return map[type] || type
}
@ -264,7 +265,8 @@ const getTagType = (type: string) => {
'USE': 'warning',
'PRODUCTION': 'info',
'SCRAP': 'danger',
'LOSS': 'warning' // ★ 补充:盘亏出库标签样式
'LOSS': 'warning', // ★ 补充:盘亏出库标签样式
'REPAIR': 'info'
}
return map[type] || ''
}