Compare commits
7 Commits
0c3e57f5b5
...
bf104535e5
| Author | SHA1 | Date | |
|---|---|---|---|
| bf104535e5 | |||
| 3a07f1a272 | |||
| 36e9f500aa | |||
| 39f3f8af45 | |||
| b6ec45a8b8 | |||
| a4bd6f54d4 | |||
| fe52da77e9 |
@ -19,6 +19,8 @@ ssh -t $SERVER "sudo mkdir -p $REMOTE_BACKUP_DIR && \
|
|||||||
sudo mkdir -p inventory-backend inventory-web models_prod && \
|
sudo mkdir -p inventory-backend inventory-web models_prod && \
|
||||||
echo '>> 执行代码备份...' && \
|
echo '>> 执行代码备份...' && \
|
||||||
sudo tar -czf $REMOTE_BACKUP_DIR/code_backup.tar.gz inventory-backend inventory-web docker-compose.prod.yml && \
|
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 模型文件...' && \
|
echo '>> 检查 CLIP 模型文件...' && \
|
||||||
if [ ! -f models_prod/clip_vision.onnx ]; then \
|
if [ ! -f models_prod/clip_vision.onnx ]; then \
|
||||||
echo '⚠️ 警告: models_prod/clip_vision.onnx 不存在!拍照识图功能将无法使用。'; \
|
echo '⚠️ 警告: models_prod/clip_vision.onnx 不存在!拍照识图功能将无法使用。'; \
|
||||||
@ -48,6 +50,13 @@ tar -czf deploy.tar.gz \
|
|||||||
--exclude="inventory-backend/models" \
|
--exclude="inventory-backend/models" \
|
||||||
--exclude="inventory-backend/pgdata" \
|
--exclude="inventory-backend/pgdata" \
|
||||||
--exclude="inventory-backend/pgdata_docker" \
|
--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" \
|
--exclude="data_copy" \
|
||||||
inventory-backend inventory-web docker-compose.prod.yml
|
inventory-backend inventory-web docker-compose.prod.yml
|
||||||
|
|
||||||
@ -81,6 +90,20 @@ ssh -t $SERVER "cd $REMOTE_DIR && \
|
|||||||
sudo tar -xzf deploy.tar.gz && \
|
sudo tar -xzf deploy.tar.gz && \
|
||||||
echo '>> 重启 Docker 容器...' && \
|
echo '>> 重启 Docker 容器...' && \
|
||||||
sudo docker compose -f docker-compose.prod.yml up -d --build backend frontend && \
|
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"
|
sudo rm deploy.tar.gz"
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
|
|||||||
@ -468,6 +468,45 @@ def approve_outbound_request(request_id):
|
|||||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
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. 获取审批单列表
|
# 6. 获取审批单列表
|
||||||
# GET /api/v1/outbound/request
|
# GET /api/v1/outbound/request
|
||||||
|
|||||||
@ -356,7 +356,30 @@ class ScrapService:
|
|||||||
).join(MaterialBase, TransRepair.base_id == MaterialBase.id).filter(
|
).join(MaterialBase, TransRepair.base_id == MaterialBase.id).filter(
|
||||||
MaterialBase.company_name == company_limit
|
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))
|
query = query.filter(TransScrap.id.in_(all_matches))
|
||||||
|
|
||||||
# 按时间倒序
|
# 按时间倒序
|
||||||
@ -390,7 +413,24 @@ class ScrapService:
|
|||||||
material_name = ''
|
material_name = ''
|
||||||
spec_model = ''
|
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)
|
repair = TransRepair.query.get(r.stock_id)
|
||||||
if repair:
|
if repair:
|
||||||
|
|||||||
@ -110,6 +110,38 @@ def submit_return():
|
|||||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
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'])
|
@trans_bp.route('/records', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
|||||||
@ -82,7 +82,7 @@ class OutboundApproval(db.Model):
|
|||||||
'applicant_name': self._get_user_name(self.applicant_id),
|
'applicant_name': self._get_user_name(self.applicant_id),
|
||||||
'remark': self.remark,
|
'remark': self.remark,
|
||||||
'status': self.status,
|
'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(),
|
'allowed_approvers': self.get_allowed_approvers(),
|
||||||
'actual_approver_id': self.actual_approver_id,
|
'actual_approver_id': self.actual_approver_id,
|
||||||
'approver_name': self._get_user_name(self.actual_approver_id) if self.actual_approver_id else None,
|
'approver_name': self._get_user_name(self.actual_approver_id) if self.actual_approver_id else None,
|
||||||
|
|||||||
@ -293,7 +293,8 @@ class BomService:
|
|||||||
BomTable,
|
BomTable,
|
||||||
MaterialBase.name.label('child_name'),
|
MaterialBase.name.label('child_name'),
|
||||||
MaterialBase.spec_model.label('child_spec')
|
MaterialBase.spec_model.label('child_spec')
|
||||||
).join(
|
).outerjoin(
|
||||||
|
# ★ LEFT JOIN:子件物料被删除后仍返回父件 BOM 结构,避免误报 "BOM 不存在"
|
||||||
MaterialBase, BomTable.child_id == MaterialBase.id
|
MaterialBase, BomTable.child_id == MaterialBase.id
|
||||||
).filter(
|
).filter(
|
||||||
BomTable.bom_no == bom_no
|
BomTable.bom_no == bom_no
|
||||||
@ -322,7 +323,7 @@ class BomService:
|
|||||||
for bom, child_name, child_spec in rows:
|
for bom, child_name, child_spec in rows:
|
||||||
children.append({
|
children.append({
|
||||||
'child_id': bom.child_id,
|
'child_id': bom.child_id,
|
||||||
'child_name': child_name,
|
'child_name': child_name or '[已删除物料]',
|
||||||
'child_spec': child_spec or '',
|
'child_spec': child_spec or '',
|
||||||
'dosage': float(bom.dosage) if bom.dosage else 0.0,
|
'dosage': float(bom.dosage) if bom.dosage else 0.0,
|
||||||
'remark': bom.remark or ''
|
'remark': bom.remark or ''
|
||||||
|
|||||||
@ -994,6 +994,52 @@ class OutboundApprovalService:
|
|||||||
logger.error(f"[Email] 外层发送异常: {e}")
|
logger.error(f"[Email] 外层发送异常: {e}")
|
||||||
|
|
||||||
@staticmethod
|
@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):
|
def get_request_list(page=1, per_page=10, applicant_id=None, status=None):
|
||||||
"""
|
"""
|
||||||
获取审批单列表
|
获取审批单列表
|
||||||
|
|||||||
@ -327,6 +327,86 @@ class TransService:
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
raise e
|
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
|
@staticmethod
|
||||||
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all'):
|
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all'):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -239,7 +239,7 @@ const handleLogout = () => {
|
|||||||
<footer v-if="!isLoginPage" class="app-footer">
|
<footer v-if="!isLoginPage" class="app-footer">
|
||||||
<span class="version-tag">
|
<span class="version-tag">
|
||||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||||
当前版本:V3.68
|
当前版本:V3.69
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@ -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 匹配库存(替代前端 while(true) 全量加载)
|
||||||
* 根据 BOM 子件的 base_id 列表,服务端精确查询有库存的记录
|
* 根据 BOM 子件的 base_id 列表,服务端精确查询有库存的记录
|
||||||
|
|||||||
@ -223,7 +223,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<el-table :data="bomDetailList" size="small" border style="margin-top: 8px; width: 100%;">
|
<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="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">
|
<el-table-column label="需求量" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span>{{ row.need }}</span>
|
<span>{{ row.need }}</span>
|
||||||
@ -239,14 +239,21 @@
|
|||||||
<span :style="{ color: row.shortage > 0 ? '#F56C6C' : '#67C23A', fontWeight: 'bold' }">{{ row.shortage > 0 ? row.shortage : '✓' }}</span>
|
<span :style="{ color: row.shortage > 0 ? '#F56C6C' : '#67C23A', fontWeight: 'bold' }">{{ row.shortage > 0 ? row.shortage : '✓' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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-table>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<div style="margin-left: 100px; color: #909399; font-size: 12px;">
|
<div style="margin-left: 100px; color: #909399; font-size: 12px;">
|
||||||
注意:系统将自动计算所需原料数量 ( 配方用量 × 套数 )。
|
注意:系统将自动计算所需原料数量 ( 配方用量 × 套数 )。缺货物料不会加入出库单,请先补货。
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="bomSelectVisible = false">取消</el-button>
|
<el-button @click="bomSelectVisible = false">取消</el-button>
|
||||||
|
<el-button v-if="bomDetailList.length > 0" :icon="Printer" @click="printBomChecklist">打印 BOM 清单</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="userStore.hasPermission('outbound_selection:operation')"
|
v-if="userStore.hasPermission('outbound_selection:operation')"
|
||||||
type="primary"
|
type="primary"
|
||||||
@ -257,6 +264,59 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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
|
<el-dialog
|
||||||
v-model="previewVisible"
|
v-model="previewVisible"
|
||||||
title="出库单核对与打印"
|
title="出库单核对与打印"
|
||||||
@ -554,7 +614,8 @@ const bomDetailList = computed(() => {
|
|||||||
const shortage = Math.max(0, totalNeed - stock)
|
const shortage = Math.max(0, totalNeed - stock)
|
||||||
return {
|
return {
|
||||||
name: bomItem.child_name || bomItem.name || '未知物料',
|
name: bomItem.child_name || bomItem.name || '未知物料',
|
||||||
sku: bomItem.child_sku || bomItem.sku || '-',
|
// ★ 用规格型号替代 SKU(getBomWithStock 返回的 children 无 sku,只有 child_spec)
|
||||||
|
sku: bomItem.child_spec || bomItem.spec_model || '-',
|
||||||
need: totalNeed,
|
need: totalNeed,
|
||||||
available: stock,
|
available: stock,
|
||||||
shortage
|
shortage
|
||||||
@ -569,6 +630,11 @@ const bomDetailList = computed(() => {
|
|||||||
|
|
||||||
const hasShortage = computed(() => bomDetailList.value.some((item: any) => item.shortage > 0) && bomSets.value > maxBuildableSets.value)
|
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) => {
|
const getTypeTag = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@ -741,6 +807,23 @@ const openBomSelect = async () => {
|
|||||||
bomOptions.value = Array.from(groupMap.entries()).map(([category, items]) => ({
|
bomOptions.value = Array.from(groupMap.entries()).map(([category, items]) => ({
|
||||||
category, count: items.length, 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) {
|
} catch (e) {
|
||||||
ElMessage.error('加载 BOM 列表失败')
|
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
|
const bomRows = currentBomDetail.value
|
||||||
|
|
||||||
// ★ 服务端精确查询 BOM 子件匹配库存(替代前端 while(true) 全量加载)
|
// ★ 服务端精确查询 BOM 子件匹配库存(替代前端 while(true) 全量加载)
|
||||||
@ -818,12 +922,58 @@ const confirmBomAdd = async () => {
|
|||||||
if (addedCount > 0) {
|
if (addedCount > 0) {
|
||||||
const tip = skippedCount > 0 ? `(跳过 ${skippedCount} 种缺货物料)` : ''
|
const tip = skippedCount > 0 ? `(跳过 ${skippedCount} 种缺货物料)` : ''
|
||||||
ElMessage.success(`成功添加 ${addedCount} 类物料${tip}`)
|
ElMessage.success(`成功添加 ${addedCount} 类物料${tip}`)
|
||||||
|
|
||||||
|
// ★ 记录本次缺货清单(持久化,防止下次重复出/漏出)
|
||||||
|
saveBomShortage(selectedBomNo.value, bomDetailList.value.filter(i => i.shortage > 0))
|
||||||
bomSelectVisible.value = false
|
bomSelectVisible.value = false
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning('该 BOM 所有物料库存均为 0')
|
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[]) => {
|
const handleSelectionChange = (val: any[]) => {
|
||||||
@ -946,7 +1096,7 @@ const confirmSubmitRequest = async () => {
|
|||||||
warehouse_location: item.warehouse_location || '',
|
warehouse_location: item.warehouse_location || '',
|
||||||
quantity: item.export_quantity || 0
|
quantity: item.export_quantity || 0
|
||||||
})),
|
})),
|
||||||
remark: trimmed,
|
remark: buildRemarkWithShortage(trimmed),
|
||||||
approver_id: requestApproverId.value
|
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 () => {
|
const confirmPrint = async () => {
|
||||||
previewVisible.value = false;
|
previewVisible.value = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = validSelectedItems.value.map(item => ({
|
const payload = validSelectedItems.value.map(item => ({
|
||||||
name: item.name, standard: item.standard, quantity: item.export_quantity
|
name: item.name, standard: item.standard, quantity: item.export_quantity
|
||||||
|
|||||||
@ -15,19 +15,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ★ 出库模式切换 -->
|
<!-- ★ 出库模式:仅支持按单出库(已移除直接出库) -->
|
||||||
<div class="mode-switch-bar">
|
<div class="mode-switch-bar">
|
||||||
<el-radio-group v-model="outboundMode" size="default" @change="handleModeChange">
|
<el-tag type="primary" size="large" style="font-size: 14px;">按单出库</el-tag>
|
||||||
<el-radio-button value="by-request">按单出库</el-radio-button>
|
<span class="mode-hint">请先选择已审批通过的申请单,再扫码出库</span>
|
||||||
<el-radio-button value="direct">直接出库</el-radio-button>
|
|
||||||
</el-radio-group>
|
|
||||||
<span class="mode-hint">
|
|
||||||
{{ outboundMode === 'by-request' ? '需先选择已审批通过的申请单' : '无需审批单,自由扫码出库' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ★ 按单出库:审批单选择 -->
|
<!-- ★ 按单出库:审批单选择 -->
|
||||||
<div v-if="outboundMode === 'by-request'" class="approval-request-select">
|
<div class="approval-request-select">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="selectedRequestId"
|
v-model="selectedRequestId"
|
||||||
placeholder="请选择已审批通过的出库申请单"
|
placeholder="请选择已审批通过的出库申请单"
|
||||||
@ -51,10 +46,16 @@
|
|||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
<p class="select-tip">仅显示已通过(status=1)的审批单</p>
|
<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>
|
||||||
|
|
||||||
<!-- ★ 按单出库:计划清单预览 -->
|
<!-- ★ 按单出库:计划清单预览 -->
|
||||||
<div v-if="outboundMode === 'by-request' && selectedRequest" class="planned-items-section">
|
<div v-if="selectedRequest" class="planned-items-section">
|
||||||
<div class="planned-header">
|
<div class="planned-header">
|
||||||
<span class="planned-title">计划出库清单</span>
|
<span class="planned-title">计划出库清单</span>
|
||||||
<el-tag type="success" size="small">{{ selectedRequest.items?.length || 0 }} 种</el-tag>
|
<el-tag type="success" size="small">{{ selectedRequest.items?.length || 0 }} 种</el-tag>
|
||||||
@ -79,7 +80,7 @@
|
|||||||
|
|
||||||
<div class="scan-section">
|
<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;">
|
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
|
||||||
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
||||||
<span class="text">请先选择审批单</span>
|
<span class="text">请先选择审批单</span>
|
||||||
@ -289,7 +290,7 @@ import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
||||||
import QrScanner from '@/components/QrScanner/index.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 { uploadFile } from '@/api/common/upload'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
@ -304,10 +305,11 @@ const barcodeRef = ref()
|
|||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
||||||
// ★ 双轨制模式
|
// ★ 双轨制模式
|
||||||
const outboundMode = ref<'by-request' | 'direct'>('by-request') // 'by-request' | 'direct'
|
// ★ 已移除直接出库模式,所有出库必须按单出库(通过 selectedRequest 校验)
|
||||||
const approvalRequests = ref<any[]>([])
|
const approvalRequests = ref<any[]>([])
|
||||||
const selectedRequest = ref<any>(null)
|
const selectedRequest = ref<any>(null)
|
||||||
const requestsLoading = ref(false)
|
const requestsLoading = ref(false)
|
||||||
|
const closingRequest = ref(false)
|
||||||
|
|
||||||
// 签名相关
|
// 签名相关
|
||||||
const showSignatureDialog = ref(false)
|
const showSignatureDialog = ref(false)
|
||||||
@ -355,18 +357,6 @@ const selectedRequestId = computed({
|
|||||||
|
|
||||||
const plannedItems = computed(() => selectedRequest.value?.items ?? [])
|
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 () => {
|
const loadApprovalRequests = async () => {
|
||||||
requestsLoading.value = true
|
requestsLoading.value = true
|
||||||
@ -397,6 +387,37 @@ const handleRequestChange = (val: number | null) => {
|
|||||||
signaturePreviewUrl.value = ''
|
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 validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => {
|
||||||
const normalizedName = scannedName.trim()
|
const normalizedName = scannedName.trim()
|
||||||
@ -489,7 +510,7 @@ const handleManualInput = async () => {
|
|||||||
if (!code) return
|
if (!code) return
|
||||||
|
|
||||||
// ★ 按单出库模式:必须先选择申请单
|
// ★ 按单出库模式:必须先选择申请单
|
||||||
if (outboundMode.value === 'by-request' && !selectedRequest.value) {
|
if (!selectedRequest.value) {
|
||||||
ElMessage.warning('请先选择要出库的审批申请单')
|
ElMessage.warning('请先选择要出库的审批申请单')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -503,14 +524,12 @@ const handleManualInput = async () => {
|
|||||||
const item = cartItems.value[existIndex]
|
const item = cartItems.value[existIndex]
|
||||||
|
|
||||||
// ★ 按单模式:追加时仍需校验计划数量
|
// ★ 按单模式:追加时仍需校验计划数量
|
||||||
if (outboundMode.value === 'by-request') {
|
const err = validateAgainstPlan(item.name, item.spec_model, 1)
|
||||||
const err = validateAgainstPlan(item.name, item.spec_model, 1)
|
if (err) {
|
||||||
if (err) {
|
ElMessage.error(err)
|
||||||
ElMessage.error(err)
|
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
barcodeInput.value = ''
|
||||||
barcodeInput.value = ''
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxQty = parseFloat(item.available_quantity)
|
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)
|
||||||
const err = validateAgainstPlan(item.name, item.spec_model, 1)
|
if (err) {
|
||||||
if (err) {
|
ElMessage.error(err)
|
||||||
ElMessage.error(err)
|
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
barcodeInput.value = ''
|
||||||
barcodeInput.value = ''
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加入购物车
|
// 加入购物车
|
||||||
@ -610,6 +627,11 @@ const submitForm = async () => {
|
|||||||
}
|
}
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
if (cartItems.value.length === 0) return ElMessage.warning('请先添加商品')
|
if (cartItems.value.length === 0) return ElMessage.warning('请先添加商品')
|
||||||
|
// ★ 按单出库:提交前必须已选择审批申请单
|
||||||
|
if (!selectedRequest.value) {
|
||||||
|
ElMessage.warning('请先选择要出库的审批申请单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await formRef.value.validate(async (valid: boolean) => {
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
@ -653,7 +675,7 @@ const submitForm = async () => {
|
|||||||
// 3. 组装发给后端的包
|
// 3. 组装发给后端的包
|
||||||
const submitPayload: any = {
|
const submitPayload: any = {
|
||||||
outbound_type: form.outbound_type,
|
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,
|
consumer_name: form.consumer_name,
|
||||||
operator_name: form.operator_name,
|
operator_name: form.operator_name,
|
||||||
remark: form.remark,
|
remark: form.remark,
|
||||||
|
|||||||
@ -105,7 +105,8 @@
|
|||||||
|
|
||||||
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
|
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
|
||||||
<template #default="{row}">
|
<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-if="row.status === 'partial_returned'" type="warning">部分归还</el-tag>
|
||||||
<el-tag v-else type="danger">未还</el-tag>
|
<el-tag v-else type="danger">未还</el-tag>
|
||||||
</template>
|
</template>
|
||||||
@ -126,6 +127,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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-table>
|
||||||
|
|
||||||
<el-pagination
|
<el-pagination
|
||||||
@ -135,11 +147,52 @@
|
|||||||
@current-change="handlePage"
|
@current-change="handlePage"
|
||||||
style="margin-top:10px; text-align:right"
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 request from '@/utils/request'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/zh-cn'
|
import 'dayjs/locale/zh-cn'
|
||||||
@ -206,6 +259,67 @@ const searchType = ref('all')
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const loading = ref(false)
|
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 () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@ -231,7 +345,16 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
groupMap.get(item.borrow_no).children.push(item)
|
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
|
total.value = res.data.total
|
||||||
} finally { loading.value = false }
|
} finally { loading.value = false }
|
||||||
|
|||||||
Reference in New Issue
Block a user