Compare commits
9 Commits
master
...
cd55a6aee1
| Author | SHA1 | Date | |
|---|---|---|---|
| cd55a6aee1 | |||
| 6f4917f57e | |||
| e31ef59df0 | |||
| 87864a1c4f | |||
| 7a4ea8acfb | |||
| 9a04f65eb7 | |||
| 7a78975ce7 | |||
| 3afea217b7 | |||
| 2f8a5c55b1 |
52
docker-compose.yml
Normal file
52
docker-compose.yml
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# --- 数据库服务 ---
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: inventory_db
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: 1234
|
||||||
|
POSTGRES_DB: inventory_system
|
||||||
|
volumes:
|
||||||
|
# 数据持久化
|
||||||
|
- ./pgdata_docker:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5434:5432"
|
||||||
|
|
||||||
|
# --- 后端 Flask 服务 ---
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./inventory-backend # 【修改】指向你的新后端目录
|
||||||
|
container_name: inventory_api
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./inventory-backend:/app # 挂载代码,实现热更新
|
||||||
|
# 加上 --reload 参数,代码变了自动重启
|
||||||
|
command: gunicorn -c gunicorn.conf.py run:app --reload
|
||||||
|
environment:
|
||||||
|
# Host 必须写 'db'
|
||||||
|
DATABASE_URL: postgresql://test:1234@db:5432/inventory_system
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
# --- 前端 Vue+Nginx 服务 ---
|
||||||
|
# --- 前端 Vue 开发服务 ---
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./inventory-web
|
||||||
|
container_name: inventory_ui
|
||||||
|
restart: always
|
||||||
|
# 【重点1】把本地代码挂载进去,实现“热更新”
|
||||||
|
volumes:
|
||||||
|
- ./inventory-web:/app
|
||||||
|
- /app/node_modules # 排除 node_modules,防止冲突
|
||||||
|
# 【重点2】开发模式端口通常是 5173
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
6
inventory-backend/.dockerignore
Normal file
6
inventory-backend/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.git/
|
||||||
|
.env
|
||||||
|
pgdata/
|
||||||
17
inventory-backend/Dockerfile
Normal file
17
inventory-backend/Dockerfile
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# 【修改】使用与你环境一致的 Python 3.8
|
||||||
|
FROM python:3.8
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 1. 复制依赖并安装
|
||||||
|
COPY requirements.txt .
|
||||||
|
# 安装依赖 + gunicorn
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||||
|
pip install --no-cache-dir gunicorn
|
||||||
|
|
||||||
|
# 2. 复制后端代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 3. 启动命令
|
||||||
|
# 假设你的入口文件是 run.py,实例叫 app
|
||||||
|
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "run:app"]
|
||||||
0
inventory-backend/__init__.py
Normal file
0
inventory-backend/__init__.py
Normal file
@ -1,7 +1,6 @@
|
|||||||
from flask import Flask
|
from flask import Flask
|
||||||
from config import Config
|
from config import Config
|
||||||
from app.extensions import db, ma
|
from app.extensions import db, migrate, cors
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@ -9,18 +8,31 @@ def create_app():
|
|||||||
|
|
||||||
# 初始化插件
|
# 初始化插件
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
ma.init_app(app)
|
migrate.init_app(app, db)
|
||||||
|
# 确保跨域配置正确,允许前端访问
|
||||||
|
cors.init_app(app, resources={r"/api/*": {"origins": "*"}})
|
||||||
|
|
||||||
# 【新增关键步骤】: 显式导入 models,让 SQLAlchemy 认识所有的表
|
# --- 注册蓝图 ---
|
||||||
# 必须放在 db.init_app 之后,create_all 或 蓝图注册 之前
|
|
||||||
from app import models
|
|
||||||
|
|
||||||
# 注册路由蓝图
|
# 1. 保持原有的 stocks 模块
|
||||||
from app.api.v1.stocks import stock_bp
|
try:
|
||||||
app.register_blueprint(stock_bp, url_prefix='/api/v1')
|
from app.api.v1.stocks import stocks_bp
|
||||||
|
app.register_blueprint(stocks_bp, url_prefix='/api/v1/stocks')
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"⚠️ 警告: 原有 stocks 蓝图导入失败: {e}")
|
||||||
|
|
||||||
# 【可选】如果你没有用 Flask-Migrate,可以用下面这句话自动建表(开发阶段)
|
# 2. 注册新的入库聚合蓝图
|
||||||
|
# 核心:必须先导入,再注册。路径对应 app/api/v1/inbound/__init__.py
|
||||||
|
try:
|
||||||
|
from app.api.v1.inbound import inbound_bp
|
||||||
|
# 最终路径结构:/api/v1/inbound/buy/list
|
||||||
|
app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound')
|
||||||
|
print("✅ 入库模块蓝图注册成功")
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"❌ 严重错误: 入库模块 inbound 蓝图导入失败: {e}")
|
||||||
|
|
||||||
|
# 打印路由映射,仅在本地调试时建议开启
|
||||||
# with app.app_context():
|
# with app.app_context():
|
||||||
# db.create_all()
|
# print(app.url_map)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
18
inventory-backend/app/api/v1/inbound/__init__.py
Normal file
18
inventory-backend/app/api/v1/inbound/__init__.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
# 1. 导入同目录下的 buy 模块 (假设文件名为 buy.py)
|
||||||
|
from .buy import inbound_buy_bp
|
||||||
|
|
||||||
|
# 2. 【关键修改】导入同目录下的 base 模块
|
||||||
|
# 使用相对导入 .base,这样 Python 就会去 app/api/v1/inbound/base.py 找
|
||||||
|
from .base import inbound_base_bp
|
||||||
|
|
||||||
|
# 创建父级蓝图 'inbound'
|
||||||
|
inbound_bp = Blueprint('inbound', __name__)
|
||||||
|
|
||||||
|
# 3. 挂载子蓝图
|
||||||
|
# 最终路由将是: /api/v1/inbound/buy/...
|
||||||
|
inbound_bp.register_blueprint(inbound_buy_bp, url_prefix='/buy')
|
||||||
|
|
||||||
|
# 最终路由将是: /api/v1/inbound/base/...
|
||||||
|
inbound_bp.register_blueprint(inbound_base_bp, url_prefix='/base')
|
||||||
96
inventory-backend/app/api/v1/inbound/base.py
Normal file
96
inventory-backend/app/api/v1/inbound/base.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
# 修改为这一行,指向 app/services/inbound/base_service.py
|
||||||
|
from app.services.inbound.base_service import MaterialBaseService
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
# 定义蓝图
|
||||||
|
# name='inbound_base' 确保全局唯一,防止和其他蓝图重名
|
||||||
|
inbound_base_bp = Blueprint('inbound_base', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. 获取基础信息列表 (GET)
|
||||||
|
# 路由: /api/v1/inbound/base/list
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@inbound_base_bp.route('/list', methods=['GET'])
|
||||||
|
def get_list():
|
||||||
|
try:
|
||||||
|
# 获取分页参数
|
||||||
|
page = request.args.get('pageNum', 1, type=int)
|
||||||
|
limit = request.args.get('pageSize', 10, type=int)
|
||||||
|
|
||||||
|
# 获取筛选参数
|
||||||
|
filters = {
|
||||||
|
"keyword": request.args.get('keyword'),
|
||||||
|
"category": request.args.get('category'),
|
||||||
|
"type": request.args.get('type'),
|
||||||
|
"isEnabled": request.args.get('isEnabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
# 调用 Service 层逻辑
|
||||||
|
result = MaterialBaseService.get_list(page, limit, filters)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": result
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. 新增基础信息 (POST)
|
||||||
|
# 路由: /api/v1/inbound/base/
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@inbound_base_bp.route('/', methods=['POST'])
|
||||||
|
def add_material():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"code": 400, "msg": "No data provided"}), 400
|
||||||
|
|
||||||
|
MaterialBaseService.create_material(data)
|
||||||
|
return jsonify({"code": 200, "msg": "新增成功"})
|
||||||
|
except ValueError as ve:
|
||||||
|
# 捕获业务逻辑验证错误(如名称重复)
|
||||||
|
return jsonify({"code": 400, "msg": str(ve)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"code": 500, "msg": "系统错误"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. 修改基础信息 (PUT)
|
||||||
|
# 路由: /api/v1/inbound/base/
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@inbound_base_bp.route('/', methods=['PUT'])
|
||||||
|
def update_material():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or not data.get('id'):
|
||||||
|
return jsonify({"code": 400, "msg": "ID不能为空"}), 400
|
||||||
|
|
||||||
|
MaterialBaseService.update_material(data.get('id'), data)
|
||||||
|
return jsonify({"code": 200, "msg": "更新成功"})
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 4. 删除基础信息 (DELETE)
|
||||||
|
# 路由: /api/v1/inbound/base/<id>
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@inbound_base_bp.route('/<int:id>', methods=['DELETE'])
|
||||||
|
def delete_material(id):
|
||||||
|
try:
|
||||||
|
MaterialBaseService.delete_material(id)
|
||||||
|
return jsonify({"code": 200, "msg": "删除成功"})
|
||||||
|
except ValueError as ve:
|
||||||
|
# 捕获依赖检查错误(如已被库存引用)
|
||||||
|
return jsonify({"code": 400, "msg": str(ve)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
77
inventory-backend/app/api/v1/inbound/buy.py
Normal file
77
inventory-backend/app/api/v1/inbound/buy.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from app.services.inbound.buy_service import BuyInboundService
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
inbound_buy_bp = Blueprint('inbound_buy', __name__)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 0. 基础物料搜索 (新增)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@inbound_buy_bp.route('/search-base', methods=['GET'])
|
||||||
|
def search_base():
|
||||||
|
"""
|
||||||
|
供前端下拉框远程搜索使用
|
||||||
|
Query Param: keyword (名称或规格)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
keyword = request.args.get('keyword', '')
|
||||||
|
data = BuyInboundService.search_base_material(keyword)
|
||||||
|
return jsonify({
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": data
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. 获取列表
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@inbound_buy_bp.route('/list', methods=['GET'])
|
||||||
|
def get_list():
|
||||||
|
try:
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
limit = request.args.get('pageSize', 15, type=int)
|
||||||
|
result = BuyInboundService.get_list(page, limit)
|
||||||
|
return jsonify({"code": 200, "msg": "success", "data": result})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. 新增入库
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@inbound_buy_bp.route('/submit', methods=['POST'])
|
||||||
|
def submit():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"code": 400, "msg": "No data"}), 400
|
||||||
|
BuyInboundService.handle_inbound(data)
|
||||||
|
return jsonify({"code": 200, "msg": "入库成功"})
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. 更新入库
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@inbound_buy_bp.route('/<int:id>', methods=['PUT'])
|
||||||
|
def update_buy(id):
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
BuyInboundService.update_inbound(id, data)
|
||||||
|
return jsonify({"code": 200, "msg": "更新成功"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 4. 删除
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@inbound_buy_bp.route('/<int:id>', methods=['DELETE'])
|
||||||
|
def delete_buy(id):
|
||||||
|
try:
|
||||||
|
BuyInboundService.delete_inbound(id)
|
||||||
|
return jsonify({"code": 200, "msg": "删除成功"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
0
inventory-backend/app/api/v1/inbound/product.py
Normal file
0
inventory-backend/app/api/v1/inbound/product.py
Normal file
0
inventory-backend/app/api/v1/inbound/semi.py
Normal file
0
inventory-backend/app/api/v1/inbound/semi.py
Normal file
@ -1,34 +0,0 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
|
||||||
from app.services.stock_service import create_inbound_stock
|
|
||||||
from app.schemas.stock_schema import StockBuySchema
|
|
||||||
|
|
||||||
stock_bp = Blueprint('stocks', __name__)
|
|
||||||
|
|
||||||
@stock_bp.route('/buy-inbound', methods=['POST'])
|
|
||||||
def buy_inbound():
|
|
||||||
"""
|
|
||||||
采购入库接口
|
|
||||||
POST /api/v1/buy-inbound
|
|
||||||
Body: { "material_id": 1, "qty_inbound": 100, "price_unit": 10.5 ... }
|
|
||||||
"""
|
|
||||||
# 1. 接收 JSON 数据
|
|
||||||
json_data = request.get_json()
|
|
||||||
if not json_data:
|
|
||||||
return jsonify({"message": "No input data provided"}), 400
|
|
||||||
|
|
||||||
# 2. 数据校验
|
|
||||||
schema = StockBuySchema()
|
|
||||||
try:
|
|
||||||
# 这一步只做校验,不直接生成对象,因为我们要在 Service 里手动处理逻辑
|
|
||||||
data = schema.load(json_data, partial=True)
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"message": "Validation error", "errors": e.messages}), 422
|
|
||||||
|
|
||||||
# 3. 调用业务逻辑
|
|
||||||
try:
|
|
||||||
new_stock = create_inbound_stock(data)
|
|
||||||
# 4. 返回成功结果
|
|
||||||
result = schema.dump(new_stock)
|
|
||||||
return jsonify({"message": "Inbound successful", "data": result}), 201
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"message": "Internal Server Error", "error": str(e)}), 500
|
|
||||||
@ -1,6 +1,8 @@
|
|||||||
|
# 文件路径: app/extensions.py
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_marshmallow import Marshmallow
|
from flask_migrate import Migrate
|
||||||
|
from flask_cors import CORS # 解决前后端跨域问题
|
||||||
|
|
||||||
# 初始化数据库和序列化工具
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
ma = Marshmallow()
|
migrate = Migrate()
|
||||||
|
cors = CORS()
|
||||||
@ -1,11 +1,84 @@
|
|||||||
|
# app/models/material.py
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class MaterialBase(db.Model):
|
class MaterialBase(db.Model):
|
||||||
|
"""
|
||||||
|
基础信息表模型
|
||||||
|
对应数据库表: material_base
|
||||||
|
"""
|
||||||
__tablename__ = 'material_base'
|
__tablename__ = 'material_base'
|
||||||
|
|
||||||
|
# 1. 基础字段 (必须与 SQL 建表语句完全一致)
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
sku_code = db.Column(db.String(100), unique=True, nullable=False)
|
name = db.Column(db.String(255), nullable=False, comment='基础信息名称')
|
||||||
name = db.Column(db.String(255), nullable=False)
|
|
||||||
spec_model = db.Column(db.String(255))
|
# 类别 (对应 SQL: category)
|
||||||
unit = db.Column(db.String(50))
|
category = db.Column(db.String(100), comment='类别')
|
||||||
# 其他字段按需添加,入库时主要是为了外键关联
|
|
||||||
|
# 类型 (对应 SQL: material_type) -> 前端 prop="type"
|
||||||
|
material_type = db.Column(db.String(100), comment='类型')
|
||||||
|
|
||||||
|
# 规格型号 (对应 SQL: spec_model) -> 前端 prop="spec"
|
||||||
|
spec_model = db.Column(db.String(255), comment='规格型号')
|
||||||
|
|
||||||
|
unit = db.Column(db.String(50), comment='计量单位')
|
||||||
|
|
||||||
|
# 可见等级
|
||||||
|
visibility_level = db.Column(db.Integer, default=0, comment='信息可见等级')
|
||||||
|
|
||||||
|
# 链接与图片
|
||||||
|
manual_link = db.Column(db.Text, comment='通用说明书链接')
|
||||||
|
product_image = db.Column(db.Text, comment='通用产品图链接')
|
||||||
|
|
||||||
|
# 启用状态 (注意:SQL中是 boolean)
|
||||||
|
is_enabled = db.Column(db.Boolean, default=True, comment='是否启用')
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ⚠️ 注意:你之前提供的 SQL 建表语句中【没有】下面这两个时间字段。
|
||||||
|
# 如果数据库里没有这两列,代码运行到这里会报错 (UndefinedColumn)。
|
||||||
|
# 我先将其注释掉。如果你确认数据库已经 Alter Table 加了这两列,请取消注释。
|
||||||
|
# ============================================================
|
||||||
|
# create_time = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
# update_time = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# 【核心关联】
|
||||||
|
# 关联采购库存表 (StockBuy),lazy='dynamic' 允许后续做 .count() 统计
|
||||||
|
# 确保 app/models/stock.py 中有 back_populates='material'
|
||||||
|
stock_buys = db.relationship('StockBuy', back_populates='material', lazy='dynamic')
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""
|
||||||
|
序列化方法:将模型转换为字典,供API返回JSON使用
|
||||||
|
这里是解决【前端表格空白】最关键的地方
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'category': self.category,
|
||||||
|
|
||||||
|
# =========================================
|
||||||
|
# 关键映射区 (解决前后端字段名不一致问题)
|
||||||
|
# =========================================
|
||||||
|
# 数据库叫 material_type -> 前端叫 type
|
||||||
|
'type': self.material_type,
|
||||||
|
|
||||||
|
# 数据库叫 spec_model -> 前端叫 spec
|
||||||
|
'spec': self.spec_model,
|
||||||
|
|
||||||
|
'unit': self.unit,
|
||||||
|
|
||||||
|
# 驼峰命名适配
|
||||||
|
'visibilityLevel': self.visibility_level,
|
||||||
|
'generalManual': self.manual_link,
|
||||||
|
'generalImage': self.product_image,
|
||||||
|
|
||||||
|
# 状态处理:前端 Switch 通常接受 boolean 或 1/0
|
||||||
|
# 数据库里的 true -> 返回 1 (启用)
|
||||||
|
# 数据库里的 false/None -> 返回 0 (禁用)
|
||||||
|
'isEnabled': 1 if self.is_enabled else 0,
|
||||||
|
|
||||||
|
# 如果上方注释了 create_time,这里也要注释,否则会报错
|
||||||
|
# 'createTime': self.create_time.strftime('%Y-%m-%d %H:%M:%S') if hasattr(self, 'create_time') and self.create_time else None
|
||||||
|
}
|
||||||
@ -1,25 +1,112 @@
|
|||||||
|
# app/models/stock.py
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class StockBuy(db.Model):
|
class StockBuy(db.Model):
|
||||||
|
"""
|
||||||
|
采购入库库存表
|
||||||
|
对应数据库表: stock_buy
|
||||||
|
"""
|
||||||
__tablename__ = 'stock_buy'
|
__tablename__ = 'stock_buy'
|
||||||
|
|
||||||
|
# 主键
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
material_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
|
||||||
inbound_date = db.Column(db.DateTime, default=datetime.now)
|
|
||||||
barcode = db.Column(db.String(100))
|
|
||||||
batch_no = db.Column(db.String(100))
|
|
||||||
|
|
||||||
# 数量相关 (使用 Numeric 对应数据库的 NUMERIC)
|
# 【核心关联】外键关联 material_base 表
|
||||||
qty_inbound = db.Column(db.Numeric(19, 4), default=0)
|
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||||
qty_current = db.Column(db.Numeric(19, 4), default=0)
|
|
||||||
qty_available = db.Column(db.Numeric(19, 4), default=0)
|
# --- 身份标识 ---
|
||||||
|
sku = db.Column(db.String(100))
|
||||||
|
in_date = db.Column(db.Date)
|
||||||
|
barcode = db.Column(db.String(100)) # 条码
|
||||||
|
serial_number = db.Column(db.String(100)) # 序列号
|
||||||
|
batch_number = db.Column(db.String(100)) # 批号
|
||||||
|
|
||||||
|
# --- 数量 ---
|
||||||
|
in_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||||
|
stock_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||||
|
available_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||||
|
|
||||||
|
# --- 状态与位置 ---
|
||||||
|
status = db.Column(db.String(50)) # 在库/出库/损耗
|
||||||
|
inspection_status = db.Column(db.String(50)) # 未检/合格/不合格
|
||||||
|
warehouse_location = db.Column(db.String(100))
|
||||||
|
|
||||||
|
# --- 财务与商务 ---
|
||||||
|
unit_price = db.Column(db.Numeric(19, 4), default=0)
|
||||||
|
total_price = db.Column(db.Numeric(19, 4), default=0)
|
||||||
|
currency = db.Column(db.String(20), default='CNY')
|
||||||
|
exchange_rate = db.Column(db.Numeric(15, 6), default=1.0)
|
||||||
|
|
||||||
price_unit = db.Column(db.Numeric(19, 4), default=0)
|
|
||||||
price_total = db.Column(db.Numeric(19, 4), default=0)
|
|
||||||
supplier_name = db.Column(db.String(255))
|
supplier_name = db.Column(db.String(255))
|
||||||
warehouse_loc = db.Column(db.String(100))
|
|
||||||
|
|
||||||
# 建立关联,方便查询物料详情
|
# [关键映射区]:Python属性名 = DB列名
|
||||||
material = db.relationship('MaterialBase', backref='buy_stocks')
|
# 前端传 purchaser -> 存入 buyer_name
|
||||||
|
buyer_name = db.Column(db.String(100))
|
||||||
|
|
||||||
|
# 前端传 purchaser_email -> 存入 buyer_email
|
||||||
|
buyer_email = db.Column(db.String(100))
|
||||||
|
|
||||||
|
# 前端传 source_link -> 存入 original_link
|
||||||
|
original_link = db.Column(db.Text)
|
||||||
|
|
||||||
|
detail_link = db.Column(db.Text)
|
||||||
|
arrival_photo = db.Column(db.Text)
|
||||||
|
|
||||||
|
# [这就是报错缺失的字段],请确保执行了 ALTER TABLE
|
||||||
|
remark = db.Column(db.Text)
|
||||||
|
|
||||||
|
# 【关系定义】
|
||||||
|
# 建立与 MaterialBase 的关系,方便通过 stock.material 访问基础信息
|
||||||
|
material = db.relationship('MaterialBase', back_populates='stock_buys')
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""
|
||||||
|
序列化:将模型转换为字典,主要用于单条查询或内部调用
|
||||||
|
列表查询主要依赖 Service 层的手动构建以提高性能
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'base_id': self.base_id,
|
||||||
|
|
||||||
|
# 级联基础信息 (防止 None 报错)
|
||||||
|
'material_name': self.material.name if self.material else '',
|
||||||
|
'spec_model': self.material.spec_model if self.material else '',
|
||||||
|
'category': self.material.category if self.material else '',
|
||||||
|
'unit': self.material.unit if self.material else '',
|
||||||
|
'material_type': self.material.material_type if self.material else '',
|
||||||
|
|
||||||
|
# 实体信息
|
||||||
|
'sku': self.sku,
|
||||||
|
'inbound_date': self.in_date.strftime('%Y-%m-%d') if self.in_date else '',
|
||||||
|
'barcode': self.barcode,
|
||||||
|
'serial_number': self.serial_number,
|
||||||
|
'batch_number': self.batch_number,
|
||||||
|
'warehouse_loc': self.warehouse_location,
|
||||||
|
'status': self.status,
|
||||||
|
'inspection_status': self.inspection_status,
|
||||||
|
'remark': self.remark,
|
||||||
|
|
||||||
|
# 数量 (转为float防止json序列化报错)
|
||||||
|
'in_quantity': float(self.in_quantity or 0),
|
||||||
|
'qty_inbound': float(self.in_quantity or 0), # 兼容字段
|
||||||
|
'stock_quantity': float(self.stock_quantity or 0),
|
||||||
|
'qty_stock': float(self.stock_quantity or 0), # 兼容字段
|
||||||
|
'available_quantity': float(self.available_quantity or 0),
|
||||||
|
'qty_available': float(self.available_quantity or 0), # 兼容字段
|
||||||
|
|
||||||
|
# 财务
|
||||||
|
'unit_price': float(self.unit_price or 0),
|
||||||
|
'total_price': float(self.total_price or 0),
|
||||||
|
'currency': self.currency,
|
||||||
|
'exchange_rate': float(self.exchange_rate or 1.0),
|
||||||
|
|
||||||
|
# 商务 (字段映射)
|
||||||
|
'supplier_name': self.supplier_name,
|
||||||
|
'purchaser': self.buyer_name, # 映射回前端
|
||||||
|
'purchaser_email': self.buyer_email, # 映射回前端
|
||||||
|
'source_link': self.original_link, # 映射回前端
|
||||||
|
'detail_link': self.detail_link,
|
||||||
|
'arrival_photo': self.arrival_photo
|
||||||
|
}
|
||||||
@ -1,14 +1,42 @@
|
|||||||
from app.extensions import ma
|
from marshmallow import Schema, fields, validate, validates_schema, ValidationError
|
||||||
from app.models.stock import StockBuy
|
|
||||||
from marshmallow import fields
|
|
||||||
|
|
||||||
class StockBuySchema(ma.SQLAlchemyAutoSchema):
|
|
||||||
class Meta:
|
|
||||||
model = StockBuy
|
|
||||||
load_instance = True # 反序列化时自动创建模型实例
|
|
||||||
include_fk = True # 包含外键 material_id
|
|
||||||
|
|
||||||
# 必须字段校验
|
class StockBuySchema(Schema):
|
||||||
material_id = fields.Integer(required=True)
|
# 只用于输出的字段
|
||||||
qty_inbound = fields.Decimal(required=True, as_string=True)
|
id = fields.Int(dump_only=True)
|
||||||
price_unit = fields.Decimal(missing=0, as_string=True)
|
|
||||||
|
# --- 输入字段 ---
|
||||||
|
|
||||||
|
# 1. 核心识别字段
|
||||||
|
material_id = fields.Int(missing=None) # 如果是老物料,可能传ID
|
||||||
|
sku_code = fields.Str(required=True, error_messages={"required": "SKU编码是必填项"}) # 必填
|
||||||
|
|
||||||
|
# 2. 新物料自动建档字段 (如果是新SKU,这些需要校验)
|
||||||
|
material_name = fields.Str(missing=None)
|
||||||
|
spec_model = fields.Str(missing=None)
|
||||||
|
unit = fields.Str(missing=None)
|
||||||
|
category = fields.Str(missing=None)
|
||||||
|
|
||||||
|
# 3. 入库业务字段
|
||||||
|
qty_inbound = fields.Float(required=True, validate=validate.Range(min=0.0001, error="入库数量必须大于0"))
|
||||||
|
price_unit = fields.Float(missing=0)
|
||||||
|
|
||||||
|
inbound_date = fields.DateTime(format='%Y-%m-%d %H:%M:%S')
|
||||||
|
batch_no = fields.Str(missing='')
|
||||||
|
warehouse_loc = fields.Str(missing='')
|
||||||
|
supplier_name = fields.Str(missing='')
|
||||||
|
|
||||||
|
@validates_schema
|
||||||
|
def validate_material_logic(self, data, **kwargs):
|
||||||
|
"""
|
||||||
|
自定义校验逻辑:
|
||||||
|
如果用户没传 material_id,说明可能想新建物料。
|
||||||
|
虽然最终是否新建由 Service 层判断数据库决定,
|
||||||
|
但这里可以做一个弱校验:尽量让用户填上名字。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
# 这里暂时不强制抛出错误,交给 Service 层处理 "SKU不存在且无名字" 的情况
|
||||||
|
|
||||||
|
|
||||||
|
# 实例化 Schema
|
||||||
|
stock_buy_schema = StockBuySchema()
|
||||||
0
inventory-backend/app/services/inbound/__init__.py
Normal file
0
inventory-backend/app/services/inbound/__init__.py
Normal file
139
inventory-backend/app/services/inbound/base_service.py
Normal file
139
inventory-backend/app/services/inbound/base_service.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
from app.extensions import db
|
||||||
|
from app.models.material import MaterialBase
|
||||||
|
from app.models.stock import StockBuy # 需要引入库存表做删除时的依赖检查
|
||||||
|
from sqlalchemy import or_
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialBaseService:
|
||||||
|
@staticmethod
|
||||||
|
def get_list(page, limit, filters=None):
|
||||||
|
"""
|
||||||
|
获取基础信息列表
|
||||||
|
:param page: 页码
|
||||||
|
:param limit: 每页条数
|
||||||
|
:param filters: 筛选条件字典 {keyword, category, type, isEnabled}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = MaterialBase.query
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
# 1. 关键词模糊搜索 (名称 或 规格型号)
|
||||||
|
if filters.get('keyword'):
|
||||||
|
kw = f"%{filters['keyword']}%"
|
||||||
|
query = query.filter(or_(
|
||||||
|
MaterialBase.name.like(kw),
|
||||||
|
MaterialBase.spec_model.like(kw)
|
||||||
|
))
|
||||||
|
|
||||||
|
# 2. 精确筛选
|
||||||
|
if filters.get('category'):
|
||||||
|
query = query.filter_by(category=filters['category'])
|
||||||
|
|
||||||
|
if filters.get('type'):
|
||||||
|
query = query.filter_by(material_type=filters['type'])
|
||||||
|
|
||||||
|
if filters.get('isEnabled') is not None:
|
||||||
|
# 前端传 1/0,转为 Boolean
|
||||||
|
is_active = bool(int(filters['isEnabled']))
|
||||||
|
query = query.filter_by(is_enabled=is_active)
|
||||||
|
|
||||||
|
# 按 ID 倒序排列
|
||||||
|
pagination = query.order_by(MaterialBase.id.desc()).paginate(page=page, per_page=limit, error_out=False)
|
||||||
|
|
||||||
|
items = [item.to_dict() for item in pagination.items]
|
||||||
|
return {"total": pagination.total, "items": items}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"查询基础信息列表失败: {e}")
|
||||||
|
# 生产环境建议记录日志
|
||||||
|
return {"total": 0, "items": []}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_material(data):
|
||||||
|
"""新增基础信息"""
|
||||||
|
try:
|
||||||
|
# 0. 基础校验
|
||||||
|
if not data.get('name') or not data.get('spec'):
|
||||||
|
raise ValueError("名称和规格型号不能为空")
|
||||||
|
|
||||||
|
# 1. 查重 (名称+规格型号 唯一)
|
||||||
|
exist = MaterialBase.query.filter_by(
|
||||||
|
name=data['name'],
|
||||||
|
spec_model=data['spec']
|
||||||
|
).first()
|
||||||
|
if exist:
|
||||||
|
raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})")
|
||||||
|
|
||||||
|
# 2. 创建对象 (注意前端驼峰 -> 后端下划线映射)
|
||||||
|
new_material = MaterialBase(
|
||||||
|
name=data['name'],
|
||||||
|
spec_model=data['spec'], # 映射
|
||||||
|
category=data.get('category'),
|
||||||
|
material_type=data.get('type'), # 映射
|
||||||
|
unit=data.get('unit'),
|
||||||
|
visibility_level=data.get('visibilityLevel'), # 映射
|
||||||
|
manual_link=data.get('generalManual'), # 映射
|
||||||
|
product_image=data.get('generalImage'), # 映射
|
||||||
|
is_enabled=True if data.get('isEnabled', 1) == 1 else False
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(new_material)
|
||||||
|
db.session.commit()
|
||||||
|
return new_material
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_material(m_id, data):
|
||||||
|
"""修改基础信息"""
|
||||||
|
try:
|
||||||
|
material = MaterialBase.query.get(m_id)
|
||||||
|
if not material:
|
||||||
|
raise ValueError("数据不存在")
|
||||||
|
|
||||||
|
# 更新字段 (仅更新传入的字段)
|
||||||
|
if 'name' in data: material.name = data['name']
|
||||||
|
if 'spec' in data: material.spec_model = data['spec']
|
||||||
|
if 'category' in data: material.category = data['category']
|
||||||
|
if 'type' in data: material.material_type = data['type']
|
||||||
|
if 'unit' in data: material.unit = data['unit']
|
||||||
|
if 'visibilityLevel' in data: material.visibility_level = data['visibilityLevel']
|
||||||
|
if 'generalManual' in data: material.manual_link = data['generalManual']
|
||||||
|
if 'generalImage' in data: material.product_image = data['generalImage']
|
||||||
|
|
||||||
|
if 'isEnabled' in data:
|
||||||
|
material.is_enabled = bool(int(data['isEnabled']))
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return material
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_material(m_id):
|
||||||
|
"""删除基础信息 (带依赖检查)"""
|
||||||
|
try:
|
||||||
|
material = MaterialBase.query.get(m_id)
|
||||||
|
if not material:
|
||||||
|
raise ValueError("数据不存在")
|
||||||
|
|
||||||
|
# 1. 依赖检查:如果该基础信息已经在库存表(StockBuy)中使用,禁止物理删除
|
||||||
|
# 这里假设 StockBuy 表有一个外键或字段指向 MaterialBase (e.g., base_id)
|
||||||
|
usage_count = StockBuy.query.filter_by(base_id=m_id).count()
|
||||||
|
if usage_count > 0:
|
||||||
|
raise ValueError(f"无法删除:该基础信息已被 {usage_count} 条库存记录引用,请先清理库存或仅禁用此条目。")
|
||||||
|
|
||||||
|
# 2. 执行删除
|
||||||
|
db.session.delete(material)
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"删除基础信息失败: {e}")
|
||||||
|
raise e
|
||||||
275
inventory-backend/app/services/inbound/buy_service.py
Normal file
275
inventory-backend/app/services/inbound/buy_service.py
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
from app.extensions import db
|
||||||
|
from app.models.material import MaterialBase
|
||||||
|
from app.models.stock import StockBuy
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import or_, func # 引入 func 用于聚合计算
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
|
class BuyInboundService:
|
||||||
|
@staticmethod
|
||||||
|
def search_base_material(keyword):
|
||||||
|
try:
|
||||||
|
if not keyword:
|
||||||
|
return []
|
||||||
|
query = MaterialBase.query.filter(
|
||||||
|
MaterialBase.is_enabled == True,
|
||||||
|
or_(
|
||||||
|
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||||
|
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||||||
|
)
|
||||||
|
).limit(20)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for item in query.all():
|
||||||
|
results.append({
|
||||||
|
'id': item.id,
|
||||||
|
'name': item.name,
|
||||||
|
'spec': item.spec_model,
|
||||||
|
'category': item.category,
|
||||||
|
'unit': item.unit,
|
||||||
|
'type': item.material_type,
|
||||||
|
'status': '启用'
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def handle_inbound(data):
|
||||||
|
try:
|
||||||
|
base_id = data.get('base_id')
|
||||||
|
if not base_id:
|
||||||
|
raise ValueError("必须选择基础物料进行入库 (缺少 base_id)")
|
||||||
|
|
||||||
|
material = MaterialBase.query.get(base_id)
|
||||||
|
if not material:
|
||||||
|
raise ValueError(f"ID为 {base_id} 的基础物料不存在")
|
||||||
|
|
||||||
|
in_date_val = datetime.utcnow().date()
|
||||||
|
if data.get('in_date'):
|
||||||
|
try:
|
||||||
|
if len(str(data['in_date'])) > 10:
|
||||||
|
in_date_val = datetime.strptime(str(data['in_date'])[:10], '%Y-%m-%d').date()
|
||||||
|
else:
|
||||||
|
in_date_val = datetime.strptime(str(data['in_date']), '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
in_qty = float(data.get('in_quantity') or 0)
|
||||||
|
u_price = float(data.get('unit_price') or 0)
|
||||||
|
|
||||||
|
new_stock = StockBuy(
|
||||||
|
base_id=material.id,
|
||||||
|
sku=data.get('sku'),
|
||||||
|
in_date=in_date_val,
|
||||||
|
serial_number=data.get('serial_number'),
|
||||||
|
batch_number=data.get('batch_number'),
|
||||||
|
barcode=data.get('barcode'),
|
||||||
|
status='在库',
|
||||||
|
in_quantity=in_qty,
|
||||||
|
stock_quantity=in_qty,
|
||||||
|
available_quantity=in_qty,
|
||||||
|
inspection_status=data.get('inspection_status', '未检'),
|
||||||
|
warehouse_location=data.get('warehouse_location'),
|
||||||
|
unit_price=u_price,
|
||||||
|
total_price=in_qty * u_price,
|
||||||
|
currency=data.get('currency', 'CNY'),
|
||||||
|
exchange_rate=data.get('exchange_rate', 1.0),
|
||||||
|
supplier_name=data.get('supplier_name'),
|
||||||
|
buyer_name=data.get('purchaser'),
|
||||||
|
buyer_email=data.get('purchaser_email'),
|
||||||
|
original_link=data.get('source_link'),
|
||||||
|
detail_link=data.get('detail_link'),
|
||||||
|
arrival_photo=data.get('arrival_photo'),
|
||||||
|
remark=data.get('remark')
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(new_stock)
|
||||||
|
db.session.commit()
|
||||||
|
return new_stock
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_inbound(stock_id, data):
|
||||||
|
try:
|
||||||
|
print(f"----- UPDATE DEBUG: ID={stock_id} -----")
|
||||||
|
print(f"Payload: {data}")
|
||||||
|
|
||||||
|
stock = StockBuy.query.get(stock_id)
|
||||||
|
if not stock:
|
||||||
|
raise ValueError("记录不存在")
|
||||||
|
|
||||||
|
field_mapping = {
|
||||||
|
'sku': 'sku',
|
||||||
|
'barcode': 'barcode',
|
||||||
|
'warehouse_location': 'warehouse_location',
|
||||||
|
'serial_number': 'serial_number',
|
||||||
|
'batch_number': 'batch_number',
|
||||||
|
'status': 'status',
|
||||||
|
'inspection_status': 'inspection_status',
|
||||||
|
'supplier_name': 'supplier_name',
|
||||||
|
'detail_link': 'detail_link',
|
||||||
|
'arrival_photo': 'arrival_photo',
|
||||||
|
'remark': 'remark',
|
||||||
|
'currency': 'currency',
|
||||||
|
'exchange_rate': 'exchange_rate',
|
||||||
|
'purchaser': 'buyer_name',
|
||||||
|
'purchaser_email': 'buyer_email',
|
||||||
|
'source_link': 'original_link'
|
||||||
|
}
|
||||||
|
|
||||||
|
for frontend_key, db_attr in field_mapping.items():
|
||||||
|
if frontend_key in data:
|
||||||
|
setattr(stock, db_attr, data[frontend_key])
|
||||||
|
|
||||||
|
qty_changed = False
|
||||||
|
price_changed = False
|
||||||
|
|
||||||
|
if 'in_quantity' in data:
|
||||||
|
new_qty = float(data['in_quantity'])
|
||||||
|
old_qty = float(stock.in_quantity)
|
||||||
|
if new_qty != old_qty:
|
||||||
|
diff = new_qty - old_qty
|
||||||
|
stock.in_quantity = new_qty
|
||||||
|
stock.stock_quantity = float(stock.stock_quantity) + diff
|
||||||
|
stock.available_quantity = float(stock.available_quantity) + diff
|
||||||
|
qty_changed = True
|
||||||
|
|
||||||
|
if 'unit_price' in data:
|
||||||
|
new_price = float(data['unit_price'])
|
||||||
|
old_price = float(stock.unit_price)
|
||||||
|
if new_price != old_price:
|
||||||
|
stock.unit_price = new_price
|
||||||
|
price_changed = True
|
||||||
|
|
||||||
|
if qty_changed or price_changed:
|
||||||
|
stock.total_price = float(stock.in_quantity) * float(stock.unit_price)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
print("----- UPDATE SUCCESS -----")
|
||||||
|
return stock
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"----- UPDATE FAILED: {str(e)} -----")
|
||||||
|
traceback.print_exc()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_inbound(stock_id):
|
||||||
|
try:
|
||||||
|
stock = StockBuy.query.get(stock_id)
|
||||||
|
if not stock:
|
||||||
|
raise ValueError("记录不存在")
|
||||||
|
db.session.delete(stock)
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_list(page, limit, keyword=None):
|
||||||
|
try:
|
||||||
|
# 1. 查询分页数据
|
||||||
|
query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||||
|
MaterialBase.spec_model.ilike(f'%{keyword}%'),
|
||||||
|
StockBuy.batch_number.ilike(f'%{keyword}%'),
|
||||||
|
StockBuy.serial_number.ilike(f'%{keyword}%'),
|
||||||
|
StockBuy.sku.ilike(f'%{keyword}%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pagination = query.order_by(StockBuy.id.desc()).paginate(page=page, per_page=limit, error_out=False)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# 新增逻辑:计算总库存
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# 2. 提取当前页所有涉及的 base_id
|
||||||
|
current_items = pagination.items
|
||||||
|
base_ids = list(set([item.base_id for item in current_items if item.base_id]))
|
||||||
|
|
||||||
|
# 3. 聚合查询:一次性查出这些 base_id 对应的 stock_quantity 和 available_quantity 总和
|
||||||
|
stock_map = {}
|
||||||
|
if base_ids:
|
||||||
|
# SELECT base_id, SUM(stock_quantity), SUM(available_quantity) FROM stock_buy WHERE base_id IN (...) GROUP BY base_id
|
||||||
|
aggregates = db.session.query(
|
||||||
|
StockBuy.base_id,
|
||||||
|
func.sum(StockBuy.stock_quantity).label('total_stock'),
|
||||||
|
func.sum(StockBuy.available_quantity).label('total_avail')
|
||||||
|
).filter(StockBuy.base_id.in_(base_ids)).group_by(StockBuy.base_id).all()
|
||||||
|
|
||||||
|
for agg in aggregates:
|
||||||
|
stock_map[agg.base_id] = {
|
||||||
|
'total_stock': float(agg.total_stock or 0),
|
||||||
|
'total_avail': float(agg.total_avail or 0)
|
||||||
|
}
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for item in current_items:
|
||||||
|
mat_name = item.material.name if item.material else '未知物料'
|
||||||
|
mat_spec = item.material.spec_model if item.material else ''
|
||||||
|
mat_cat = item.material.category if item.material else ''
|
||||||
|
mat_unit = item.material.unit if item.material else ''
|
||||||
|
mat_type = item.material.material_type if item.material else ''
|
||||||
|
|
||||||
|
# 获取该物料的统计数据
|
||||||
|
stats = stock_map.get(item.base_id, {'total_stock': 0, 'total_avail': 0})
|
||||||
|
|
||||||
|
d = {
|
||||||
|
'id': item.id,
|
||||||
|
'base_id': item.base_id,
|
||||||
|
'material_name': mat_name,
|
||||||
|
'spec_model': mat_spec,
|
||||||
|
'category': mat_cat,
|
||||||
|
'unit': mat_unit,
|
||||||
|
'material_type': mat_type,
|
||||||
|
|
||||||
|
'sku': item.sku,
|
||||||
|
'inbound_date': str(item.in_date) if item.in_date else '',
|
||||||
|
'barcode': item.barcode,
|
||||||
|
'serial_number': item.serial_number,
|
||||||
|
'batch_number': item.batch_number,
|
||||||
|
'status': item.status,
|
||||||
|
'inspection_status': item.inspection_status,
|
||||||
|
|
||||||
|
# --- 原始批次数据 (用于编辑) ---
|
||||||
|
'qty_inbound': float(item.in_quantity or 0),
|
||||||
|
'qty_stock': float(item.stock_quantity or 0),
|
||||||
|
'qty_available': float(item.available_quantity or 0),
|
||||||
|
|
||||||
|
# --- [新增] 聚合统计数据 (用于列表显示) ---
|
||||||
|
'sum_stock': stats['total_stock'],
|
||||||
|
'sum_available': stats['total_avail'],
|
||||||
|
|
||||||
|
'warehouse_loc': item.warehouse_location,
|
||||||
|
'unit_price': float(item.unit_price or 0),
|
||||||
|
'total_price': float(item.total_price or 0),
|
||||||
|
'currency': item.currency,
|
||||||
|
'exchange_rate': float(item.exchange_rate or 1),
|
||||||
|
'supplier_name': item.supplier_name,
|
||||||
|
'purchaser': item.buyer_name,
|
||||||
|
'purchaser_email': item.buyer_email,
|
||||||
|
'source_link': item.original_link,
|
||||||
|
'detail_link': item.detail_link,
|
||||||
|
'arrival_photo': item.arrival_photo,
|
||||||
|
'remark': item.remark
|
||||||
|
}
|
||||||
|
items.append(d)
|
||||||
|
|
||||||
|
return {"total": pagination.total, "items": items}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"List Error: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
|
return {"total": 0, "items": []}
|
||||||
@ -1,39 +0,0 @@
|
|||||||
from app.extensions import db
|
|
||||||
from app.models.stock import StockBuy
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
|
|
||||||
|
|
||||||
def create_inbound_stock(data):
|
|
||||||
"""
|
|
||||||
处理采购入库逻辑
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 1. 计算总价
|
|
||||||
qty = data.get('qty_inbound')
|
|
||||||
price = data.get('price_unit', 0)
|
|
||||||
total = float(qty) * float(price)
|
|
||||||
|
|
||||||
# 2. 创建库存记录
|
|
||||||
# 注意:入库时,当前库存(current)和可用库存(available)通常等于入库数量
|
|
||||||
new_stock = StockBuy(
|
|
||||||
material_id=data['material_id'],
|
|
||||||
barcode=data.get('barcode'),
|
|
||||||
batch_no=data.get('batch_no'),
|
|
||||||
qty_inbound=qty,
|
|
||||||
qty_current=qty, # 初始:当前=入库
|
|
||||||
qty_available=qty, # 初始:可用=入库
|
|
||||||
price_unit=price,
|
|
||||||
price_total=total,
|
|
||||||
supplier_name=data.get('supplier_name'),
|
|
||||||
warehouse_loc=data.get('warehouse_loc'),
|
|
||||||
inbound_date=data.get('inbound_date') # 如果前端没传,Model会默认用当前时间
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.add(new_stock)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return new_stock
|
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
db.session.rollback()
|
|
||||||
raise e
|
|
||||||
@ -1,14 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
# 数据库连接配置
|
# 【核心修改】
|
||||||
# 请务必将 '你的密码' 替换为你 PostgreSQL 的真实密码
|
# 优先读取 Docker 传入的 'DATABASE_URL' 环境变量。
|
||||||
# 如果数据库不在本地,请将 localhost 替换为 IP 地址
|
# 如果读不到(比如你在非 Docker 环境下本地直接运行),才回退使用 'localhost'。
|
||||||
SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:1234@localhost:5432/inventory_system'
|
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||||
|
'DATABASE_URL',
|
||||||
|
'postgresql://test:1234@localhost:5432/inventory_system'
|
||||||
|
)
|
||||||
|
|
||||||
# 关闭 SQLAlchemy 的事件追踪,减少内存消耗
|
# 关闭 SQLAlchemy 的事件追踪,减少内存消耗
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
# Flask 的密钥,用于 Session 加密等,开发环境随便写一个即可
|
# Flask 的密钥
|
||||||
SECRET_KEY = 'dev-secret-key-1234'
|
SECRET_KEY = 'dev-secret-key-1234'
|
||||||
@ -1,17 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
db:
|
|
||||||
image: postgres:15-alpine # 使用轻量级的 Alpine 版本
|
|
||||||
container_name: inventory_db
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: test # 自定义用户名
|
|
||||||
POSTGRES_PASSWORD: 1234 # 自定义密码 (开发环境简单点没事)
|
|
||||||
POSTGRES_DB: inventory_system # 默认创建的数据库名
|
|
||||||
ports:
|
|
||||||
- "5432:5432" # 将容器的5432端口映射到 WSL 的5432端口
|
|
||||||
volumes:
|
|
||||||
- ./pgdata:/var/lib/postgresql/data # 【重要】数据持久化!防止重启容器数据丢失
|
|
||||||
|
|
||||||
# 这里以后可以加你的 pgadmin 或者 redis 等其他服务
|
|
||||||
21
inventory-backend/gunicorn.conf.py
Normal file
21
inventory-backend/gunicorn.conf.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# inventory-backend/gunicorn.conf.py
|
||||||
|
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
|
# 原来的写法:根据 CPU 自动算,容易在强机上算太多
|
||||||
|
# workers = multiprocessing.cpu_count() * 2 + 1
|
||||||
|
|
||||||
|
# --- 优化后的写法 ---
|
||||||
|
# 我们设置一个上限:如果是开发环境或为了省资源,最多不超过 8 个
|
||||||
|
# 这样既有并发能力(8个分身足够开发测试用了),又不会撑爆数据库
|
||||||
|
cpu_calc = multiprocessing.cpu_count() * 2 + 1
|
||||||
|
workers = min(cpu_calc, 8)
|
||||||
|
|
||||||
|
# 线程数保持不变
|
||||||
|
threads = 2
|
||||||
|
|
||||||
|
bind = "0.0.0.0:8000"
|
||||||
|
timeout = 120
|
||||||
|
loglevel = 'info'
|
||||||
|
accesslog = '-' # 输出到标准输出(Docker logs 能看到)
|
||||||
|
errorlog = '-'
|
||||||
@ -1,6 +1,12 @@
|
|||||||
|
# 文件路径: run.py (在项目根目录下,与 config.py 同级)
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
# debug=True 修改代码后会自动重启
|
||||||
|
print("\n====== 当前所有注册路由 ======")
|
||||||
|
for rule in app.url_map.iter_rules():
|
||||||
|
print(f"{rule} -> {rule.endpoint}")
|
||||||
|
print("==============================\n")
|
||||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
3
inventory-web/.env.development
Normal file
3
inventory-web/.env.development
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# .env.development
|
||||||
|
# 注意:这里必须写你电脑的局域网 IP
|
||||||
|
VITE_API_BASE_URL=http://172.16.0.95:8000/api/v1
|
||||||
31
inventory-web/Dockerfile
Normal file
31
inventory-web/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# ---------------------------------------
|
||||||
|
# 这是开发模式 (Development Mode) 的配置
|
||||||
|
# ---------------------------------------
|
||||||
|
|
||||||
|
# 1. 使用 Node 20 的 Alpine 版本 (轻量级)
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# 【关键新增】安装 libc6 兼容库
|
||||||
|
# 这一步能解决 90% 的 "Cannot find module ... musl.node" 或二进制文件缺失问题
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 2. 优先复制 package.json 和 lock 文件
|
||||||
|
# 这样如果只改代码不改依赖,Docker 会利用缓存跳过安装步骤,构建更快
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 3. 安装依赖
|
||||||
|
# 这一步会在容器内部下载适合 Alpine Linux 的依赖包
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# 4. 复制其余源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 5. 暴露端口 (仅作声明,方便查看)
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# 6. 启动开发服务器
|
||||||
|
# 必须加 --host,否则只能在容器内部访问,无法通过浏览器 localhost 访问
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host"]
|
||||||
26
inventory-web/nginx.conf
Normal file
26
inventory-web/nginx.conf
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_min_length 1k;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript;
|
||||||
|
|
||||||
|
# 1. 前端页面
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. 后端接口代理
|
||||||
|
location /api {
|
||||||
|
# 'backend' 对应 docker-compose 里的服务名
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
||||||
2289
inventory-web/package-lock.json
generated
2289
inventory-web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,30 +1,135 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import HelloWorld from './components/HelloWorld.vue'
|
// 1. 引入需要的图标组件
|
||||||
|
import { InfoFilled } from '@element-plus/icons-vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="app-wrapper">
|
||||||
<a href="https://vite.dev" target="_blank">
|
<header class="app-header">
|
||||||
<img src="/vite.svg" class="logo" alt="Vite logo" />
|
<div class="logo-container">
|
||||||
</a>
|
<router-link to="/" class="home-link">
|
||||||
<a href="https://vuejs.org/" target="_blank">
|
<img src="@/assets/iris.png" class="logo" alt="Logo" />
|
||||||
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
|
<span class="system-title">IRIS 库存管理系统</span>
|
||||||
</a>
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<span class="version-tag">
|
||||||
|
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||||
|
当前版本: 1.0 Beta (测试版)
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<HelloWorld msg="Vite + Vue" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style>
|
||||||
|
/* 注意:App.vue 中的 style 标签通常不加 scoped,
|
||||||
|
或者将全局样式(html, body)单独放在一个 style 标签中,
|
||||||
|
以确保 html, body 的高度设置能生效
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* --- 全局重置样式 Start --- */
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background-color: #f5f7fa; /* 整体背景色 */
|
||||||
|
overflow: hidden; /* 防止最外层出现双滚动条 */
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
/* --- 全局重置样式 End --- */
|
||||||
|
|
||||||
|
.app-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh; /* 强制占满视口高度 */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部栏样式 */
|
||||||
|
.app-header {
|
||||||
|
height: 60px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-bottom: 1px solid #dcdfe6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
flex-shrink: 0; /* 禁止被压缩 */
|
||||||
|
z-index: 1000; /* 确保头部在最上层 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
height: 6em;
|
height: 36px; /* 稍微调整高度适配 */
|
||||||
padding: 1.5em;
|
width: auto;
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
}
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
.system-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
.logo.vue:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #42b883aa);
|
/* 内容区样式 */
|
||||||
|
.app-content {
|
||||||
|
flex: 1; /* 自动占据剩余空间 */
|
||||||
|
overflow: hidden; /* 这里设为 hidden,让内部的 Layout 组件去处理滚动 */
|
||||||
|
position: relative;
|
||||||
|
/* 如果您希望整个页面有内边距,可以加 padding;
|
||||||
|
但通常建议 padding 加在具体的业务页面里,保持 Layout 铺满 */
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
/* 底部栏样式 */
|
||||||
|
.app-footer {
|
||||||
|
height: 36px;
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
border-top: 1px solid #e4e7ed;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0; /* 禁止被压缩 */
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-tag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e6a23c; /* 橙色警告色 */
|
||||||
|
background: rgba(230, 162, 60, 0.1); /* 淡橙色背景 */
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
0
inventory-web/src/api/auth.ts
Normal file
0
inventory-web/src/api/auth.ts
Normal file
45
inventory-web/src/api/inbound/buy.ts
Normal file
45
inventory-web/src/api/inbound/buy.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// 1. 获取列表
|
||||||
|
export function getBuyList(params: any) {
|
||||||
|
return request({
|
||||||
|
url: '/inbound/buy/list',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 新增入库
|
||||||
|
export function createBuyInbound(data: any) {
|
||||||
|
return request({
|
||||||
|
url: '/inbound/buy/submit',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新入库
|
||||||
|
export function updateBuyInbound(id: number, data: any) {
|
||||||
|
return request({
|
||||||
|
url: `/inbound/buy/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 删除入库
|
||||||
|
export function deleteBuyInbound(id: number) {
|
||||||
|
return request({
|
||||||
|
url: `/inbound/buy/${id}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 搜索基础物料
|
||||||
|
export function searchMaterialBase(keyword: string) {
|
||||||
|
return request({
|
||||||
|
url: '/inbound/buy/search-base',
|
||||||
|
method: 'get',
|
||||||
|
params: { keyword }
|
||||||
|
})
|
||||||
|
}
|
||||||
0
inventory-web/src/api/inbound/product.ts
Normal file
0
inventory-web/src/api/inbound/product.ts
Normal file
0
inventory-web/src/api/inbound/semi.ts
Normal file
0
inventory-web/src/api/inbound/semi.ts
Normal file
44
inventory-web/src/api/material_base.ts
Normal file
44
inventory-web/src/api/material_base.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// 1. 获取基础信息列表
|
||||||
|
export function listMaterialBase(params: any) {
|
||||||
|
return request({
|
||||||
|
url: '/inbound/base/list',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 新增基础信息
|
||||||
|
export function addMaterialBase(data: any) {
|
||||||
|
return request({
|
||||||
|
url: '/inbound/base/',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 修改基础信息 (包含状态启用/禁用)
|
||||||
|
export function updateMaterialBase(data: any) {
|
||||||
|
return request({
|
||||||
|
url: '/inbound/base/',
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 删除基础信息
|
||||||
|
export function delMaterialBase(id: number) {
|
||||||
|
return request({
|
||||||
|
url: `/inbound/base/${id}`, // 注意这里是反引号,用于拼接 URL
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 获取详情 (可选,用于编辑回显)
|
||||||
|
export function getMaterialBase(id: number) {
|
||||||
|
return request({
|
||||||
|
url: `/inbound/base/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
0
inventory-web/src/api/transaction.ts
Normal file
0
inventory-web/src/api/transaction.ts
Normal file
BIN
inventory-web/src/assets/iris.png
Normal file
BIN
inventory-web/src/assets/iris.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 496 B |
36
inventory-web/src/layout/components/AppMain.vue
Normal file
36
inventory-web/src/layout/components/AppMain.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<section class="app-main">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="fade-transform" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-main {
|
||||||
|
/* 确保占满容器 */
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 简单的页面切换动画 */
|
||||||
|
.fade-transform-leave-active,
|
||||||
|
.fade-transform-enter-active {
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-transform-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-transform-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
95
inventory-web/src/layout/components/Sidebar/index.vue
Normal file
95
inventory-web/src/layout/components/Sidebar/index.vue
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<el-menu
|
||||||
|
:default-active="activeMenu"
|
||||||
|
background-color="#304156"
|
||||||
|
text-color="#bfcbd9"
|
||||||
|
active-text-color="#409EFF"
|
||||||
|
:unique-opened="true"
|
||||||
|
router
|
||||||
|
class="el-menu-vertical"
|
||||||
|
>
|
||||||
|
<template v-for="route in menuRoutes" :key="route.path">
|
||||||
|
|
||||||
|
<el-menu-item
|
||||||
|
v-if="!route.children || route.children.length === 1"
|
||||||
|
:index="resolvePath(route)"
|
||||||
|
>
|
||||||
|
<el-icon v-if="getMeta(route).icon">
|
||||||
|
<component :is="getMeta(route).icon" />
|
||||||
|
</el-icon>
|
||||||
|
<span>{{ getMeta(route).title }}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-sub-menu v-else :index="route.path">
|
||||||
|
<template #title>
|
||||||
|
<el-icon v-if="route.meta && route.meta.icon">
|
||||||
|
<component :is="route.meta.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<span>{{ route.meta?.title }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-menu-item
|
||||||
|
v-for="child in route.children"
|
||||||
|
:key="child.path"
|
||||||
|
:index="resolvePath(route, child)"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<span>{{ child.meta?.title }}</span>
|
||||||
|
</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</el-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 1. 获取当前激活的菜单路径
|
||||||
|
const activeMenu = computed(() => {
|
||||||
|
return route.path
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. 获取需要在菜单中显示的路由(过滤掉 hidden 的路由)
|
||||||
|
const menuRoutes = computed(() => {
|
||||||
|
return router.options.routes.filter((r: any) => !r.meta?.hidden)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 辅助函数:获取 meta 信息
|
||||||
|
const getMeta = (route: any) => {
|
||||||
|
if (route.meta) return route.meta
|
||||||
|
// 如果是 layout 嵌套层(如首页),取第一个子路由的 meta
|
||||||
|
if (route.children && route.children.length > 0) {
|
||||||
|
return route.children[0].meta
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 辅助函数:拼接路径
|
||||||
|
const resolvePath = (parent: any, child?: any) => {
|
||||||
|
// 如果是首页这种 layout 嵌套结构
|
||||||
|
if (!child && parent.children && parent.children.length === 1) {
|
||||||
|
return parent.path === '/' ? '/dashboard' : parent.path + '/' + parent.children[0].path
|
||||||
|
}
|
||||||
|
// 如果是普通子菜单
|
||||||
|
if (child) {
|
||||||
|
return parent.path + '/' + child.path
|
||||||
|
}
|
||||||
|
return parent.path
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.el-menu-vertical {
|
||||||
|
border-right: none; /* 去掉 Element Plus 菜单默认的右边框 */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
:deep(.el-menu-item.is-active) {
|
||||||
|
background-color: #263445 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
43
inventory-web/src/layout/index.vue
Normal file
43
inventory-web/src/layout/index.vue
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout-wrapper">
|
||||||
|
<Sidebar class="sidebar-container" />
|
||||||
|
|
||||||
|
<div class="main-container">
|
||||||
|
<AppMain />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Sidebar from './components/Sidebar/index.vue'
|
||||||
|
import AppMain from './components/AppMain.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-wrapper {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%; /* 继承 App.vue 中 app-content 的高度 */
|
||||||
|
overflow: hidden; /* 防止最外层出现滚动条 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-container {
|
||||||
|
width: 210px; /* 固定侧边栏宽度 */
|
||||||
|
height: 100%;
|
||||||
|
background-color: #304156; /* 侧边栏背景色 */
|
||||||
|
flex-shrink: 0; /* 防止被挤压 */
|
||||||
|
overflow-y: auto; /* 侧边栏内容过多时允许滚动 */
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
flex: 1; /* 自动占满右侧剩余空间 */
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto; /* 关键:页面内容过多时,只在右侧区域滚动 */
|
||||||
|
background-color: #f0f2f5; /* 右侧灰色背景,让白色卡片更明显 */
|
||||||
|
padding: 20px; /* 给内部页面留出边距 */
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,5 +1,29 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import './style.css'
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
// 1. 引入路由配置 (确保你已经创建了 src/router/index.ts)
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
// 2. 引入 Element Plus (UI组件库)
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
// 引入中文包
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
|
||||||
|
// 3. 引入图标
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 注册所有图标
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用插件
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, {
|
||||||
|
locale: zhCn, // 设置为中文
|
||||||
|
})
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
154
inventory-web/src/router/index.ts
Normal file
154
inventory-web/src/router/index.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
// 核心修改点:使用 'type' 关键字导入 RouteRecordRaw,或者将其分开导入
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import Layout from '@/layout/index.vue'
|
||||||
|
|
||||||
|
const routes: Array<RouteRecordRaw> = [
|
||||||
|
// 1. 首页 Dashboard
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: Layout,
|
||||||
|
redirect: '/dashboard',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('@/views/dashboard/index.vue'),
|
||||||
|
meta: { title: '首页', icon: 'HomeFilled' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. 基础信息 (对应 views/material/list.vue)
|
||||||
|
{
|
||||||
|
path: '/material',
|
||||||
|
component: Layout,
|
||||||
|
redirect: '/material/index',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'index',
|
||||||
|
name: 'MaterialBase',
|
||||||
|
// 基础信息列表
|
||||||
|
component: () => import('@/views/material/list.vue'),
|
||||||
|
meta: { title: '基础信息', icon: 'Box' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. 库存管理 (采购/半成品/成品/权益)
|
||||||
|
{
|
||||||
|
path: '/inventory',
|
||||||
|
component: Layout,
|
||||||
|
meta: { title: '库存管理', icon: 'Shop' },
|
||||||
|
redirect: '/inventory/buy',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'buy',
|
||||||
|
name: 'InventoryBuy',
|
||||||
|
// 采购入库页面
|
||||||
|
component: () => import('@/views/stock/inbound/buy.vue'),
|
||||||
|
meta: { title: '采购件' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'semi',
|
||||||
|
name: 'InventorySemi',
|
||||||
|
// 半成品页面
|
||||||
|
component: () => import('@/views/stock/inbound/semi.vue'),
|
||||||
|
meta: { title: '半成品' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'product',
|
||||||
|
name: 'InventoryProduct',
|
||||||
|
// 成品页面
|
||||||
|
component: () => import('@/views/stock/inbound/product.vue'),
|
||||||
|
meta: { title: '成品' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'service',
|
||||||
|
name: 'InventoryService',
|
||||||
|
// 服务权益页面
|
||||||
|
component: () => import('@/views/stock/inbound/service.vue'),
|
||||||
|
meta: { title: '服务权益' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. 业务操作 (借库/维修/报废)
|
||||||
|
{
|
||||||
|
path: '/operation',
|
||||||
|
component: Layout,
|
||||||
|
meta: { title: '业务操作', icon: 'Operation' },
|
||||||
|
redirect: '/operation/borrow',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'borrow',
|
||||||
|
name: 'OpBorrow',
|
||||||
|
// 借库页面
|
||||||
|
component: () => import('@/views/transaction/borrow.vue'),
|
||||||
|
meta: { title: '借库' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'repair',
|
||||||
|
name: 'OpRepair',
|
||||||
|
// 维修页面 (指向 return.vue)
|
||||||
|
component: () => import('@/views/transaction/return.vue'),
|
||||||
|
meta: { title: '维修' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'scrap',
|
||||||
|
name: 'OpScrap',
|
||||||
|
// 报废页面
|
||||||
|
component: () => import('@/views/transaction/scrap.vue'),
|
||||||
|
meta: { title: '报废' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* * 暂时屏蔽 BOM 和 系统管理
|
||||||
|
*/
|
||||||
|
// {
|
||||||
|
// path: '/bom',
|
||||||
|
// component: Layout,
|
||||||
|
// children: [
|
||||||
|
// {
|
||||||
|
// path: 'index',
|
||||||
|
// name: 'BOM',
|
||||||
|
// component: () => import('@/views/bom/index.vue'),
|
||||||
|
// meta: { title: 'BOM管理', icon: 'List' }
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// path: '/system',
|
||||||
|
// component: Layout,
|
||||||
|
// meta: { title: '系统管理', icon: 'Setting' },
|
||||||
|
// children: [
|
||||||
|
// {
|
||||||
|
// path: 'user',
|
||||||
|
// name: 'UserManage',
|
||||||
|
// component: () => import('@/views/system/user.vue'),
|
||||||
|
// meta: { title: '用户管理', icon: 'User' }
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// path: 'log',
|
||||||
|
// name: 'OpLog',
|
||||||
|
// component: () => import('@/views/system/log.vue'),
|
||||||
|
// meta: { title: '操作日志', icon: 'Document' }
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// },
|
||||||
|
|
||||||
|
// 404 路由
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
meta: { hidden: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
0
inventory-web/src/router/permission.ts
Normal file
0
inventory-web/src/router/permission.ts
Normal file
0
inventory-web/src/stores/tagsView.ts
Normal file
0
inventory-web/src/stores/tagsView.ts
Normal file
11
inventory-web/src/stores/user.vue
Normal file
11
inventory-web/src/stores/user.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
0
inventory-web/src/utils/format.ts
Normal file
0
inventory-web/src/utils/format.ts
Normal file
47
inventory-web/src/utils/request.ts
Normal file
47
inventory-web/src/utils/request.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
// 1. 创建 axios 实例
|
||||||
|
const service = axios.create({
|
||||||
|
// 【修改这里】不要写死 '/api/v1',改为读取环境变量
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. 请求拦截器 (可以在这里加 Token)
|
||||||
|
service.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// 如果以后有登录 token,就在这里加
|
||||||
|
// const token = localStorage.getItem('token')
|
||||||
|
// if (token) {
|
||||||
|
// config.headers['Authorization'] = 'Bearer ' + token
|
||||||
|
// }
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. 响应拦截器 (统一处理错误)
|
||||||
|
service.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const res = response.data
|
||||||
|
// 这里可以根据后端的 code 来判断
|
||||||
|
// 假设你的后端成功返回 code: 200
|
||||||
|
if (res.code && res.code !== 200) {
|
||||||
|
ElMessage.error(res.msg || 'Error')
|
||||||
|
return Promise.reject(new Error(res.msg || 'Error'))
|
||||||
|
} else {
|
||||||
|
return res // 直接返回数据部分
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log('err' + error)
|
||||||
|
ElMessage.error(error.message || '请求失败')
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 4. 【关键】必须默认导出 service
|
||||||
|
export default service
|
||||||
0
inventory-web/src/utils/validate.ts
Normal file
0
inventory-web/src/utils/validate.ts
Normal file
108
inventory-web/src/views/dashboard/index.vue
Normal file
108
inventory-web/src/views/dashboard/index.vue
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<el-card class="welcome-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="title">👋 欢迎回来,Admin</span>
|
||||||
|
<el-tag type="success">系统运行正常</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<h2>IRIS 库存管理系统</h2>
|
||||||
|
<p class="subtitle">请选择您要进行的业务操作:</p>
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
<el-button type="primary" size="large" @click="handleNav('/inventory/buy')">
|
||||||
|
<el-icon style="margin-right: 5px"><ShoppingCart /></el-icon>
|
||||||
|
采购入库
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button type="success" size="large" @click="handleNav('/material/index')">
|
||||||
|
<el-icon style="margin-right: 5px"><Box /></el-icon>
|
||||||
|
基础信息
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button type="warning" size="large" @click="handleNav('/operation/borrow')">
|
||||||
|
<el-icon style="margin-right: 5px"><Operation /></el-icon>
|
||||||
|
借库申请
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
// 引入需要的图标
|
||||||
|
import { Box, TrendCharts, ShoppingCart, Operation } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 统一跳转函数
|
||||||
|
const handleNav = (path: string) => {
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-container {
|
||||||
|
/* 使用 100% 宽度和高度,利用 Flex 居中显示 */
|
||||||
|
height: calc(100vh - 84px); /* 减去顶部导航栏的高度,防止出现双滚动条 */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #f0f2f5; /* 给背景加个淡灰色,突出卡片 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-card {
|
||||||
|
width: 800px; /*稍微加宽一点 */
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 8px; /* 圆角更好看 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #409EFF; /* 使用主题蓝 */
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap; /* 防止屏幕过窄时按钮挤压 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 给按钮加一点悬浮效果 */
|
||||||
|
.el-button {
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.el-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
inventory-web/src/views/login/index.vue
Normal file
11
inventory-web/src/views/login/index.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
551
inventory-web/src/views/material/list.vue
Normal file
551
inventory-web/src/views/material/list.vue
Normal file
@ -0,0 +1,551 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="filter-wrapper">
|
||||||
|
<div class="filter-container">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.keyword"
|
||||||
|
placeholder="请输入名称或规格 (支持模糊搜索)"
|
||||||
|
style="width: 240px; margin-right: 10px;"
|
||||||
|
clearable
|
||||||
|
@input="handleInputSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.category"
|
||||||
|
placeholder="类别"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
style="width: 140px; margin-right: 10px;"
|
||||||
|
@change="handleQuery"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.type"
|
||||||
|
placeholder="类型"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
style="width: 140px; margin-right: 10px;"
|
||||||
|
@change="handleQuery"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.isEnabled"
|
||||||
|
placeholder="状态"
|
||||||
|
clearable
|
||||||
|
style="width: 100px; margin-right: 10px;"
|
||||||
|
@change="handleQuery"
|
||||||
|
>
|
||||||
|
<el-option label="启用" :value="1" />
|
||||||
|
<el-option label="禁用" :value="0" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-button type="primary" plain @click="handleQuery">搜索</el-button>
|
||||||
|
<el-button plain @click="resetQuery">重置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-toolbar">
|
||||||
|
<el-button type="primary" @click="handleAdd" style="margin-right: 10px">
|
||||||
|
<el-icon style="margin-right: 5px"><Plus /></el-icon>新增
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-tooltip content="刷新" placement="top">
|
||||||
|
<el-button circle :icon="Refresh" @click="getList" />
|
||||||
|
</el-tooltip>
|
||||||
|
|
||||||
|
<el-dropdown trigger="click" @command="handleSizeChange">
|
||||||
|
<el-button circle :icon="Rank" style="margin-left: 8px" title="表格密度" />
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="large">宽松 (默认)</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="default">中等</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="small">紧凑</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
|
||||||
|
<el-popover placement="bottom" :width="150" trigger="click">
|
||||||
|
<template #reference>
|
||||||
|
<el-button circle :icon="Setting" style="margin-left: 8px" title="列设置" />
|
||||||
|
</template>
|
||||||
|
<div class="column-setting-list">
|
||||||
|
<div style="font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px">
|
||||||
|
列展示设置
|
||||||
|
</div>
|
||||||
|
<el-checkbox v-model="columns.id.visible" label="ID" />
|
||||||
|
<el-checkbox v-model="columns.name.visible" label="名称" />
|
||||||
|
<el-checkbox v-model="columns.category.visible" label="类别" />
|
||||||
|
<el-checkbox v-model="columns.type.visible" label="类型" />
|
||||||
|
<el-checkbox v-model="columns.spec.visible" label="规格型号" />
|
||||||
|
<el-checkbox v-model="columns.unit.visible" label="单位" />
|
||||||
|
<el-checkbox v-model="columns.visibilityLevel.visible" label="可见等级" />
|
||||||
|
<el-checkbox v-model="columns.files.visible" label="资料" />
|
||||||
|
<el-checkbox v-model="columns.isEnabled.visible" label="状态" />
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="tableData"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
:size="tableSize"
|
||||||
|
style="width: 100%; margin-top: 15px"
|
||||||
|
>
|
||||||
|
<el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" />
|
||||||
|
<el-table-column v-if="columns.name.visible" prop="name" label="基础信息名称" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column v-if="columns.category.visible" prop="category" label="类别" min-width="120" align="center" show-overflow-tooltip>
|
||||||
|
<template #default="scope">{{ scope.row.category || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="columns.type.visible" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip>
|
||||||
|
<template #default="scope">{{ scope.row.type || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="columns.spec.visible" prop="spec" label="规格型号" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column v-if="columns.unit.visible" prop="unit" label="单位" min-width="80" align="center" />
|
||||||
|
<el-table-column v-if="columns.visibilityLevel.visible" prop="visibilityLevel" label="可见等级" min-width="100" align="center">
|
||||||
|
<template #default="scope">L{{ scope.row.visibilityLevel }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="columns.files.visible" label="资料" min-width="100" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button v-if="scope.row.generalImage" link type="primary" :icon="Picture" title="查看图片" @click="openLink(scope.row.generalImage)" />
|
||||||
|
<el-button v-if="scope.row.generalManual" link type="primary" :icon="Document" title="查看说明书" @click="openLink(scope.row.generalManual)" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="columns.isEnabled.visible" prop="isEnabled" label="是否启用" min-width="100" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-switch
|
||||||
|
v-model="scope.row.isEnabled"
|
||||||
|
:active-value="1"
|
||||||
|
:inactive-value="0"
|
||||||
|
:loading="scope.row.statusLoading"
|
||||||
|
@change="handleStatusChange(scope.row)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" min-width="150" fixed="right" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px; text-align: right;">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
:total="total"
|
||||||
|
v-model:current-page="queryParams.pageNum"
|
||||||
|
v-model:page-size="queryParams.pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
@size-change="getList"
|
||||||
|
@current-change="getList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialog.visible"
|
||||||
|
:title="dialog.title"
|
||||||
|
width="600px"
|
||||||
|
append-to-body
|
||||||
|
@close="cancel"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||||
|
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="请输入基础信息名称" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-row>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="类别" prop="category">
|
||||||
|
<el-autocomplete
|
||||||
|
v-model="form.category"
|
||||||
|
:fetch-suggestions="querySearchCategory"
|
||||||
|
placeholder="可输入或选择"
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="类型" prop="type">
|
||||||
|
<el-autocomplete
|
||||||
|
v-model="form.type"
|
||||||
|
:fetch-suggestions="querySearchType"
|
||||||
|
placeholder="可输入或选择"
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="规格型号" prop="spec">
|
||||||
|
<el-input v-model="form.spec" placeholder="请输入规格型号" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="计量单位" prop="unit">
|
||||||
|
<el-input v-model="form.unit" placeholder="如: 个, 台, 米" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="可见等级" prop="visibilityLevel">
|
||||||
|
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" />
|
||||||
|
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0为最低,9为最高)</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="说明书链接" prop="generalManual">
|
||||||
|
<el-input v-model="form.generalManual" placeholder="请输入说明书URL链接" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="产品图链接" prop="generalImage">
|
||||||
|
<el-input v-model="form.generalImage" placeholder="请输入图片URL链接" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="状态" prop="isEnabled">
|
||||||
|
<el-radio-group v-model="form.isEnabled">
|
||||||
|
<el-radio :value="1">启用</el-radio>
|
||||||
|
<el-radio :value="0">禁用</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="cancel">取 消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm" :loading="submitLoading">确 定</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, nextTick } from 'vue';
|
||||||
|
import { Plus, Picture, Document, Refresh, Setting, Rank } from '@element-plus/icons-vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus';
|
||||||
|
|
||||||
|
import {
|
||||||
|
listMaterialBase,
|
||||||
|
addMaterialBase,
|
||||||
|
updateMaterialBase,
|
||||||
|
delMaterialBase
|
||||||
|
} from '@/api/material_base';
|
||||||
|
|
||||||
|
// --- 类型定义 ---
|
||||||
|
interface MaterialBaseVO {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
type: string;
|
||||||
|
spec: string;
|
||||||
|
unit: string;
|
||||||
|
visibilityLevel: number;
|
||||||
|
generalManual?: string;
|
||||||
|
generalImage?: string;
|
||||||
|
isEnabled: number;
|
||||||
|
statusLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueryParams {
|
||||||
|
pageNum: number;
|
||||||
|
pageSize: number;
|
||||||
|
keyword: string;
|
||||||
|
category: string;
|
||||||
|
type: string;
|
||||||
|
isEnabled?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 响应式数据 ---
|
||||||
|
const loading = ref(false);
|
||||||
|
const total = ref(0);
|
||||||
|
const tableData = ref<MaterialBaseVO[]>([]);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const tableSize = ref<'large' | 'default' | 'small'>('large');
|
||||||
|
|
||||||
|
const columns = reactive({
|
||||||
|
id: { visible: true },
|
||||||
|
name: { visible: true },
|
||||||
|
category: { visible: true },
|
||||||
|
type: { visible: true },
|
||||||
|
spec: { visible: true },
|
||||||
|
unit: { visible: true },
|
||||||
|
visibilityLevel: { visible: true },
|
||||||
|
files: { visible: true },
|
||||||
|
isEnabled: { visible: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryOptions = ref<string[]>([]);
|
||||||
|
const typeOptions = ref<string[]>([]);
|
||||||
|
|
||||||
|
const queryParams = reactive<QueryParams>({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: '',
|
||||||
|
category: '',
|
||||||
|
type: '',
|
||||||
|
isEnabled: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 弹窗与表单相关 ---
|
||||||
|
const dialog = reactive({
|
||||||
|
visible: false,
|
||||||
|
title: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
|
||||||
|
const initForm = {
|
||||||
|
id: undefined,
|
||||||
|
name: '',
|
||||||
|
category: '',
|
||||||
|
type: '',
|
||||||
|
spec: '',
|
||||||
|
unit: '',
|
||||||
|
visibilityLevel: 0,
|
||||||
|
generalManual: '',
|
||||||
|
generalImage: '',
|
||||||
|
isEnabled: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = ref({...initForm});
|
||||||
|
|
||||||
|
const rules = reactive<FormRules>({
|
||||||
|
name: [{ required: true, message: '请输入基础信息名称', trigger: 'blur' }],
|
||||||
|
category: [{ required: true, message: '请选择或输入类别', trigger: 'change' }],
|
||||||
|
type: [{ required: true, message: '请选择或输入类型', trigger: 'change' }],
|
||||||
|
spec: [{ required: true, message: '请输入规格型号', trigger: 'blur' }],
|
||||||
|
unit: [{ required: true, message: '请输入单位', trigger: 'blur' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 业务逻辑方法 ---
|
||||||
|
|
||||||
|
const extractDynamicOptions = (items: MaterialBaseVO[]) => {
|
||||||
|
if (!items || items.length === 0) return;
|
||||||
|
const newCategories = new Set(categoryOptions.value);
|
||||||
|
const newTypes = new Set(typeOptions.value);
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item.category) newCategories.add(item.category);
|
||||||
|
if (item.type) newTypes.add(item.type);
|
||||||
|
});
|
||||||
|
categoryOptions.value = Array.from(newCategories);
|
||||||
|
typeOptions.value = Array.from(newTypes);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 【核心新增】Autocomplete 的建议查询方法
|
||||||
|
// 格式化数据以适配 el-autocomplete 的回调参数格式 [{ value: 'abc' }]
|
||||||
|
const querySearchCategory = (queryString: string, cb: any) => {
|
||||||
|
const results = queryString
|
||||||
|
? categoryOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
|
||||||
|
: categoryOptions.value;
|
||||||
|
// el-autocomplete 默认只展示 value 属性
|
||||||
|
const formattedResults = results.map(item => ({ value: item }));
|
||||||
|
cb(formattedResults);
|
||||||
|
};
|
||||||
|
|
||||||
|
const querySearchType = (queryString: string, cb: any) => {
|
||||||
|
const results = queryString
|
||||||
|
? typeOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
|
||||||
|
: typeOptions.value;
|
||||||
|
const formattedResults = results.map(item => ({ value: item }));
|
||||||
|
cb(formattedResults);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getList = () => {
|
||||||
|
loading.value = true;
|
||||||
|
listMaterialBase(queryParams)
|
||||||
|
.then((response: any) => {
|
||||||
|
if (response && response.data) {
|
||||||
|
tableData.value = response.data.items;
|
||||||
|
total.value = response.data.total;
|
||||||
|
extractDynamicOptions(tableData.value);
|
||||||
|
} else {
|
||||||
|
tableData.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
tableData.value = [];
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const handleInputSearch = () => {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
queryParams.pageNum = 1;
|
||||||
|
getList();
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
queryParams.pageNum = 1;
|
||||||
|
getList();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetQuery = () => {
|
||||||
|
queryParams.keyword = '';
|
||||||
|
queryParams.category = '';
|
||||||
|
queryParams.type = '';
|
||||||
|
queryParams.isEnabled = undefined;
|
||||||
|
handleQuery();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSizeChange = (command: 'large' | 'default' | 'small') => {
|
||||||
|
tableSize.value = command;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
resetForm();
|
||||||
|
dialog.title = '新增基础信息';
|
||||||
|
dialog.visible = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (row: MaterialBaseVO) => {
|
||||||
|
resetForm();
|
||||||
|
dialog.title = '编辑基础信息';
|
||||||
|
dialog.visible = true;
|
||||||
|
nextTick(() => {
|
||||||
|
Object.assign(form.value, row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name });
|
||||||
|
if (nameRes.data?.items?.some((item: MaterialBaseVO) => item.name === name)) {
|
||||||
|
ElMessage.error(`添加失败:已存在名称为 "${name}" 的基础信息!`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const specRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: spec });
|
||||||
|
if (specRes.data?.items?.some((item: MaterialBaseVO) => item.spec === spec)) {
|
||||||
|
ElMessage.error(`添加失败:已存在规格/编号为 "${spec}" 的基础信息!`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!formRef.value) return;
|
||||||
|
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (valid) {
|
||||||
|
submitLoading.value = true;
|
||||||
|
try {
|
||||||
|
if (!form.value.id) {
|
||||||
|
const isDuplicate = await checkDuplicate(form.value.name, form.value.spec);
|
||||||
|
if (isDuplicate) {
|
||||||
|
submitLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const requestApi = form.value.id ? updateMaterialBase : addMaterialBase;
|
||||||
|
const actionText = form.value.id ? '修改' : '新增';
|
||||||
|
await requestApi(form.value);
|
||||||
|
ElMessage.success(`${actionText}成功`);
|
||||||
|
dialog.visible = false;
|
||||||
|
getList();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
dialog.visible = false;
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.value = {...initForm};
|
||||||
|
if (formRef.value) formRef.value.resetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = (row: MaterialBaseVO) => {
|
||||||
|
row.statusLoading = true;
|
||||||
|
const text = row.isEnabled === 1 ? "启用" : "停用";
|
||||||
|
const updateData = { id: row.id, isEnabled: row.isEnabled };
|
||||||
|
updateMaterialBase(updateData)
|
||||||
|
.then(() => ElMessage.success(`已${text} "${row.name}"`))
|
||||||
|
.catch(() => { row.isEnabled = row.isEnabled === 1 ? 0 : 1; })
|
||||||
|
.finally(() => { row.statusLoading = false; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (row: MaterialBaseVO) => {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
`是否确认删除名称为 "${row.name}" 的数据项?`,
|
||||||
|
"警告",
|
||||||
|
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
|
||||||
|
).then(() => {
|
||||||
|
delMaterialBase(row.id).then(() => {
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
if (tableData.value.length === 1 && queryParams.pageNum > 1) queryParams.pageNum--;
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openLink = (url: string) => {
|
||||||
|
if (!url) return;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.filter-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.filter-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.right-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.column-setting-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
807
inventory-web/src/views/stock/inbound/buy.vue
Normal file
807
inventory-web/src/views/stock/inbound/buy.vue
Normal file
@ -0,0 +1,807 @@
|
|||||||
|
<template>
|
||||||
|
<div class="buy-module">
|
||||||
|
<div class="header-tools">
|
||||||
|
<div class="left-tools">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.keyword"
|
||||||
|
placeholder="🔍 搜索物料名称 / 规格 / 批号 / SN / 供应商..."
|
||||||
|
class="search-input"
|
||||||
|
clearable
|
||||||
|
@clear="fetchData"
|
||||||
|
@keyup.enter="fetchData"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button :icon="Search" @click="fetchData" />
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-tools">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="handleCreate" class="action-btn">采购入库登记</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
|
||||||
|
|
||||||
|
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
|
||||||
|
<template #reference>
|
||||||
|
<el-button :icon="Setting" class="action-btn">表头</el-button>
|
||||||
|
</template>
|
||||||
|
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
|
||||||
|
<div class="col-group-title">基础信息</div>
|
||||||
|
<el-row :gutter="10">
|
||||||
|
<el-col :span="12" v-for="c in baseColumns" :key="c.prop"><el-checkbox :label="c.prop">{{ c.label }}</el-checkbox></el-col>
|
||||||
|
</el-row>
|
||||||
|
<div class="col-group-title" style="margin-top:10px">库存与商务</div>
|
||||||
|
<el-row :gutter="10">
|
||||||
|
<el-col :span="12" v-for="c in stockColumns" :key="c.prop"><el-checkbox :label="c.prop">{{ c.label }}</el-checkbox></el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="tableData"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
style="width: 100%"
|
||||||
|
class="modern-table"
|
||||||
|
highlight-current-row
|
||||||
|
header-cell-class-name="table-header-gray"
|
||||||
|
>
|
||||||
|
<template v-for="col in allColumns" :key="col.prop">
|
||||||
|
<el-table-column
|
||||||
|
v-if="visibleColumnProps.includes(col.prop)"
|
||||||
|
:prop="col.prop"
|
||||||
|
:label="col.label"
|
||||||
|
:min-width="col.minWidth || '140'"
|
||||||
|
show-overflow-tooltip
|
||||||
|
>
|
||||||
|
<template #default="scope" v-if="['serial_number', 'batch_number'].includes(col.prop)">
|
||||||
|
<span v-if="scope.row[col.prop]" :class="col.prop === 'serial_number' ? 'tag-sn' : 'tag-bn'">
|
||||||
|
{{ scope.row[col.prop] }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-placeholder">-</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default="scope" v-else-if="col.prop === 'qty_stock'">
|
||||||
|
<span class="stock-num">{{ scope.row.sum_stock }}</span>
|
||||||
|
<el-tag size="small" type="info" effect="plain" class="sum-tag">总</el-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default="scope" v-else-if="col.prop === 'qty_available'">
|
||||||
|
<span class="avail-num">{{ scope.row.sum_available }}</span>
|
||||||
|
<el-tag size="small" type="info" effect="plain" class="sum-tag">总</el-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default="scope" v-else-if="col.prop === 'status'">
|
||||||
|
<el-tag :type="getStatusType(scope.row.status)" effect="light" round>
|
||||||
|
{{ scope.row.status }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default="scope" v-else-if="col.prop.includes('link')">
|
||||||
|
<el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank" :underline="false">
|
||||||
|
<el-icon><Link /></el-icon> 查看
|
||||||
|
</el-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default="scope" v-else-if="['unit_price', 'total_price'].includes(col.prop)">
|
||||||
|
<span class="money-text">{{ formatMoney(scope.row[col.prop], scope.row.currency) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="160" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
|
||||||
|
<el-popconfirm title="确定删除该条记录吗?不可恢复。" @confirm="handleDelete(row)" width="220">
|
||||||
|
<template #reference>
|
||||||
|
<el-button link type="danger" size="default">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
class="pagination-bar"
|
||||||
|
v-model:current-page="queryParams.page"
|
||||||
|
v-model:page-size="queryParams.pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[15, 30, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@size-change="fetchData"
|
||||||
|
@current-change="fetchData"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="dialogStatus === 'create' ? '新增采购入库' : '编辑入库信息'"
|
||||||
|
width="1050px"
|
||||||
|
top="5vh"
|
||||||
|
destroy-on-close
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
class="stylish-dialog"
|
||||||
|
>
|
||||||
|
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="large" class="stylish-form">
|
||||||
|
|
||||||
|
<div class="form-card basic-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<el-icon class="icon"><Box /></el-icon>
|
||||||
|
<span>1. 基础信息</span>
|
||||||
|
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择物料)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
|
||||||
|
<el-col :span="14">
|
||||||
|
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
|
||||||
|
<el-select
|
||||||
|
v-model="form.base_id"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
reserve-keyword
|
||||||
|
placeholder="输入名称 / 规格型号进行模糊搜索..."
|
||||||
|
:remote-method="handleSearchMaterial"
|
||||||
|
:loading="searchLoading"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="onMaterialSelected"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in materialOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name + ' [' + item.spec + ']'"
|
||||||
|
:value="item.id"
|
||||||
|
>
|
||||||
|
<div class="option-item">
|
||||||
|
<span class="opt-name">{{ item.name }}</span>
|
||||||
|
<span class="opt-spec">{{ item.spec }}</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="10">
|
||||||
|
<div class="info-alert">
|
||||||
|
<el-icon><InfoFilled /></el-icon> 仅展示状态为“启用”的基础物料
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<div class="read-only-grid">
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view" /></el-form-item></el-col>
|
||||||
|
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" disabled class="is-text-view" /></el-form-item></el-col>
|
||||||
|
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view" /></el-form-item></el-col>
|
||||||
|
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view" /></el-form-item></el-col>
|
||||||
|
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view" /></el-form-item></el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-card inbound-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<el-icon class="icon"><House /></el-icon>
|
||||||
|
<span>2. 入库详情</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="6"><el-form-item label="编码/SKU" prop="sku"><el-input v-model="form.sku" placeholder="选填" /></el-form-item></el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="入库日期" prop="in_date">
|
||||||
|
<el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码" /></el-form-item></el-col>
|
||||||
|
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" placeholder="例如: A-01-02" /></el-form-item></el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<div class="identity-panel">
|
||||||
|
<el-row>
|
||||||
|
<el-col :span="24" style="margin-bottom: 12px;">
|
||||||
|
<el-radio-group v-model="entryMode" @change="handleEntryModeChange" :disabled="modeLocked" class="custom-radio-group">
|
||||||
|
<el-radio-button label="batch">按批号入库 (Batch)</el-radio-button>
|
||||||
|
<el-radio-button label="serial">按序列号入库 (SN)</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<span v-if="modeLocked" class="locked-msg"><el-icon><Lock /></el-icon> 根据历史记录已锁定入库方式</span>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="批号" prop="batch_number">
|
||||||
|
<el-input
|
||||||
|
v-model="form.batch_number"
|
||||||
|
:placeholder="entryMode === 'batch' ? '系统生成...' : '不可用'"
|
||||||
|
:disabled="entryMode === 'serial'"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<template #prefix><span class="prefix-tag bn">BN</span></template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="序列号" prop="serial_number">
|
||||||
|
<el-input
|
||||||
|
v-model="form.serial_number"
|
||||||
|
:placeholder="entryMode === 'serial' ? '请扫描 SN...' : '不可用'"
|
||||||
|
:disabled="entryMode === 'batch'"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<template #prefix><span class="prefix-tag sn">SN</span></template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-row :gutter="24" style="margin-top: 15px;">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="入库数量" prop="in_quantity">
|
||||||
|
<el-input-number v-model="form.in_quantity" :min="1" controls-position="right" style="width:100%" class="strong-input" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<template v-if="dialogStatus === 'update'">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="当前库存" prop="stock_quantity">
|
||||||
|
<el-input-number v-model="form.stock_quantity" disabled style="width:100%" :controls="false" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="当前可用" prop="available_quantity">
|
||||||
|
<el-input-number v-model="form.available_quantity" disabled style="width:100%" :controls="false" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="库存状态" prop="status">
|
||||||
|
<el-select v-model="form.status" style="width:100%">
|
||||||
|
<el-option label="在库" value="在库" />
|
||||||
|
<el-option label="出库" value="出库" />
|
||||||
|
<el-option label="损耗" value="损耗" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="到检状态" prop="inspection_status">
|
||||||
|
<el-select v-model="form.inspection_status" style="width:100%">
|
||||||
|
<el-option label="未检" value="未检"><span style="color:#909399">⚪ 未检</span></el-option>
|
||||||
|
<el-option label="合格" value="合格"><span style="color:#67C23A">🟢 合格</span></el-option>
|
||||||
|
<el-option label="不合格" value="不合格"><span style="color:#F56C6C">🔴 不合格</span></el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12"><el-form-item label="到货图片" prop="arrival_photo"><el-input v-model="form.arrival_photo" placeholder="输入图片 URL" /></el-form-item></el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<div class="divider-text">商务与采购信息</div>
|
||||||
|
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="6"><el-form-item label="币种"><el-input v-model="form.currency" /></el-form-item></el-col>
|
||||||
|
<el-col :span="6"><el-form-item label="汇率"><el-input-number v-model="form.exchange_rate" :precision="2" controls-position="right" style="width:100%"/></el-form-item></el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="含税单价" prop="unit_price">
|
||||||
|
<el-input-number v-model="form.unit_price" :precision="4" controls-position="right" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="总价">
|
||||||
|
<el-input-number v-model="form.total_price" :precision="2" disabled :controls="false" style="width:100%" class="total-price-input"/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="8"><el-form-item label="供应商"><el-input v-model="form.supplier_name" placeholder="供应商全称" /></el-form-item></el-col>
|
||||||
|
<el-col :span="8"><el-form-item label="采购人"><el-input v-model="form.purchaser" /></el-form-item></el-col>
|
||||||
|
<el-col :span="8"><el-form-item label="采购邮箱"><el-input v-model="form.purchaser_email" /></el-form-item></el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12"><el-form-item label="原始链接"><el-input v-model="form.source_link" placeholder="http://" /></el-form-item></el-col>
|
||||||
|
<el-col :span="12"><el-form-item label="详情链接"><el-input v-model="form.detail_link" placeholder="http://" /></el-form-item></el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="visible = false" size="large">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn">
|
||||||
|
{{ dialogStatus === 'create' ? '确认入库' : '保存修改' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
|
import { Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import {
|
||||||
|
getBuyList,
|
||||||
|
createBuyInbound,
|
||||||
|
updateBuyInbound,
|
||||||
|
deleteBuyInbound,
|
||||||
|
searchMaterialBase
|
||||||
|
} from '@/api/inbound/buy'
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// 状态与变量
|
||||||
|
// ------------------------------------
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const visible = ref(false)
|
||||||
|
const searchLoading = ref(false)
|
||||||
|
const dialogStatus = ref<'create' | 'update'>('create')
|
||||||
|
const tableData = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const formRef = ref()
|
||||||
|
const queryParams = reactive({ page: 1, pageSize: 15, keyword: '' })
|
||||||
|
const materialOptions = ref<any[]>([])
|
||||||
|
|
||||||
|
const entryMode = ref('batch')
|
||||||
|
const modeLocked = ref(false)
|
||||||
|
|
||||||
|
// 列定义
|
||||||
|
const baseColumns = [
|
||||||
|
{ prop: 'material_name', label: '名称' },
|
||||||
|
{ prop: 'category', label: '类别' },
|
||||||
|
{ prop: 'material_type', label: '类型' },
|
||||||
|
{ prop: 'spec_model', label: '规格型号' },
|
||||||
|
{ prop: 'unit', label: '单位' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const stockColumns = [
|
||||||
|
{ prop: 'id', label: 'ID', minWidth: '60' },
|
||||||
|
{ prop: 'base_id', label: 'BaseID', minWidth: '80' },
|
||||||
|
{ prop: 'sku', label: 'SKU', minWidth: '120' },
|
||||||
|
{ prop: 'inbound_date', label: '入库日期', minWidth: '120' },
|
||||||
|
{ prop: 'barcode', label: '条码', minWidth: '120' },
|
||||||
|
{ prop: 'serial_number', label: '序列号', minWidth: '150' },
|
||||||
|
{ prop: 'batch_number', label: '批号', minWidth: '150' },
|
||||||
|
{ prop: 'status', label: '状态', minWidth: '100' },
|
||||||
|
{ prop: 'inspection_status', label: '到检', minWidth: '100' },
|
||||||
|
{ prop: 'qty_inbound', label: '入库量', minWidth: '100' },
|
||||||
|
{ prop: 'qty_stock', label: '库存数', minWidth: '100' },
|
||||||
|
{ prop: 'qty_available', label: '可用数', minWidth: '100' },
|
||||||
|
{ prop: 'warehouse_loc', label: '库位', minWidth: '120' },
|
||||||
|
{ prop: 'unit_price', label: '单价', minWidth: '120' },
|
||||||
|
{ prop: 'total_price', label: '总价', minWidth: '120' },
|
||||||
|
{ prop: 'currency', label: '币种', minWidth: '80' },
|
||||||
|
{ prop: 'exchange_rate', label: '汇率', minWidth: '80' },
|
||||||
|
{ prop: 'supplier_name', label: '供应商', minWidth: '150' },
|
||||||
|
{ prop: 'purchaser', label: '采购人', minWidth: '100' },
|
||||||
|
{ prop: 'purchaser_email', label: '邮箱', minWidth: '150' },
|
||||||
|
{ prop: 'source_link', label: '采购链接', minWidth: '100' },
|
||||||
|
{ prop: 'detail_link', label: '详情链接', minWidth: '100' },
|
||||||
|
{ prop: 'arrival_photo', label: '到货图', minWidth: '100' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const allColumns = [...baseColumns, ...stockColumns]
|
||||||
|
|
||||||
|
// 表头持久化
|
||||||
|
const STORAGE_KEY = 'stock_buy_visible_columns'
|
||||||
|
const defaultColumns = [
|
||||||
|
'material_name', 'category', 'material_type', 'spec_model', 'unit',
|
||||||
|
'inbound_date', 'serial_number', 'batch_number', 'status', 'inspection_status',
|
||||||
|
'unit_price', 'total_price', 'supplier_name', 'purchaser', 'qty_stock', 'qty_available'
|
||||||
|
]
|
||||||
|
|
||||||
|
const getSavedColumns = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return saved ? JSON.parse(saved) : defaultColumns
|
||||||
|
} catch (e) {
|
||||||
|
return defaultColumns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleColumnProps = ref(getSavedColumns())
|
||||||
|
|
||||||
|
watch(visibleColumnProps, (newVal) => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(newVal))
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
id: undefined,
|
||||||
|
base_id: undefined as number | undefined,
|
||||||
|
material_name: '', spec_model: '', category: '', unit: '', material_type: '',
|
||||||
|
sku: '', barcode: '', in_date: '',
|
||||||
|
serial_number: '', batch_number: '',
|
||||||
|
status: '在库', inspection_status: '未检',
|
||||||
|
in_quantity: 1, stock_quantity: 1, available_quantity: 1,
|
||||||
|
warehouse_location: '',
|
||||||
|
unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00,
|
||||||
|
supplier_name: '', purchaser: '', purchaser_email: '',
|
||||||
|
source_link: '', detail_link: '', arrival_photo: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// 逻辑校验规则
|
||||||
|
// ------------------------------------
|
||||||
|
const validateUnique = (rule: any, value: string, callback: any) => {
|
||||||
|
if (!value) return callback()
|
||||||
|
const isDuplicate = tableData.value.some((row: any) => {
|
||||||
|
if (dialogStatus.value === 'update' && row.id === form.id) return false
|
||||||
|
if (rule.field === 'serial_number' && row.serial_number === value) return true
|
||||||
|
if (rule.field === 'batch_number' && row.batch_number === value) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (isDuplicate) {
|
||||||
|
callback(new Error('编号重复'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateIdentity = (rule: any, value: any, callback: any) => {
|
||||||
|
if (entryMode.value === 'serial' && !form.serial_number && rule.field === 'serial_number') {
|
||||||
|
callback(new Error('SN必填'))
|
||||||
|
} else if (entryMode.value === 'batch' && !form.batch_number && rule.field === 'batch_number') {
|
||||||
|
callback(new Error('批号必填'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
base_id: [{ required: true, message: '请选择物料', trigger: 'change' }],
|
||||||
|
in_quantity: [{ required: true, message: '请输入数量', trigger: 'blur' }],
|
||||||
|
serial_number: [{ validator: validateIdentity, trigger: 'blur' }, { validator: validateUnique, trigger: 'blur' }],
|
||||||
|
batch_number: [{ validator: validateIdentity, trigger: 'blur' }, { validator: validateUnique, trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// 核心逻辑函数
|
||||||
|
// ------------------------------------
|
||||||
|
const checkHistoryAndSetMode = async (baseId: number) => {
|
||||||
|
try {
|
||||||
|
const res: any = await getBuyList({ page: 1, pageSize: 1000 })
|
||||||
|
const allItems = res.data.items || []
|
||||||
|
const historyItems = allItems.filter((item: any) => item.base_id === baseId)
|
||||||
|
|
||||||
|
if (historyItems.length > 0) {
|
||||||
|
modeLocked.value = true
|
||||||
|
const latest = historyItems.sort((a: any, b: any) => b.id - a.id)[0]
|
||||||
|
if (latest.serial_number) {
|
||||||
|
entryMode.value = 'serial'
|
||||||
|
form.serial_number = ''
|
||||||
|
form.batch_number = ''
|
||||||
|
} else {
|
||||||
|
entryMode.value = 'batch'
|
||||||
|
form.serial_number = ''
|
||||||
|
const lastBatch = latest.batch_number || '000000'
|
||||||
|
form.batch_number = incrementBatchNumber(lastBatch)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
modeLocked.value = false
|
||||||
|
entryMode.value = 'batch'
|
||||||
|
form.batch_number = '000001'
|
||||||
|
}
|
||||||
|
|
||||||
|
if(formRef.value) {
|
||||||
|
formRef.value.clearValidate('serial_number')
|
||||||
|
formRef.value.clearValidate('batch_number')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
modeLocked.value = false
|
||||||
|
entryMode.value = 'batch'
|
||||||
|
form.batch_number = '000001'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const incrementBatchNumber = (batchStr: string) => {
|
||||||
|
if (!batchStr || !/^\d+$/.test(batchStr)) return '000001'
|
||||||
|
const num = parseInt(batchStr, 10)
|
||||||
|
return (num + 1).toString().padStart(6, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEntryModeChange = (val: string) => {
|
||||||
|
if (val === 'batch') {
|
||||||
|
form.serial_number = ''
|
||||||
|
form.batch_number = '000001'
|
||||||
|
if(formRef.value) formRef.value.clearValidate('serial_number')
|
||||||
|
} else {
|
||||||
|
form.batch_number = ''
|
||||||
|
if(formRef.value) formRef.value.clearValidate('batch_number')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchMaterial = async (query: string) => {
|
||||||
|
if (query) {
|
||||||
|
searchLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await searchMaterialBase(query)
|
||||||
|
materialOptions.value = res.data || []
|
||||||
|
} finally {
|
||||||
|
searchLoading.value = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
materialOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMaterialSelected = (val: number) => {
|
||||||
|
const item = materialOptions.value.find(i => i.id === val)
|
||||||
|
if (item) {
|
||||||
|
form.material_name = item.name
|
||||||
|
form.spec_model = item.spec
|
||||||
|
form.category = item.category
|
||||||
|
form.unit = item.unit
|
||||||
|
form.material_type = item.type
|
||||||
|
checkHistoryAndSetMode(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [form.in_quantity, form.unit_price], () => {
|
||||||
|
form.total_price = Number((form.in_quantity * form.unit_price).toFixed(4))
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await getBuyList(queryParams)
|
||||||
|
tableData.value = res.data.items || []
|
||||||
|
total.value = res.data.total || 0
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
dialogStatus.value = 'create'
|
||||||
|
resetForm()
|
||||||
|
form.in_date = dayjs().format('YYYY-MM-DD')
|
||||||
|
modeLocked.value = false
|
||||||
|
entryMode.value = 'batch'
|
||||||
|
form.batch_number = ''
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = (row: any) => {
|
||||||
|
dialogStatus.value = 'update'
|
||||||
|
resetForm()
|
||||||
|
modeLocked.value = true
|
||||||
|
|
||||||
|
form.id = row.id
|
||||||
|
form.base_id = row.base_id
|
||||||
|
form.material_name = row.material_name
|
||||||
|
form.spec_model = row.spec_model
|
||||||
|
form.category = row.category
|
||||||
|
form.unit = row.unit
|
||||||
|
form.material_type = row.material_type
|
||||||
|
form.sku = row.sku
|
||||||
|
form.barcode = row.barcode
|
||||||
|
form.in_date = row.inbound_date
|
||||||
|
form.warehouse_location = row.warehouse_loc
|
||||||
|
|
||||||
|
if (row.serial_number) {
|
||||||
|
entryMode.value = 'serial'
|
||||||
|
form.serial_number = row.serial_number
|
||||||
|
form.batch_number = ''
|
||||||
|
} else {
|
||||||
|
entryMode.value = 'batch'
|
||||||
|
form.batch_number = row.batch_number
|
||||||
|
form.serial_number = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
form.status = row.status
|
||||||
|
form.inspection_status = row.inspection_status
|
||||||
|
form.in_quantity = Number(row.qty_inbound) || 0
|
||||||
|
form.stock_quantity = Number(row.qty_stock) || 0
|
||||||
|
form.available_quantity = Number(row.qty_available) || 0
|
||||||
|
|
||||||
|
form.unit_price = Number(row.unit_price) || 0
|
||||||
|
form.total_price = Number(row.total_price) || 0
|
||||||
|
form.currency = row.currency
|
||||||
|
form.exchange_rate = Number(row.exchange_rate)
|
||||||
|
form.supplier_name = row.supplier_name
|
||||||
|
form.purchaser = row.purchaser
|
||||||
|
form.purchaser_email = row.purchaser_email
|
||||||
|
form.source_link = row.source_link
|
||||||
|
form.detail_link = row.detail_link
|
||||||
|
form.arrival_photo = row.arrival_photo
|
||||||
|
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (valid) {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (dialogStatus.value === 'create') {
|
||||||
|
await createBuyInbound(form)
|
||||||
|
ElMessage.success('入库成功')
|
||||||
|
} else {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
in_quantity: Number(form.in_quantity),
|
||||||
|
unit_price: Number(form.unit_price)
|
||||||
|
}
|
||||||
|
await updateBuyInbound(form.id!, payload)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
}
|
||||||
|
await fetchData()
|
||||||
|
visible.value = false
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.msg || '操作失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: any) => {
|
||||||
|
try {
|
||||||
|
await deleteBuyInbound(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchData()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
materialOptions.value = []
|
||||||
|
Object.assign(form, {
|
||||||
|
id: undefined, base_id: undefined,
|
||||||
|
material_name: '', spec_model: '', category: '', unit: '', material_type: '',
|
||||||
|
sku: '', barcode: '', in_date: '',
|
||||||
|
serial_number: '', batch_number: '',
|
||||||
|
status: '在库', inspection_status: '未检',
|
||||||
|
in_quantity: 1, stock_quantity: 1, available_quantity: 1,
|
||||||
|
warehouse_location: '',
|
||||||
|
unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00,
|
||||||
|
supplier_name: '', purchaser: '', purchaser_email: '',
|
||||||
|
source_link: '', detail_link: '', arrival_photo: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusType = (status: string) => {
|
||||||
|
const map: any = { '在库': 'success', '出库': 'info', '损耗': 'danger' }
|
||||||
|
return map[status] || 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMoney = (val: any, currency = '¥') => {
|
||||||
|
const num = Number(val)
|
||||||
|
return isNaN(num) ? '-' : `${currency} ${num.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => fetchData())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 全局布局 */
|
||||||
|
.buy-module {
|
||||||
|
background: #f5f7fa; /* 整体背景微灰,突出内容 */
|
||||||
|
padding: 20px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部工具栏 */
|
||||||
|
.header-tools {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.left-tools { flex: 0 0 350px; }
|
||||||
|
.right-tools { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.action-btn { font-weight: 500; }
|
||||||
|
|
||||||
|
/* 表格美化 */
|
||||||
|
.modern-table {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
:deep(.table-header-gray th) {
|
||||||
|
background-color: #f8f9fb !important;
|
||||||
|
color: #606266;
|
||||||
|
font-weight: 600;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
.tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; }
|
||||||
|
.tag-bn { color: #67C23A; font-weight: bold; font-family: monospace; }
|
||||||
|
.money-text { font-family: 'Consolas', monospace; color: #303133; }
|
||||||
|
.stock-num { font-weight: bold; color: #333; font-size: 15px; }
|
||||||
|
.avail-num { font-weight: bold; color: #67C23A; font-size: 15px; }
|
||||||
|
.sum-tag { margin-left: 4px; transform: scale(0.9); }
|
||||||
|
|
||||||
|
/* 弹窗与表单美化 */
|
||||||
|
.stylish-form .form-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
background: #fcfcfc;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #303133;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; }
|
||||||
|
.card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; }
|
||||||
|
|
||||||
|
.card-content { padding: 20px; }
|
||||||
|
|
||||||
|
/* 基础信息卡片 (蓝色调,示读) */
|
||||||
|
.basic-card { border-left: 4px solid #409EFF; }
|
||||||
|
.is-text-view :deep(.el-input__wrapper) {
|
||||||
|
box-shadow: none !important;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-bottom: 1px solid #dcdfe6;
|
||||||
|
border-radius: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.is-text-view :deep(.el-input__inner) { color: #606266; font-weight: 500; }
|
||||||
|
|
||||||
|
/* 入库信息卡片 */
|
||||||
|
.inbound-card { border-left: 4px solid #67C23A; }
|
||||||
|
|
||||||
|
/* 身份区域 (SN/BN) */
|
||||||
|
.identity-panel {
|
||||||
|
background: #fffbf0;
|
||||||
|
border: 1px dashed #e6a23c;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.custom-radio-group { margin-bottom: 10px; }
|
||||||
|
.locked-msg { font-size: 12px; color: #e6a23c; margin-left: 15px; }
|
||||||
|
.prefix-tag { font-weight: bold; font-size: 12px; padding: 0 5px; border-radius: 4px; }
|
||||||
|
.prefix-tag.bn { color: #67C23A; background: #f0f9eb; }
|
||||||
|
.prefix-tag.sn { color: #409EFF; background: #ecf5ff; }
|
||||||
|
|
||||||
|
/* 分割线 */
|
||||||
|
.divider-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
margin: 30px 0 20px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.divider-text::before, .divider-text::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
.divider-text::before { margin-right: 15px; }
|
||||||
|
.divider-text::after { margin-left: 15px; }
|
||||||
|
|
||||||
|
/* 底部按钮 */
|
||||||
|
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; margin-top: 20px; }
|
||||||
|
.info-alert { font-size: 12px; color: #909399; margin-top: 10px; display: flex; align-items: center; gap: 5px; }
|
||||||
|
.option-item { display: flex; justify-content: space-between; width: 100%; }
|
||||||
|
.opt-name { font-weight: bold; }
|
||||||
|
.opt-spec { color: #8492a6; font-size: 13px; }
|
||||||
|
.total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; }
|
||||||
|
</style>
|
||||||
27
inventory-web/src/views/stock/inbound/index.vue
Normal file
27
inventory-web/src/views/stock/inbound/index.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inbound-container" style="padding: 20px;">
|
||||||
|
<el-tabs v-model="activeModule" type="border-card">
|
||||||
|
<el-tab-pane label="物料采购入库" name="buy">
|
||||||
|
<BuyInbound v-if="activeModule === 'buy'" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="半成品入库" name="semi">
|
||||||
|
<SemiInbound v-if="activeModule === 'semi'" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="成品入库" name="product">
|
||||||
|
<ProductInbound v-if="activeModule === 'product'" />
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
// 因为在同一个文件夹下,直接用 ./ 即可
|
||||||
|
import BuyInbound from './buy.vue'
|
||||||
|
import SemiInbound from './semi.vue'
|
||||||
|
import ProductInbound from './product.vue'
|
||||||
|
|
||||||
|
const activeModule = ref('buy')
|
||||||
|
</script>
|
||||||
11
inventory-web/src/views/stock/inbound/product.vue
Normal file
11
inventory-web/src/views/stock/inbound/product.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
11
inventory-web/src/views/stock/inbound/semi.vue
Normal file
11
inventory-web/src/views/stock/inbound/semi.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
1
inventory-web/src/views/stock/inbound/service.vue
Normal file
1
inventory-web/src/views/stock/inbound/service.vue
Normal file
@ -0,0 +1 @@
|
|||||||
|
<template><div style="padding:20px;"><h2>服务权益管理</h2></div></template>
|
||||||
1
inventory-web/src/views/transaction/borrow.vue
Normal file
1
inventory-web/src/views/transaction/borrow.vue
Normal file
@ -0,0 +1 @@
|
|||||||
|
<template><div style="padding:20px;"><h2>借库申请</h2></div></template>
|
||||||
1
inventory-web/src/views/transaction/return.vue
Normal file
1
inventory-web/src/views/transaction/return.vue
Normal file
@ -0,0 +1 @@
|
|||||||
|
<template><div style="padding:20px;"><h2>维修登记</h2></div></template>
|
||||||
1
inventory-web/src/views/transaction/scrap.vue
Normal file
1
inventory-web/src/views/transaction/scrap.vue
Normal file
@ -0,0 +1 @@
|
|||||||
|
<template><div style="padding:20px;"><h2>报废处理</h2></div></template>
|
||||||
@ -1,7 +1,30 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
})
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// 【关键修改1】必须设置为 0.0.0.0,否则容器外无法访问
|
||||||
|
host: '0.0.0.0',
|
||||||
|
// 【关键修改2】显式指定端口,与 docker-compose 映射保持一致
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
// 【关键修改3】
|
||||||
|
// 1. 'backend' 是 docker-compose.yml 里的服务名
|
||||||
|
// 2. 端口改为 8000 (Gunicorn 配置的端口)
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
// 注意:如果你的 Flask 路由代码里没有写 /api 前缀(例如 @app.route('/login')),
|
||||||
|
// 那么你需要取消下面这行的注释,把 /api 去掉,否则后端会收到 /api/login 报 404
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user