7 Commits

Author SHA1 Message Date
bf104535e5 V3.69,8.31推送 2026-08-31 10:30:03 +08:00
3a07f1a272 fix(bom): BOM 详情查询改为 LEFT JOIN,子件物料被删后仍返回父件结构
- get_bom_detail 使用 outerjoin 替代 join
- 子件物料被删除后 child_name 显示 [已删除物料] 而非误报 BOM 不存在
- 修复按 BOM 出库时因子件删除导致 404 BOM 不存在
2026-08-31 10:28:22 +08:00
36e9f500aa feat(borrow): 借库未归还直接报废功能(关联报废单流程)
后端:
- trans_service 新增 scrap_borrow 方法:未归还借用标记为 scrapped,
  扣减总库存 stock_quantity(可用已在借出时冻结),生成 TransScrap 报废记录
- transactions.py 新增 POST /borrow/scrap 接口(复用 op_return:operation 权限,库管/主管可操作)
- scrap.py query_records 支持 trans_borrow 来源的物料名解析 + 行级隔离

前端:
- records.vue 未归还记录新增「报废」按钮(库管/主管可见)
- 报废弹窗:勾选明细 + 填报废原因 + 二次确认
- 状态显示新增「已报废」标签;整单所有明细报废时标记为 scrapped
2026-08-31 10:21:28 +08:00
39f3f8af45 feat(outbound): 审批单手动完结/作废功能
- 后端: outbound_service 新增 close_request 方法(状态 1-已通过 → 4-已完结)
- 后端: 新增 POST /api/v1/outbound/request/<id>/close 接口
- 模型: status_text 数组增加「已完结」
- 前端: create.vue 审批单选择区新增「强制完结此单」按钮
  完结后刷新列表,该单从已通过下拉中移除
- 权限: 超级管理员/审批人/拥有 outbound_create:operation 的用户可完结(库管可操作)
2026-08-31 10:15:33 +08:00
b6ec45a8b8 feat(outbound): 出库流程强化(缺料防漏 + 完全移除直接出库)
① BOM 清单 SKU 改为规格型号(getBomWithStock 返回 children 无 sku,只有 child_spec)
② 缺货清单自动写入出库申请备注(借鸡生蛋:历史欠料永久留存在申请单)
③ 打印单底部增加欠料待补签收区(补料人签字/日期)
④ 有缺料时阻断式二次确认,防手快漏看
⑤ 完全移除直接出库模式,所有出库必须按单出库
   - create.vue 删除 radio 切换、direct 相关逻辑
   - 提交前强制校验已选择审批申请单
2026-08-31 09:37:14 +08:00
a4bd6f54d4 feat(outbound): BOM 出库增加清单打印与缺货持久化提醒
- BOM 弹窗物料清单增加「本次结果」列(可出/缺货未出)
- 增加「打印 BOM 清单」按钮,可打印当前 BOM 的完整出库清单
- 缺货物料持久化到 localStorage,下次打开同一 BOM 时自动提示历史缺货记录
  防止重复出库或漏出
- 打印区域使用与 A4 出库单一致的 iframe 打印机制
2026-08-31 09:26:53 +08:00
fe52da77e9 feat(deploy): 部署脚本优化
- 打包时排除测试脚本/临时SQL/编译输出等垃圾文件
- 增加 SSL 证书备份
- 部署后自动验证 nginx 代理指向 inventory_api_prod
- 部署后自动验证 API 连通性(检测 404 及时报警)
2026-08-31 09:21:00 +08:00
13 changed files with 669 additions and 57 deletions

View File

@ -19,6 +19,8 @@ ssh -t $SERVER "sudo mkdir -p $REMOTE_BACKUP_DIR && \
sudo mkdir -p inventory-backend inventory-web models_prod && \
echo '>> 执行代码备份...' && \
sudo tar -czf $REMOTE_BACKUP_DIR/code_backup.tar.gz inventory-backend inventory-web docker-compose.prod.yml && \
echo '>> 备份 SSL 证书...' && \
(sudo tar -czf $REMOTE_BACKUP_DIR/ssl_backup.tar.gz ssl 2>/dev/null || echo '⚠️ ssl 目录不存在,跳过') && \
echo '>> 检查 CLIP 模型文件...' && \
if [ ! -f models_prod/clip_vision.onnx ]; then \
echo '⚠️ 警告: models_prod/clip_vision.onnx 不存在!拍照识图功能将无法使用。'; \
@ -48,6 +50,13 @@ tar -czf deploy.tar.gz \
--exclude="inventory-backend/models" \
--exclude="inventory-backend/pgdata" \
--exclude="inventory-backend/pgdata_docker" \
--exclude="inventory-backend/test_*.py" \
--exclude="inventory-backend/fix_*.sql" \
--exclude="inventory-backend/*.sql" \
--exclude="inventory-backend/import_test_edge_cases.xlsx" \
--exclude="inventory-backend/import_edge_case_test.xlsx" \
--exclude="inventory-web/tsc-out.txt" \
--exclude="deploy.tar.gz" \
--exclude="data_copy" \
inventory-backend inventory-web docker-compose.prod.yml
@ -81,6 +90,20 @@ ssh -t $SERVER "cd $REMOTE_DIR && \
sudo tar -xzf deploy.tar.gz && \
echo '>> 重启 Docker 容器...' && \
sudo docker compose -f docker-compose.prod.yml up -d --build backend frontend && \
echo '>> 验证 nginx 代理配置...' && \
sleep 5 && \
if sudo docker exec inventory_ui_prod grep -q "inventory_api_prod" /etc/nginx/conf.d/default.conf; then \
echo '✅ nginx 代理已指向 inventory_api_prod'; \
else \
echo '⚠️ 警告: nginx 代理仍是 backend可能出现 /api/ 404请检查 nginx.conf'; \
fi && \
echo '>> 验证 API 连通性...' && \
API_CODE=$(curl -k -s -o /dev/null -w "%{http_code}" "https://localhost/api/inbound/base/units") && \
if [ "$API_CODE" = "404" ]; then \
echo '⚠️ 警告: API 返回 404代理可能指向了错误的容器'; \
else \
echo "✅ API 连通性正常 HTTP=$API_CODE"; \
fi && \
sudo rm deploy.tar.gz"
if [ $? -ne 0 ]; then

View File

@ -468,6 +468,45 @@ def approve_outbound_request(request_id):
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 5.5 手动完结/作废审批单
# POST /api/v1/outbound/request/<id>/close
# --------------------------------------------------------
@outbound_bp.route('/request/<int:request_id>/close', methods=['POST'])
@jwt_required()
@permission_required('outbound_approval')
def close_outbound_request(request_id):
"""
手动完结/作废已通过的审批单(状态 1-已通过 → 4-已完结)
适用场景:已通过但无法出库/作废的单据,库管手动清理,
使其从"已审批通过"列表中消失。
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
success, message, approval = OutboundApprovalService.close_request(
request_id=request_id,
user_id=user_id,
user_role=user_role
)
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
# --------------------------------------------------------
# 6. 获取审批单列表
# GET /api/v1/outbound/request

View File

@ -356,7 +356,30 @@ class ScrapService:
).join(MaterialBase, TransRepair.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
all_matches = buy_subq.union(semi_subq, product_subq, repair_subq).subquery()
# ★ 借库转报废stock_id 指向 trans_borrow.id通过 borrow 关联源库存表再关联 MaterialBase
from app.models.transaction import TransBorrow
borrow_stock_buy = db.session.query(TransScrap.id).join(
TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id,
TransScrap.source_table == 'trans_borrow')
).join(StockBuy, TransBorrow.stock_id == StockBuy.id).join(
MaterialBase, StockBuy.base_id == MaterialBase.id
).filter(MaterialBase.company_name == company_limit)
borrow_stock_semi = db.session.query(TransScrap.id).join(
TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id,
TransScrap.source_table == 'trans_borrow')
).join(StockSemi, TransBorrow.stock_id == StockSemi.id).join(
MaterialBase, StockSemi.base_id == MaterialBase.id
).filter(MaterialBase.company_name == company_limit)
borrow_stock_product = db.session.query(TransScrap.id).join(
TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id,
TransScrap.source_table == 'trans_borrow')
).join(StockProduct, TransBorrow.stock_id == StockProduct.id).join(
MaterialBase, StockProduct.base_id == MaterialBase.id
).filter(MaterialBase.company_name == company_limit)
all_matches = buy_subq.union(
semi_subq, product_subq, repair_subq,
borrow_stock_buy, borrow_stock_semi, borrow_stock_product
).subquery()
query = query.filter(TransScrap.id.in_(all_matches))
# 按时间倒序
@ -390,7 +413,24 @@ class ScrapService:
material_name = ''
spec_model = ''
if r.source_table == 'trans_repair':
if r.source_table == 'trans_borrow':
# 借库转报废stock_id 指向 trans_borrow.id通过源库存表取物料
from app.models.transaction import TransBorrow
borrow = TransBorrow.query.get(r.stock_id)
if borrow:
bstock_model = None
if borrow.source_table == 'stock_buy':
bstock_model = StockBuy.query.get(borrow.stock_id)
elif borrow.source_table == 'stock_semi':
bstock_model = StockSemi.query.get(borrow.stock_id)
elif borrow.source_table == 'stock_product':
bstock_model = StockProduct.query.get(borrow.stock_id)
if bstock_model and hasattr(bstock_model, 'base_id') and bstock_model.base_id:
base = MaterialBase.query.get(bstock_model.base_id)
if base:
material_name = base.name or ''
spec_model = base.spec_model or ''
elif r.source_table == 'trans_repair':
# 维修单
repair = TransRepair.query.get(r.stock_id)
if repair:

View File

@ -110,6 +110,38 @@ def submit_return():
return jsonify({'code': 400, 'msg': str(e)}), 400
# --- 借库报废(未归还直接报废,关联报废单流程)---
@trans_bp.route('/borrow/scrap', methods=['POST'])
@jwt_required()
@permission_required('op_return:operation') # 复用归还权限:能归还的库管即可报废
@audit_log(
module='借库管理',
action='借库报废',
get_target_name_fn=lambda: request.get_json().get('reason') if request.get_json() else None
)
def scrap_borrow():
"""
借库未归还直接报废(库管/主管操作)
请求体: { "record_ids": [1, 2, 3], "reason": "物品丢失" }
"""
data = request.get_json() or {}
record_ids = data.get('record_ids', [])
reason = data.get('reason', '')
if not record_ids:
return jsonify({'code': 400, 'msg': '请选择要报废的借出记录'}), 400
user = get_jwt_identity() or 'Unknown'
try:
result = TransService.scrap_borrow(record_ids, operator_name=user, reason=reason)
return jsonify({'code': 200, 'msg': f'已报废 {result["count"]} 条借出记录', 'data': result})
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'报废失败: {str(e)}'}), 500
# --- 记录列表 ---
@trans_bp.route('/records', methods=['GET'])
@jwt_required()

View File

@ -82,7 +82,7 @@ class OutboundApproval(db.Model):
'applicant_name': self._get_user_name(self.applicant_id),
'remark': self.remark,
'status': self.status,
'status_text': ['待审批', '已通过', '已驳回', '已完成'][self.status] if self.status in [0, 1, 2, 3] else '未知',
'status_text': ['待审批', '已通过', '已驳回', '已完成', '已完结'][self.status] if self.status in [0, 1, 2, 3, 4] else '未知',
'allowed_approvers': self.get_allowed_approvers(),
'actual_approver_id': self.actual_approver_id,
'approver_name': self._get_user_name(self.actual_approver_id) if self.actual_approver_id else None,

View File

@ -293,7 +293,8 @@ class BomService:
BomTable,
MaterialBase.name.label('child_name'),
MaterialBase.spec_model.label('child_spec')
).join(
).outerjoin(
# ★ LEFT JOIN子件物料被删除后仍返回父件 BOM 结构,避免误报 "BOM 不存在"
MaterialBase, BomTable.child_id == MaterialBase.id
).filter(
BomTable.bom_no == bom_no
@ -322,7 +323,7 @@ class BomService:
for bom, child_name, child_spec in rows:
children.append({
'child_id': bom.child_id,
'child_name': child_name,
'child_name': child_name or '[已删除物料]',
'child_spec': child_spec or '',
'dosage': float(bom.dosage) if bom.dosage else 0.0,
'remark': bom.remark or ''

View File

@ -994,6 +994,52 @@ class OutboundApprovalService:
logger.error(f"[Email] 外层发送异常: {e}")
@staticmethod
def close_request(request_id, user_id, user_role):
"""
手动完结/作废审批单(状态 1-已通过 → 4-已完结)
适用场景:已通过但无法出库/作废的单据,库管手动清理,
使其从"已审批通过"列表中消失。
Args:
request_id: 审批单ID
user_id: 操作人ID
user_role: 操作人角色
Returns:
(success: bool, message: str, approval: OutboundApproval or None)
"""
from app.models.outbound import OutboundApproval
approval = OutboundApproval.query.get(request_id)
if not approval:
return False, "审批单不存在", None
if approval.status != 1:
return False, f"仅「已通过」的审批单可完结 (当前状态: {approval.status})", None
# 权限检查:超级管理员、审批人 或 拥有出库操作权限(库管/主管)
if not OutboundApprovalService.can_approve(approval, user_id, user_role):
# 放宽:拥有 outbound_create:operation 的用户(库管)也可完结
from app.models.system import SysRolePermission
has_outbound_op = SysRolePermission.query.filter(
SysRolePermission.role_code == user_role,
SysRolePermission.target_code.in_(['outbound_create:operation', 'outbound_create:*'])
).first() is not None
if not has_outbound_op:
return False, "您没有完结此单的权限", None
try:
approval.status = 4 # 4-已完结(手动作废)
approval.actual_approver_id = user_id
approval.approved_at = None
db.session.commit()
return True, "审批单已完结", approval
except Exception as e:
db.session.rollback()
return False, f"完结失败: {str(e)}", None
def get_request_list(page=1, per_page=10, applicant_id=None, status=None):
"""
获取审批单列表

View File

@ -327,6 +327,86 @@ class TransService:
db.session.rollback()
raise e
@staticmethod
def scrap_borrow(record_ids, operator_name='System', reason=''):
"""
借库未归还直接报废(关联报废单流程)
适用场景:借出的物品确认丢失/损坏/无法归还,库管/主管手动报废。
- 该笔借用不再追讨归还,从借还记录中标记为已报废
- 生成一条 TransScrap 报废记录(关联 trans_borrow
- 总库存 stock_quantity 扣减(该物品确认损失);可用库存已在借出时冻结,无需重复扣
Args:
record_ids: TransBorrow 记录ID列表
operator_name: 操作人
reason: 报废原因
Returns:
{count: 报废条数}
"""
from app.models.transaction import TransScrap
if not record_ids:
raise ValueError('请选择要报废的借出记录')
if not reason:
raise ValueError('请填写报废原因')
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
created_records = []
scraped_count = 0
try:
for rid in record_ids:
record = TransBorrow.query.with_for_update().get(rid)
if not record:
continue
# 仅未归还的借用可报废
if record.is_returned:
continue
pending_qty = float(record.quantity) - float(record.returned_quantity or 0)
if pending_qty <= 0:
continue
# 1. 标记借用记录为已报废(不再追讨归还)
record.is_returned = True
record.status = 'scrapped'
record.return_time = datetime.now()
record.return_operator = operator_name
# 2. 扣减总库存(该物品确认损失);可用库存已在借出时冻结
ModelClass = model_map.get(record.source_table)
if ModelClass:
stock = ModelClass.query.with_for_update().get(record.stock_id)
if stock:
stock.stock_quantity = float(stock.stock_quantity or 0) - pending_qty
# 防止负库存
if stock.stock_quantity < 0:
stock.stock_quantity = 0
# 3. 创建报废记录
scrap_record = TransScrap(
sku=record.sku,
source_table='trans_borrow', # 标识来源为借出转报废
stock_id=record.id,
quantity=pending_qty,
reason=f"[借库报废] {reason}",
operator_name=operator_name,
approval_status='approved',
cost_at_scrap=0,
total_loss=0
)
db.session.add(scrap_record)
created_records.append(scrap_record)
scraped_count += 1
db.session.commit()
return {'count': scraped_count}
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all'):
"""

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.68
当前版本:V3.69
</span>
</footer>

View File

@ -124,6 +124,17 @@ export function approveRequest(id: number, data: { action: 'approve' | 'reject';
})
}
/**
* 手动完结/作废已通过的出库申请单(状态 1-已通过 → 4-已完结)
* @param id 审批单ID
*/
export function closeRequest(id: number) {
return request({
url: `/v1/outbound/request/${id}/close`,
method: 'post'
})
}
/**
* BOM 匹配库存(替代前端 while(true) 全量加载)
* 根据 BOM 子件的 base_id 列表,服务端精确查询有库存的记录

View File

@ -223,7 +223,7 @@
</template>
<el-table :data="bomDetailList" size="small" border style="margin-top: 8px; width: 100%;">
<el-table-column prop="name" label="物料名称" min-width="120" />
<el-table-column prop="sku" label="SKU" width="100" />
<el-table-column prop="sku" label="规格型号" width="120" />
<el-table-column label="需求量" width="80">
<template #default="{ row }">
<span>{{ row.need }}</span>
@ -239,14 +239,21 @@
<span :style="{ color: row.shortage > 0 ? '#F56C6C' : '#67C23A', fontWeight: 'bold' }">{{ row.shortage > 0 ? row.shortage : '✓' }}</span>
</template>
</el-table-column>
<el-table-column label="本次结果" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.shortage > 0" type="danger" size="small">缺货未出</el-tag>
<el-tag v-else type="success" size="small">可出</el-tag>
</template>
</el-table-column>
</el-table>
</el-alert>
<div style="margin-left: 100px; color: #909399; font-size: 12px;">
注意系统将自动计算所需原料数量 ( 配方用量 × 套数 )
注意系统将自动计算所需原料数量 ( 配方用量 × 套数 )缺货物料不会加入出库单请先补货
</div>
<template #footer>
<el-button @click="bomSelectVisible = false">取消</el-button>
<el-button v-if="bomDetailList.length > 0" :icon="Printer" @click="printBomChecklist">打印 BOM 清单</el-button>
<el-button
v-if="userStore.hasPermission('outbound_selection:operation')"
type="primary"
@ -257,6 +264,59 @@
</template>
</el-dialog>
<!-- BOM 打印区域隐藏打印时克隆到 iframe -->
<div id="bom-print-area" class="print-bom-area" style="display:none;">
<h2 style="text-align:center; margin:0 0 8px;">BOM 出库清单</h2>
<p style="text-align:center; margin:0 0 16px; font-size:13px;">
BOM: <b>{{ selectedBomNo }}</b> 套数: <b>{{ bomSets }}</b> 生成时间: {{ bomPrintTime }}
</p>
<table class="print-table">
<thead>
<tr>
<th>序号</th><th>物料名称</th><th>规格型号</th><th>需求量</th><th>可用库存</th><th>缺料</th><th>结果</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in bomDetailList" :key="idx">
<td>{{ idx + 1 }}</td>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.need }}</td>
<td>{{ row.available }}</td>
<td>{{ row.shortage > 0 ? row.shortage : '-' }}</td>
<td>{{ row.shortage > 0 ? '缺货未出' : '可出' }}</td>
</tr>
</tbody>
</table>
<p style="margin-top:12px;">
可出 <b style="color:#67C23A;">{{ bomAvailableCount }}</b>
缺货 <b style="color:#F56C6C;">{{ bomShortageCount }}</b>
</p>
<p style="color:#909399; font-size:12px;">缺货物料不会加入本次出库单请补货后重新按 BOM 出库</p>
<!-- 欠料待补签收区仅当有缺货时打印此联 -->
<div v-if="bomShortageCount > 0" style="margin-top: 20px; border-top: 2px dashed #000; padding-top: 10px;">
<p style="font-weight: bold; font-size: 14px;"> 欠料待补清单请补料后在此签字</p>
<table class="print-table" style="margin-top: 6px;">
<thead>
<tr>
<th>序号</th><th>物料名称</th><th>规格型号</th><th>需补数量</th><th>补领人签字</th><th>日期</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in bomDetailList.filter(i => i.shortage > 0)" :key="idx">
<td>{{ idx + 1 }}</td>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.shortage }}</td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
<el-dialog
v-model="previewVisible"
title="出库单核对与打印"
@ -554,7 +614,8 @@ const bomDetailList = computed(() => {
const shortage = Math.max(0, totalNeed - stock)
return {
name: bomItem.child_name || bomItem.name || '未知物料',
sku: bomItem.child_sku || bomItem.sku || '-',
// ★ 用规格型号替代 SKUgetBomWithStock 返回的 children 无 sku只有 child_spec
sku: bomItem.child_spec || bomItem.spec_model || '-',
need: totalNeed,
available: stock,
shortage
@ -569,6 +630,11 @@ const bomDetailList = computed(() => {
const hasShortage = computed(() => bomDetailList.value.some((item: any) => item.shortage > 0) && bomSets.value > maxBuildableSets.value)
// ★ BOM 打印统计
const bomPrintTime = ref('')
const bomAvailableCount = computed(() => bomDetailList.value.filter(i => i.shortage === 0).length)
const bomShortageCount = computed(() => bomDetailList.value.filter(i => i.shortage > 0).length)
// --- 辅助方法 ---
const getTypeTag = (type: string) => {
switch (type) {
@ -741,6 +807,23 @@ const openBomSelect = async () => {
bomOptions.value = Array.from(groupMap.entries()).map(([category, items]) => ({
category, count: items.length, items
}))
// ★ 提示有历史缺货记录的 BOM防止下次重复出/漏出
const pendingBoms = Object.keys(bomShortageRecords.value).filter(no =>
flatItems.some(i => i.bom_no === no)
)
if (pendingBoms.length > 0) {
ElMessageBox.confirm(
`检测到以下 BOM 存在未完成的缺货记录(上次按 BOM 出库时缺货):\n\n` +
pendingBoms.map(no => {
const rec = bomShortageRecords.value[no]
return `${no}${rec.items.length} 种缺货,${rec.time}`
}).join('\n') +
`\n\n建议先补货后再出库避免漏出。是否查看`,
'存在未完成出库',
{ confirmButtonText: '知道了', cancelButtonText: '忽略', type: 'warning' }
).catch(() => {})
}
} catch (e) {
ElMessage.error('加载 BOM 列表失败')
}
@ -774,6 +857,27 @@ const confirmBomAdd = async () => {
}
}
// ★ 阻断式二次确认:有缺料时强制确认,防止手快漏看
const shortageItems = bomDetailList.value.filter(i => i.shortage > 0)
if (shortageItems.length > 0) {
const shortageText = shortageItems.map(i => `${i.name}${i.sku})需 ${i.need} 个,缺 ${i.shortage}`).join('\n')
try {
await ElMessageBox.confirm(
`当前 BOM 有 ${shortageItems.length} 项物料库存不足:\n\n${shortageText}\n\n` +
`继续出库将【只扣减可用库存】,缺货物料不会加入本次出库单。\n是否确认【部分出库】`,
'⚠️ 存在缺料,请确认',
{
confirmButtonText: '确认部分出库',
cancelButtonText: '取消',
type: 'warning',
distinguishCancelAndClose: true
}
)
} catch (e) {
return // 用户取消
}
}
const bomRows = currentBomDetail.value
// ★ 服务端精确查询 BOM 子件匹配库存(替代前端 while(true) 全量加载)
@ -818,12 +922,58 @@ const confirmBomAdd = async () => {
if (addedCount > 0) {
const tip = skippedCount > 0 ? `(跳过 ${skippedCount} 种缺货物料)` : ''
ElMessage.success(`成功添加 ${addedCount} 类物料${tip}`)
// ★ 记录本次缺货清单(持久化,防止下次重复出/漏出)
saveBomShortage(selectedBomNo.value, bomDetailList.value.filter(i => i.shortage > 0))
bomSelectVisible.value = false
} else {
ElMessage.warning('该 BOM 所有物料库存均为 0')
// 全部缺货也要记录
saveBomShortage(selectedBomNo.value, bomDetailList.value.filter(i => i.shortage > 0))
}
}
// ★ 缺货记录持久化:按 BOM 编号保存到 localStorage
const BOM_SHORTAGE_KEY = 'MOM_BOM_SHORTAGE_RECORDS'
const bomShortageRecords = ref<Record<string, any>>(JSON.parse(localStorage.getItem(BOM_SHORTAGE_KEY) || '{}'))
// ★ 将缺货清单拼接进备注(借鸡生蛋:历史缺货记录永久留存在出库申请单备注)
const buildRemarkWithShortage = (baseRemark: string): string => {
const parts: string[] = []
if (baseRemark) parts.push(baseRemark)
const shortageBoms = Object.keys(bomShortageRecords.value)
if (shortageBoms.length > 0) {
const detail: string[] = []
for (const no of shortageBoms) {
const rec = bomShortageRecords.value[no]
if (!rec?.items?.length) continue
const items = rec.items.map((i: any) => `${i.name}(${i.shortage}个)`).join('、')
detail.push(`${no}: ${items}`)
}
if (detail.length > 0) {
parts.push(`[BOM欠料待补] ${detail.join('')}`)
}
}
return parts.join(' | ')
}
const saveBomShortage = (bomNo: string, shortageList: any[]) => {
const records = { ...bomShortageRecords.value }
if (shortageList.length > 0) {
records[bomNo] = {
time: new Date().toLocaleString('zh-CN'),
items: shortageList.map(i => ({ name: i.name, sku: i.sku, need: i.need, available: i.available, shortage: i.shortage }))
}
} else {
// 本次没有缺货 → 清除该 BOM 的历史缺货记录
delete records[bomNo]
}
bomShortageRecords.value = records
localStorage.setItem(BOM_SHORTAGE_KEY, JSON.stringify(records))
}
// --- 通用逻辑 ---
const handleSelectionChange = (val: any[]) => {
@ -946,7 +1096,7 @@ const confirmSubmitRequest = async () => {
warehouse_location: item.warehouse_location || '',
quantity: item.export_quantity || 0
})),
remark: trimmed,
remark: buildRemarkWithShortage(trimmed),
approver_id: requestApproverId.value
}
@ -963,9 +1113,54 @@ const confirmSubmitRequest = async () => {
}
}
// ★ BOM 清单打印(复用与 confirmPrint 相同的 iframe 打印机制)
const printBomChecklist = () => {
if (bomDetailList.value.length === 0) return ElMessage.warning('请先选择 BOM 并加载清单')
bomPrintTime.value = new Date().toLocaleString('zh-CN')
setTimeout(() => {
const printElement = document.getElementById('bom-print-area')
if (!printElement) return
const iframe = document.createElement('iframe')
iframe.style.position = 'fixed'
iframe.style.right = '0'
iframe.style.bottom = '0'
iframe.style.width = '0'
iframe.style.height = '0'
iframe.style.border = '0'
document.body.appendChild(iframe)
const iframeDoc = iframe.contentWindow?.document
if (!iframeDoc) return
iframeDoc.open()
iframeDoc.write('<html><head><title>BOM 出库清单</title></head><body></body></html>')
iframeDoc.close()
const style = `
body { font-family: 'Microsoft YaHei', sans-serif; }
.print-bom-area { display: block !important; padding: 20px; }
.print-table { width: 100%; border-collapse: collapse; }
.print-table th, .print-table td { border: 1px solid #000; padding: 6px 8px; font-size: 13px; text-align: center; }
.print-table th { background: #f0f0f0; }
`;
const styleEl = iframeDoc.createElement('style')
styleEl.textContent = style
iframeDoc.head.appendChild(styleEl)
iframeDoc.body.appendChild(printElement.cloneNode(true))
setTimeout(() => {
iframe.contentWindow?.focus()
iframe.contentWindow?.print()
setTimeout(() => document.body.removeChild(iframe), 1000)
}, 500)
}, 300)
}
const confirmPrint = async () => {
previewVisible.value = false;
try {
const payload = validSelectedItems.value.map(item => ({
name: item.name, standard: item.standard, quantity: item.export_quantity

View File

@ -15,19 +15,14 @@
</div>
</template>
<!-- 出库模式切换 -->
<!-- 出库模式仅支持按单出库已移除直接出库 -->
<div class="mode-switch-bar">
<el-radio-group v-model="outboundMode" size="default" @change="handleModeChange">
<el-radio-button value="by-request">按单出库</el-radio-button>
<el-radio-button value="direct">直接出库</el-radio-button>
</el-radio-group>
<span class="mode-hint">
{{ outboundMode === 'by-request' ? '需先选择已审批通过的申请单' : '无需审批单,自由扫码出库' }}
</span>
<el-tag type="primary" size="large" style="font-size: 14px;">按单出库</el-tag>
<span class="mode-hint">请先选择已审批通过的申请单再扫码出库</span>
</div>
<!-- 按单出库审批单选择 -->
<div v-if="outboundMode === 'by-request'" class="approval-request-select">
<div class="approval-request-select">
<el-select
v-model="selectedRequestId"
placeholder="请选择已审批通过的出库申请单"
@ -51,10 +46,16 @@
</el-option>
</el-select>
<p class="select-tip">仅显示已通过status=1的审批单</p>
<div v-if="selectedRequest && userStore.hasPermission('outbound_create:operation')" style="margin-top: 8px; display: flex; align-items: center; gap: 8px;">
<el-button type="danger" plain size="small" :loading="closingRequest" @click="handleCloseRequest">
强制完结此单
</el-button>
<span style="color: #F56C6C; font-size: 12px;">作废后该单将从下拉列表移除出库记录将无法关联此单</span>
</div>
</div>
<!-- 按单出库计划清单预览 -->
<div v-if="outboundMode === 'by-request' && selectedRequest" class="planned-items-section">
<div v-if="selectedRequest" class="planned-items-section">
<div class="planned-header">
<span class="planned-title">计划出库清单</span>
<el-tag type="success" size="small">{{ selectedRequest.items?.length || 0 }} </el-tag>
@ -79,7 +80,7 @@
<div class="scan-section">
<template v-if="outboundMode === 'by-request' && !selectedRequestId">
<template v-if="!selectedRequestId">
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">请先选择审批单</span>
@ -289,7 +290,7 @@ import { ref, reactive, nextTick, onUnmounted, onMounted, 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'
import { getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList } from '@/api/outbound'
import { getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList, closeRequest } from '@/api/outbound'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
@ -304,10 +305,11 @@ const barcodeRef = ref()
const formRef = ref()
// ★ 双轨制模式
const outboundMode = ref<'by-request' | 'direct'>('by-request') // 'by-request' | 'direct'
// ★ 已移除直接出库模式,所有出库必须按单出库(通过 selectedRequest 校验)
const approvalRequests = ref<any[]>([])
const selectedRequest = ref<any>(null)
const requestsLoading = ref(false)
const closingRequest = ref(false)
// 签名相关
const showSignatureDialog = ref(false)
@ -355,18 +357,6 @@ const selectedRequestId = computed({
const plannedItems = computed(() => selectedRequest.value?.items ?? [])
// ★ 模式切换
const handleModeChange = () => {
selectedRequest.value = null
selectedRequestId.value = null
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
}
// ★ 加载已审批通过的申请单
const loadApprovalRequests = async () => {
requestsLoading.value = true
@ -397,6 +387,37 @@ const handleRequestChange = (val: number | null) => {
signaturePreviewUrl.value = ''
}
// ★ 强制完结当前选中的申请单(作废,状态 1-已通过 → 4-已完结)
const handleCloseRequest = async () => {
if (!selectedRequest.value) return ElMessage.warning('请先选择要完结的审批单')
const req = selectedRequest.value
try {
await ElMessageBox.confirm(
`确定强制完结审批单【${req.request_no}】吗?\n\n` +
`此操作将使该单从「已通过」列表中移除,且不可恢复。\n` +
`若该单已部分出库,请确认无需再关联此单。`,
'⚠️ 强制完结确认',
{ confirmButtonText: '确认完结', cancelButtonText: '取消', type: 'warning' }
)
} catch (e) {
return // 用户取消
}
closingRequest.value = true
try {
await closeRequest(req.id)
ElMessage.success(`审批单 ${req.request_no} 已完结`)
// 完结后刷新列表并清空当前选择
selectedRequest.value = null
await loadApprovalRequests()
} catch (err: any) {
ElMessage.error(err?.msg || err?.message || '完结失败')
} finally {
closingRequest.value = false
}
}
// ★ 按单出库模式:校验扫码是否在计划内
const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => {
const normalizedName = scannedName.trim()
@ -489,7 +510,7 @@ const handleManualInput = async () => {
if (!code) return
// ★ 按单出库模式:必须先选择申请单
if (outboundMode.value === 'by-request' && !selectedRequest.value) {
if (!selectedRequest.value) {
ElMessage.warning('请先选择要出库的审批申请单')
return
}
@ -503,14 +524,12 @@ const handleManualInput = async () => {
const item = cartItems.value[existIndex]
// ★ 按单模式:追加时仍需校验计划数量
if (outboundMode.value === 'by-request') {
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
const maxQty = parseFloat(item.available_quantity)
@ -540,14 +559,12 @@ const handleManualInput = async () => {
}
// ★ 按单模式:扫码加入前校验是否在计划清单内
if (outboundMode.value === 'by-request') {
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
// 加入购物车
@ -610,6 +627,11 @@ const submitForm = async () => {
}
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加商品')
// ★ 按单出库:提交前必须已选择审批申请单
if (!selectedRequest.value) {
ElMessage.warning('请先选择要出库的审批申请单')
return
}
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
@ -653,7 +675,7 @@ const submitForm = async () => {
// 3. 组装发给后端的包
const submitPayload: any = {
outbound_type: form.outbound_type,
request_id: outboundMode.value === 'by-request' && selectedRequest.value ? selectedRequest.value.id : null,
request_id: selectedRequest.value ? selectedRequest.value.id : null,
consumer_name: form.consumer_name,
operator_name: form.operator_name,
remark: form.remark,

View File

@ -105,7 +105,8 @@
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{row}">
<el-tag v-if="row.status === 'returned'" type="success"></el-tag>
<el-tag v-if="row.status === 'scrapped'" type="info">报废</el-tag>
<el-tag v-else-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>
@ -126,6 +127,17 @@
</div>
</template>
</el-table-column>
<!-- 报废操作列仅未归还记录 + 库管/主管可见 -->
<el-table-column v-if="canScrap" label="操作" width="90" align="center" fixed="right">
<template #default="{row}">
<el-button
v-if="row.status === 'borrowed' || row.status === 'partial_returned'"
type="danger" link size="small"
@click="openScrapDialog(row)"
>报废</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@ -135,11 +147,52 @@
@current-change="handlePage"
style="margin-top:10px; text-align:right"
/>
<!-- 借库报废弹窗 -->
<el-dialog v-model="scrapDialogVisible" title="借库报废(未归还)" width="650px" destroy-on-close>
<el-alert
title="报废后该笔借出不再追讨归还,并从总库存中扣除。此操作不可恢复,请谨慎确认。"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 12px"
/>
<div style="margin-bottom: 12px;">
<div style="font-weight: 600; margin-bottom: 6px;">单号: {{ scrapBorrowNo }}</div>
<div style="font-size: 13px; color: #909399;">请勾选要报废的未归还明细</div>
</div>
<el-table :data="scrapCandidates" border size="small" max-height="300" @selection-change="handleScrapSelection">
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="material_name" label="物料名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="100" show-overflow-tooltip />
<el-table-column label="待还数量" width="80" align="center">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">{{ row.pending_quantity }}</span>
</template>
</el-table-column>
<el-table-column prop="borrow_time" label="借出时间" width="130" />
</el-table>
<el-form label-width="80px" style="margin-top: 14px;">
<el-form-item label="报废原因" required>
<el-input
v-model="scrapReason"
type="textarea"
:rows="2"
placeholder="请填写报废原因,如:物品丢失 / 损坏无法归还"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="scrapDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="scrapSubmitting" @click="confirmScrap">确认报废</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
@ -206,6 +259,67 @@ const searchType = ref('all')
const page = ref(1)
const loading = ref(false)
// ★ 借库报废相关
const canScrap = computed(() =>
userStore.role === 'SUPER_ADMIN' ||
userStore.hasPermission('op_return:operation') ||
userStore.username === 'IRIS'
)
const scrapDialogVisible = ref(false)
const scrapSubmitting = ref(false)
const scrapBorrowNo = ref('')
const scrapCandidates = ref<any[]>([])
const scrapSelected = ref<any[]>([])
const scrapReason = ref('')
// 打开报废弹窗:收集该单号下所有未归还明细
const openScrapDialog = (row: any) => {
const unreturned = (row.children || []).filter((c: any) => (c.pending_quantity || 0) > 0)
scrapBorrowNo.value = row.borrow_no
scrapCandidates.value = unreturned
scrapSelected.value = []
scrapReason.value = ''
scrapDialogVisible.value = true
}
// 勾选变更
const handleScrapSelection = (rows: any[]) => {
scrapSelected.value = rows
}
// 确认报废
const confirmScrap = async () => {
if (scrapSelected.value.length === 0) return ElMessage.warning('请至少选择一条要报废的借出记录')
if (!scrapReason.value.trim()) return ElMessage.warning('请填写报废原因')
try {
await ElMessageBox.confirm(
`确定报废 ${scrapSelected.value.length} 条借出记录吗?\n\n报废后不再追讨归还并从总库存中扣除。此操作不可恢复`,
'⚠️ 报废确认',
{ confirmButtonText: '确认报废', cancelButtonText: '取消', type: 'warning' }
)
} catch (e) {
return // 用户取消
}
scrapSubmitting.value = true
try {
const recordIds = scrapSelected.value.map((c: any) => c.id)
await request({
url: '/v1/transactions/borrow/scrap',
method: 'post',
data: { record_ids: recordIds, reason: scrapReason.value }
})
ElMessage.success(`已报废 ${recordIds.length} 条借出记录`)
scrapDialogVisible.value = false
fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || err?.message || '报废失败')
} finally {
scrapSubmitting.value = false
}
}
const fetchData = async () => {
loading.value = true
try {
@ -231,7 +345,16 @@ const fetchData = async () => {
}
groupMap.get(item.borrow_no).children.push(item)
})
list.value = Array.from(groupMap.values())
// ★ 整单状态重算:所有明细都已报废 → 整单标记 scrapped
list.value = Array.from(groupMap.values()).map(group => {
const allScrapped = group.children.length > 0 &&
group.children.every((c: any) => c.status === 'scrapped')
if (allScrapped) {
group.status = 'scrapped'
}
return group
})
total.value = res.data.total
} finally { loading.value = false }