3 Commits

9 changed files with 1209 additions and 1 deletions

View File

@ -126,6 +126,17 @@ def create_app():
except ImportError as e:
print(f"❌ 错误: Scrap 模块导入失败: {e}")
# -----------------------------------------------------
# 2.8 注册采购管理模块
# -----------------------------------------------------
try:
from app.api.v1.purchase import purchase_bp
app.register_blueprint(purchase_bp, url_prefix='/api/v1/purchase')
app.register_blueprint(purchase_bp, url_prefix='/api/purchase', name='purchase_legacy')
print("✅ Purchase 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Purchase 模块导入失败: {e}")
# -----------------------------------------------------
# 2.7 注册 BOM 模块
# -----------------------------------------------------

View File

@ -0,0 +1,202 @@
import traceback
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.services.purchase_service import PurchaseService
from app.utils.decorators import permission_required
purchase_bp = Blueprint('purchase', __name__, url_prefix='/api/v1/purchase')
def get_current_user_id():
"""获取当前登录用户ID"""
identity = get_jwt_identity()
return identity
def get_current_user_role():
"""获取当前用户角色"""
from flask_jwt_extended import get_jwt
claims = get_jwt()
return claims.get('role')
# --------------------------------------------------------
# 1. 采购申请列表
# GET /api/v1/purchase
# --------------------------------------------------------
@purchase_bp.route('', methods=['GET'])
@jwt_required()
def get_purchase_list():
"""获取采购申请列表"""
try:
page = int(request.args.get('page', 1))
per_page = int(request.args.get('limit', 20))
status = request.args.get('status')
status = int(status) if status is not None else None
user_id = get_current_user_id()
role = get_current_user_role()
# 普通用户SUPERVISOR 和 SUPER_ADMIN 除外)只看自己提交的
is_admin = role in ('SUPERVISOR', 'SUPER_ADMIN')
result = PurchaseService.get_purchase_list(
page=page,
per_page=per_page,
requester_id=None if is_admin else user_id,
status=status
)
return jsonify({'code': 200, 'msg': '获取成功', 'data': result})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取失败: {str(e)}'}), 500
# --------------------------------------------------------
# 2. 创建采购申请
# POST /api/v1/purchase
# --------------------------------------------------------
@purchase_bp.route('', methods=['POST'])
@jwt_required()
def create_purchase_request():
"""创建采购申请"""
try:
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
user_id = get_current_user_id()
# 必填校验
required = ['name', 'quantity', 'purchase_date', 'approver_id']
for field in required:
if field not in data or str(data.get(field, '')).strip() == '':
return jsonify({'code': 400, 'msg': f'缺少必填字段: {field}'}), 400
purchase = PurchaseService.create_purchase_request(data, requester_id=user_id)
return jsonify({
'code': 200,
'msg': '创建成功',
'data': purchase.to_dict()
}), 200
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
# --------------------------------------------------------
# 3. 获取采购申请详情
# GET /api/v1/purchase/<id>
# --------------------------------------------------------
@purchase_bp.route('/<int:purchase_id>', methods=['GET'])
@jwt_required()
def get_purchase_detail(purchase_id):
"""获取采购申请详情"""
try:
purchase = PurchaseService.get_purchase_by_id(purchase_id)
if not purchase:
return jsonify({'code': 404, 'msg': '采购申请不存在'}), 404
# 普通用户只能看自己的
user_id = get_current_user_id()
role = get_current_user_role()
is_admin = role in ('SUPERVISOR', 'SUPER_ADMIN')
if not is_admin and purchase['requester_id'] != user_id:
return jsonify({'code': 403, 'msg': '无权查看此申请'}), 403
return jsonify({'code': 200, 'msg': '获取成功', 'data': purchase}), 200
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 4. 审批采购申请
# PATCH /api/v1/purchase/<id>/approve
# --------------------------------------------------------
@purchase_bp.route('/<int:purchase_id>/approve', methods=['PATCH'])
@jwt_required()
def approve_purchase_request(purchase_id):
"""审批采购申请"""
try:
user_id = get_current_user_id()
role = get_current_user_role()
if role not in ('SUPERVISOR', 'SUPER_ADMIN'):
return jsonify({'code': 403, 'msg': '只有主管或超级管理员可以审批'}), 403
data = request.get_json() or {}
action = data.get('action', 'approve')
reject_reason = data.get('reject_reason')
if action not in ('approve', 'reject'):
return jsonify({'code': 400, 'msg': '无效的审批操作'}), 400
if action == 'reject' and not reject_reason:
return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400
purchase = PurchaseService.approve_purchase_request(
purchase_id=purchase_id,
user_id=user_id,
action=action,
reject_reason=reject_reason
)
msg = '审批通过' if action == 'approve' else '已驳回'
return jsonify({'code': 200, 'msg': msg, 'data': purchase.to_dict()}), 200
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
# --------------------------------------------------------
# 5. 获取可选审批人列表
# GET /api/v1/purchase/approvers
# --------------------------------------------------------
@purchase_bp.route('/approvers', methods=['GET'])
@jwt_required()
def get_purchase_approvers():
"""获取可选审批人列表(主管+超管)"""
try:
from app.models.system import SysUser
users = SysUser.query.filter(
SysUser.role.in_(['SUPER_ADMIN', 'SUPERVISOR']),
SysUser.status == 'active'
).all()
return jsonify({
'code': 200,
'msg': '获取成功',
'data': [
{'id': u.id, 'username': u.username, 'email': u.email or '', 'role': u.role}
for u in users
]
}), 200
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 6. 根据名称/规格自动补全
# GET /api/v1/purchase/auto-fill?keyword=xxx
# --------------------------------------------------------
@purchase_bp.route('/auto-fill', methods=['GET'])
@jwt_required()
def auto_fill_purchase():
"""根据名称或规格自动补全另一个字段"""
try:
keyword = request.args.get('keyword', '').strip()
if not keyword:
return jsonify({'code': 200, 'msg': 'ok', 'data': None}), 200
result = PurchaseService.auto_fill_from_material(keyword)
return jsonify({'code': 200, 'msg': 'ok', 'data': result}), 200
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -15,5 +15,11 @@ except ImportError:
# 4. 出库记录 (如果有BuyService 用到了 TransOutbound)
try:
from app.models.outbound import TransOutbound, OutboundApproval
except ImportError:
pass
# 5. 采购申请
try:
from app.models.purchase import PurchaseRequest
except ImportError:
pass

View File

@ -0,0 +1,86 @@
import json
from app.extensions import db, beijing_time
from datetime import datetime
class PurchaseRequest(db.Model):
"""
采购申请表
"""
__tablename__ = 'purchase_request'
id = db.Column(db.Integer, primary_key=True)
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
name = db.Column(db.String(255), nullable=False, comment='采购名称')
spec_model = db.Column(db.String(255), comment='规格型号')
quantity = db.Column(db.Numeric(19, 4), nullable=False, comment='采购数量')
purchase_date = db.Column(db.Date, nullable=False, comment='采购时间')
supplier_link = db.Column(db.String(500), comment='商家地址链接')
remark = db.Column(db.Text, comment='备注信息')
images = db.Column(db.Text, comment='图片列表JSON')
unit_price = db.Column(db.Numeric(19, 4), default=0, comment='单价')
total_price = db.Column(db.Numeric(19, 4), default=0, comment='总价')
status = db.Column(db.Integer, default=0, nullable=False)
requester_id = db.Column(db.Integer, nullable=False, index=True)
approver_id = db.Column(db.Integer, index=True)
approved_at = db.Column(db.DateTime)
reject_reason = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=beijing_time, nullable=False)
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time, nullable=False)
def _safe_parse_json(self, value):
if value is None:
return []
if isinstance(value, (list, dict)):
return value
if isinstance(value, str):
val = value.strip()
if not val:
return []
try:
parsed = json.loads(val)
return parsed if isinstance(parsed, list) else []
except Exception:
return []
return []
def get_images(self):
return self._safe_parse_json(self.images)
def set_images(self, image_list):
self.images = json.dumps(image_list, ensure_ascii=False) if image_list else '[]'
def _get_user_name(self, user_id):
if not user_id:
return ""
from app.models.system import SysUser
try:
user = db.session.get(SysUser, user_id)
return user.username if user else f"未知用户({user_id})"
except Exception:
return f"用户({user_id})"
def to_dict(self):
return {
'id': self.id,
'request_no': self.request_no,
'name': self.name,
'spec_model': self.spec_model or '',
'quantity': float(self.quantity) if self.quantity else 0,
'purchase_date': self.purchase_date.strftime('%Y-%m-%d') if self.purchase_date else None,
'supplier_link': self.supplier_link or '',
'remark': self.remark or '',
'images': self.get_images(),
'unit_price': float(self.unit_price) if self.unit_price else 0,
'total_price': float(self.total_price) if self.total_price else 0,
'status': self.status,
'status_text': ['待审批', '已通过', '已驳回', '已完成'][self.status] if self.status in [0, 1, 2, 3] else '未知',
'requester_id': self.requester_id,
'requester_name': self._get_user_name(self.requester_id),
'approver_id': self.approver_id,
'approver_name': self._get_user_name(self.approver_id) if self.approver_id else None,
'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None,
'reject_reason': self.reject_reason or '',
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
}

View File

@ -0,0 +1,231 @@
import json
from datetime import datetime, timezone, timedelta, date
from sqlalchemy import func
from app.extensions import db
from app.models.purchase import PurchaseRequest
from app.models.base import MaterialBase
class PurchaseService:
@staticmethod
def generate_request_no():
"""生成采购单号: PUR-yyyyMMdd-HHmm-当日流水(4位)"""
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
date_str = now.strftime('%Y%m%d')
time_str = now.strftime('%H%M')
prefix = f"PUR-{date_str}-{time_str}-"
existing_count = db.session.query(func.count(func.distinct(PurchaseRequest.request_no))) \
.filter(PurchaseRequest.request_no.like(f"{prefix}%")).scalar()
return f"{prefix}{(existing_count + 1):04d}"
@staticmethod
def auto_fill_from_material(keyword: str):
"""
根据 name 或 spec_model 自动补全另一个字段
keyword: 用户输入的名称或规格
返回: {'name': ..., 'spec_model': ...} 或 None
"""
if not keyword:
return None
material = MaterialBase.query.filter(
(MaterialBase.name.ilike(f'%{keyword}%')) |
(MaterialBase.spec_model.ilike(f'%{keyword}%'))
).first()
if material:
return {
'name': material.name,
'spec_model': material.spec_model or ''
}
return None
@staticmethod
def create_purchase_request(data: dict, requester_id: int):
"""
创建采购申请
data 包含: name, spec_model, quantity, purchase_date, supplier_link, remark, images,
unit_price, total_price, approver_id
"""
request_no = PurchaseService.generate_request_no()
purchase_date = data.get('purchase_date')
if isinstance(purchase_date, str):
purchase_date = datetime.strptime(purchase_date, '%Y-%m-%d').date()
elif isinstance(purchase_date, datetime):
purchase_date = purchase_date.date()
purchase = PurchaseRequest(
request_no=request_no,
name=data['name'],
spec_model=data.get('spec_model', ''),
quantity=float(data['quantity']),
purchase_date=purchase_date,
supplier_link=data.get('supplier_link', ''),
remark=data.get('remark', ''),
images=json.dumps(data.get('images', []), ensure_ascii=False) if data.get('images') else '[]',
unit_price=float(data.get('unit_price', 0) or 0),
total_price=float(data.get('total_price', 0) or 0),
requester_id=requester_id,
approver_id=data.get('approver_id'),
status=0
)
db.session.add(purchase)
db.session.commit()
# 发送邮件给审批人
PurchaseService._notify_new_request(purchase)
return purchase
@staticmethod
def approve_purchase_request(purchase_id: int, user_id: int, action: str, reject_reason: str = None):
"""
审批采购申请
action: 'approve''reject'
"""
purchase = db.session.get(PurchaseRequest, purchase_id)
if not purchase:
raise ValueError("采购申请不存在")
if purchase.status != 0:
raise ValueError("当前状态不允许审批")
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
if action == 'approve':
purchase.status = 1
purchase.approver_id = user_id
purchase.approved_at = now
db.session.commit()
PurchaseService._notify_approved(purchase)
elif action == 'reject':
purchase.status = 2
purchase.approver_id = user_id
purchase.approved_at = now
purchase.reject_reason = reject_reason or ''
db.session.commit()
PurchaseService._notify_rejected(purchase)
else:
raise ValueError("无效的审批操作")
return purchase
@staticmethod
def get_purchase_list(page=1, per_page=20, requester_id=None, status=None):
"""获取采购申请列表,普通用户只看自己的,主管/超管看全部"""
query = PurchaseRequest.query
if requester_id is not None:
query = query.filter(PurchaseRequest.requester_id == requester_id)
if status is not None:
query = query.filter(PurchaseRequest.status == status)
query = query.order_by(PurchaseRequest.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
return {
'items': [p.to_dict() for p in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}
@staticmethod
def get_purchase_by_id(purchase_id: int):
purchase = db.session.get(PurchaseRequest, purchase_id)
return purchase.to_dict() if purchase else None
@staticmethod
def _notify_new_request(purchase):
"""发送新申请邮件给审批人"""
try:
from app.utils.email_service import send_email
from app.models.system import SysUser
if not purchase.approver_id:
return
approver = db.session.get(SysUser, purchase.approver_id)
if not approver or not approver.email:
return
subject = f"【待审批】采购申请单 {purchase.request_no}"
content = f"""您好,
您有一笔新的采购申请待审批:
申请单号:{purchase.request_no}
采购物品:{purchase.name}
规格型号:{purchase.spec_model or '-'}
采购数量:{float(purchase.quantity)}
申请时间:{purchase.created_at.strftime('%Y-%m-%d %H:%M') if purchase.created_at else '-'}
备注说明:{purchase.remark or ''}
请登录仓库管理系统进行审批。
此邮件由系统自动发送,请勿回复。
"""
send_email(approver.email, subject, content)
except Exception as e:
try:
from flask import current_app
current_app.logger.error(f"[Email] 采购申请通知审批人失败: {e}")
except Exception:
print(f"[Email] 采购申请通知审批人失败: {e}")
@staticmethod
def _notify_approved(purchase):
"""审批通过后通知申请人"""
try:
from app.utils.email_service import send_email
from app.models.system import SysUser
requester = db.session.get(SysUser, purchase.requester_id)
if not requester or not requester.email:
return
subject = f"【已通过】采购申请单 {purchase.request_no}"
content = f"""{"尊敬的 " + requester.username + ",您好" if requester.username else "您好"}
您的采购申请单 {purchase.request_no}{purchase.name})已审批通过,现已交给库管。
待库管完成入库后,您可在系统中查询采购记录。
此邮件由系统自动发送,请勿回复。
"""
send_email(requester.email, subject, content)
except Exception as e:
try:
from flask import current_app
current_app.logger.error(f"[Email] 采购申请通过通知申请人失败: {e}")
except Exception:
print(f"[Email] 采购申请通过通知申请人失败: {e}")
@staticmethod
def _notify_rejected(purchase):
"""审批驳回后通知申请人"""
try:
from app.utils.email_service import send_email
from app.models.system import SysUser
requester = db.session.get(SysUser, purchase.requester_id)
if not requester or not requester.email:
return
subject = f"【已驳回】采购申请单 {purchase.request_no}"
content = f"""{"尊敬的 " + requester.username + ",您好" if requester.username else "您好"}
您的采购申请单 {purchase.request_no}{purchase.name})已被驳回。
驳回原因:{purchase.reject_reason or '未说明'}
请登录仓库管理系统查看详情。
此邮件由系统自动发送,请勿回复。
"""
send_email(requester.email, subject, content)
except Exception as e:
print(f"[Email] 采购申请驳回通知失败: {e}")

View File

@ -0,0 +1,91 @@
import request from '@/utils/request'
export interface PurchaseItem {
id?: number
request_no?: string
name: string
spec_model?: string
quantity: number
purchase_date: string
supplier_link?: string
remark?: string
images?: string[]
unit_price?: number
total_price?: number
status: number
status_text?: string
requester_id?: number
requester_name?: string
approver_id?: number
approver_name?: string
approved_at?: string
reject_reason?: string
created_at?: string
updated_at?: string
}
export interface Approver {
id: number
username: string
email: string
role: string
}
// 获取采购申请列表
export function getPurchaseList(params: {
page?: number
limit?: number
status?: number
}) {
return request({
url: '/purchase',
method: 'get',
params
})
}
// 创建采购申请
export function createPurchase(data: PurchaseItem) {
return request({
url: '/purchase',
method: 'post',
data
})
}
// 获取采购申请详情
export function getPurchaseDetail(id: number) {
return request({
url: `/purchase/${id}`,
method: 'get'
})
}
// 审批采购申请
export function approvePurchase(id: number, data: {
action: 'approve' | 'reject'
reject_reason?: string
}) {
return request({
url: `/purchase/${id}/approve`,
method: 'patch',
data
})
}
// 获取可选审批人列表
export function getPurchaseApprovers() {
return request({
url: '/purchase/approvers',
method: 'get'
})
}
// 根据名称/规格自动补全
export function autoFillPurchase(keyword: string) {
return request({
url: '/purchase/auto-fill',
method: 'get',
params: { keyword }
})
}

View File

@ -180,6 +180,21 @@ const routes: Array<RouteRecordRaw> = [
]
},
// 5.1 采购管理
{
path: '/purchase',
component: Layout,
meta: { title: '采购管理', icon: 'ShoppingCart' },
children: [
{
path: '',
name: 'PurchaseList',
component: () => import('@/views/purchase/index.vue'),
meta: { title: '采购申请' }
}
]
},
// 6. 借库管理
{
path: '/operation',

View File

@ -335,7 +335,7 @@
<el-option
v-for="user in approvers"
:key="user.id"
:label="`${user.username} (${user.role === 'SUPER_ADMIN' ? '超级管理员' : '主管'})`"
:label="`${user.username}`"
:value="user.id"
/>
</el-select>

View File

@ -0,0 +1,566 @@
<template>
<div class="app-container">
<!-- 顶部工具栏 -->
<div class="filter-container">
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已完成</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
<el-button type="success" :icon="Plus" @click="openCreateDialog">
新建采购申请
</el-button>
</div>
<!-- 数据表格 -->
<el-table v-loading="loading" :data="list" border stripe style="margin-top: 16px;" row-key="id">
<el-table-column prop="request_no" label="申请单号" width="180" />
<el-table-column prop="name" label="采购物品" min-width="150" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="80" align="center" />
<el-table-column prop="purchase_date" label="采购日期" width="110" />
<el-table-column label="单价/总价" width="120" align="right">
<template #default="{ row }">
<span v-if="row.unit_price || row.total_price">
{{ row.unit_price || '-' }} / {{ row.total_price || '-' }}
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="requester_name" label="申请人" width="100" />
<el-table-column prop="approver_name" label="审批人" width="100" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDetailDialog(row)">详情</el-button>
<template v-if="row.status === 0 && canApprove">
<el-button type="success" link size="small" @click="handleApprove(row)">通过</el-button>
<el-button type="danger" link size="small" @click="openRejectDialog(row)">驳回</el-button>
</template>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
background
style="margin-top: 16px; justify-content: flex-end; display: flex;"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
<!-- ========== 新建/编辑弹窗 ========== -->
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="700px" destroy-on-close :close-on-click-modal="false">
<el-form ref="formRef" :model="form" label-width="110px">
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="采购物品" required>
<el-select
v-model="materialBaseId"
filterable
remote
reserve-keyword
clearable
placeholder="输入名称或规格搜索..."
:remote-method="handleSearchMaterialDebounced"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
popper-class="long-dropdown"
>
<el-option
v-for="item in materialOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>{{ item.name }}</span>
<span style="color: #999; font-size: 12px;">{{ item.spec_model || '-' }}</span>
</div>
</el-option>
</el-select>
<div v-if="autoFillHint" style="font-size: 12px; color: #67C23A; margin-top: 4px;">{{ autoFillHint }}</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号">
<el-input v-model="form.spec_model" placeholder="从物料列表选择后自动填充" clearable />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采购数量" required>
<el-input-number v-model="form.quantity" :min="1" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采购日期" required>
<el-date-picker v-model="form.purchase_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="审批人" required>
<el-select v-model="form.approver_id" placeholder="请选择审批人" style="width: 100%;" filterable>
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="单价">
<el-input-number v-model="form.unit_price" :min="0" :precision="2" style="width: 100%;" @change="onUnitPriceChange" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="总价">
<el-input-number v-model="form.total_price" :min="0" :precision="2" style="width: 100%;" @change="onTotalPriceChange" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="商家链接">
<el-input v-model="form.supplier_link" placeholder="商家地址链接(选填)" clearable />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注信息(选填)" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="图片上传">
<el-upload
v-model:file-list="fileList"
:http-request="customUpload"
:on-remove="handleRemoveImage"
:before-upload="beforeAvatarUpload"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
list-type="picture-card"
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="formDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">确认提交</el-button>
</template>
</el-dialog>
<!-- ========== 详情弹窗 ========== -->
<el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close>
<el-descriptions :column="2" border>
<el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(detail.status)" size="small">{{ detail.status_text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="采购物品">{{ detail.name }}</el-descriptions-item>
<el-descriptions-item label="规格型号">{{ detail.spec_model || '-' }}</el-descriptions-item>
<el-descriptions-item label="采购数量">{{ detail.quantity }}</el-descriptions-item>
<el-descriptions-item label="采购日期">{{ detail.purchase_date }}</el-descriptions-item>
<el-descriptions-item label="单价">{{ detail.unit_price || '-' }}</el-descriptions-item>
<el-descriptions-item label="总价">{{ detail.total_price || '-' }}</el-descriptions-item>
<el-descriptions-item label="申请人">{{ detail.requester_name }}</el-descriptions-item>
<el-descriptions-item label="审批人">{{ detail.approver_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="审批时间">{{ detail.approved_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="商家链接">
<a v-if="detail.supplier_link" :href="detail.supplier_link" target="_blank" style="color: #409EFF;">
{{ detail.supplier_link }}
</a>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || '-' }}</el-descriptions-item>
<el-descriptions-item label="驳回原因" :span="2" v-if="detail.reject_reason">
<span style="color: #F56C6C;">{{ detail.reject_reason }}</span>
</el-descriptions-item>
</el-descriptions>
<!-- 图片展示 -->
<div v-if="detail.images && detail.images.length > 0" style="margin-top: 16px;">
<div style="font-weight: bold; margin-bottom: 8px;">图片</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<el-image
v-for="(img, idx) in detail.images"
:key="idx"
:src="getImageUrl(img)"
:preview-src-list="detail.images.map(u => getImageUrl(u))"
fit="cover"
style="width: 100px; height: 100px; border-radius: 4px; cursor: pointer;"
/>
</div>
</div>
</el-dialog>
<!-- ========== 驳回原因弹窗 ========== -->
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
</el-form-item>
<el-form-item label="驳回原因" required>
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请填写驳回原因必填" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { searchMaterialBase } from '@/api/inbound/buy'
import {
getPurchaseList, createPurchase, getPurchaseDetail,
approvePurchase, getPurchaseApprovers, autoFillPurchase
} from '@/api/purchase'
import { uploadFile, deleteFile } from '@/api/common/upload'
import type { FormInstance } from 'element-plus'
const userStore = useUserStore()
// --- 状态 ---
const list = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
const submitLoading = ref(false)
const rejectLoading = ref(false)
const fileList = ref<any[]>([])
// 驳回
const rejectDialogVisible = ref(false)
const currentRejectRow = ref<any>(null)
const rejectReason = ref('')
// 详情
const detailDialogVisible = ref(false)
const detail = ref<any>({})
// 创建弹窗
const formDialogVisible = ref(false)
const dialogTitle = ref('新建采购申请')
const formRef = ref<FormInstance>()
const isUploading = ref(false)
const autoFillHint = ref('')
// 审批人
const approvers = ref<any[]>([])
// 物料搜索
const materialOptions = ref<any[]>([])
const searchLoading = ref(false)
const materialBaseId = ref<number | null>(null)
// 表单
const form = ref({
name: '',
spec_model: '',
quantity: 1,
purchase_date: '',
supplier_link: '',
remark: '',
unit_price: undefined as number | undefined,
total_price: undefined as number | undefined,
approver_id: undefined as number | undefined,
images: [] as string[]
})
// --- 计算属性 ---
const canApprove = computed(() => {
return userStore.role === 'SUPER_ADMIN' || userStore.role === 'SUPERVISOR'
})
// --- 工具函数 ---
const statusTagType = (status: number) => {
const map: Record<number, string> = {
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
}
return map[status] ?? 'info'
}
const getImageUrl = (url: string) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `/api/v1/common/files/${url}`
}
const formatDate = (d: string) => d ? d.split(' ')[0] : ''
// --- 数据获取 ---
const fetchData = async () => {
loading.value = true
try {
const params: any = { page: page.value, limit: pageSize.value }
if (filterStatus.value !== '') params.status = filterStatus.value
const res: any = await getPurchaseList(params)
list.value = res.data?.items || []
total.value = res.data?.total || 0
} catch (err: any) {
ElMessage.error(err?.msg || '加载失败')
} finally {
loading.value = false
}
}
const fetchApprovers = async () => {
try {
const res: any = await getPurchaseApprovers()
approvers.value = res.data || []
} catch (e) {
console.error(e)
}
}
// --- 筛选 & 分页 ---
const handleStatusChange = () => {
page.value = 1
fetchData()
}
const handlePageChange = (p: number) => { page.value = p; fetchData() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
// --- 新建 ---
const openCreateDialog = () => {
dialogTitle.value = '新建采购申请'
form.value = {
name: '', spec_model: '', quantity: 1,
purchase_date: new Date().toISOString().split('T')[0],
supplier_link: '', remark: '',
unit_price: undefined, total_price: undefined,
approver_id: undefined, images: []
}
materialBaseId.value = null
materialOptions.value = []
autoFillHint.value = ''
fileList.value = []
formDialogVisible.value = true
}
// --- 物料搜索 ---
let searchTimer: any = null
const handleSearchMaterialDebounced = (query: string) => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => handleSearchMaterial(query), 300)
}
const handleSearchMaterial = async (query: string) => {
if (!query.trim()) {
materialOptions.value = []
return
}
searchLoading.value = true
try {
const res: any = await searchMaterialBase(query, 1)
materialOptions.value = res.data || []
} finally {
searchLoading.value = false
}
}
const onMaterialSelected = (id: number | null) => {
if (!id) {
materialBaseId.value = null
return
}
const item = materialOptions.value.find(i => i.id === id)
if (item) {
form.value.name = item.name || item.material_name || ''
form.value.spec_model = item.spec_model || item.spec || ''
materialBaseId.value = id
autoFillHint.value = ''
}
}
// --- 价格自动计算(需确认)---
let priceConfirmTimer: any = null
const onUnitPriceChange = (val: number | undefined) => {
if (priceConfirmTimer) clearTimeout(priceConfirmTimer)
priceConfirmTimer = setTimeout(() => {
if (val !== undefined && val > 0 && form.value.quantity > 0 && !form.value.total_price) {
ElMessageBox.confirm(
`即将自动计算总价:${val} × ${form.value.quantity} = ${+(val * form.value.quantity).toFixed(2)},是否继续?`,
'自动计算确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }
).then(() => {
form.value.total_price = +(val * form.value.quantity).toFixed(2)
}).catch(() => {})
} else if (val !== undefined && val > 0 && form.value.quantity > 0) {
form.value.total_price = +(val * form.value.quantity).toFixed(2)
}
}, 300)
}
const onTotalPriceChange = (val: number | undefined) => {
if (priceConfirmTimer) clearTimeout(priceConfirmTimer)
priceConfirmTimer = setTimeout(() => {
if (val !== undefined && val > 0 && form.value.quantity > 0 && !form.value.unit_price) {
ElMessageBox.confirm(
`即将自动计算单价:${val} ÷ ${form.value.quantity} = ${+(val / form.value.quantity).toFixed(4)},是否继续?`,
'自动计算确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }
).then(() => {
form.value.unit_price = +(val / form.value.quantity).toFixed(4)
}).catch(() => {})
} else if (val !== undefined && val > 0 && form.value.quantity > 0) {
form.value.unit_price = +(val / form.value.quantity).toFixed(4)
}
}, 300)
}
// --- 上传 ---
const beforeAvatarUpload = (rawFile: any) => {
const isTypeValid = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'].includes(rawFile.type)
if (!isTypeValid) {
ElMessage.error('仅支持 JPG/PNG/GIF/WEBP 图片')
return false
}
if (rawFile.size / 1024 / 1024 > 10) {
ElMessage.error('图片不能超过 10MB')
return false
}
return true
}
const customUpload = async (options: any) => {
const { file, onSuccess, onError } = options
const formData = new FormData()
formData.append('file', file)
isUploading.value = true
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url
form.value.images!.push(newUrl)
onSuccess(res)
} else {
ElMessage.error(res.msg || '上传失败')
onError(new Error(res.msg))
}
} catch (e) {
ElMessage.error('网络错误')
onError(e)
} finally {
isUploading.value = false
}
}
const handleRemoveImage = async (uploadFile: any) => {
const urlToRemove = form.value.images!.find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
form.value.images = form.value.images!.filter(u => u !== urlToRemove)
const filename = urlToRemove.split('/').pop()
if (filename && !urlToRemove.startsWith('http')) {
await deleteFile(filename).catch(() => {})
}
}
// --- 提交 ---
const submitForm = async () => {
if (!form.value.name.trim()) { ElMessage.warning('请选择或填写采购物品'); return }
if (!form.value.approver_id) { ElMessage.warning('请选择审批人'); return }
if (!form.value.purchase_date) { ElMessage.warning('请选择采购日期'); return }
if (!form.value.images || form.value.images.length === 0) { ElMessage.warning('请上传至少一张图片'); return }
submitLoading.value = true
try {
await createPurchase(form.value as any)
ElMessage.success('提交成功,审批人将收到邮件通知')
formDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '提交失败')
} finally {
submitLoading.value = false
}
}
// --- 详情 ---
const openDetailDialog = async (row: any) => {
try {
const res: any = await getPurchaseDetail(row.id)
detail.value = res.data || {}
detailDialogVisible.value = true
} catch (e) {
ElMessage.error('获取详情失败')
}
}
// --- 审批操作 ---
const handleApprove = async (row: any) => {
try {
await ElMessageBox.confirm(`确定要通过采购申请「${row.request_no}」吗?`, '审批确认', {
confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info'
})
} catch { return }
try {
await approvePurchase(row.id, { action: 'approve' })
ElMessage.success('已通过')
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '操作失败')
}
}
const openRejectDialog = (row: any) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
if (!rejectReason.value.trim()) { ElMessage.warning('请填写驳回原因'); return }
rejectLoading.value = true
try {
await approvePurchase(currentRejectRow.value.id, {
action: 'reject', reject_reason: rejectReason.value
})
ElMessage.success('已驳回')
rejectDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '操作失败')
} finally {
rejectLoading.value = false
}
}
// --- 初始化 ---
onMounted(() => {
fetchData()
fetchApprovers()
})
</script>
<style scoped>
.app-container { padding: 20px; }
.filter-container { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
</style>