Files
KCGL/inventory-backend/app/services/purchase_service.py
yueli 8755837fe8 fix(timezone): 审批单据时间统一为 naive 北京时间,消除 8 小时偏差
成因:approved_at / executed_at 列是 timestamp without time zone,而赋值用了
带时区的 datetime.now(timezone(timedelta(hours=8)))。psycopg2 对 naive 列不会
剥掉 tzinfo,而是转成 naive UTC 再写入,结果比同为北京时间的 created_at 早 8 小时。
(代码里并无 datetime.utcnow(),真实成因是 aware 值被驱动的隐式 UTC 转换。)

改动:
  · outbound_service / borrow_service 的审批分支
    datetime.now(beijing_tz).replace(tzinfo=None) → beijing_time()
    (免审批分支已在上一轮改为 beijing_time,此处补齐审批分支);
  · purchase_service 审批/完结分支同源缺陷一并修复;
  · scrap_approval / scrap_approval_service 的 _beijing()、_beijing_now() 由
    tz-aware 改为 naive —— 配合上述迁移把列类型改为 naive,
    若仍返回 aware 值,timestamptz→timestamp 后会反向早 8 小时。

实测四表(scrap/outbound/borrow/purchase)created_at 与 approved_at 差值
均在同一秒内(-1ms ~ -12ms)。
2026-09-10 10:14:31 +08:00

480 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
from datetime import datetime, timezone, timedelta, date
from sqlalchemy import func
from app.extensions import db, beijing_time
from app.models.purchase import PurchaseRequest
from app.models.base import MaterialBase
class PurchaseService:
@staticmethod
def generate_request_no(batch_seq: int = None):
"""
生成采购单号: PUR-yyyyMMdd-HHmm-批次-批内序号
Args:
batch_seq: 今日第几次采购批次(前端提交一批时传入,同一批共享)
None 时回退为旧格式 PUR-日期-时间-流水
返回:
- 有 batch_seq: PUR-20260831-1021-0001-0001批次号-批内序号)
- 无 batch_seq: PUR-20260831-1021-0001旧格式兼容
"""
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
date_str = now.strftime('%Y%m%d')
time_str = now.strftime('%H%M')
if batch_seq is not None:
# 批次前缀: PUR-日期-时间-批次
batch_prefix = f"PUR-{date_str}-{time_str}-{batch_seq:04d}"
# 批内序号: 该批次前缀下的记录数 + 1
item_count = db.session.query(func.count(func.distinct(PurchaseRequest.request_no))) \
.filter(PurchaseRequest.request_no.like(f"{batch_prefix}-%")).scalar()
return f"{batch_prefix}-{(item_count + 1):04d}"
# 旧格式兼容: PUR-日期-时间-流水
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 get_next_batch_seq():
"""
返回今日下一个采购批次号(今日第几次)
统计今日已有的不同批次单号第4段数量+1
"""
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}-"
# 查询今日所有单号提取第4段批次号去重
rows = db.session.query(PurchaseRequest.request_no) \
.filter(PurchaseRequest.request_no.like(f"{prefix}%")).all()
batch_seqs = set()
for (rn,) in rows:
parts = rn.split('-')
if len(parts) >= 4:
batch_seqs.add(parts[3])
return len(batch_seqs) + 1
@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, base_id (可选)
"""
batch_seq = data.get('batch_seq')
request_no = PurchaseService.generate_request_no(batch_seq=batch_seq)
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()
# [新增] 自动匹配/关联基础物料
base_id = data.get('base_id')
if not base_id and data.get('name'):
# 尝试通过 name + spec_model 精确匹配 MaterialBase
material = MaterialBase.query.filter(
MaterialBase.name == data['name'],
MaterialBase.spec_model == data.get('spec_model', ''),
MaterialBase.is_enabled == True
).first()
if material:
base_id = material.id
purchase = PurchaseRequest(
request_no=request_no,
base_id=base_id, # [新增]
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),
tax_rate=float(data.get('tax_rate', 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("采购申请不存在")
# ★ approved_at 与 created_at(beijing_time) 同为 naive 本地时间,避免存库时被转成 UTC 早 8 小时
now = beijing_time()
# ★ 完结:库管将「已通过(1)」的申请单置为「已完结(4)」(仿出库审批)
if action == 'close':
if purchase.status != 1:
raise ValueError("仅「已通过」的采购申请可完结")
purchase.status = 4
purchase.approver_id = user_id
purchase.approved_at = now
db.session.commit()
return purchase
if purchase.status != 0:
raise ValueError("当前状态不允许审批")
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):
"""获取采购申请列表,普通用户只看自己的,主管/超管看同公司全部"""
from app.utils.decorators import get_current_company_filter
from app.models.system import SysUser
from sqlalchemy import or_
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)
# 【行级数据隔离】同公司可见:匹配 MaterialBase.company_name 或 SysUser.department
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.outerjoin(MaterialBase, PurchaseRequest.base_id == MaterialBase.id) \
.outerjoin(SysUser, PurchaseRequest.requester_id == SysUser.id) \
.filter(or_(
MaterialBase.company_name == company_limit,
SysUser.department == company_limit
))
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 get_approved_requests(page=1, per_page=20, keyword=None):
"""
获取已审批通过且未入库的采购申请列表(专供库管按单入库使用)
筛选条件:
- status == 1已审批通过
- 尚未被任何 StockBuy 关联request_id 未被引用)
返回字段包含: 采购申请信息 + MaterialBase 基础物料信息
"""
from app.models.inbound.buy import StockBuy
# 子查询:所有已被入库引用的 request_id去重
stocked_ids = db.session.query(StockBuy.request_id).filter(
StockBuy.request_id.isnot(None)
).distinct().subquery()
# 主查询:已通过 且 不在已入库集合中
query = db.session.query(PurchaseRequest).filter(
PurchaseRequest.status == 1
).filter(
~PurchaseRequest.id.in_(stocked_ids)
)
# 可选关键词搜索:采购单号 / 名称 / 规格
if keyword:
k = f'%{keyword.strip()}%'
query = query.filter(
PurchaseRequest.request_no.ilike(k) |
PurchaseRequest.name.ilike(k) |
PurchaseRequest.spec_model.ilike(k)
)
query = query.order_by(PurchaseRequest.approved_at.desc().nullslast(),
PurchaseRequest.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
# ★ 批量预取 MaterialBase三段式查询base_id 精确 → name 精确 → name 模糊)
purchase_items = list(pagination.items)
base_ids = {p.base_id for p in purchase_items if p.base_id}
names_no_base = [p.name for p in purchase_items if p.name and not p.base_id]
material_map = {} # base_id → MaterialBase
name_map = {} # (name, spec) or (name, '__fallback__') → MaterialBase
# 阶段1: base_id 精确预取
if base_ids:
materials = MaterialBase.query.filter(MaterialBase.id.in_(base_ids)).all()
material_map = {m.id: m for m in materials}
# 阶段2: name 精确预取name.in_
if names_no_base:
exact_matches = MaterialBase.query.filter(
MaterialBase.name.in_(names_no_base),
MaterialBase.is_enabled == True
).all()
for m in exact_matches:
name_map[(m.name, m.spec_model or '')] = m
# 阶段3: name 模糊预取(仅>=2字符 + LIMIT 500 防爆炸)
unmatched_names = []
for p in purchase_items:
if p.base_id and p.base_id in material_map:
continue
if p.name and (p.name, p.spec_model or '') in name_map:
continue
n = (p.name or '').strip()
if len(n) >= 2: # ★ 安全阈值至少2字符才做模糊匹配
unmatched_names.append(n)
if unmatched_names:
fuzzy_conditions = [
MaterialBase.name.ilike(f'%{n}%') for n in unmatched_names
]
fuzzy_matches = MaterialBase.query.filter(
db.or_(*fuzzy_conditions),
MaterialBase.is_enabled == True
).order_by(MaterialBase.id.desc()).limit(500).all() # ★ 硬上限最多500条
for m in fuzzy_matches:
key = (m.name, '__fuzzy__')
if key not in name_map:
name_map[key] = m
# 内存匹配O(1) 字典查找)
items = []
for p in purchase_items:
item = p.to_dict()
material = None
# 1. base_id 精确
if p.base_id:
material = material_map.get(p.base_id)
# 2. name + spec 精确
if not material and p.name:
material = name_map.get((p.name, p.spec_model or ''))
# 3. name 模糊回退
if not material and p.name:
material = name_map.get((p.name, '__fuzzy__'))
if material:
item['material'] = {
'id': material.id,
'company_name': material.company_name or '',
'name': material.name,
'spec_model': material.spec_model or '',
'category': material.category or '',
'unit': material.unit or '',
'type': material.material_type or '',
'is_inspection_required': bool(material.is_inspection_required),
}
else:
item['material'] = None
items.append(item)
return {
'items': items,
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}
@staticmethod
def search_base_material(keyword: str, page: int = 1, limit: int = 20):
"""
物料基础信息搜索,支持 name/spec_model/company_name 模糊匹配,返回分页结果
用于采购申请弹窗的物料远程搜索
"""
from sqlalchemy import and_, or_
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword:
k = keyword.strip()
k_str = f'%{k}%'
query = query.filter(or_(
MaterialBase.name.ilike(k_str),
MaterialBase.spec_model.ilike(k_str),
MaterialBase.company_name.ilike(k_str)
))
query = query.order_by(MaterialBase.id.desc())
pagination = query.paginate(page=page, per_page=limit, error_out=False)
items = []
for item in pagination.items:
items.append({
'id': item.id,
'company_name': item.company_name,
'name': item.name,
'spec_model': item.spec_model,
'category': item.category,
'unit': item.unit,
'type': item.material_type,
'pinyin': getattr(item, 'pinyin', ''),
'status': '启用'
})
return {
'items': items,
'total': pagination.total,
'page': page,
'has_next': pagination.has_next
}
@staticmethod
def _notify_new_request(purchase):
"""发送新申请邮件给审批人"""
try:
from app.utils.email_service import send_email_async
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_async(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_async
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_async(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_async
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_async(requester.email, subject, content)
except Exception as e:
print(f"[Email] 采购申请驳回通知失败: {e}")