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:
yueli
2026-07-14 15:25:00 +08:00
parent 88b754120b
commit a11b7972c3
9 changed files with 643 additions and 16 deletions

View File

@ -208,7 +208,30 @@ def auto_fill_purchase():
# --------------------------------------------------------
# 7. 物料基础信息搜索(分页)
# 7. 已审批且未入库的采购单列表(供库管按单入库使用)
# GET /api/v1/purchase/approved-unstocked?page=1&keyword=xxx
# --------------------------------------------------------
@purchase_bp.route('/approved-unstocked', methods=['GET'])
@jwt_required()
def get_approved_unstocked_requests():
"""获取已审批通过且未入库的采购申请列表"""
try:
page = int(request.args.get('page', 1))
per_page = int(request.args.get('limit', 20))
keyword = request.args.get('keyword', '').strip() or None
result = PurchaseService.get_approved_requests(
page=page, per_page=per_page, keyword=keyword
)
return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取失败: {str(e)}'}), 500
# --------------------------------------------------------
# 8. 物料基础信息搜索(分页)
# GET /api/v1/purchase/search-material?keyword=xxx&page=1
# --------------------------------------------------------
@purchase_bp.route('/search-material', methods=['GET'])

View File

@ -4,6 +4,7 @@ from pgvector.sqlalchemy import Vector
import json
# 显式导入 MaterialBase 以防 relationship 找不到引用
from app.models.base import MaterialBase
from app.models.purchase import PurchaseRequest
class StockBuy(db.Model):
@ -15,6 +16,8 @@ class StockBuy(db.Model):
id = db.Column(db.Integer, primary_key=True)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 批量 IN 查询高频列
# [新增] 关联采购申请单,打通"按单入库"链路
request_id = db.Column(db.Integer, db.ForeignKey('purchase_request.id'), index=True, comment='关联采购申请单ID')
# 身份标识
sku = db.Column(db.String(100), index=True) # ★ 条码/SKU 快速定位
@ -61,6 +64,7 @@ class StockBuy(db.Model):
# 关系定义
base = db.relationship('MaterialBase', back_populates='stock_buys')
purchase_request = db.relationship('PurchaseRequest', back_populates='stock_buys')
def to_dict(self):
# 辅助解析函数
@ -77,6 +81,9 @@ class StockBuy(db.Model):
return {
'id': self.id,
'base_id': self.base_id,
'request_id': self.request_id,
# [新增] 采购申请单号(便于前端展示)
'request_no': self.purchase_request.request_no if self.purchase_request else '',
# [修改] 增加公司名称
'company_name': self.base.company_name if self.base else '',

View File

@ -11,6 +11,8 @@ class PurchaseRequest(db.Model):
id = db.Column(db.Integer, primary_key=True)
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
# [新增] 硬关联基础物料表,打通"按单入库"链路
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), index=True, comment='关联基础物料ID')
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='采购数量')
@ -23,6 +25,8 @@ class PurchaseRequest(db.Model):
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)
# [新增] 反向关联:该采购申请对应的入库记录
stock_buys = db.relationship('StockBuy', back_populates='purchase_request', lazy='dynamic')
approved_at = db.Column(db.DateTime)
reject_reason = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=beijing_time, nullable=False)
@ -64,6 +68,7 @@ class PurchaseRequest(db.Model):
return {
'id': self.id,
'request_no': self.request_no,
'base_id': self.base_id,
'name': self.name,
'spec_model': self.spec_model or '',
'quantity': float(self.quantity) if self.quantity else 0,

View File

@ -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()

View File

@ -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):
"""

View File

@ -0,0 +1,79 @@
"""打通采购申请与采购入库的按单入库链路
Revision ID: a1b2c3d4e5f6
Revises: None (或填当前最新 revision)
Create Date: 2026-07-14
变更内容:
1. purchase_request 表新增 base_id (FK → material_base.id)
2. stock_buy 表新增 request_id (FK → purchase_request.id)
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a1b2c3d4e5f6'
down_revision = None # ← 替换为当前最新 revision ID
branch_labels = None
depends_on = None
def upgrade():
# ============================================================
# 1. purchase_request 新增 base_id
# ============================================================
with op.batch_alter_table('purchase_request', schema=None) as batch_op:
batch_op.add_column(
sa.Column('base_id', sa.Integer(), nullable=True,
comment='关联基础物料ID')
)
batch_op.create_foreign_key(
'fk_purchase_request_base_id',
'material_base',
['base_id'],
['id'],
ondelete='SET NULL'
)
batch_op.create_index(
'ix_purchase_request_base_id',
['base_id']
)
# ============================================================
# 2. stock_buy 新增 request_id
# ============================================================
with op.batch_alter_table('stock_buy', schema=None) as batch_op:
batch_op.add_column(
sa.Column('request_id', sa.Integer(), nullable=True,
comment='关联采购申请单ID')
)
batch_op.create_foreign_key(
'fk_stock_buy_request_id',
'purchase_request',
['request_id'],
['id'],
ondelete='SET NULL'
)
batch_op.create_index(
'ix_stock_buy_request_id',
['request_id']
)
def downgrade():
# ============================================================
# 回滚:stock_buy 删除 request_id
# ============================================================
with op.batch_alter_table('stock_buy', schema=None) as batch_op:
batch_op.drop_constraint('fk_stock_buy_request_id', type_='foreignkey')
batch_op.drop_index('ix_stock_buy_request_id')
batch_op.drop_column('request_id')
# ============================================================
# 回滚:purchase_request 删除 base_id
# ============================================================
with op.batch_alter_table('purchase_request', schema=None) as batch_op:
batch_op.drop_constraint('fk_purchase_request_base_id', type_='foreignkey')
batch_op.drop_index('ix_purchase_request_base_id')
batch_op.drop_column('base_id')

View File

@ -0,0 +1,93 @@
-- ============================================================
-- migrations_add_purchase_stockbuy_link.sql
-- 打通「采购申请」与「采购入库」的按单入库链路
--
-- 变更内容:
-- 1. purchase_request 表新增 base_id 列(FK → material_base.id)
-- 2. stock_buy 表新增 request_id 列(FK → purchase_request.id)
--
-- 执行方式(任选一种):
-- psql -h <host> -U postgres -d inventory_system -f migrations_add_purchase_stockbuy_link.sql
-- 或在 pgAdmin / Navicat / DBeaver 中打开执行
-- ============================================================
BEGIN;
-- ============================================================
-- 1. purchase_request 新增 base_id
-- 硬关联基础物料表,使采购申请能精确锁定物料
-- ============================================================
ALTER TABLE purchase_request
ADD COLUMN IF NOT EXISTS base_id INTEGER;
-- 添加外键约束(如果列已存在但无约束,会补充)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'fk_purchase_request_base_id'
AND table_name = 'purchase_request'
) THEN
ALTER TABLE purchase_request
ADD CONSTRAINT fk_purchase_request_base_id
FOREIGN KEY (base_id) REFERENCES material_base(id)
ON DELETE SET NULL;
END IF;
END;
$$;
-- 为 base_id 添加索引(支持按物料筛选采购申请)
CREATE INDEX IF NOT EXISTS ix_purchase_request_base_id
ON purchase_request (base_id);
COMMENT ON COLUMN purchase_request.base_id IS '关联基础物料ID';
-- ============================================================
-- 2. stock_buy 新增 request_id
-- 关联采购申请单,使入库记录可追溯到来源采购单
-- ============================================================
ALTER TABLE stock_buy
ADD COLUMN IF NOT EXISTS request_id INTEGER;
-- 添加外键约束
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'fk_stock_buy_request_id'
AND table_name = 'stock_buy'
) THEN
ALTER TABLE stock_buy
ADD CONSTRAINT fk_stock_buy_request_id
FOREIGN KEY (request_id) REFERENCES purchase_request(id)
ON DELETE SET NULL;
END IF;
END;
$$;
-- 为 request_id 添加索引(支持"按单查入库"的追溯查询)
CREATE INDEX IF NOT EXISTS ix_stock_buy_request_id
ON stock_buy (request_id);
COMMENT ON COLUMN stock_buy.request_id IS '关联采购申请单ID';
-- ============================================================
-- 3. 验证结果
-- ============================================================
SELECT
column_name,
data_type,
is_nullable,
pg_catalog.col_description(
(SELECT c.oid FROM pg_catalog.pg_class c
WHERE c.relname = table_name),
ordinal_position
) AS comment
FROM information_schema.columns
WHERE table_name IN ('purchase_request', 'stock_buy')
AND column_name IN ('base_id', 'request_id')
ORDER BY table_name, column_name;
COMMIT;