feat: 打通采购申请与入库的按单入库链路
后端变更: - PurchaseRequest 新增 base_id 硬关联 MaterialBase,StockBuy 新增 request_id 关联采购单 - handle_inbound 支持 request_id 入参,入库后自动反写采购单状态为已完成 - 新增 get_approved_requests 接口,返回已审批未入库采购单列表(含物料信息+历史数据回退匹配) - create_purchase_request 新增 name+spec_model 自动匹配 MaterialBase - 新增 SQL 和 Alembic 数据库迁移脚本 前端变更: - 入库表单新增"从采购单导入"弹窗,支持搜索/选中已审批采购单并一键填充物料和商务信息 - 单价/总价交叉推算,缺项自动补全 - 选中行蓝色高亮+点击整行选中,行级交互优化
This commit is contained in:
@ -165,6 +165,9 @@ class BuyInboundService:
|
||||
generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
final_barcode = data.get('barcode') or generated_sku
|
||||
|
||||
# [新增] 按单入库:关联采购申请单
|
||||
request_id = data.get('request_id')
|
||||
|
||||
new_stock = StockBuy(
|
||||
base_id=material.id, global_print_id=next_global_id, sku=generated_sku, barcode=final_barcode,
|
||||
in_date=in_date_val, serial_number=data.get('serial_number'), batch_number=data.get('batch_number'),
|
||||
@ -172,6 +175,9 @@ class BuyInboundService:
|
||||
inspection_status=data.get('inspection_status', '未检'),
|
||||
warehouse_location=data.get('warehouse_location'),
|
||||
|
||||
# [新增] 按单入库关联
|
||||
request_id=request_id,
|
||||
|
||||
# 价格信息
|
||||
pre_tax_unit_price=u_price,
|
||||
post_tax_unit_price=post_tax_price,
|
||||
@ -189,6 +195,15 @@ class BuyInboundService:
|
||||
db.session.add(new_stock)
|
||||
db.session.flush() # 获取 new_stock.id
|
||||
|
||||
# [新增] 按单入库:反写采购申请单状态为"已完成"
|
||||
if request_id:
|
||||
from app.models.purchase import PurchaseRequest
|
||||
purchase_req = db.session.get(PurchaseRequest, request_id)
|
||||
if purchase_req:
|
||||
if purchase_req.status != 1:
|
||||
raise ValueError(f"采购单【{purchase_req.request_no}】当前状态不允许入库,请确认审批状态")
|
||||
purchase_req.status = 3 # 3 = 已完成/已入库
|
||||
|
||||
# 先提交主事务(入库单必须落盘),图片向量异步后台提取
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@ -45,7 +45,7 @@ class PurchaseService:
|
||||
"""
|
||||
创建采购申请
|
||||
data 包含: name, spec_model, quantity, purchase_date, supplier_link, remark, images,
|
||||
unit_price, total_price, approver_id
|
||||
unit_price, total_price, approver_id, base_id (可选)
|
||||
"""
|
||||
request_no = PurchaseService.generate_request_no()
|
||||
|
||||
@ -55,8 +55,21 @@ class PurchaseService:
|
||||
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']),
|
||||
@ -138,6 +151,91 @@ class PurchaseService:
|
||||
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)
|
||||
|
||||
items = []
|
||||
for p in pagination.items:
|
||||
item = p.to_dict()
|
||||
material = None
|
||||
|
||||
# 附加物料基础信息
|
||||
if p.base_id:
|
||||
# 优先走 base_id 硬关联
|
||||
material = db.session.get(MaterialBase, p.base_id)
|
||||
|
||||
# 回退匹配:历史采购单没有 base_id,通过 name + spec_model 模糊匹配
|
||||
if not material and p.name:
|
||||
material = MaterialBase.query.filter(
|
||||
MaterialBase.name == p.name,
|
||||
MaterialBase.spec_model == (p.spec_model or ''),
|
||||
MaterialBase.is_enabled == True
|
||||
).first()
|
||||
# 如果精确匹配失败,仅按 name 模糊匹配(取最新一条)
|
||||
if not material:
|
||||
material = MaterialBase.query.filter(
|
||||
MaterialBase.name.ilike(f'%{p.name}%'),
|
||||
MaterialBase.is_enabled == True
|
||||
).order_by(MaterialBase.id.desc()).first()
|
||||
|
||||
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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user