diff --git a/inventory-backend/app/api/v1/purchase.py b/inventory-backend/app/api/v1/purchase.py index d52ba4e..04ebffb 100644 --- a/inventory-backend/app/api/v1/purchase.py +++ b/inventory-backend/app/api/v1/purchase.py @@ -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']) diff --git a/inventory-backend/app/models/inbound/buy.py b/inventory-backend/app/models/inbound/buy.py index 3e0d7fd..8c47a0b 100644 --- a/inventory-backend/app/models/inbound/buy.py +++ b/inventory-backend/app/models/inbound/buy.py @@ -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 '', diff --git a/inventory-backend/app/models/purchase.py b/inventory-backend/app/models/purchase.py index bd62e3b..b4dff12 100644 --- a/inventory-backend/app/models/purchase.py +++ b/inventory-backend/app/models/purchase.py @@ -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, diff --git a/inventory-backend/app/services/inbound/buy_service.py b/inventory-backend/app/services/inbound/buy_service.py index 32bac8a..22e4040 100644 --- a/inventory-backend/app/services/inbound/buy_service.py +++ b/inventory-backend/app/services/inbound/buy_service.py @@ -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() diff --git a/inventory-backend/app/services/purchase_service.py b/inventory-backend/app/services/purchase_service.py index 41befd1..1d477f8 100644 --- a/inventory-backend/app/services/purchase_service.py +++ b/inventory-backend/app/services/purchase_service.py @@ -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): """ diff --git a/inventory-backend/migrations/versions/a1b2c3d4e5f6_add_purchase_stockbuy_link.py b/inventory-backend/migrations/versions/a1b2c3d4e5f6_add_purchase_stockbuy_link.py new file mode 100644 index 0000000..78258ae --- /dev/null +++ b/inventory-backend/migrations/versions/a1b2c3d4e5f6_add_purchase_stockbuy_link.py @@ -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') diff --git a/inventory-backend/migrations_add_purchase_stockbuy_link.sql b/inventory-backend/migrations_add_purchase_stockbuy_link.sql new file mode 100644 index 0000000..6d73c5f --- /dev/null +++ b/inventory-backend/migrations_add_purchase_stockbuy_link.sql @@ -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 -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; diff --git a/inventory-web/src/api/purchase.ts b/inventory-web/src/api/purchase.ts index f16b957..dedcc09 100644 --- a/inventory-web/src/api/purchase.ts +++ b/inventory-web/src/api/purchase.ts @@ -3,6 +3,7 @@ import request from '@/utils/request' export interface PurchaseItem { id?: number request_no?: string + base_id?: number name: string spec_model?: string quantity: number @@ -22,6 +23,17 @@ export interface PurchaseItem { reject_reason?: string created_at?: string updated_at?: string + // [新增] 关联物料基础信息(已审批未入库列表返回) + material?: { + id: number + company_name: string + name: string + spec_model: string + category: string + unit: string + type: string + is_inspection_required: boolean + } | null } export interface Approver { @@ -98,3 +110,16 @@ export function searchMaterialPurchase(keyword: string, page: number = 1) { params: { keyword, page } }) } + +// [新增] 获取已审批通过且未入库的采购单列表(供库管按单入库使用) +export function getApprovedUnstockedRequests(params: { + page?: number + limit?: number + keyword?: string +}) { + return request({ + url: '/purchase/approved-unstocked', + method: 'get', + params + }) +} diff --git a/inventory-web/src/views/stock/inbound/buy.vue b/inventory-web/src/views/stock/inbound/buy.vue index 4a60542..2221118 100644 --- a/inventory-web/src/views/stock/inbound/buy.vue +++ b/inventory-web/src/views/stock/inbound/buy.vue @@ -280,6 +280,18 @@ 1. 基础信息 + + + 从采购单导入 + + (请先搜索锁定物料) + + +
+ 按单入库 + + 已关联采购申请单,入库后将自动标记为「已完成」 + +
+
@@ -649,6 +670,89 @@ /> + + +
+ + + + 搜索 +
+ + + + + + + + + + + + + + + + + +
+ +
+ + +
+
@@ -678,7 +782,7 @@