91 Commits

Author SHA1 Message Date
dxc
af1a95017b 选择基础信息内容修改 2026-02-10 15:00:57 +08:00
dxc
8cae6ee7f6 refactor: remove local history caching and add API suggestions
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-10 14:38:15 +08:00
dxc
94ff7cecdc feat: add backend autocomplete for suppliers and users in inbound
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-10 13:51:19 +08:00
dxc
17a61b489c fix: prevent inbound of disabled materials 2026-02-10 13:50:26 +08:00
dxc
18da3979a9 删除废弃文件 2026-02-10 11:51:03 +08:00
dxc
88d32067ae 将邮箱设定为必选项 2026-02-10 11:50:07 +08:00
dxc
b98f89bfe4 chore: add .material->.base refactor check comments
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-10 11:34:50 +08:00
dxc
c4d2e703f1 docs: add checklist for .material to .base refactoring
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-10 11:19:54 +08:00
dxc
e876505a1b 修复出库时候找不到名称等问题 2026-02-10 11:13:07 +08:00
dxc
bccbeaadce 修改扫码时间间隔分辨率以及添加语音播报,对边框进行缩减由210px修改为180px 2026-02-10 10:32:59 +08:00
dxc
1edd9a95c6 修改服务权益和入库记录排序 2026-02-10 10:02:47 +08:00
dxc
2d0593078b 修改拍照的大小以及增加放大缩小编辑等功能 2026-02-10 09:59:32 +08:00
dxc
a0ed92319c 修改拍照上传逻辑,避免平板不可以调用照相机 2026-02-10 09:27:52 +08:00
dxc
d4b23790a1 fix: only close camera dialog on successful upload
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 17:15:50 +08:00
dxc
aee0fc4380 inventory-web/src/views/stock/inbound/buy.vue
```python
<<<<<<< SEARCH
const cameraInputRef = ref<HTMLInputElement | null>(null)
const currentCameraField = ref<'arrival_photo' | 'inspection_report'>('arrival_photo')
=======
const cameraDialogVisible = ref(false)
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null)
const currentCameraField = ref<'arrival_photo' | 'inspection_report'>('arrival_photo')
>>>>>>> REPLACE
```

inventory-web/src/views/stock/inbound/buy.vue
```python
<<<<<<< SEARCH
const triggerCamera = (field: 'arrival_photo' | 'inspection_report') => { currentCameraField.value = field; if (cameraInputRef.value) cameraInputRef.value.click() }
=======
const triggerCamera = (field: 'arrival_photo' | 'inspection_report') => {
  currentCameraField.value = field;
  cameraDialogVisible.value = true;
}
>>>>>>> REPLACE
```

Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 17:02:02 +08:00
dxc
107c311391 feat: add WebRTC camera component for in-app photo capture
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 16:57:47 +08:00
dxc
5c8cefdb69 feat: implement adaptive layout with internal scrolling for outbound selection
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 16:18:50 +08:00
dxc
50361dba9a 针对于上传图片以及借库还库和出库选单进行更改 2026-02-09 16:08:47 +08:00
dxc
eb771ec4f1 fix: fix BOM parents SQL error and remove unused add children button
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 16:04:12 +08:00
dxc
58f0ce48e2 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 15:55:57 +08:00
dxc
4be42ae5f5 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 15:54:12 +08:00
dxc
170e80e2a5 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 15:50:56 +08:00
dxc
e535a2d99c (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 15:41:11 +08:00
dxc
81c0e93d46 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 15:30:10 +08:00
dxc
c0463cb7dc (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 15:28:42 +08:00
dxc
20e4329a44 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 15:25:05 +08:00
dxc
b61072eea0 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 15:19:19 +08:00
dxc
40abb53721 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 15:07:54 +08:00
dxc
fdf22b9973 修改条形码为二维码,同时对于扫码展示部分进行修改 2026-02-09 14:48:09 +08:00
dxc
bdee5fb27a (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 13:15:48 +08:00
dxc
70f75cc72b (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 12:19:56 +08:00
dxc
94d3149bd9 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 12:15:00 +08:00
dxc
6131b474a1 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 12:11:01 +08:00
dxc
164988ab62 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 11:59:35 +08:00
dxc
8138e8cf5f (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 11:53:58 +08:00
dxc
b57e9f5bba (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 11:50:03 +08:00
dxc
c06b96f149 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 11:44:24 +08:00
dxc
03aea51e9a (no commit message provided) 2026-02-09 11:44:24 +08:00
dxc
592830c213 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 11:34:09 +08:00
dxc
89a29f0b65 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 11:29:37 +08:00
dxc
49453d47f6 (no commit message provided) 2026-02-09 11:29:36 +08:00
dxc
1b88171985 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 11:04:04 +08:00
dxc
f234ca6793 (no commit message provided)
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-09 10:59:36 +08:00
dxc
3f398b74e5 更新忽略规则 2026-02-09 09:49:25 +08:00
dxc
04ee938cd1 借库逻辑实现 2026-02-06 17:11:47 +08:00
dxc
387c8973d6 盘库盲盘以及导出excel实现 2026-02-06 14:30:14 +08:00
dxc
489e62e55b 摄像头逻辑进行修改,更改分辨率进行快速的读取识别 2026-02-06 11:28:48 +08:00
dxc
e027ebd4a9 盘库操作初设计 2026-02-06 10:16:37 +08:00
dxc
c1ddb8093f 出库进行修改,确保可以进行多个样例的出库以及出库的记录展示 2026-02-05 16:54:11 +08:00
dxc
3f6ab3e607 修改出库签名逻辑,对签字进行单独屏幕 2026-02-05 15:51:19 +08:00
dxc
374c4932f0 修改semi页面的弹窗滚动条的适配内容 2026-02-05 15:09:37 +08:00
dxc
cad5fd696c 修正新增入库时3个组件的名称筛选逻辑 2026-02-05 15:04:06 +08:00
dxc
4f90e02dcf 修改时间时区问题 2026-02-05 14:36:36 +08:00
dxc
0bc47d306d 增加入库记录页面,同时修正三组入库的时间问题 2026-02-05 14:30:11 +08:00
dxc
10e53cab23 修改半成品与成品出入库相关联逻辑 2026-02-05 13:17:39 +08:00
dxc
aa40d4a6da 成品入库与出库相关联 2026-02-05 11:58:35 +08:00
dxc
bf8cf37ff9 半成品入库与出库相关联 2026-02-05 11:51:25 +08:00
dxc
1e5627dc0a 修改采购件入库逻辑 2026-02-05 11:37:06 +08:00
dxc
273f20f5c3 采购件入库与出库相关联 2026-02-05 11:08:29 +08:00
dxc
f3b60dfc54 出库操作逻辑上面实现,成功跑通 2026-02-05 10:20:52 +08:00
dxc
797b611530 出库逻辑添加,扫码识别编码成功,后续对应逻辑没有完成 2026-02-04 17:22:20 +08:00
dxc
596f366fc4 进入界面的调整 2026-02-04 15:55:20 +08:00
dxc
c1c525b699 登录界面调整 2026-02-04 15:41:51 +08:00
dxc
ea17413bc1 新增用户页面更新以及调整 2026-02-04 15:16:14 +08:00
dxc
c1e08062f2 修正名称与俗名关系 2026-02-04 14:37:18 +08:00
dxc
fd5600b65b 修改登录退出逻辑 2026-02-04 14:29:59 +08:00
dxc
13590b1fac 超级管理员登录设置 2026-02-04 13:30:07 +08:00
dxc
4aa43a0607 打印标签内容以及尺寸确定 2026-02-04 10:35:13 +08:00
dxc
3257973820 成品图像上传初实现,支持多图,检测报告的图片以及链接上传 2026-02-03 13:20:17 +08:00
dxc
d084bd29dd 半成品图像上传初实现,支持多图,检测报告的图片以及链接上传 2026-02-03 13:06:18 +08:00
dxc
ba3085c1f2 采购件图像上传初实现,支持多图,检测报告的图片以及链接上传 2026-02-03 11:55:33 +08:00
dxc
7fa40115d9 采购件图像上传初实现 2026-02-03 11:16:12 +08:00
dxc
efcd2d923c 对于成品的条形码进行功能实现 2026-02-03 09:17:28 +08:00
dxc
98450d73f1 对于半成品的条形码进行更改 2026-02-03 09:01:03 +08:00
dxc
11a4e5f48a 针对于条形码生成进行修改 2026-02-02 16:43:35 +08:00
dxc
cf6a4a8957 添加条形码内容 2026-02-02 15:06:20 +08:00
dxc
a1133aac94 三个基础入库页面修改新增弹窗内容展示,下拉框以及弹窗屏幕大小自适应性 2026-01-30 12:58:19 +08:00
dxc
0009fe3121 维护三个基础物件入库时候与数据库不匹配问题 2026-01-30 11:51:16 +08:00
dxc
30181fd21b 维护三个基础物件入库时候与数据库不匹配问题 2026-01-30 11:50:35 +08:00
dxc
482c5a2cb2 修改基础信息启用停用内容,进行修复 2026-01-30 11:21:10 +08:00
dxc
06ba2d7563 采购件,半成品,产品页面初步完成 2026-01-29 09:27:56 +08:00
dxc
b0df5c7458 添加半成品页面进行数据 2026-01-28 17:44:39 +08:00
dxc
cd55a6aee1 Merge remote-tracking branch 'origin/1.0入库操作' into 1.0入库操作
# Conflicts:
#	inventory-backend/app/services/inbound/buy_service.py
#	inventory-web/src/views/stock/inbound/buy.vue
2026-01-28 11:50:55 +08:00
dxc
6f4917f57e 针对于采购页面进行优化逻辑 2026-01-28 11:49:59 +08:00
dxc
e31ef59df0 针对于采购页面进行优化逻辑 2026-01-28 11:22:08 +08:00
dxc
87864a1c4f 基础信息和采购件页面返回值读取正确 2026-01-28 09:13:20 +08:00
dxc
7a4ea8acfb 基础信息读取错误,未修改完成 2026-01-28 08:54:11 +08:00
dxc
9a04f65eb7 基础信息读取错误,未修改完成 2026-01-27 18:10:09 +08:00
dxc
7a78975ce7 采购件管理修改页面文字大小以及调整文字栏间距 2026-01-27 16:43:44 +08:00
dxc
3afea217b7 物料-采购件入库页面功能实现 2026-01-27 15:50:23 +08:00
dxc
2f8a5c55b1 python-flask和Vue两种模式初模板 2026-01-26 17:00:12 +08:00
117 changed files with 16031 additions and 2535 deletions

3
.gitignore vendored
View File

@ -15,3 +15,6 @@ inventory-web/*.local
.vscode/
.DS_Store
*.log
pgdata_docker/
inventory-backend/uploads/
.aider*

52
docker-compose.yml Normal file
View 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 # 挂载代码,实现热更新
# 【核心修改】显式挂载 uploads 目录,确保图片持久化且宿主机可见
- ./inventory-backend/uploads:/app/uploads
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 开发服务 ---
frontend:
build:
context: ./inventory-web
container_name: inventory_ui
restart: always
# 把本地代码挂载进去,实现“热更新”
volumes:
- ./inventory-web:/app
- /app/node_modules # 排除 node_modules防止冲突
# 开发模式端口通常是 5173
ports:
- "5173:5173"
depends_on:
- backend

View File

@ -0,0 +1,6 @@
venv/
__pycache__/
*.pyc
.git/
.env
pgdata/

View 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"]

View File

View File

@ -1,26 +1,149 @@
# 文件路径: inventory-backend/app/__init__.py
from flask import Flask
from config import Config
from app.extensions import db, ma
from app.extensions import db, migrate, cors, jwt
import os
def create_app():
app = Flask(__name__)
app.config.from_object(Config)
# 初始化插件
# =========================================================
# 1. 初始化插件
# =========================================================
db.init_app(app)
ma.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app) # 初始化 JWT
# 【新增关键步骤】: 显式导入 models让 SQLAlchemy 认识所有的表
# 必须放在 db.init_app 之后create_all 或 蓝图注册 之前
from app import models
# 允许所有 /api/ 开头的请求跨域,支持 credentials
cors.init_app(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True)
# 注册路由蓝图
from app.api.v1.stocks import stock_bp
app.register_blueprint(stock_bp, url_prefix='/api/v1')
# =========================================================
# 2. 注册蓝图 (Blueprints)
# ---------------------------------------------------------
# 注意:为了解决前端请求不带 /v1 导致的 404 错误,
# 下面的模块都采用了 "双重注册" 策略:
# 1. 标准地址: /api/v1/...
# 2. 兼容地址: /api/... (name 参数必须不同)
# =========================================================
# 【可选】如果你没有用 Flask-Migrate可以用下面这句话自动建表开发阶段
# with app.app_context():
# db.create_all()
# -----------------------------------------------------
# 2.0 注册权限与认证模块 (Auth)
# -----------------------------------------------------
try:
from app.api.v1.auth import auth_bp
# 标准
app.register_blueprint(auth_bp, url_prefix='/api/v1/auth')
# 兼容 (防止前端忘记写 v1)
app.register_blueprint(auth_bp, url_prefix='/api/auth', name='auth_legacy')
print("✅ Auth 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Auth 模块导入失败: {e}")
return app
# -----------------------------------------------------
# 2.1 注册入库聚合模块 (Inbound)
# -----------------------------------------------------
try:
from app.api.v1.inbound import inbound_bp
# 标准: /api/v1/inbound/base/list
app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound')
# 兼容: /api/inbound/base/list (修复前端 404)
app.register_blueprint(inbound_bp, url_prefix='/api/inbound', name='inbound_legacy')
print("✅ Inbound 模块注册成功 (已启用兼容模式: /api/inbound)")
except ImportError as e:
print(f"❌ 错误: Inbound 模块导入失败: {e}")
# -----------------------------------------------------
# 2.2 注册通用打印模块 (Common Print)
# -----------------------------------------------------
try:
from app.api.v1.common.print import print_bp
app.register_blueprint(print_bp, url_prefix='/api/v1/common/print')
app.register_blueprint(print_bp, url_prefix='/api/common/print', name='print_legacy')
print("✅ Print 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Print 模块导入失败: {e}")
# -----------------------------------------------------
# 2.3 注册通用上传模块 (Common Upload)
# -----------------------------------------------------
try:
from app.api.v1.common.upload import upload_bp
app.register_blueprint(upload_bp, url_prefix='/api/v1/common')
app.register_blueprint(upload_bp, url_prefix='/api/common', name='upload_legacy')
print("✅ Upload 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Upload 模块导入失败: {e}")
# -----------------------------------------------------
# 2.4 注册业务操作模块 (Transactions - 借还/维修/报废)
# ★★★ 关键修改:将前缀改为 /api/v1/transactions 以匹配前端请求 ★★★
# -----------------------------------------------------
try:
from app.api.v1.transactions import trans_bp
# 标准: /api/v1/transactions/borrow
app.register_blueprint(trans_bp, url_prefix='/api/v1/transactions')
# 兼容: /api/transactions/borrow
app.register_blueprint(trans_bp, url_prefix='/api/transactions', name='trans_legacy')
print("✅ Transactions 模块注册成功")
except ImportError as e:
# 允许模块不存在时不崩溃,但在开发借还功能时这里报错说明 trans_bp 定义有问题
print(f"⚠️ 提示: Transaction 模块导入失败 (请检查 app/api/v1/transactions.py): {e}")
# -----------------------------------------------------
# 2.5 注册出库模块 (Outbound)
# -----------------------------------------------------
try:
from app.api.v1.outbound import outbound_bp
# 标准: /api/v1/outbound
app.register_blueprint(outbound_bp, url_prefix='/api/v1/outbound')
# 兼容: /api/outbound
app.register_blueprint(outbound_bp, url_prefix='/api/outbound', name='outbound_legacy')
print("✅ Outbound 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Outbound 模块导入失败: {e}")
# -----------------------------------------------------
# 2.6 注册 BOM 模块
# -----------------------------------------------------
try:
from app.api.v1.bom import bom_bp
# 标准: /api/v1/bom
app.register_blueprint(bom_bp, url_prefix='/api/v1/bom')
# 兼容: /api/bom
app.register_blueprint(bom_bp, url_prefix='/api/bom', name='bom_legacy')
print("✅ BOM 模块注册成功")
except ImportError as e:
print(f"❌ 错误: BOM 模块导入失败: {e}")
# =========================================================
# 3. 预加载数据模型
# =========================================================
with app.app_context():
try:
# 基础与库存模型
from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
# 出库模型
from app.models.outbound import TransOutbound
# 系统与业务模型
from app.models.system import SysUser, SysLog
# 确保借还模型被加载
from app.models.transaction import TransBorrow, TransRepair, TransScrap
# 首次运行时可取消注释自动建表 (但在生产环境建议使用 flask db upgrade)
# db.create_all()
except ImportError as e:
print(f"⚠️ 模型预加载部分失败 (检查是否缺少文件): {e}")
except Exception as e:
print(f"⚠️ 模型预加载发生未知错误: {e}")
return app

View File

@ -0,0 +1,7 @@
from flask import Blueprint
from .inbound import inbound_bp
from .bom import bom_bp
v1_bp = Blueprint('v1', __name__)
v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound')
v1_bp.register_blueprint(bom_bp, url_prefix='/bom')

View File

@ -0,0 +1,90 @@
# app/api/v1/auth.py
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt
from app.services.auth_service import AuthService
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['POST'])
def login():
try:
data = request.get_json()
if not data:
return jsonify({'msg': '无效的请求数据'}), 400
if not data.get('username') or not data.get('password'):
return jsonify({'msg': '请输入用户名和密码'}), 400
result = AuthService.login(data)
response_data = {
'msg': '登录成功',
'access_token': result.get('access_token'),
'user': result.get('user')
}
return jsonify(response_data), 200
except ValueError as ve:
return jsonify({'msg': str(ve)}), 401
except Exception as e:
current_app.logger.error(f"Login Failed Error: {str(e)}")
return jsonify({'msg': f'服务器内部错误: {str(e)}'}), 500
@auth_bp.route('/user/create', methods=['POST'])
@jwt_required()
def create_user():
try:
data = request.get_json()
claims = get_jwt()
operator_role = claims.get('role')
result = AuthService.create_user(data, operator_role)
return jsonify({'msg': '用户创建成功', 'data': result}), 201
except Exception as e:
current_app.logger.error(f"User Create Failed: {str(e)}")
return jsonify({'msg': str(e)}), 400
# [新增] 更新用户
@auth_bp.route('/user/<int:user_id>', methods=['PUT'])
@jwt_required()
def update_user(user_id):
try:
data = request.get_json()
claims = get_jwt()
operator_role = claims.get('role')
result = AuthService.update_user(user_id, data, operator_role)
return jsonify({'msg': '用户更新成功', 'data': result}), 200
except Exception as e:
current_app.logger.error(f"User Update Failed: {str(e)}")
return jsonify({'msg': str(e)}), 400
@auth_bp.route('/users', methods=['GET'])
@jwt_required()
def get_users():
try:
users = AuthService.get_all_users()
return jsonify({'msg': '获取成功', 'data': users}), 200
except Exception as e:
current_app.logger.error(f"Get Users Failed: {str(e)}")
return jsonify({'msg': '获取用户列表失败'}), 500
@auth_bp.route('/user/<int:user_id>', methods=['DELETE'])
@jwt_required()
def delete_user(user_id):
try:
claims = get_jwt()
operator_role = claims.get('role')
AuthService.delete_user(user_id, operator_role)
return jsonify({'msg': '删除成功'}), 200
except Exception as e:
current_app.logger.error(f"Delete User Failed: {str(e)}")
return jsonify({'msg': str(e)}), 400

View File

@ -0,0 +1,76 @@
from flask import Blueprint, request, jsonify, current_app
from app.services.bom_service import BomService
from app.models.base import MaterialBase
from app.models.bom import BomTable
from app.extensions import db
from flask_jwt_extended import jwt_required
from sqlalchemy import distinct
bom_bp = Blueprint('bom', __name__)
@bom_bp.route('/<int:parent_id>', methods=['GET'])
@jwt_required()
def get_bom(parent_id):
try:
data = BomService.get_bom_with_stock(parent_id)
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'获取BOM失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('', methods=['POST'])
@jwt_required()
def save_bom():
try:
req_data = request.get_json()
parent_id = req_data.get('parent_id')
child_list = req_data.get('children', [])
if not parent_id or not isinstance(child_list, list):
return jsonify({'code': 400, 'msg': '参数错误'}), 400
BomService.create_or_update_bom(parent_id, child_list)
return jsonify({
'code': 200,
'msg': '保存成功'
})
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
current_app.logger.error(f'保存BOM失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/base/list', methods=['GET'])
@jwt_required()
def get_material_base_list():
"""获取所有基础物料列表,用于前端下拉框"""
try:
materials = MaterialBase.query.filter_by(is_enabled=True).order_by(MaterialBase.id.desc()).all()
data = [item.to_dict() for item in materials]
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'获取基础物料列表失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/parents', methods=['GET'])
@jwt_required()
def get_bom_parents():
"""获取所有已定义BOM的父件物料列表"""
try:
subq = db.session.query(BomTable.parent_id).distinct().subquery()
parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all()
data = [item.to_dict() for item in parents]
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'获取BOM父件列表失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500

View File

@ -0,0 +1,27 @@
# app/api/v1/common/print.py
from flask import Blueprint, request, jsonify
from app.services.print.label_service import LabelPrintService
from app.models.inbound.buy import StockBuy
# 引入其他模型 StockSemi, StockProduct
import traceback
print_bp = Blueprint('print', __name__)
@print_bp.route('/preview', methods=['POST'])
def preview_label():
try:
data = request.get_json()
# 如果只传了ID和类型可以在这里查库补全数据也可以直接前端传全量数据
img_base64 = LabelPrintService.generate_preview_image(data)
return jsonify({"code": 200, "msg": "success", "data": img_base64})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
@print_bp.route('/execute', methods=['POST'])
def execute_print():
try:
data = request.get_json()
LabelPrintService.send_to_printer(data)
return jsonify({"code": 200, "msg": "指令已发送至打印机"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -0,0 +1,131 @@
# 文件路径: inventory-backend/app/api/v1/common/upload.py
import os
import uuid
from flask import Blueprint, request, jsonify, send_from_directory
# 定义蓝图
# 注意:在 app/__init__.py 或类似入口文件中,注册此蓝图时 url_prefix 通常应为 '/api/v1/common'
upload_bp = Blueprint('upload', __name__)
# =========================================================
# 配置上传路径
# =========================================================
def get_project_root():
"""获取项目根目录 inventory-backend"""
current_path = os.path.abspath(__file__)
# 向上回退直到找到根目录,根据你的目录结构可能需要调整层级
# 假设结构: inventory-backend/app/api/v1/common/upload.py (回退5层)
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_path)))))
return base
BASE_DIR = get_project_root()
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
# 允许上传的文件后缀
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx'}
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def ensure_upload_folder_exists():
if not os.path.exists(UPLOAD_FOLDER):
try:
os.makedirs(UPLOAD_FOLDER)
print(f"✅ [Upload] 目录创建成功: {UPLOAD_FOLDER}")
except Exception as e:
print(f"❌ [Upload] 目录创建失败: {e}")
# ------------------------------------------------------------------
# 1. 文件上传接口
# 完整 URL: /api/v1/common/upload (POST)
# ------------------------------------------------------------------
@upload_bp.route('/upload', methods=['POST'])
def upload_file():
ensure_upload_folder_exists()
if 'file' not in request.files:
return jsonify({"code": 400, "msg": "未找到文件部分"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"code": 400, "msg": "未选择文件"}), 400
if file and allowed_file(file.filename):
try:
# 获取后缀并生成唯一文件名
ext = file.filename.rsplit('.', 1)[1].lower()
new_filename = f"{uuid.uuid4().hex}.{ext}"
save_path = os.path.join(UPLOAD_FOLDER, new_filename)
file.save(save_path)
print(f"💾 [Upload] 文件已保存: {save_path}")
# 生成访问 URL (返回给前端的相对路径)
# 前端展示时通常拼接 baseURL或者直接使用此路径访问
# 这里的 /api/v1/common 需与蓝图注册路径一致
file_url = f"/api/v1/common/files/{new_filename}"
return jsonify({
"code": 200,
"msg": "上传成功",
"data": {
"url": file_url,
"filename": new_filename
}
})
except Exception as e:
print(f"❌ [Upload] 保存异常: {e}")
return jsonify({"code": 500, "msg": "文件保存失败"}), 500
return jsonify({"code": 400, "msg": "不支持的文件格式"}), 400
# ------------------------------------------------------------------
# 2. 静态文件访问接口 (回显)
# 完整 URL: /api/v1/common/files/<filename> (GET)
# ------------------------------------------------------------------
@upload_bp.route('/files/<filename>', methods=['GET'])
def uploaded_file(filename):
full_path = os.path.join(UPLOAD_FOLDER, filename)
if not os.path.exists(full_path):
# 尝试调试路径问题
print(f"❌ [File Access] 文件未找到: {full_path}")
return jsonify({"code": 404, "msg": "文件不存在"}), 404
return send_from_directory(UPLOAD_FOLDER, filename)
# ------------------------------------------------------------------
# 3. 文件删除接口 (同步删除物理文件)
# 完整 URL: /api/v1/common/files/<filename> (DELETE)
# ------------------------------------------------------------------
@upload_bp.route('/files/<filename>', methods=['DELETE'])
def delete_file(filename):
try:
# 安全处理文件名 (防止路径遍历)
safe_filename = os.path.basename(filename)
file_path = os.path.join(UPLOAD_FOLDER, safe_filename)
print(f"🗑️ [Delete] 尝试删除文件: {file_path}")
if os.path.exists(file_path):
os.remove(file_path)
print(f"✅ [Delete] 文件删除成功")
return jsonify({"code": 200, "msg": "文件已删除"})
else:
print(f"⚠️ [Delete] 文件不存在,无需删除")
# 即使文件不存在也返回成功,保证前端逻辑闭环
return jsonify({"code": 200, "msg": "文件不存在或已删除"})
except Exception as e:
print(f"❌ [Delete] 删除异常: {e}")
return jsonify({"code": 500, "msg": f"删除失败: {str(e)}"}), 500

View File

@ -0,0 +1,25 @@
from flask import Blueprint
# 首先创建主蓝图,避免子模块导入时出现循环依赖
inbound_bp = Blueprint('inbound', __name__)
# 导入各子模块的蓝图(此时 inbound_bp 已定义,子模块可以安全导入它)
from .buy import inbound_buy_bp
from .semi import inbound_semi_bp
from .base import inbound_base_bp
from .product import inbound_product_bp
from .inbound_summary import bp as inbound_summary_bp
from .stock import bp as inbound_stock_bp
# 导入 service 模块,使其路由装饰器可以正常注册到 inbound_bp 上
from . import service
# 注册子蓝图
inbound_bp.register_blueprint(inbound_buy_bp, url_prefix='/buy')
inbound_bp.register_blueprint(inbound_semi_bp, url_prefix='/semi')
inbound_bp.register_blueprint(inbound_base_bp, url_prefix='/base')
inbound_bp.register_blueprint(inbound_product_bp, url_prefix='/product')
inbound_bp.register_blueprint(inbound_summary_bp, url_prefix='/summary')
inbound_bp.register_blueprint(inbound_stock_bp, url_prefix='/stock')
# service 模块的路由已经直接附加到 inbound_bp无需再注册子蓝图

View File

@ -0,0 +1,93 @@
# 文件路径: app/api/v1/inbound/base.py
from flask import Blueprint, request, jsonify
from app.services.inbound.base_service import MaterialBaseService
import traceback
inbound_base_bp = Blueprint('stock_base', __name__)
# ==============================================================================
# 1. 搜索接口 (GET /api/v1/inbound/base/search)
# ==============================================================================
@inbound_base_bp.route('/search', methods=['GET'])
def search_base():
try:
keyword = request.args.get('keyword', '')
data = MaterialBaseService.search_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
# ==============================================================================
# 2. 列表接口 (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) # 前端传的是 pageNum
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', None)
}
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
# ==============================================================================
# 3. 新增接口 (POST /api/v1/inbound/base/)
# 注意:前端 material_base.ts 可能会请求 / 或 /add这里统一匹配
# ==============================================================================
@inbound_base_bp.route('/', methods=['POST'])
def create():
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 e:
# 捕获业务逻辑验证错误 (如名称为空)
return jsonify({"code": 400, "msg": str(e)}), 400
except Exception as e:
# 捕获系统错误
traceback.print_exc()
return jsonify({"code": 500, "msg": f"系统错误: {str(e)}"}), 500
# ==============================================================================
# 4. 修改接口 (PUT /api/v1/inbound/base/<id>)
# ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['PUT'])
def update(id):
try:
data = request.get_json()
MaterialBaseService.update_material(id, data)
return jsonify({"code": 200, "msg": "修改成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 5. 删除接口 (DELETE /api/v1/inbound/base/<id>)
# ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['DELETE'])
def delete(id):
try:
MaterialBaseService.delete_material(id)
return jsonify({"code": 200, "msg": "删除成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -0,0 +1,135 @@
# app/api/v1/inbound/buy.py
from flask import Blueprint, request, jsonify
from app.services.inbound.buy_service import BuyInboundService
import traceback
inbound_buy_bp = Blueprint('stock_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)
keyword = request.args.get('keyword', '')
# 获取状态列表参数,前端传参格式: statuses=在库,借库
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
result = BuyInboundService.get_list(page, limit, keyword, statuses)
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
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
new_stock = BuyInboundService.handle_inbound(data)
return jsonify({
"code": 200,
"msg": "入库成功",
"data": new_stock.to_dict()
})
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
# ------------------------------------------------------------------
# 5. 获取关联的出库历史
# ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>/history', methods=['GET'])
def get_history(id):
try:
history = BuyInboundService.get_outbound_history(id)
return jsonify({
"code": 200,
"msg": "success",
"data": history
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 6. 供应商建议
# ------------------------------------------------------------------
@inbound_buy_bp.route('/suggestions/suppliers', methods=['GET'])
def get_supplier_suggestions():
base_id = request.args.get('base_id', type=int)
if not base_id:
return jsonify({"code": 400, "msg": "base_id required"}), 400
data = BuyInboundService.get_history_suppliers(base_id)
return jsonify({"code": 200, "msg": "success", "data": data})
# ------------------------------------------------------------------
# 7. 系统用户建议
# ------------------------------------------------------------------
@inbound_buy_bp.route('/suggestions/users', methods=['GET'])
def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = BuyInboundService.search_system_users(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})

View File

@ -0,0 +1,35 @@
from flask import Blueprint, request, jsonify # .material -> .base refactor checked
from app.services.inbound.inbound_summary_service import InboundSummaryService
# 定义蓝图
bp = Blueprint('inbound_summary', __name__)
@bp.route('/list', methods=['GET'])
def get_list():
try:
# 获取参数
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int) # 默认每页20
keyword = request.args.get('keyword', '')
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
source_type = request.args.get('source_type') # 可选:筛选 specific table
result = InboundSummaryService.get_list(
page=page,
per_page=per_page,
keyword=keyword,
start_date=start_date,
end_date=end_date,
source_type=source_type
)
return jsonify({
'code': 200,
'msg': 'success',
'data': result
})
except Exception as e:
# 生产环境建议记录详细日志
print(f"Inbound Summary Error: {str(e)}")
return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -0,0 +1,116 @@
# inventory-backend/app/api/v1/inbound/product.py
from flask import Blueprint, request, jsonify
from app.services.inbound.product_service import ProductInboundService
import traceback
inbound_product_bp = Blueprint('stock_product', __name__)
# ------------------------------------------------------------------
# 0. 基础物料搜索 (关键接口:配合 Service 实现自动回填)
# ------------------------------------------------------------------
@inbound_product_bp.route('/search-base', methods=['GET'])
def search_base():
"""
对应前端 API: /inbound/product/search-base
功能: 模糊搜索基础物料,返回 spec, unit, category, type 等详细信息
"""
try:
keyword = request.args.get('keyword', '')
# 调用 Service 层已修复的 search_base_material 方法
data = ProductInboundService.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. 获取列表 (支持 status 多选筛选)
# ------------------------------------------------------------------
@inbound_product_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)
keyword = request.args.get('keyword', '')
# 接收状态参数 (逗号分隔字符串 -> 列表)
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
result = ProductInboundService.get_list(page, limit, keyword, statuses)
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 2. 新增入库
# ------------------------------------------------------------------
@inbound_product_bp.route('/submit', methods=['POST'])
def submit():
try:
# 调用 Service 处理入库,获取新创建的对象
new_stock = ProductInboundService.handle_inbound(request.get_json())
# 返回成功信息以及新创建的数据包含生成的ID和SKU供前端自动打印使用
return jsonify({
"code": 200,
"msg": "入库成功",
"data": new_stock.to_dict()
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 3. 更新入库
# ------------------------------------------------------------------
@inbound_product_bp.route('/<int:id>', methods=['PUT'])
def update(id):
try:
ProductInboundService.update_inbound(id, request.get_json())
return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 4. 删除
# ------------------------------------------------------------------
@inbound_product_bp.route('/<int:id>', methods=['DELETE'])
def delete(id):
try:
ProductInboundService.delete_inbound(id)
return jsonify({"code": 200, "msg": "删除成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 5. 获取出库历史
# ------------------------------------------------------------------
@inbound_product_bp.route('/<int:id>/history', methods=['GET'])
def get_history(id):
try:
data = ProductInboundService.get_outbound_history(id)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 6. 系统用户建议
# ------------------------------------------------------------------
@inbound_product_bp.route('/suggestions/users', methods=['GET'])
def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = ProductInboundService.search_system_users(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})

View File

@ -0,0 +1,130 @@
# inventory-backend/app/api/v1/inbound/semi.py
from flask import Blueprint, request, jsonify
from app.services.inbound.semi_service import SemiInboundService
import traceback
# 定义蓝图
inbound_semi_bp = Blueprint('stock_semi', __name__)
# ------------------------------------------------------------------
# 0. 基础物料搜索 (复用逻辑)
# ------------------------------------------------------------------
@inbound_semi_bp.route('/search-base', methods=['GET'])
def search_base():
"""
供前端下拉框远程搜索使用 (搜索半成品类型的基础物料)
Query Param: keyword (名称或规格)
"""
try:
keyword = request.args.get('keyword', '')
# 这里复用 Service 中的搜索逻辑
data = SemiInboundService.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_semi_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)
# 支持按关键字搜索BOM号、工单号、SN、批号等
keyword = request.args.get('keyword', '')
# [修改] 获取状态列表参数
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
result = SemiInboundService.get_list(page, limit, keyword, statuses)
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 2. 新增半成品入库 (修改:返回创建的对象数据)
# ------------------------------------------------------------------
@inbound_semi_bp.route('/submit', methods=['POST'])
def submit():
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data"}), 400
# 修改:调用 Service 处理入库,获取新创建的对象
new_stock = SemiInboundService.handle_inbound(data)
# 修改返回成功信息以及新创建的数据包含生成的ID和SKU供前端打印使用
return jsonify({
"code": 200,
"msg": "入库成功",
"data": new_stock.to_dict()
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 3. 更新半成品入库信息
# ------------------------------------------------------------------
@inbound_semi_bp.route('/<int:id>', methods=['PUT'])
def update_semi(id):
try:
data = request.get_json()
SemiInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 4. 删除半成品入库记录
# ------------------------------------------------------------------
@inbound_semi_bp.route('/<int:id>', methods=['DELETE'])
def delete_semi(id):
try:
SemiInboundService.delete_inbound(id)
return jsonify({"code": 200, "msg": "删除成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 5. [新增] 获取关联出库历史
# ------------------------------------------------------------------
@inbound_semi_bp.route('/<int:id>/history', methods=['GET'])
def get_history(id):
try:
data = SemiInboundService.get_outbound_history(id)
return jsonify({
"code": 200,
"msg": "success",
"data": data
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 6. 系统用户建议
# ------------------------------------------------------------------
@inbound_semi_bp.route('/suggestions/users', methods=['GET'])
def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = SemiInboundService.search_system_users(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})

View File

@ -0,0 +1,164 @@
from flask import request, jsonify, current_app
from flask_jwt_extended import jwt_required
from . import inbound_bp
from app.schemas.stock_schema import stock_service_schema
from app.services.inbound.service_service import ServiceService
from app.utils.decorators import role_required
@inbound_bp.route('/service/search-base', methods=['GET'])
@jwt_required()
def search_base():
"""搜索基础物料"""
keyword = request.args.get('keyword', '')
try:
data = ServiceService.search_base_material(keyword)
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'搜索基础物料失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@inbound_bp.route('/service', methods=['GET'])
@jwt_required()
def get_service_list():
"""获取服务权益列表"""
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
keyword = request.args.get('keyword', None)
start_date = request.args.get('start_date', None)
end_date = request.args.get('end_date', None)
provider_name = request.args.get('provider_name', None)
try:
result = ServiceService.get_service_list(
page=page,
per_page=per_page,
keyword=keyword,
start_date=start_date,
end_date=end_date,
provider_name=provider_name
)
return jsonify({
'code': 200,
'msg': 'success',
'data': result
})
except Exception as e:
current_app.logger.error(f'获取服务列表失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@inbound_bp.route('/service', methods=['POST'])
@jwt_required()
@role_required('admin,manager')
def create_service():
"""创建服务权益"""
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
errors = stock_service_schema.validate(data)
if errors:
return jsonify({'code': 400, 'msg': '数据校验失败', 'errors': errors}), 400
try:
service = ServiceService.create_service(data)
return jsonify({
'code': 201,
'msg': '创建成功',
'data': stock_service_schema.dump(service)
}), 201
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
current_app.logger.error(f'创建服务权益失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@inbound_bp.route('/service/<int:service_id>', methods=['GET'])
@jwt_required()
def get_service(service_id):
"""获取单个服务权益详情"""
try:
service = ServiceService.get_service(service_id)
return jsonify({
'code': 200,
'msg': 'success',
'data': stock_service_schema.dump(service)
})
except ValueError as e:
return jsonify({'code': 404, 'msg': str(e)}), 404
except Exception as e:
current_app.logger.error(f'获取服务权益详情失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@inbound_bp.route('/service/<int:service_id>', methods=['PUT'])
@jwt_required()
@role_required('admin,manager')
def update_service(service_id):
"""更新服务权益"""
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
# 部分字段不允许更新,可在此过滤
allowed_fields = {'sale_price', 'provider_name', 'description'}
filtered_data = {k: v for k, v in data.items() if k in allowed_fields}
if not filtered_data:
return jsonify({'code': 400, 'msg': '无有效更新字段'}), 400
try:
service = ServiceService.update_service(service_id, filtered_data)
return jsonify({
'code': 200,
'msg': '更新成功',
'data': stock_service_schema.dump(service)
})
except ValueError as e:
return jsonify({'code': 404, 'msg': str(e)}), 404
except Exception as e:
current_app.logger.error(f'更新服务权益失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@inbound_bp.route('/service/<int:service_id>', methods=['DELETE'])
@jwt_required()
@role_required('admin,manager')
def delete_service(service_id):
"""删除服务权益"""
try:
ServiceService.delete_service(service_id)
return jsonify({
'code': 200,
'msg': '删除成功'
})
except ValueError as e:
return jsonify({'code': 404, 'msg': str(e)}), 404
except Exception as e:
current_app.logger.error(f'删除服务权益失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ------------------------------------------------------------------
# 供应商建议
# ------------------------------------------------------------------
@inbound_bp.route('/service/suggestions/providers', methods=['GET'])
@jwt_required()
def get_provider_suggestions():
base_id = request.args.get('base_id', type=int)
if not base_id:
return jsonify({'code': 400, 'msg': 'base_id required'}), 400
data = ServiceService.get_history_providers(base_id)
return jsonify({'code': 200, 'msg': 'success', 'data': data})
# ------------------------------------------------------------------
# 系统用户建议
# ------------------------------------------------------------------
@inbound_bp.route('/service/suggestions/users', methods=['GET'])
@jwt_required()
def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = ServiceService.search_system_users(keyword)
return jsonify({'code': 200, 'msg': 'success', 'data': data})

View File

@ -0,0 +1,136 @@
from flask import Blueprint, jsonify, request
from app.extensions import db
# ★★★ 修复点:必须引入 datetime否则下方更新时间时会报错 500 ★★★
from datetime import datetime
# 导入模型
from app.models.inbound.buy import StockBuy
from app.models.inbound.stocktake import StocktakeDraft
# 尝试导入半成品和成品
try:
from app.models.inbound.semi import StockSemi
except ImportError:
StockSemi = None
try:
from app.models.inbound.product import StockProduct
except ImportError:
StockProduct = None
from app.services.print.network_print_service import NetworkPrintService
bp = Blueprint('stock_ops', __name__)
@bp.route('/all', methods=['GET'])
def get_all_stock():
"""
获取所有库存 > 0 的物品
"""
try:
# 1. 采购件
materials = []
if StockBuy:
materials = StockBuy.query.filter(StockBuy.stock_quantity > 0).all()
# 2. 半成品
semis = []
if StockSemi:
try:
semis = StockSemi.query.filter(StockSemi.stock_quantity > 0).all()
except Exception:
semis = []
# 3. 成品
products = []
if StockProduct:
try:
products = StockProduct.query.filter(StockProduct.stock_quantity > 0).all()
except Exception:
products = []
return jsonify({
"materials": [item.to_dict() for item in materials],
"semis": [item.to_dict() for item in semis],
"products": [item.to_dict() for item in products]
}), 200
except Exception as e:
print(f"Error: {e}")
return jsonify({"message": f"查询库存失败: {str(e)}"}), 500
# --- 草稿箱接口 ---
@bp.route('/draft/list', methods=['GET'])
def get_drafts():
"""获取当前用户的盘点进度"""
user_id = request.args.get('user_id', 'admin')
drafts = StocktakeDraft.query.filter_by(user_id=user_id).all()
return jsonify([d.to_dict() for d in drafts]), 200
@bp.route('/draft/add', methods=['POST'])
def add_draft():
"""扫码同步 (支持更新数量)"""
try:
data = request.json
user_id = data.get('user_id', 'admin')
uuid = data.get('uuid')
quantity = data.get('quantity', 1)
# 查找是否已存在
draft = StocktakeDraft.query.filter_by(user_id=user_id, uuid=uuid).first()
if draft:
# 如果已存在,更新数量和时间
draft.quantity = quantity
# ★ 修复点:这里需要 datetime 对象
draft.scan_time = datetime.now()
else:
# 如果不存在,创建新的
draft = StocktakeDraft(user_id=user_id, uuid=uuid, quantity=quantity)
db.session.add(draft)
db.session.commit()
return jsonify({"message": "Saved"}), 200
except Exception as e:
print(f"Add Draft Error: {e}")
return jsonify({"message": str(e)}), 500
@bp.route('/draft/clear', methods=['POST'])
def clear_draft():
"""清空进度"""
data = request.json
user_id = data.get('user_id', 'admin')
StocktakeDraft.query.filter_by(user_id=user_id).delete()
db.session.commit()
return jsonify({"message": "Cleared"}), 200
# --- 打印接口 ---
@bp.route('/print/selection', methods=['POST'])
def print_selection():
try:
data = request.json
items = data.get('items', [])
if not items: return jsonify({"message": "未选择任何物品"}), 400
printer = NetworkPrintService()
success, msg = printer.print_outbound_selection(items)
return jsonify({"message": "打印指令已发送" if success else msg}), 200 if success else 500
except Exception as e:
return jsonify({"message": str(e)}), 500
@bp.route('/print/stocktake', methods=['POST'])
def print_stocktake():
try:
data = request.json
printer = NetworkPrintService()
success, msg = printer.print_stocktake_report(data)
return jsonify({"message": "盘点报告已发送" if success else msg}), 200 if success else 500
except Exception as e:
return jsonify({"message": str(e)}), 500

View File

@ -0,0 +1,109 @@
from flask import Blueprint, request, jsonify
from app.services.outbound_service import OutboundService
from flask_jwt_extended import jwt_required, get_jwt_identity
import traceback
outbound_bp = Blueprint('outbound', __name__, url_prefix='/outbound')
# --------------------------------------------------------
# 1. 扫码查询库存接口 (关联三个库存表)
# GET /api/v1/outbound/scan?barcode=...
# --------------------------------------------------------
@outbound_bp.route('/scan', methods=['GET'])
@jwt_required()
def scan_barcode():
barcode = request.args.get('barcode')
if not barcode:
return jsonify({'code': 400, 'msg': '请提供条码'}), 400
try:
# 调用 Service 层去三个表中查找 (Service已更新会返回价格)
result = OutboundService.get_stock_by_barcode(barcode)
if result:
return jsonify({
'code': 200,
'msg': '扫描成功',
'data': result
})
else:
return jsonify({
'code': 404,
'msg': '未找到对应的库存记录,请确认条码是否正确'
}), 404
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'扫描查询出错: {str(e)}'}), 500
# --------------------------------------------------------
# 2. 提交出库单接口 (批量)
# POST /api/v1/outbound
# --------------------------------------------------------
@outbound_bp.route('', methods=['POST'])
@jwt_required()
def create_outbound():
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
# 获取当前登录用户名 (JWT identity)
current_user_name = get_jwt_identity()
if not current_user_name:
current_user_name = 'Unknown'
# 获取最终的操作员名称
final_operator = data.get('operator_name')
if not final_operator:
final_operator = current_user_name
# 必填校验 (针对整个单据)
# items 必须是列表且不为空consumer_name 和 signature_path 必填
if 'items' not in data or not data['items']:
return jsonify({'code': 400, 'msg': '出库商品列表不能为空'}), 400
if not data.get('consumer_name') or not data.get('signature_path'):
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400
try:
# ★ [修改] 调用批量创建服务
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
return jsonify({
'code': 200,
'msg': '出库成功',
'data': {'outbound_no': outbound_no}
})
except ValueError as e:
# 业务逻辑错误 (如库存不足)
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 3. 获取出库记录列表 (分组展示)
# GET /api/v1/outbound
# --------------------------------------------------------
@outbound_bp.route('', methods=['GET'])
@jwt_required()
def get_outbound_list():
try:
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
keyword = request.args.get('keyword', '')
# 如果前端传了日期范围,可以解析处理,这里暂略
# ★ [修改] 调用分组查询服务
result = OutboundService.get_grouped_list(page, limit, keyword)
return jsonify({
'code': 200,
'msg': '获取成功',
'data': result
})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500

View 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

View File

@ -0,0 +1,58 @@
from flask import Blueprint, jsonify, request # .material -> .base refactor checked
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.services.trans_service import TransService
import traceback
trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
# --- 借库接口 ---
@trans_bp.route('/borrow', methods=['POST'])
@jwt_required()
def create_borrow():
data = request.get_json()
try:
no = TransService.create_borrow(data)
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
except Exception as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
# --- 还库辅助:扫码查找借出记录 ---
@trans_bp.route('/return/scan', methods=['GET'])
@jwt_required()
def scan_borrowed_item():
barcode = request.args.get('barcode')
if not barcode:
return jsonify({'code': 400, 'msg': '无条码'}), 400
res = TransService.scan_for_return(barcode)
if res:
return jsonify({'code': 200, 'data': res})
else:
return jsonify({'code': 404, 'msg': '未找到该物品的未还记录'}), 404
# --- 还库提交 ---
@trans_bp.route('/return', methods=['POST'])
@jwt_required()
def submit_return():
data = request.get_json()
user = get_jwt_identity() # 库管
try:
TransService.process_return(data, operator_name=user)
return jsonify({'code': 200, 'msg': '还库成功'})
except Exception as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
# --- 记录列表 ---
@trans_bp.route('/records', methods=['GET'])
@jwt_required()
def get_records():
status = request.args.get('status', 'all')
page = int(request.args.get('page', 1))
keyword = request.args.get('keyword', '')
res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword)
return jsonify({'code': 200, 'data': res})

View File

@ -1,6 +1,28 @@
from flask_sqlalchemy import SQLAlchemy
from flask_marshmallow import Marshmallow
from flask_migrate import Migrate
from flask_cors import CORS
from flask_jwt_extended import JWTManager # 确保引入了 JWTManager
# 初始化数据库和序列化工具
# 1. 创建扩展实例(此时未绑定具体的 App
db = SQLAlchemy()
ma = Marshmallow()
migrate = Migrate()
cors = CORS()
jwt = JWTManager() # 必须实例化
# 2. 定义初始化函数 (供工厂函数 create_app 调用)
def init_extensions(app):
"""
统一初始化所有 Flask 扩展
"""
# 初始化数据库
db.init_app(app)
# 初始化迁移工具
migrate.init_app(app, db)
# 初始化跨域设置 (允许 /api/* 路径被所有来源访问)
cors.init_app(app, resources={r"/api/*": {"origins": "*"}})
# 初始化 JWT (这一步至关重要,缺少它会导致 500 错误)
jwt.init_app(app)

View File

@ -1,2 +1,19 @@
from app.models.material import MaterialBase
from app.models.stock import StockBuy
# app/models/__init__.py
# 1. 基础物料 (必须先加载,因为 buy 依赖它)
from app.models.base import MaterialBase
# 2. 采购入库 (现在的类名是 StockBuy)
from app.models.inbound.buy import StockBuy
# 3. 半成品入库 (如果有)
try:
from app.models.inbound.semi import StockSemi
except ImportError:
pass
# 4. 出库记录 (如果有BuyService 用到了 TransOutbound)
try:
from app.models.outbound import TransOutbound
except ImportError:
pass

View File

@ -0,0 +1,73 @@
# app/models/base.py
from app.extensions import db
import json
class MaterialBase(db.Model):
"""
基础信息表模型
对应数据库表: material_base
"""
__tablename__ = 'material_base'
# 1. 基础字段
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, comment='名称')
common_name = db.Column(db.String(255), comment='俗名')
category = db.Column(db.String(100), comment='类别')
material_type = db.Column(db.String(100), comment='类型')
spec_model = db.Column(db.String(255), comment='规格型号')
unit = db.Column(db.String(50), comment='计量单位')
# 可见等级
visibility_level = db.Column(db.Integer, default=0, comment='信息可见等级')
# 链接与图片 (现在存储 JSON 字符串)
manual_link = db.Column(db.Text, comment='通用说明书')
product_image = db.Column(db.Text, comment='通用产品图')
# 启用状态
is_enabled = db.Column(db.Boolean, default=True, comment='是否启用')
# ============================================================
# 关联关系区域
# ============================================================
# 1. 关联采购库存 (StockBuy) - 修改 back_populates 为 'base'
stock_buys = db.relationship('StockBuy', back_populates='base', lazy='dynamic')
# 2. 关联半成品库存 (StockSemi) - 修改 back_populates 为 'base'
stock_semis = db.relationship('StockSemi', back_populates='base', lazy='dynamic')
# 3. 关联成品库存 (StockProduct) - 修改 back_populates 为 'base'
stock_products = db.relationship('StockProduct', back_populates='base', lazy='dynamic')
def to_dict(self):
"""
序列化方法
"""
# 辅助解析函数:将数据库存储的 JSON 字符串转为 List
def parse_list(json_str):
if not json_str:
return []
try:
# 兼容旧数据:如果不是 JSON 格式(比如是单个 URL则包装成 list
if not json_str.startswith('['):
return [json_str]
return json.loads(json_str)
except:
return []
return {
'id': self.id,
'name': self.name,
'commonName': self.common_name,
'category': self.category,
'type': self.material_type,
'spec': self.spec_model,
'unit': self.unit,
'visibilityLevel': self.visibility_level,
# 修改:解析为列表返回
'generalManual': parse_list(self.manual_link),
'generalImage': parse_list(self.product_image),
'isEnabled': 1 if self.is_enabled else 0,
}

View File

@ -0,0 +1,17 @@
from app.extensions import db
class BomTable(db.Model):
__tablename__ = 'bom_table'
id = db.Column(db.Integer, primary_key=True)
parent_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
child_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
bom_no = db.Column(db.String(100), comment='BOM编号')
version = db.Column(db.String(50), comment='版本')
dosage = db.Column(db.Numeric(19, 4), comment='个数')
loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%(已废弃)', default=0, nullable=True)
remark = db.Column(db.Text, comment='备注')
# relationships
parent = db.relationship('MaterialBase', foreign_keys=[parent_id], backref='bom_parents')
child = db.relationship('MaterialBase', foreign_keys=[child_id], backref='bom_children')

View File

@ -0,0 +1,114 @@
# inventory-backend/app/models/inbound/buy.py
from app.extensions import db
import json
# 显式导入 MaterialBase 以防 relationship 找不到引用
from app.models.base import MaterialBase
class StockBuy(db.Model):
"""
采购入库库存表
对应数据库表: stock_buy
"""
__tablename__ = 'stock_buy'
id = db.Column(db.Integer, primary_key=True)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
# 身份标识
sku = db.Column(db.String(100))
in_date = db.Column(db.DateTime)
barcode = db.Column(db.String(100))
serial_number = db.Column(db.String(100))
batch_number = db.Column(db.String(100))
# 状态
status = db.Column(db.String(50), default='在库')
inspection_status = db.Column(db.String(50))
warehouse_location = 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)
# 财务与商务
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)
supplier_name = db.Column(db.String(255))
buyer_name = db.Column(db.String(100)) # 对应 SQL: buyer_name
buyer_email = db.Column(db.String(100)) # 对应 SQL: buyer_email
original_link = db.Column(db.Text) # 对应 SQL: original_link
detail_link = db.Column(db.Text)
# 图片字段 (存储 JSON 字符串)
arrival_photo = db.Column(db.Text)
# [新增] 检测报告图片路径 (存储 JSON 字符串)
inspection_report = db.Column(db.Text)
# [新增] 全局打印流水号 (用于跨表连续编号,对应 Sequence: global_print_seq)
global_print_id = db.Column(db.Integer)
# 关系定义 [已修改]
base = db.relationship('MaterialBase', back_populates='stock_buys')
def to_dict(self):
# 辅助解析函数:将数据库存储的 JSON 字符串转为 List
def parse_img_list(json_str):
if not json_str:
return []
try:
# 兼容旧数据:如果不是 JSON 格式(比如是单个 URL则包装成 list
if not json_str.startswith('['):
return [json_str]
return json.loads(json_str)
except:
return []
return {
'id': self.id,
'base_id': self.base_id,
# [已修改] 使用 self.base
'material_name': self.base.name if self.base else '',
'spec_model': self.base.spec_model if self.base else '',
'category': self.base.category if self.base else '',
'unit': self.base.unit if self.base else '',
'material_type': self.base.material_type if self.base 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,
'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': parse_img_list(self.arrival_photo),
'inspection_report': parse_img_list(self.inspection_report),
# [新增] 返回全局打印ID及其格式化字符串
'global_print_id': self.global_print_id,
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
}

View File

@ -0,0 +1,131 @@
# app/models/inbound/product.py
from app.extensions import db
import json
from app.models.base import MaterialBase
class StockProduct(db.Model):
"""
成品入库库存表
对应数据库表: stock_product
"""
__tablename__ = 'stock_product'
id = db.Column(db.Integer, primary_key=True)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
# 身份标识
sku = db.Column(db.String(100))
production_date = db.Column(db.DateTime)
barcode = db.Column(db.String(100))
serial_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))
warehouse_location = db.Column(db.String(100))
# 生产与成本
bom_code = db.Column('bom_id', db.String(100))
bom_version = db.Column(db.String(50))
work_order_code = db.Column('work_order_id', db.String(100))
raw_material_cost = db.Column(db.Numeric(19, 4), default=0)
manual_cost = db.Column(db.Numeric(19, 4), default=0)
production_manager = db.Column('producer_name', db.String(100))
production_time_range = db.Column(db.String(255))
# 质量与检测 (均为 JSON 存储)
quality_status = db.Column(db.String(50))
quality_report_link = db.Column(db.Text) # 质量报告
inspection_report_link = db.Column(db.Text) # 检测报告(旧字段升级为JSON)
# [新增] 成品实拍图 (JSON 存储)
product_photo = db.Column(db.Text)
detail_link = db.Column(db.Text)
remark = db.Column(db.Text)
# 销售相关
sale_price = db.Column(db.Numeric(19, 4), default=0)
order_id = db.Column(db.String(100))
# 全局打印流水号
global_print_id = db.Column(db.Integer)
# 关系定义 [已修改]
base = db.relationship('MaterialBase', back_populates='stock_products')
def to_dict(self):
raw_val = float(self.raw_material_cost or 0)
man_val = float(self.manual_cost or 0)
unit_total = raw_val + man_val
# 辅助解析函数
def parse_img_list(json_str):
if not json_str:
return []
try:
if not json_str.startswith('['):
return [json_str] # 兼容旧数据单链接
return json.loads(json_str)
except:
return []
return {
'id': self.id,
'base_id': self.base_id,
# [已修改] 使用 self.base
'material_name': self.base.name if self.base else '',
'spec_model': self.base.spec_model if self.base else '',
'category': self.base.category if self.base else '',
'unit': self.base.unit if self.base else '',
'material_type': self.base.material_type if self.base else '',
'sku': self.sku,
'inbound_date': self.production_date.strftime('%Y-%m-%d') if self.production_date else '',
'barcode': self.barcode,
'serial_number': self.serial_number,
'warehouse_loc': self.warehouse_location,
'status': self.status,
'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),
'bom_code': self.bom_code,
'bom_version': self.bom_version,
'work_order_code': self.work_order_code,
'raw_material_cost': raw_val,
'manual_cost': man_val,
'unit_total_cost': unit_total,
'production_manager': self.production_manager,
'production_time_range': self.production_time_range,
'production_start_time': self.production_time_range.split(' ~ ')[
0] if self.production_time_range and ' ~ ' in self.production_time_range else '',
'production_end_time': self.production_time_range.split(' ~ ')[
1] if self.production_time_range and ' ~ ' in self.production_time_range else '',
'quality_status': self.quality_status,
# [核心修改] 三个图片/链接字段全部解析为数组
'product_photo': parse_img_list(self.product_photo),
'quality_report_link': parse_img_list(self.quality_report_link),
'inspection_report_link': parse_img_list(self.inspection_report_link),
'detail_link': self.detail_link,
'remark': self.remark,
'sale_price': float(self.sale_price or 0),
'order_id': self.order_id,
'global_print_id': self.global_print_id,
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
}

View File

@ -0,0 +1,127 @@
# app/models/inbound/semi.py
from app.extensions import db
import json
from app.models.base import MaterialBase
class StockSemi(db.Model):
"""
半成品入库库存表
"""
__tablename__ = 'stock_semi'
id = db.Column(db.Integer, primary_key=True)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
sku = db.Column(db.String(100))
production_date = db.Column(db.DateTime)
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))
warehouse_location = db.Column(db.String(100))
# 半成品特有字段
bom_code = db.Column('bom_id', db.String(100))
bom_version = db.Column(db.String(50))
work_order_code = db.Column('work_order_id', db.String(100))
raw_material_cost = db.Column(db.Numeric(19, 4), default=0)
manual_cost = db.Column(db.Numeric(19, 4), default=0)
total_price = db.Column(db.Numeric(19, 4), default=0)
production_manager = db.Column('producer_name', db.String(100))
production_start_time = db.Column(db.DateTime)
production_end_time = db.Column(db.DateTime)
production_time_range = db.Column(db.String(255))
quality_status = db.Column(db.String(50))
# [修改] 质量报告 (存储 JSON 字符串: 图片列表 + 链接)
quality_report_link = db.Column(db.Text)
# [新增] 到货图片 (存储 JSON 字符串)
arrival_photo = db.Column(db.Text)
detail_link = db.Column(db.Text)
remark = db.Column(db.Text)
# [新增] 全局打印流水号
global_print_id = db.Column(db.Integer)
# 关系定义 [已修改]
base = db.relationship('MaterialBase', back_populates='stock_semis')
def to_dict(self):
raw_val = float(self.raw_material_cost or 0)
man_val = float(self.manual_cost or 0)
unit_total = raw_val + man_val
# 辅助解析函数:将数据库存储的 JSON 字符串转为 List
def parse_img_list(json_str):
if not json_str:
return []
try:
# 兼容旧数据:如果不是 JSON 格式(比如是单个 URL则包装成 list
if not json_str.startswith('['):
return [json_str]
return json.loads(json_str)
except:
return []
return {
'id': self.id,
'base_id': self.base_id,
# [已修改] 使用 self.base
'material_name': self.base.name if self.base else '',
'spec_model': self.base.spec_model if self.base else '',
'category': self.base.category if self.base else '',
'unit': self.base.unit if self.base else '',
'material_type': self.base.material_type if self.base else '',
'sku': self.sku,
'inbound_date': self.production_date.strftime('%Y-%m-%d') if self.production_date else '',
'barcode': self.barcode,
'serial_number': self.serial_number,
'batch_number': self.batch_number,
'warehouse_loc': self.warehouse_location,
'status': self.status,
'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),
'bom_code': self.bom_code,
'bom_version': self.bom_version,
'work_order_code': self.work_order_code,
'raw_material_cost': raw_val,
'manual_cost': man_val,
'unit_total_cost': unit_total,
'total_price': float(self.total_price or 0),
'production_manager': self.production_manager,
'production_time_range': self.production_time_range,
'production_start_time': str(self.production_start_time) if self.production_start_time else '',
'production_end_time': str(self.production_end_time) if self.production_end_time else '',
'quality_status': self.quality_status,
# [修改] 解析 JSON 字符串为数组返回给前端
'quality_report_link': parse_img_list(self.quality_report_link),
'arrival_photo': parse_img_list(self.arrival_photo),
'detail_link': self.detail_link,
'remark': self.remark,
'global_print_id': self.global_print_id,
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
}

View File

@ -0,0 +1,46 @@
from app import db
from datetime import datetime
class StockService(db.Model):
"""
服务权益库存表
对应数据库表: stock_service
"""
__tablename__ = 'stock_service'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
# 关联基础物料信息
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
# 系统生成的SKU格式 SRV-YYYYMMDD-XXXX
sku = db.Column(db.String(64), unique=True, nullable=False)
# 售价
sale_price = db.Column(db.Numeric(10, 2), nullable=False)
# 服务商名称
provider_name = db.Column(db.String(255), nullable=False, default='')
# 服务详情/简介
description = db.Column(db.Text, default='')
# 创建时间与更新时间
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, nullable=False)
# 软删除标志
is_deleted = db.Column(db.Boolean, default=False, nullable=False)
# 关系(可选)
material_base = db.relationship('MaterialBase', backref='service_stocks', lazy='joined')
def to_dict(self):
"""转为字典,用于 API 响应"""
return {
'id': self.id,
'base_id': self.base_id,
'sku': self.sku,
'sale_price': float(self.sale_price) if self.sale_price is not None else 0,
'provider_name': self.provider_name,
'description': self.description,
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
'material_name': self.material_base.name if self.material_base else None,
'spec_model': self.material_base.spec_model if self.material_base else None,
'unit': self.material_base.unit if self.material_base else None,
}

View File

@ -0,0 +1,22 @@
from app.extensions import db # .material -> .base refactor checked
from datetime import datetime
class StocktakeDraft(db.Model):
__tablename__ = 'stocktake_draft'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.String(100), default='admin')
uuid = db.Column(db.String(100))
# ★ 新增 quantity 字段
quantity = db.Column(db.Numeric(19, 4), default=1)
scan_time = db.Column(db.DateTime, default=datetime.now)
def to_dict(self):
return {
'id': self.id,
'user_id': self.user_id,
'uuid': self.uuid,
# ★ 返回 quantity
'quantity': float(self.quantity or 1),
'scan_time': self.scan_time.strftime('%Y-%m-%d %H:%M:%S')
}

View File

@ -1,11 +0,0 @@
from app.extensions import db
class MaterialBase(db.Model):
__tablename__ = 'material_base'
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)
spec_model = db.Column(db.String(255))
unit = db.Column(db.String(50))
# 其他字段按需添加,入库时主要是为了外键关联

View File

@ -0,0 +1,47 @@
from app.extensions import db
from datetime import datetime
class TransOutbound(db.Model):
__tablename__ = 'trans_outbound'
id = db.Column(db.Integer, primary_key=True)
# 修改:不再唯一,因为批量出库时多个商品共用一个单号
outbound_no = db.Column(db.String(100), nullable=False)
# 关联源库存信息
sku = db.Column(db.String(100))
source_table = db.Column(db.String(50)) # 'stock_buy', 'stock_product', 'stock_semi'
stock_id = db.Column(db.Integer) # 对应源表的主键ID
barcode = db.Column(db.String(100)) # 实际扫码内容
# 业务信息
outbound_type = db.Column(db.String(50), default='SALES') # SALES(销售), USE(领用), TRANSFER(调拨)
quantity = db.Column(db.Numeric(19, 4), nullable=False)
# [新增] 出库时的单价,用于计算金额
unit_price = db.Column(db.Numeric(19, 2), default=0)
# 签字与追溯
consumer_name = db.Column(db.String(100)) # 领用人/客户
signature_path = db.Column(db.Text) # 电子签名图片路径
outbound_time = db.Column(db.DateTime, default=datetime.now)
operator_name = db.Column(db.String(100)) # 操作员
remark = db.Column(db.Text)
def to_dict(self):
return {
'id': self.id,
'outbound_no': self.outbound_no,
'sku': self.sku,
'source_table': self.source_table,
'outbound_type': self.outbound_type,
'quantity': float(self.quantity) if self.quantity else 0,
'unit_price': float(self.unit_price) if self.unit_price else 0,
'consumer_name': self.consumer_name,
'signature_path': self.signature_path,
'outbound_time': self.outbound_time.strftime('%Y-%m-%d %H:%M:%S') if self.outbound_time else None,
'operator_name': self.operator_name,
'remark': self.remark
}

View File

@ -1,25 +0,0 @@
from app.extensions import db
from datetime import datetime
class StockBuy(db.Model):
__tablename__ = 'stock_buy'
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)
qty_inbound = db.Column(db.Numeric(19, 4), default=0)
qty_current = db.Column(db.Numeric(19, 4), default=0)
qty_available = db.Column(db.Numeric(19, 4), default=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))
warehouse_loc = db.Column(db.String(100))
# 建立关联,方便查询物料详情
material = db.relationship('MaterialBase', backref='buy_stocks')

View File

@ -0,0 +1,65 @@
# app/models/system.py
from app.extensions import db
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
class SysUser(db.Model):
__tablename__ = 'sys_user'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), nullable=False)
# 注意:如果允许邮箱为空,建议去掉 unique=True 或者在数据库层面处理空字符串
email = db.Column(db.String(100), unique=True)
department = db.Column(db.String(100))
role = db.Column(db.String(50))
status = db.Column(db.String(20), default='active')
password_hash = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.now) # 新增创建时间
def set_password(self, password):
"""生成加密密码"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""验证密码"""
return check_password_hash(self.password_hash, password)
def to_dict(self):
"""序列化为字典,供接口返回使用"""
return {
'id': self.id,
'username': self.username,
'email': self.email,
'department': self.department,
'role': self.role,
'status': self.status,
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else ''
}
class SysLog(db.Model):
"""
系统操作日志表
对应数据库表: sys_log
"""
__tablename__ = 'sys_log'
id = db.Column(db.Integer, primary_key=True)
op_time = db.Column(db.DateTime, default=datetime.now)
op_user_name = db.Column(db.String(100))
op_user_id = db.Column(db.String(50))
module_name = db.Column(db.String(100))
action_type = db.Column(db.String(50))
target_table = db.Column(db.String(100))
target_id = db.Column(db.Integer)
description = db.Column(db.Text)
ip_address = db.Column(db.String(50))
def to_dict(self):
return {
'id': self.id,
'op_time': self.op_time.isoformat() if self.op_time else None,
'op_user_name': self.op_user_name,
'module_name': self.module_name,
'action_type': self.action_type,
'description': self.description
}

View File

@ -0,0 +1,119 @@
from app.extensions import db
from datetime import datetime
class TransBorrow(db.Model):
__tablename__ = 'trans_borrow'
id = db.Column(db.Integer, primary_key=True)
borrow_no = db.Column(db.String(100))
sku = db.Column(db.String(100))
source_table = db.Column(db.String(50))
stock_id = db.Column(db.Integer)
barcode = db.Column(db.String(100))
quantity = db.Column(db.Numeric(19, 4))
borrower_name = db.Column(db.String(100))
borrow_time = db.Column(db.DateTime, default=datetime.now)
borrow_signature = db.Column(db.Text)
expected_return_time = db.Column(db.DateTime)
is_returned = db.Column(db.Boolean, default=False)
return_time = db.Column(db.DateTime)
return_operator = db.Column(db.String(100))
return_signature = db.Column(db.Text)
return_location = db.Column(db.String(100))
status = db.Column(db.String(20), default='borrowed')
remark = db.Column(db.Text)
def to_dict(self):
return {
'id': self.id,
'borrow_no': self.borrow_no,
'sku': self.sku,
'source_table': self.source_table,
'stock_id': self.stock_id,
'barcode': self.barcode,
'quantity': float(self.quantity) if self.quantity is not None else None,
'borrower_name': self.borrower_name,
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else None,
'borrow_signature': self.borrow_signature,
'expected_return_time': self.expected_return_time.strftime('%Y-%m-%d %H:%M') if self.expected_return_time else None,
'is_returned': self.is_returned,
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M') if self.return_time else None,
'return_operator': self.return_operator,
'return_signature': self.return_signature,
'return_location': self.return_location,
'status': self.status,
'remark': self.remark,
}
class TransRepair(db.Model):
__tablename__ = 'trans_repair'
id = db.Column(db.Integer, primary_key=True)
sku = db.Column(db.String(100))
source_table = db.Column(db.String(50))
stock_id = db.Column(db.Integer)
arrival_date = db.Column(db.Date)
expected_repair_time = db.Column(db.String(100))
shipping_date = db.Column(db.Date)
is_self_made = db.Column(db.Boolean, default=False)
related_product_id = db.Column(db.Integer)
related_contract_id = db.Column(db.String(100))
repair_manager = db.Column(db.String(100))
fault_description = db.Column(db.Text)
repair_result = db.Column(db.Text)
cost_price = db.Column(db.Numeric(19, 4))
sale_price = db.Column(db.Numeric(19, 4))
def to_dict(self):
return {
'id': self.id,
'sku': self.sku,
'source_table': self.source_table,
'stock_id': self.stock_id,
'arrival_date': self.arrival_date.strftime('%Y-%m-%d') if self.arrival_date else None,
'expected_repair_time': self.expected_repair_time,
'shipping_date': self.shipping_date.strftime('%Y-%m-%d') if self.shipping_date else None,
'is_self_made': self.is_self_made,
'related_product_id': self.related_product_id,
'related_contract_id': self.related_contract_id,
'repair_manager': self.repair_manager,
'fault_description': self.fault_description,
'repair_result': self.repair_result,
'cost_price': float(self.cost_price) if self.cost_price is not None else None,
'sale_price': float(self.sale_price) if self.sale_price is not None else None,
}
class TransScrap(db.Model):
__tablename__ = 'trans_scrap'
id = db.Column(db.Integer, primary_key=True)
sku = db.Column(db.String(100))
source_table = db.Column(db.String(50))
stock_id = db.Column(db.Integer)
quantity = db.Column(db.Numeric(19, 4))
reason = db.Column(db.Text)
operator_name = db.Column(db.String(100))
operation_time = db.Column(db.DateTime, default=datetime.now)
approver_name = db.Column(db.String(100))
approval_status = db.Column(db.String(20), default='pending')
cost_at_scrap = db.Column(db.Numeric(19, 4))
total_loss = db.Column(db.Numeric(19, 4))
def to_dict(self):
return {
'id': self.id,
'sku': self.sku,
'source_table': self.source_table,
'stock_id': self.stock_id,
'quantity': float(self.quantity) if self.quantity is not None else None,
'reason': self.reason,
'operator_name': self.operator_name,
'operation_time': self.operation_time.strftime('%Y-%m-%d %H:%M:%S') if self.operation_time else None,
'approver_name': self.approver_name,
'approval_status': self.approval_status,
'cost_at_scrap': float(self.cost_at_scrap) if self.cost_at_scrap is not None else None,
'total_loss': float(self.total_loss) if self.total_loss is not None else None,
}

View File

@ -1,14 +1,65 @@
from app.extensions import ma
from app.models.stock import StockBuy
from marshmallow import fields
from marshmallow import Schema, fields, validate, validates_schema, ValidationError
class StockBuySchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = StockBuy
load_instance = True # 反序列化时自动创建模型实例
include_fk = True # 包含外键 material_id
# 必须字段校验
material_id = fields.Integer(required=True)
qty_inbound = fields.Decimal(required=True, as_string=True)
price_unit = fields.Decimal(missing=0, as_string=True)
class StockBuySchema(Schema):
# 只用于输出的字段
id = fields.Int(dump_only=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不存在且无名字" 的情况
class StockServiceSchema(Schema):
# 只用于输出的字段
id = fields.Int(dump_only=True)
sku = fields.Str(dump_only=True)
created_at = fields.DateTime(format='%Y-%m-%d %H:%M:%S', dump_only=True)
updated_at = fields.DateTime(format='%Y-%m-%d %H:%M:%S', dump_only=True)
material_name = fields.Str(dump_only=True)
spec_model = fields.Str(dump_only=True)
unit = fields.Str(dump_only=True)
# 输入字段
base_id = fields.Int(required=True, error_messages={"required": "必须选择基础物料"})
sale_price = fields.Float(required=True, validate=validate.Range(min=0, error="售价不能为负数"))
provider_name = fields.Str(required=True, error_messages={"required": "服务商名称不能为空"})
description = fields.Str(missing='')
@validates_schema
def validate_base_id(self, data, **kwargs):
# 可以在这里添加对 base_id 是否存在的检查,但更建议在 Service 层进行
pass
# 实例化 Schema
stock_buy_schema = StockBuySchema()
stock_service_schema = StockServiceSchema()

View File

@ -0,0 +1,156 @@
# app/services/auth_service.py
from app.models.system import SysUser
from app.extensions import db
from flask_jwt_extended import create_access_token
from app.utils.constants import UserRole
class AuthService:
# 硬编码的超级管理员凭证
SUPER_ADMIN_USER = "IRIS"
SUPER_ADMIN_PASS = "licahk"
@staticmethod
def login(data):
username = data.get('username')
password = data.get('password')
user_role = None
user_id = None
user_info = {}
# 1. 优先检查硬编码的超级管理员
if username == AuthService.SUPER_ADMIN_USER:
if password == AuthService.SUPER_ADMIN_PASS:
user_role = UserRole.SUPER_ADMIN
user_id = 0 # 虚拟ID
user_info = {
'username': username,
'role': user_role,
'department': 'System'
}
else:
raise ValueError("密码错误")
# 2. 如果不是 IRIS检查数据库用户
else:
user = SysUser.query.filter_by(username=username).first()
if not user:
raise ValueError("用户不存在")
if not user.check_password(password):
raise ValueError("密码错误")
if user.status != 'active':
raise ValueError("账号已被禁用,请联系管理员")
user_role = user.role
user_id = user.id
user_info = user.to_dict()
# 3. 生成 Token
access_token = create_access_token(
identity=user_id,
additional_claims={'role': user_role, 'username': username}
)
return {
'access_token': access_token,
'user': user_info
}
@staticmethod
def create_user(data, operator_role):
"""
创建新用户 (仅限管理员使用)
"""
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
raise Exception("权限不足:只有超级管理员或主管可以创建新用户")
if SysUser.query.filter_by(username=data.get('username')).first():
raise Exception("用户名已存在")
role = data.get('role')
valid_roles = [v for k, v in UserRole.__dict__.items() if not k.startswith('__')]
if role not in valid_roles:
raise Exception(f"角色无效,可选角色: {valid_roles}")
email = data.get('email', '')
if email and SysUser.query.filter_by(email=email).first():
raise Exception("邮箱已被使用")
new_user = SysUser(
username=data.get('username'),
email=email,
department=data.get('department', ''),
role=role,
status='active'
)
new_user.set_password(data.get('password'))
db.session.add(new_user)
db.session.commit()
return new_user.to_dict()
@staticmethod
def update_user(user_id, data, operator_role):
"""
[新增] 更新用户信息
"""
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
raise Exception("权限不足:只有超级管理员或主管可以修改用户信息")
user = SysUser.query.get(user_id)
if not user:
raise Exception("用户不存在")
# 1. 更新基本信息
if 'role' in data:
valid_roles = [v for k, v in UserRole.__dict__.items() if not k.startswith('__')]
if data['role'] not in valid_roles:
raise Exception(f"角色无效")
user.role = data['role']
if 'department' in data:
user.department = data['department']
if 'email' in data:
# 如果修改了邮箱,且新邮箱已被其他人使用
email = data['email']
if email and email != user.email:
existing = SysUser.query.filter_by(email=email).first()
if existing:
raise Exception("该邮箱已被其他用户使用")
user.email = email
# 2. 如果提供了密码,则重置密码;否则保持原密码
new_password = data.get('password')
if new_password and str(new_password).strip():
if len(new_password) < 6:
raise Exception("密码长度至少6位")
user.set_password(new_password)
db.session.commit()
return user.to_dict()
@staticmethod
def get_all_users():
"""获取所有系统用户"""
users = SysUser.query.order_by(SysUser.id.desc()).all()
return [user.to_dict() for user in users]
@staticmethod
def delete_user(user_id, operator_role):
"""删除用户"""
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
raise Exception("权限不足")
user = SysUser.query.get(user_id)
if not user:
raise Exception("用户不存在")
db.session.delete(user)
db.session.commit()
return True

View File

@ -0,0 +1,67 @@
from app.extensions import db
from app.models.bom import BomTable
from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from sqlalchemy import func
class BomService:
@staticmethod
def create_or_update_bom(parent_id, child_list):
"""
保存/更新父件的BOM子件关系
child_list: [{"child_id": int, "dosage": float, "remark": str}, ...]
"""
# 校验父件不能与子件相同
for item in child_list:
if item['child_id'] == parent_id:
raise ValueError('父件与子件不能是同一物料')
# 删除该父件原有的BOM记录
BomTable.query.filter_by(parent_id=parent_id).delete()
# 插入新的
for item in child_list:
bom = BomTable(
parent_id=parent_id,
child_id=item['child_id'],
dosage=item.get('dosage', 0),
remark=item.get('remark', '')
)
db.session.add(bom)
db.session.commit()
return True
@staticmethod
def get_bom_with_stock(parent_id):
"""
查询父件的BOM结构及库存信息
"""
bom_items = db.session.query(
BomTable,
MaterialBase.name.label('child_name')
).join(
MaterialBase, BomTable.child_id == MaterialBase.id
).filter(
BomTable.parent_id == parent_id
).all()
result = []
for bom, child_name in bom_items:
# 查询该子件在 StockBuy 中的可用库存总量
stock_qty = db.session.query(
func.coalesce(func.sum(StockBuy.available_quantity), 0)
).filter(
StockBuy.base_id == bom.child_id
).scalar() or 0
# 计算最大可生产数量
dosage = float(bom.dosage) if bom.dosage else 0
max_producible = int(stock_qty // dosage) if dosage > 0 else 0
result.append({
'child_id': bom.child_id,
'child_name': child_name,
'dosage': dosage,
'current_stock': float(stock_qty),
'max_producible': max_producible,
'remark': bom.remark or ''
})
return result

View File

@ -0,0 +1,195 @@
# 文件路径: app/services/inbound/base_service.py
from app.extensions import db
from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from sqlalchemy import or_
import traceback
import json
class MaterialBaseService:
"""
基础物料服务层
负责处理 MaterialBase 的增删改查及搜索逻辑
"""
@staticmethod
def search_material(keyword):
"""
根据关键字搜索已启用的基础物料
(供 /api/v1/inbound/base/search 接口调用)
"""
try:
if not keyword:
return []
query = MaterialBase.query.filter(
MaterialBase.is_enabled == True,
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.common_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,
'commonName': item.common_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 get_list(page, limit, filters=None):
"""
获取基础信息列表 (带分页和筛选)
"""
try:
query = MaterialBase.query
if filters:
# 1. 关键词模糊搜索 (名称 或 俗名 或 规格型号)
if filters.get('keyword'):
kw = f"%{filters['keyword']}%"
query = query.filter(or_(
MaterialBase.name.ilike(kw),
MaterialBase.common_name.ilike(kw),
MaterialBase.spec_model.ilike(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. 创建对象 (列表转JSON字符串)
new_material = MaterialBase(
name=data['name'],
common_name=data.get('commonName'),
spec_model=data['spec'],
category=data.get('category'),
material_type=data.get('type'),
unit=data.get('unit'),
visibility_level=data.get('visibilityLevel'),
# 修改:将列表 dumps 为字符串
manual_link=json.dumps(data.get('generalManual', [])),
product_image=json.dumps(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 'commonName' in data: material.common_name = data['commonName']
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']
# 修改:将列表 dumps 为字符串
if 'generalManual' in data:
material.manual_link = json.dumps(data['generalManual'])
if 'generalImage' in data:
material.product_image = json.dumps(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("数据不存在")
buy_usage_count = StockBuy.query.filter_by(base_id=m_id).count()
semi_usage_count = StockSemi.query.filter_by(base_id=m_id).count()
total_usage = buy_usage_count + semi_usage_count
if total_usage > 0:
raise ValueError(
f"无法删除:该基础物料正被使用中。\n"
f"- 采购库存记录: {buy_usage_count}\n"
f"- 半成品库存记录: {semi_usage_count}\n"
f"请先清理相关库存或仅‘禁用’此条目。"
)
db.session.delete(material)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
print(f"删除基础信息失败: {e}")
raise e

View File

@ -0,0 +1,396 @@
from app.extensions import db
from app.models.inbound.buy import StockBuy
from app.models.base import MaterialBase
# 尝试导入出库模型,如果不存在则忽略
try:
from app.models.outbound import TransOutbound
except ImportError:
TransOutbound = None
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, func, text, and_
import traceback
import json
class BuyInboundService:
# ============================================================
# 0. 辅助:唯一性校验 (核心修复)
# ============================================================
@staticmethod
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
"""
校验序列号和批号的唯一性逻辑
:param base_id: 当前物料的基础ID
:param serial_number: 序列号
:param batch_number: 批号
:param exclude_id: 排除的ID (用于编辑模式)
"""
# 1. 序列号 (SN) 全局唯一校验
# 解释: 不同规格的物料通常也不应该有相同的SN防止扫码混淆
if serial_number:
query = StockBuy.query.filter(StockBuy.serial_number == serial_number)
if exclude_id:
query = query.filter(StockBuy.id != exclude_id)
exists = query.first()
if exists:
# [修改] 获取占用该SN的物料名称 (material -> base)
occupied_name = exists.base.name if exists.base else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被物料 [{occupied_name}] 占用,请核查。")
# 2. 批号 (BN) 同物料唯一校验
# 解释: 不同规格的物料可以有相同的批号(如都有 001 批次),但同一个物料不能重复建单
if batch_number and base_id:
query = StockBuy.query.filter(
StockBuy.base_id == base_id,
StockBuy.batch_number == batch_number
)
if exclude_id:
query = query.filter(StockBuy.id != exclude_id)
if query.first():
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复录入,可直接在该批次下追加库存。")
# ============================================================
# 1. 基础物料搜索
# ============================================================
@staticmethod
def search_base_material(keyword):
try:
# [核心修改] 只查询已启用的物料,防止选择已禁用的历史物料
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword:
query = query.filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%'),
MaterialBase.pinyin.ilike(f'%{keyword}%') # 假设有拼音搜索
)
)
query = query.order_by(MaterialBase.id.desc()).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 []
# ============================================================
# 2. 新增入库逻辑
# ============================================================
@staticmethod
def handle_inbound(data):
try:
base_id = data.get('base_id')
if not base_id:
raise ValueError("必须选择基础物料")
material = MaterialBase.query.get(base_id)
if not material:
raise ValueError("所选物料不存在")
# [核心修改] 后端二次校验:如果物料已停用,禁止入库
if not material.is_enabled:
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
# --- [修复点] 执行唯一性校验 ---
BuyInboundService._check_unique(
base_id=base_id,
serial_number=data.get('serial_number'),
batch_number=data.get('batch_number')
)
# 时间处理 (强制北京时间)
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
in_date_val = current_time
if data.get('in_date'):
try:
date_str = str(data['in_date'])
if len(date_str) > 10:
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
else:
d_temp = datetime.strptime(date_str, '%Y-%m-%d')
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
current_time.hour, current_time.minute, current_time.second)
except:
in_date_val = current_time
in_qty = float(data.get('in_quantity') or 0)
u_price = float(data.get('unit_price') or 0)
# 获取全局打印ID
try:
seq_sql = text("SELECT nextval('global_print_seq')")
result = db.session.execute(seq_sql)
next_global_id = result.scalar()
except:
next_global_id = None
# SKU 生成
if next_global_id:
generated_sku = str(next_global_id).zfill(10)
else:
generated_sku = datetime.now().strftime('%Y%m%d%H%M%S')
final_barcode = data.get('barcode') or generated_sku
arrival_list = data.get('arrival_photo', [])
report_list = data.get('inspection_report', [])
new_stock = StockBuy(
base_id=material.id,
global_print_id=next_global_id,
sku=generated_sku,
barcode=final_barcode,
in_date=in_date_val,
serial_number=data.get('serial_number'),
batch_number=data.get('batch_number'),
status=data.get('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=json.dumps(arrival_list),
inspection_report=json.dumps(report_list)
)
db.session.add(new_stock)
db.session.commit()
return new_stock
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 3. 更新入库逻辑
# ============================================================
@staticmethod
def update_inbound(stock_id, data):
try:
stock = StockBuy.query.get(stock_id)
if not stock:
raise ValueError("记录不存在")
# --- [修复点] 编辑时也要校验唯一性 (排除自身ID) ---
# 如果修改了物料(base_id)或者修改了SN/BN都需要校验
new_base_id = data.get('base_id', stock.base_id)
new_sn = data.get('serial_number', stock.serial_number)
new_bn = data.get('batch_number', stock.batch_number)
BuyInboundService._check_unique(
base_id=new_base_id,
serial_number=new_sn,
batch_number=new_bn,
exclude_id=stock_id
)
# 更新字段
field_mapping = {
'sku': 'sku', 'barcode': 'barcode', 'base_id': 'base_id',
'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',
'currency': 'currency', 'exchange_rate': 'exchange_rate',
'purchaser': 'buyer_name', 'purchaser_email': 'buyer_email',
'source_link': 'original_link'
}
for k, v in field_mapping.items():
if k in data: setattr(stock, v, data[k])
if 'arrival_photo' in data and isinstance(data['arrival_photo'], list):
stock.arrival_photo = json.dumps(data['arrival_photo'])
if 'inspection_report' in data and isinstance(data['inspection_report'], list):
stock.inspection_report = json.dumps(data['inspection_report'])
# 库存数量变更逻辑
if 'in_quantity' in data:
new_qty = float(data['in_quantity'])
diff = new_qty - float(stock.in_quantity)
if diff != 0:
stock.in_quantity = new_qty
stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff
if 'unit_price' in data:
stock.unit_price = float(data['unit_price'])
stock.total_price = float(stock.in_quantity) * float(stock.unit_price)
db.session.commit()
return stock
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 4. 删除逻辑
# ============================================================
@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
# ============================================================
# 5. 获取列表
# ============================================================
@staticmethod
def get_list(page, limit, keyword=None, statuses=None):
try:
query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
if keyword:
kw = f'%{keyword}%'
query = query.filter(
or_(
MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw),
StockBuy.batch_number.ilike(kw),
StockBuy.serial_number.ilike(kw),
StockBuy.sku.ilike(kw),
StockBuy.supplier_name.ilike(kw)
)
)
if not statuses:
statuses = ['在库', '借库']
if '已出库' in statuses:
query = query.filter(StockBuy.status.in_(statuses))
else:
query = query.filter(and_(StockBuy.status.in_(statuses), StockBuy.stock_quantity > 0))
pagination = query.order_by(StockBuy.in_date.desc()).paginate(page=page, per_page=limit, error_out=False)
current_items = pagination.items
def parse_img(json_str):
if not json_str: return []
try:
return json.loads(json_str) if json_str.startswith('[') else [json_str]
except:
return []
items = []
for item in current_items:
qty_stock = float(item.stock_quantity or 0)
qty_avail = float(item.available_quantity or 0)
date_display = ''
if item.in_date:
try:
date_display = item.in_date.strftime('%Y-%m-%d')
except:
date_display = str(item.in_date)[:10]
d = {
'id': item.id,
'base_id': item.base_id,
# [核心修改] 确保这里从关联的 .base 获取信息
'material_name': item.base.name if item.base else '',
'spec_model': item.base.spec_model if item.base else '',
'category': item.base.category if item.base else '',
'unit': item.base.unit if item.base else '',
'material_type': item.base.material_type if item.base else '',
'sku': item.sku,
'inbound_date': date_display,
'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': qty_stock,
'qty_available': qty_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': parse_img(item.arrival_photo),
'inspection_report': parse_img(item.inspection_report),
'global_print_id': item.global_print_id
}
items.append(d)
return {"total": pagination.total, "items": items}
except Exception as e:
traceback.print_exc()
return {"total": 0, "items": []}
# ============================================================
# 6. 供应商历史查询
# ============================================================
@staticmethod
def get_history_suppliers(base_id):
"""返回该物料关联的供应商列表(去重)"""
try:
query = db.session.query(StockBuy.supplier_name).filter(
StockBuy.base_id == base_id,
StockBuy.supplier_name.isnot(None)
).distinct().order_by(StockBuy.supplier_name)
suppliers = [row[0] for row in query.all()]
return suppliers
except Exception:
return []
# ============================================================
# 7. 系统用户搜索
# ============================================================
@staticmethod
def search_system_users(keyword):
"""搜索系统用户(活跃状态)"""
from app.models.system import SysUser
try:
query = SysUser.query.filter(SysUser.status == 'active')
if keyword:
kw = f'%{keyword}%'
query = query.filter(db.or_(
SysUser.username.ilike(kw),
SysUser.email.ilike(kw)
))
query = query.order_by(SysUser.username)
users = []
for u in query.limit(20).all():
users.append({
'value': u.username,
'email': u.email
})
return users
except Exception:
return []

View File

@ -0,0 +1,190 @@
from sqlalchemy import select, literal, union_all, desc, asc, func, or_, cast, String, Numeric, Date # .material -> .base refactor checked
from app.extensions import db
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
import traceback
class InboundSummaryService:
@staticmethod
def get_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None, source_type=None):
"""
聚合查询:
1. 联合 StockBuy, StockSemi, StockProduct 三张表
2. 关联 MaterialBase 获取名称规格
3. 计算动态状态 (库存耗尽显示已出库)
4. 排序:默认按入库日期倒序 (最近的在前)
"""
try:
# =========================================================
# 1. 构建三个子查询 (Subqueries)
# =========================================================
# --- A. 采购件 (StockBuy) ---
q_buy = db.session.query(
StockBuy.id.label('id'),
StockBuy.base_id.label('base_id'),
StockBuy.sku.label('sku'),
StockBuy.in_date.label('inbound_date'),
StockBuy.in_quantity.label('in_qty'),
StockBuy.stock_quantity.label('current_qty'),
cast(StockBuy.supplier_name, String).label('source_info'),
StockBuy.status.label('orig_status'),
cast(StockBuy.batch_number, String).label('batch_number'),
cast(literal('buy'), String).label('source_type')
)
# --- B. 半成品 (StockSemi) ---
q_semi = db.session.query(
StockSemi.id.label('id'),
StockSemi.base_id.label('base_id'),
StockSemi.sku.label('sku'),
StockSemi.production_date.label('inbound_date'),
StockSemi.in_quantity.label('in_qty'),
StockSemi.stock_quantity.label('current_qty'),
cast(StockSemi.production_manager, String).label('source_info'),
StockSemi.status.label('orig_status'),
cast(StockSemi.batch_number, String).label('batch_number'),
cast(literal('semi'), String).label('source_type')
)
# --- C. 成品 (StockProduct) ---
q_product = db.session.query(
StockProduct.id.label('id'),
StockProduct.base_id.label('base_id'),
StockProduct.sku.label('sku'),
StockProduct.production_date.label('inbound_date'),
StockProduct.in_quantity.label('in_qty'),
StockProduct.stock_quantity.label('current_qty'),
cast(StockProduct.production_manager, String).label('source_info'),
StockProduct.status.label('orig_status'),
cast(StockProduct.serial_number, String).label('batch_number'),
cast(literal('product'), String).label('source_type')
)
# =========================================================
# 2. 组合查询 (UNION ALL)
# =========================================================
combined_query = union_all(q_buy, q_semi, q_product)
cte = combined_query.subquery()
# =========================================================
# 3. 主查询:关联 MaterialBase
# =========================================================
query = db.session.query(
cte,
MaterialBase.name.label('material_name'),
MaterialBase.spec_model.label('spec_model'),
MaterialBase.category.label('category'),
MaterialBase.material_type.label('material_type')
).outerjoin(
MaterialBase, cte.c.base_id == MaterialBase.id
)
# =========================================================
# 4. 过滤条件
# =========================================================
if keyword:
rule = or_(
cte.c.sku.ilike(f'%{keyword}%'),
cte.c.source_info.ilike(f'%{keyword}%'),
cte.c.batch_number.ilike(f'%{keyword}%'),
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
query = query.filter(rule)
if start_date and end_date:
query = query.filter(cte.c.inbound_date.between(start_date, end_date))
if source_type:
query = query.filter(cte.c.source_type == source_type)
# =========================================================
# 5. 获取总数
# =========================================================
count_query = db.session.query(func.count()) \
.select_from(cte) \
.outerjoin(MaterialBase, cte.c.base_id == MaterialBase.id)
if keyword:
count_query = count_query.filter(rule)
if start_date and end_date:
count_query = count_query.filter(cte.c.inbound_date.between(start_date, end_date))
if source_type:
count_query = count_query.filter(cte.c.source_type == source_type)
total = count_query.scalar() or 0
# =========================================================
# 6. 排序与分页
# =========================================================
# ★★★ 修改处:优先按入库日期倒序排列 (最近的在前) ★★★
# 如果日期相同,再按 SKU 排序,保证分页稳定性
query = query.order_by(desc(cte.c.inbound_date), asc(cte.c.sku))
pagination = query.limit(per_page).offset((page - 1) * per_page).all()
# =========================================================
# 7. 数据格式化
# =========================================================
items = []
type_map = {
'buy': '采购入库',
'semi': '半成品生产',
'product': '成品完工'
}
for row in pagination:
date_str = ""
if row.inbound_date:
try:
date_str = row.inbound_date.strftime('%Y-%m-%d')
except Exception:
date_str = str(row.inbound_date)
in_qty = float(row.in_qty) if row.in_qty is not None else 0.0
current_qty = float(row.current_qty) if row.current_qty is not None else 0.0
# 状态逻辑
final_status = row.orig_status
if current_qty <= 0:
final_status = "已出库"
elif current_qty < in_qty:
final_status = "部分出库"
items.append({
'id': row.id,
'sku': row.sku or "",
'name': row.material_name or "未知物品",
'spec_model': row.spec_model or "",
'category': row.category or "",
'material_type': row.material_type or "",
'inbound_date': date_str,
'quantity': in_qty,
'current_qty': current_qty,
'source_info': row.source_info or "",
'status': final_status,
'source_type': row.source_type,
'type_label': type_map.get(row.source_type, "未知类型"),
'batch_number': row.batch_number or ""
})
return {
'items': items,
'total': total,
'pages': (total + per_page - 1) // per_page if per_page > 0 else 0,
'current_page': page
}
except Exception as e:
print("【InboundSummaryService Error】:", str(e))
traceback.print_exc()
raise e

View File

@ -0,0 +1,379 @@
# app/services/inbound/product_service.py
from app.extensions import db
from app.models.base import MaterialBase
from app.models.outbound import TransOutbound
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, func, text, and_
import traceback
import json
class ProductInboundService:
# ============================================================
# 0. 辅助:唯一性校验 (新增核心逻辑)
# ============================================================
@staticmethod
def _check_unique(serial_number, exclude_id=None):
"""
校验成品的唯一性
:param serial_number: 序列号
:param exclude_id: 排除的ID (编辑模式用)
"""
from app.models.inbound.product import StockProduct
# 成品强校验序列号 (SN) - SN应该是全局唯一的
if serial_number:
query = StockProduct.query.filter(StockProduct.serial_number == serial_number)
if exclude_id:
query = query.filter(StockProduct.id != exclude_id)
exists = query.first()
if exists:
# [修改] material -> base
occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。")
# ============================================================
# 1. 基础物料搜索
# ============================================================
@staticmethod
def search_base_material(keyword):
try:
# [核心修改] 只查询已启用的物料
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
# 2. 动态条件:如果传入了关键词,则增加模糊匹配条件
if keyword:
query = query.filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
)
# 3. 排序与限制按ID倒序取最新20条
query = query.order_by(MaterialBase.id.desc()).limit(20)
# 4. 结果封装
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:
traceback.print_exc()
return []
# ============================================================
# 2. 新增入库逻辑 (强制北京时间 + 唯一性校验)
# ============================================================
@staticmethod
def handle_inbound(data):
from app.models.inbound.product import StockProduct
try:
base_id = data.get('base_id')
if not base_id: raise ValueError("必须选择基础物料")
material = MaterialBase.query.get(base_id)
if not material: raise ValueError("物料不存在")
# [核心修改] 后端二次校验:如果物料已停用,禁止入库
if not material.is_enabled:
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
# --- [核心修改] 执行唯一性校验 ---
ProductInboundService._check_unique(
serial_number=data.get('serial_number')
)
# [核心修改] 强制北京时间
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
in_date_val = current_time
if data.get('in_date'):
try:
date_str = str(data['in_date'])
if len(date_str) > 10:
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
else:
d_temp = datetime.strptime(date_str, '%Y-%m-%d')
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
current_time.hour, current_time.minute, current_time.second)
except:
in_date_val = current_time
in_qty = float(data.get('in_quantity') or 0)
p_start = data.get('production_start_time', '')
p_end = data.get('production_end_time', '')
time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None
# 全局流水号
try:
seq_sql = text("SELECT nextval('global_print_seq')")
result = db.session.execute(seq_sql)
next_global_id = result.scalar()
except:
next_global_id = None
generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S')
final_barcode = data.get('barcode') or generated_sku
photo_list = data.get('product_photo', [])
quality_list = data.get('quality_report_link', [])
inspection_list = data.get('inspection_report_link', [])
if not isinstance(photo_list, list): photo_list = []
if not isinstance(quality_list, list): quality_list = []
if not isinstance(inspection_list, list): inspection_list = []
new_stock = StockProduct(
base_id=material.id,
global_print_id=next_global_id,
sku=generated_sku,
production_date=in_date_val, # 存入 DateTime
barcode=final_barcode,
serial_number=data.get('serial_number'),
status=data.get('status', '在库'),
warehouse_location=data.get('warehouse_location'),
in_quantity=in_qty,
stock_quantity=in_qty,
available_quantity=in_qty,
bom_code=data.get('bom_code'),
bom_version=data.get('bom_version'),
work_order_code=data.get('work_order_code'),
production_manager=data.get('production_manager'),
production_time_range=time_range,
raw_material_cost=float(data.get('raw_material_cost') or 0),
manual_cost=float(data.get('manual_cost') or 0),
quality_status=data.get('quality_status', '合格'),
product_photo=json.dumps(photo_list),
quality_report_link=json.dumps(quality_list),
inspection_report_link=json.dumps(inspection_list),
detail_link=data.get('detail_link'),
remark=data.get('remark'),
sale_price=float(data.get('sale_price') or 0),
order_id=data.get('order_id')
)
db.session.add(new_stock)
db.session.commit()
return new_stock
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 3. 更新逻辑
# ============================================================
@staticmethod
def update_inbound(stock_id, data):
from app.models.inbound.product import StockProduct
try:
stock = StockProduct.query.get(stock_id)
if not stock: raise ValueError("记录不存在")
# --- [核心修改] 编辑时也要校验唯一性 ---
if 'serial_number' in data:
ProductInboundService._check_unique(
serial_number=data['serial_number'],
exclude_id=stock_id
)
fields = [
'barcode', 'serial_number', 'warehouse_location',
'status', 'quality_status', 'bom_code', 'bom_version',
'work_order_code', 'production_manager',
'detail_link', 'order_id', 'remark'
]
for f in fields:
if f in data: setattr(stock, f, data[f])
if 'product_photo' in data:
imgs = data['product_photo']
if isinstance(imgs, list): stock.product_photo = json.dumps(imgs)
if 'quality_report_link' in data:
imgs = data['quality_report_link']
if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs)
if 'inspection_report_link' in data:
imgs = data['inspection_report_link']
if isinstance(imgs, list): stock.inspection_report_link = json.dumps(imgs)
if 'sale_price' in data: stock.sale_price = float(data['sale_price'])
if 'raw_material_cost' in data: stock.raw_material_cost = float(data['raw_material_cost'])
if 'manual_cost' in data: stock.manual_cost = float(data['manual_cost'])
if 'in_quantity' in data:
new_qty = float(data['in_quantity'])
diff = new_qty - float(stock.in_quantity)
stock.in_quantity = new_qty
stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff
if 'production_start_time' in data or 'production_end_time' in data:
old_range = stock.production_time_range or " ~ "
parts = old_range.split(' ~ ')
old_start = parts[0] if len(parts) > 0 else ''
old_end = parts[1] if len(parts) > 1 else ''
start = data.get('production_start_time', old_start)
end = data.get('production_end_time', old_end)
stock.production_time_range = f"{start} ~ {end}"
db.session.commit()
return stock
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 4. 删除逻辑
# ============================================================
@staticmethod
def delete_inbound(stock_id):
from app.models.inbound.product import StockProduct
try:
stock = StockProduct.query.get(stock_id)
if stock:
db.session.delete(stock)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 5. 出库历史
# ============================================================
@staticmethod
def get_outbound_history(stock_id):
"""获取出库历史"""
try:
records = TransOutbound.query.filter_by(
source_table='stock_product', stock_id=stock_id
).order_by(TransOutbound.outbound_time.desc()).all()
return [r.to_dict() for r in records]
except:
return []
# ============================================================
# 6. 获取列表
# ============================================================
@staticmethod
def get_list(page, limit, keyword=None, statuses=None):
from app.models.inbound.product import StockProduct
try:
query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
if keyword:
query = query.filter(or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%'),
StockProduct.serial_number.ilike(f'%{keyword}%'),
StockProduct.work_order_code.ilike(f'%{keyword}%'),
StockProduct.order_id.ilike(f'%{keyword}%'),
StockProduct.sku.ilike(f'%{keyword}%')
))
if not statuses:
statuses = ['在库', '借库']
if '已出库' in statuses:
query = query.filter(StockProduct.status.in_(statuses))
else:
query = query.filter(
and_(
StockProduct.status.in_(statuses),
StockProduct.stock_quantity > 0
)
)
# 按照 production_date (入库日期) 倒序排序
pagination = query.order_by(StockProduct.production_date.desc()).paginate(page=page, per_page=limit,
error_out=False)
current_items = pagination.items
def parse_img(json_str):
if not json_str: return []
try:
return json.loads(json_str) if json_str.startswith('[') else [json_str]
except:
return []
items = []
for item in current_items:
# [注意] 因为Model层已经修改了 to_dict 内部的 material -> base所以这里直接调 to_dict 即可
d = item.to_dict()
# 格式化日期
date_display = ''
if item.production_date:
try:
date_display = item.production_date.strftime('%Y-%m-%d')
except:
date_display = str(item.production_date)[:10]
d['inbound_date'] = date_display
d['qty_stock'] = float(item.stock_quantity or 0)
d['qty_available'] = float(item.available_quantity or 0)
d['sum_stock'] = d['qty_stock']
d['sum_available'] = d['qty_available']
d['product_photo'] = parse_img(item.product_photo)
d['quality_report_link'] = parse_img(item.quality_report_link)
d['inspection_report_link'] = parse_img(item.inspection_report_link)
d['global_print_id'] = item.global_print_id
items.append(d)
return {"total": pagination.total, "items": items}
except:
traceback.print_exc()
return {"total": 0, "items": []}
# ============================================================
# 7. 系统用户搜索
# ============================================================
@staticmethod
def search_system_users(keyword):
"""搜索系统用户(活跃状态)"""
from app.models.system import SysUser
try:
query = SysUser.query.filter(SysUser.status == 'active')
if keyword:
kw = f'%{keyword}%'
query = query.filter(db.or_(
SysUser.username.ilike(kw),
SysUser.email.ilike(kw)
))
query = query.order_by(SysUser.username)
users = []
for u in query.limit(20).all():
users.append({
'value': u.username,
'email': u.email
})
return users
except Exception:
return []

View File

@ -0,0 +1,481 @@
# app/services/inbound/semi_service.py
from app.extensions import db
from app.models.base import MaterialBase
from app.models.outbound import TransOutbound
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, func, text, and_
import traceback
import json
class SemiInboundService:
# ============================================================
# 0. 辅助:唯一性校验 (新增核心逻辑)
# ============================================================
@staticmethod
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
"""
校验半成品的唯一性
:param base_id: 基础物料ID
:param serial_number: 序列号
:param batch_number: 批号
:param exclude_id: 排除的ID
"""
from app.models.inbound.semi import StockSemi
# 1. 序列号 (SN) 校验 - 全局唯一
if serial_number:
query = StockSemi.query.filter(StockSemi.serial_number == serial_number)
if exclude_id:
query = query.filter(StockSemi.id != exclude_id)
exists = query.first()
if exists:
# [修改] material -> base
occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被半成品 [{occupied_name}] 占用,请核查。")
# 2. 批号 (BN) 校验 - 同物料下不能重复开单
if batch_number and base_id:
query = StockSemi.query.filter(
StockSemi.base_id == base_id,
StockSemi.batch_number == batch_number
)
if exclude_id:
query = query.filter(StockSemi.id != exclude_id)
if query.first():
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复建单,建议在原批次上追加库存。")
# ============================================================
# 1. 基础物料搜索
# ============================================================
@staticmethod
def search_base_material(keyword):
try:
# [核心修改] 只查询已启用的物料
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
# 如果有关键词,进行模糊匹配
if keyword:
query = query.filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
)
# 统一逻辑按ID倒序限制20条
query = query.order_by(MaterialBase.id.desc()).limit(20)
results = []
for item in query.all():
results.append({
'id': item.id,
'name': item.name,
'spec': item.spec_model, # 对应前端 item.spec
'category': item.category,
'unit': item.unit,
'type': item.material_type, # 对应前端 item.type
'status': '启用'
})
return results
except Exception as e:
traceback.print_exc()
return []
# ============================================================
# 2. 新增入库逻辑
# ============================================================
@staticmethod
def handle_inbound(data):
from app.models.inbound.semi import StockSemi
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} 的基础物料不存在")
# [核心修改] 后端二次校验:如果物料已停用,禁止入库
if not material.is_enabled:
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
# --- [核心修改] 执行唯一性校验 ---
SemiInboundService._check_unique(
base_id=base_id,
serial_number=data.get('serial_number'),
batch_number=data.get('batch_number')
)
# [核心修改] 强制北京时间
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
in_date_val = current_time
if data.get('in_date'):
try:
date_str = str(data['in_date'])
if len(date_str) > 10:
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
else:
d_temp = datetime.strptime(date_str, '%Y-%m-%d')
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
current_time.hour, current_time.minute, current_time.second)
except ValueError:
in_date_val = current_time
# 2. 处理生产时间
p_start = None
p_end = None
if data.get('production_start_time'):
try:
p_start = datetime.strptime(str(data['production_start_time']), '%Y-%m-%d %H:%M:%S')
except:
pass
if data.get('production_end_time'):
try:
p_end = datetime.strptime(str(data['production_end_time']), '%Y-%m-%d %H:%M:%S')
except:
pass
time_range_str = None
raw_range = data.get('production_time_range')
if isinstance(raw_range, list):
time_range_str = " ~ ".join([str(x) for x in raw_range])
elif isinstance(raw_range, str):
time_range_str = raw_range
# 3. 处理数值和成本
in_qty = float(data.get('in_quantity') or 0)
raw_cost = float(data.get('raw_material_cost') or 0)
manual_cost = float(data.get('manual_cost') or 0)
unit_total_cost = raw_cost + manual_cost
total_value = unit_total_cost * in_qty
# 4. 获取全局打印流水号
next_global_id = 0
try:
seq_sql = text("SELECT nextval('global_print_seq')")
result = db.session.execute(seq_sql)
next_global_id = result.scalar()
except Exception as e:
print("❌ 数据库序列 global_print_seq 不存在请执行SQL创建")
raise e
generated_sku = str(next_global_id).zfill(10)
final_sku = data.get('sku')
if not final_sku:
final_sku = generated_sku
final_barcode = data.get('barcode')
if not final_barcode:
final_barcode = final_sku
arrival_list = data.get('arrival_photo', [])
quality_report_list = data.get('quality_report_link', [])
if not isinstance(arrival_list, list): arrival_list = []
if not isinstance(quality_report_list, list): quality_report_list = []
# 8. 创建记录
new_stock = StockSemi(
base_id=material.id,
global_print_id=next_global_id,
sku=final_sku,
production_date=in_date_val, # 存入 DateTime
serial_number=data.get('serial_number'),
batch_number=data.get('batch_number'),
barcode=final_barcode,
status='在库',
quality_status=data.get('quality_status', '合格'),
in_quantity=in_qty,
stock_quantity=in_qty,
available_quantity=in_qty,
warehouse_location=data.get('warehouse_location'),
bom_code=data.get('bom_code'),
bom_version=data.get('bom_version'),
work_order_code=data.get('work_order_code'),
production_manager=data.get('production_manager'),
production_start_time=p_start,
production_end_time=p_end,
production_time_range=time_range_str,
raw_material_cost=raw_cost,
manual_cost=manual_cost,
total_price=total_value,
arrival_photo=json.dumps(arrival_list),
quality_report_link=json.dumps(quality_report_list),
detail_link=data.get('detail_link'),
remark=data.get('remark')
)
db.session.add(new_stock)
db.session.commit()
return new_stock
except Exception as e:
db.session.rollback()
print("----- SemiInboundService Error -----")
traceback.print_exc()
raise e
# ============================================================
# 3. 更新逻辑
# ============================================================
@staticmethod
def update_inbound(stock_id, data):
from app.models.inbound.semi import StockSemi
try:
stock = StockSemi.query.get(stock_id)
if not stock:
raise ValueError("记录不存在")
# --- [核心修改] 编辑时也要校验唯一性 ---
new_base_id = data.get('base_id', stock.base_id)
new_sn = data.get('serial_number', stock.serial_number)
new_bn = data.get('batch_number', stock.batch_number)
SemiInboundService._check_unique(
base_id=new_base_id,
serial_number=new_sn,
batch_number=new_bn,
exclude_id=stock_id
)
field_mapping = {
'sku': 'sku',
'barcode': 'barcode',
'warehouse_location': 'warehouse_location',
'serial_number': 'serial_number',
'batch_number': 'batch_number',
'status': 'status',
'quality_status': 'quality_status',
'bom_code': 'bom_code',
'bom_version': 'bom_version',
'work_order_code': 'work_order_code',
'production_manager': 'production_manager',
'detail_link': 'detail_link',
'remark': 'remark'
}
for frontend_key, db_attr in field_mapping.items():
if frontend_key in data:
setattr(stock, db_attr, data[frontend_key])
if 'arrival_photo' in data:
imgs = data['arrival_photo']
if isinstance(imgs, list):
stock.arrival_photo = json.dumps(imgs)
if 'quality_report_link' in data:
imgs = data['quality_report_link']
if isinstance(imgs, list):
stock.quality_report_link = json.dumps(imgs)
if 'production_start_time' in data:
try:
if data['production_start_time']:
stock.production_start_time = datetime.strptime(str(data['production_start_time']),
'%Y-%m-%d %H:%M:%S')
else:
stock.production_start_time = None
except:
pass
if 'production_end_time' in data:
try:
if data['production_end_time']:
stock.production_end_time = datetime.strptime(str(data['production_end_time']),
'%Y-%m-%d %H:%M:%S')
else:
stock.production_end_time = None
except:
pass
if 'production_time_range' in data:
raw_range = data['production_time_range']
if isinstance(raw_range, list):
stock.production_time_range = " ~ ".join([str(x) for x in raw_range])
else:
stock.production_time_range = raw_range
qty_changed = False
cost_changed = False
if 'in_quantity' in data:
new_qty = float(data['in_quantity'])
diff = new_qty - float(stock.in_quantity)
if diff != 0:
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 'raw_material_cost' in data:
stock.raw_material_cost = float(data['raw_material_cost'])
cost_changed = True
if 'manual_cost' in data:
stock.manual_cost = float(data['manual_cost'])
cost_changed = True
if cost_changed or qty_changed:
unit_total = float(stock.raw_material_cost) + float(stock.manual_cost)
stock.total_price = float(stock.in_quantity) * unit_total
db.session.commit()
return stock
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 4. 删除逻辑
# ============================================================
@staticmethod
def delete_inbound(stock_id):
from app.models.inbound.semi import StockSemi
try:
stock = StockSemi.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
# ============================================================
# 5. 出库历史
# ============================================================
@staticmethod
def get_outbound_history(stock_id):
"""获取出库历史"""
try:
records = TransOutbound.query.filter_by(
source_table='stock_semi', stock_id=stock_id
).order_by(TransOutbound.outbound_time.desc()).all()
return [r.to_dict() for r in records]
except:
return []
# ============================================================
# 6. 获取列表
# ============================================================
@staticmethod
def get_list(page, limit, keyword=None, statuses=None):
from app.models.inbound.semi import StockSemi
try:
query = db.session.query(StockSemi).outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id)
if keyword:
kw = f'%{keyword}%'
query = query.filter(
or_(
MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw),
StockSemi.batch_number.ilike(kw),
StockSemi.serial_number.ilike(kw),
StockSemi.sku.ilike(kw),
StockSemi.work_order_code.ilike(kw),
StockSemi.bom_code.ilike(kw)
)
)
if not statuses:
statuses = ['在库', '借库']
if '已出库' in statuses:
query = query.filter(StockSemi.status.in_(statuses))
else:
query = query.filter(
and_(
StockSemi.status.in_(statuses),
StockSemi.stock_quantity > 0
)
)
# 按照 production_date (入库日期) 倒序排序
pagination = query.order_by(StockSemi.production_date.desc()).paginate(page=page, per_page=limit,
error_out=False)
current_items = pagination.items
def parse_img(json_str):
if not json_str: return []
try:
return json.loads(json_str) if json_str.startswith('[') else [json_str]
except:
return []
items = []
for item in current_items:
# [注意] 因为Model层已经修改了 to_dict 内部的 material -> base所以这里直接调 to_dict 即可
d = item.to_dict()
# 格式化展示日期
date_display = ''
if item.production_date:
try:
date_display = item.production_date.strftime('%Y-%m-%d')
except:
date_display = str(item.production_date)[:10]
d['inbound_date'] = date_display
d['qty_stock'] = float(item.stock_quantity or 0)
d['qty_available'] = float(item.available_quantity or 0)
d['sum_stock'] = d['qty_stock']
d['sum_available'] = d['qty_available']
d['arrival_photo'] = parse_img(item.arrival_photo)
d['quality_report_link'] = parse_img(item.quality_report_link)
d['global_print_id'] = item.global_print_id
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": []}
# ============================================================
# 7. 系统用户搜索
# ============================================================
@staticmethod
def search_system_users(keyword):
"""搜索系统用户(活跃状态)"""
from app.models.system import SysUser
try:
query = SysUser.query.filter(SysUser.status == 'active')
if keyword:
kw = f'%{keyword}%'
query = query.filter(db.or_(
SysUser.username.ilike(kw),
SysUser.email.ilike(kw)
))
query = query.order_by(SysUser.username)
users = []
for u in query.limit(20).all():
users.append({
'value': u.username,
'email': u.email
})
return users
except Exception:
return []

View File

@ -0,0 +1,206 @@
# app/services/inbound/service_service.py
from app import db
from app.models.inbound.service import StockService
from app.models.base import MaterialBase
from datetime import datetime, timedelta
import re
class ServiceService:
"""服务权益库存业务逻辑"""
SKU_PREFIX = 'SRV'
SKU_DATE_FORMAT = '%Y%m%d'
SKU_SUFFIX_LEN = 4
@classmethod
def _generate_sku(cls):
"""生成唯一SKU格式 SRV-YYYYMMDD-XXXX"""
today_str = datetime.now().strftime(cls.SKU_DATE_FORMAT)
prefix = f'{cls.SKU_PREFIX}-{today_str}-'
# 查找今天已有的最大后缀
max_sku = db.session.query(db.func.max(StockService.sku)).filter(
StockService.sku.like(f'{prefix}%')
).scalar()
if not max_sku:
suffix_num = 1
else:
# 提取后缀数字
suffix_part = max_sku.replace(prefix, '')
match = re.match(r'^(\d+)', suffix_part)
suffix_num = int(match.group(1)) if match else 0
suffix_num += 1
# 格式化为4位数字左侧补零
suffix = str(suffix_num).zfill(cls.SKU_SUFFIX_LEN)
return f'{prefix}{suffix}'
@classmethod
def search_base_material(cls, keyword):
"""搜索基础物料,供前端远程选择"""
try:
# [核心修改] 只查询已启用的物料
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword:
query = query.filter(
db.or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%'),
)
)
query = query.order_by(MaterialBase.id.desc()).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,
})
return results
except Exception as e:
import traceback
traceback.print_exc()
return []
@classmethod
def create_service(cls, data):
"""创建服务权益记录"""
# 检查基础物料是否存在
base_id = data.get('base_id')
base = MaterialBase.query.get(base_id)
if not base:
raise ValueError('基础物料不存在')
# [核心修改] 后端二次校验:如果物料已停用,禁止创建服务权益
if not base.is_enabled:
raise ValueError(f"物料【{base.name}】已停用,无法创建新的服务权益。")
# 生成SKU
sku = cls._generate_sku()
service = StockService(
base_id=data['base_id'],
sku=sku,
sale_price=data['sale_price'],
provider_name=data['provider_name'],
description=data.get('description', '')
)
db.session.add(service)
db.session.commit()
return service
@classmethod
def get_service(cls, service_id):
"""获取单个服务权益"""
service = StockService.query.filter_by(id=service_id, is_deleted=False).first()
if not service:
raise ValueError('服务权益记录不存在')
return service
@classmethod
def update_service(cls, service_id, data):
"""更新服务权益记录"""
service = cls.get_service(service_id)
# 不允许修改 base_id 和 sku业务上不允许变更基础物料
if 'sale_price' in data:
service.sale_price = data['sale_price']
if 'provider_name' in data:
service.provider_name = data['provider_name']
if 'description' in data:
service.description = data.get('description', '')
service.updated_at = datetime.now()
db.session.commit()
return service
@classmethod
def delete_service(cls, service_id):
"""软删除服务权益"""
service = cls.get_service(service_id)
service.is_deleted = True
service.updated_at = datetime.now()
db.session.commit()
return True
@classmethod
def get_service_list(cls, page=1, per_page=20, keyword=None,
start_date=None, end_date=None, provider_name=None):
"""分页查询服务权益列表"""
query = StockService.query.filter_by(is_deleted=False)
# 关键词搜索:可搜索 SKU 或 关联物料名称
if keyword:
# 子查询查找物料名称匹配的 base_id
subquery = MaterialBase.query.filter(
MaterialBase.name.ilike(f'%{keyword}%')
).subquery()
query = query.filter(
db.or_(
StockService.sku.ilike(f'%{keyword}%'),
StockService.base_id.in_([row.id for row in db.session.query(subquery.c.id)])
)
)
if start_date:
start = datetime.strptime(start_date, '%Y-%m-%d')
query = query.filter(StockService.created_at >= start)
if end_date:
end = datetime.strptime(end_date, '%Y-%m-%d')
# 包含当天
end = end + timedelta(days=1) - timedelta(seconds=1)
query = query.filter(StockService.created_at <= end)
if provider_name:
query = query.filter(StockService.provider_name.ilike(f'%{provider_name}%'))
# 总数
total = query.count()
# 分页
items = query.order_by(StockService.created_at.desc()) \
.offset((page - 1) * per_page) \
.limit(per_page).all()
return {
'items': [item.to_dict() for item in items],
'total': total,
'page': page,
'per_page': per_page
}
# ============================================================
# 供应商历史查询
# ============================================================
@classmethod
def get_history_providers(cls, base_id):
"""返回该物料关联的服务商列表(去重)"""
try:
query = db.session.query(StockService.provider_name).filter(
StockService.base_id == base_id,
StockService.provider_name.isnot(None)
).distinct().order_by(StockService.provider_name)
providers = [row[0] for row in query.all()]
return providers
except Exception:
return []
# ============================================================
# 系统用户搜索
# ============================================================
@classmethod
def search_system_users(cls, keyword):
"""搜索系统用户(活跃状态)"""
from app.models.system import SysUser
try:
query = SysUser.query.filter(SysUser.status == 'active')
if keyword:
kw = f'%{keyword}%'
query = query.filter(db.or_(
SysUser.username.ilike(kw),
SysUser.email.ilike(kw)
))
query = query.order_by(SysUser.username)
users = []
for u in query.limit(20).all():
users.append({
'value': u.username,
'email': u.email
})
return users
except Exception:
return []

View File

@ -0,0 +1,315 @@
import uuid # .material -> .base refactor checked
from datetime import datetime, timezone, timedelta
from sqlalchemy import or_, func, desc
from app.extensions import db
from app.models.outbound import TransOutbound
# 引入所有库存模型以进行查询
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
# 引入基础信息表
from app.models.base import MaterialBase
class OutboundService:
@staticmethod
def generate_outbound_no():
"""
生成出库单号: OUT-yyyyMMdd-HHmm-当日流水(4位)
例如: OUT-20260205-1558-0001
"""
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
date_str = now.strftime('%Y%m%d')
time_str = now.strftime('%H%M')
prefix = f"OUT-{date_str}-"
existing_count = db.session.query(func.count(func.distinct(TransOutbound.outbound_no))) \
.filter(TransOutbound.outbound_no.like(f"{prefix}%")).scalar()
sequence = existing_count + 1
return f"OUT-{date_str}-{time_str}-{sequence:04d}"
@staticmethod
def get_stock_by_barcode(barcode):
"""
根据扫码内容查找对应的库存物品,并附带价格信息
"""
if not barcode:
return None
clean_code = barcode.strip()
def get_price(item, table_type):
if table_type == 'stock_product':
return float(item.sale_price) if item.sale_price else 0
elif table_type == 'stock_buy':
return float(item.unit_price) if item.unit_price else 0
return 0
prod = StockProduct.query.filter(
or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code)
).first()
if prod:
res = OutboundService._format_scan_result(prod, 'stock_product')
res['price'] = get_price(prod, 'stock_product')
return res
semi = StockSemi.query.filter(
or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code)
).first()
if semi:
res = OutboundService._format_scan_result(semi, 'stock_semi')
res['price'] = 0
return res
buy = StockBuy.query.filter(
or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code)
).first()
if buy:
res = OutboundService._format_scan_result(buy, 'stock_buy')
res['price'] = get_price(buy, 'stock_buy')
return res
return None
@staticmethod
def _format_scan_result(item, table_name):
base_name = ""
base_spec = ""
base_cat = ""
base_type = ""
if hasattr(item, 'base') and item.base:
base_name = item.base.name
base_spec = item.base.spec_model
base_cat = item.base.category
base_type = item.base.material_type
if not base_name and hasattr(item, 'base_id') and item.base_id:
try:
base_info = MaterialBase.query.get(item.base_id)
if base_info:
base_name = base_info.name
base_spec = base_info.spec_model
base_cat = base_info.category
base_type = base_info.material_type
except Exception:
pass
if not base_name and hasattr(item, 'material_name'):
base_name = item.material_name
stock_qty = float(item.stock_quantity) if item.stock_quantity else 0
avail_qty = float(item.available_quantity) if item.available_quantity else 0
return {
'id': item.id,
'sku': item.sku,
'name': base_name or "未知物品",
'spec_model': base_spec or "",
'category': base_cat or "",
'material_type': base_type or "",
'source_table': table_name,
'stock_quantity': stock_qty,
'available_quantity': avail_qty,
'batch_number': getattr(item, 'batch_number', ''),
'warehouse_location': getattr(item, 'warehouse_location', ''),
'barcode': getattr(item, 'barcode', '')
}
@staticmethod
def create_outbound_batch(data, operator_name='System'):
items = data.get('items', [])
if not items:
raise ValueError("出库商品列表不能为空")
outbound_no = OutboundService.generate_outbound_no()
common_data = {
'outbound_no': outbound_no,
'consumer_name': data.get('consumer_name'),
'outbound_type': data.get('outbound_type', 'SALES'),
'signature_path': data.get('signature_path'),
'operator_name': operator_name,
'remark': data.get('remark')
}
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct
}
try:
for item in items:
source_table = item.get('source_table')
stock_id = item.get('stock_id')
quantity = float(item.get('quantity', 0))
unit_price = float(item.get('price', 0))
if quantity <= 0:
raise ValueError(f"SKU {item.get('sku')} 的出库数量必须大于0")
ModelClass = model_map.get(source_table)
if not ModelClass:
continue
stock_record = ModelClass.query.with_for_update().get(stock_id)
if not stock_record:
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
if float(stock_record.available_quantity) < quantity:
raise ValueError(f"SKU {stock_record.sku} 库存不足,当前可用: {stock_record.available_quantity}")
stock_record.stock_quantity = float(stock_record.stock_quantity) - quantity
stock_record.available_quantity = float(stock_record.available_quantity) - quantity
new_record = TransOutbound(
sku=item.get('sku'),
source_table=source_table,
stock_id=stock_id,
barcode=item.get('barcode'),
quantity=quantity,
unit_price=unit_price,
outbound_time=current_time,
**common_data
)
db.session.add(new_record)
db.session.commit()
return outbound_no
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def get_grouped_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None):
"""
查询出库记录(按出库单号分组),包含详细物品信息
"""
# 1. 查询分页单号
stmt = db.session.query(
TransOutbound.outbound_no,
func.max(TransOutbound.outbound_time).label('max_time')
).group_by(TransOutbound.outbound_no)
if keyword:
stmt = stmt.filter(or_(
TransOutbound.outbound_no.ilike(f'%{keyword}%'),
TransOutbound.consumer_name.ilike(f'%{keyword}%'),
TransOutbound.sku.ilike(f'%{keyword}%')
))
if start_date and end_date:
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
stmt = stmt.order_by(desc('max_time'))
pagination = stmt.paginate(page=page, per_page=per_page, error_out=False)
outbound_nos = [row.outbound_no for row in pagination.items]
if not outbound_nos:
return {
'items': [],
'total': 0,
'pages': 0,
'current_page': page
}
# 2. 查询详细记录
details = TransOutbound.query.filter(TransOutbound.outbound_no.in_(outbound_nos)).all()
# 3. 组装数据并查询物品详情
grouped_map = {}
# 映射表模型以便查询
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct
}
for d in details:
ono = d.outbound_no
if ono not in grouped_map:
grouped_map[ono] = {
'outbound_no': ono,
'outbound_time': d.outbound_time.strftime('%Y-%m-%d %H:%M:%S'),
'outbound_type': d.outbound_type,
'consumer_name': d.consumer_name,
'operator_name': d.operator_name,
'signature_path': d.signature_path,
'remark': d.remark,
'total_amount': 0.0,
'items': []
}
# --- 查询物品详细信息 (名称, 规格, 类型, 类别) ---
item_name = "未知物品"
item_spec = ""
item_cat = ""
item_type = ""
ModelClass = model_map.get(d.source_table)
if ModelClass and d.stock_id:
# 注意这里在循环中查询可能会有N+1问题但考虑到单页数据量通常每单条目不多暂时可接受
# 生产环境建议优化为预加载或批量查询
try:
stock_item = ModelClass.query.get(d.stock_id)
if stock_item and stock_item.base:
item_name = stock_item.base.name
item_spec = stock_item.base.spec_model
item_cat = stock_item.base.category
item_type = stock_item.base.material_type
elif stock_item and hasattr(stock_item, 'base_id') and stock_item.base_id:
base_info = MaterialBase.query.get(stock_item.base_id)
if base_info:
item_name = base_info.name
item_spec = base_info.spec_model
item_cat = base_info.category
item_type = base_info.material_type
except Exception as e:
print(f"Error fetching detail for stock_id {d.stock_id}: {e}")
# 计算金额
price = float(d.unit_price) if d.unit_price else 0
qty = float(d.quantity)
subtotal = price * qty
grouped_map[ono]['total_amount'] += subtotal
grouped_map[ono]['items'].append({
'sku': d.sku,
'name': item_name,
'spec_model': item_spec,
'category': item_cat,
'material_type': item_type,
'quantity': qty,
'unit_price': price,
'subtotal': subtotal
})
# 4. 排序输出
result_list = []
for ono in outbound_nos:
if ono in grouped_map:
obj = grouped_map[ono]
obj['items'].sort(key=lambda x: x['unit_price'], reverse=True)
obj['total_amount'] = round(obj['total_amount'], 2)
result_list.append(obj)
return {
'items': result_list,
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}

View File

@ -0,0 +1,318 @@
import socket # .material -> .base refactor checked
import base64
import os
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
# 引入二维码生成库
try:
import qrcode
except ImportError:
print("❌ 警告: 未安装 qrcode 库,无法生成二维码。请执行: pip install qrcode[pil]")
class LabelPrintService:
PRINTER_IP = "192.168.9.205"
PRINTER_PORT = 9100
# ================= 1. 尺寸与分辨率配置 (300 DPI) =================
DOTS_PER_MM = 12 # 300 DPI
LABEL_WIDTH_MM = 40
LABEL_HEIGHT_MM = 30
# 画布像素: 40mm -> 480px, 30mm -> 360px
LABEL_WIDTH = int(LABEL_WIDTH_MM * DOTS_PER_MM)
LABEL_HEIGHT = int(LABEL_HEIGHT_MM * DOTS_PER_MM)
# ================= 2. 布局配置 =================
MARGIN_LEFT = int(2 * DOTS_PER_MM) # 左边距 2mm
MARGIN_RIGHT = int(1 * DOTS_PER_MM) # 右边距 1mm
TOP_MARGIN = int(5 * DOTS_PER_MM) # 顶部边距 2mm
# 二维码尺寸 15mm * 15mm
QR_SIZE_MM = 15
QR_SIZE_PX = int(QR_SIZE_MM * DOTS_PER_MM) # 180px
# 左右分栏的间距
GAP_COLUMNS = int(2 * DOTS_PER_MM) # 2mm 间距
@staticmethod
def _get_font(size):
"""获取字体 (优先使用黑体/微软雅黑)"""
font_names = ["simhei.ttf", "msyh.ttf", "SimHei.ttf", "arial.ttf", "NotoSansCJK-Regular.ttc"]
base_dirs = [os.getcwd(), os.path.dirname(__file__), "/usr/share/fonts", "C:\\Windows\\Fonts"]
for d in base_dirs:
for name in font_names:
path = os.path.join(d, name)
if os.path.exists(path):
try:
return ImageFont.truetype(path, size)
except:
continue
return ImageFont.load_default()
@staticmethod
def _generate_qr_image(content, size_px):
"""生成指定像素大小的二维码"""
try:
if not content: content = "000000"
# 创建二维码对象
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=0,
)
qr.add_data(content)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# [重要] 必须转为 RGB 模式
img = img.convert('RGB')
# 调整为指定像素大小
return img.resize((size_px, size_px), Image.Resampling.LANCZOS)
except Exception as e:
print(f"二维码生成失败: {e}")
return Image.new('RGB', (size_px, size_px), color='gray')
@staticmethod
def draw_text_wrap(draw, text, x, y, font, max_width, line_spacing=0, stroke_width=1):
"""
[核心功能] 自动换行绘制文本
"""
if not text:
return y
lines = []
current_line = ""
# 计算折行
for char in text:
test_line = current_line + char
width = font.getlength(test_line)
if width <= max_width:
current_line = test_line
else:
if current_line: lines.append(current_line)
current_line = char
if current_line:
lines.append(current_line)
# 绘制
current_y = y
font_height = font.size
for line in lines:
if current_y + font_height > LabelPrintService.LABEL_HEIGHT:
break
draw.text(
(x, current_y),
line,
font=font,
fill='black',
stroke_width=stroke_width, # 支持动态调整粗细
stroke_fill='black'
)
current_y += font_height + line_spacing
return current_y
@staticmethod
def _create_image_object(data):
"""
[绘图层] 生成标签图片
新布局逻辑:
---------------------------------------
| [QR Code] (15mm) | 名: XXXXXX |
| | 规: XXXXXX |
| SKU: XXXXX(大/粗)| 属: XXXXXX |
| 库: XXXXX (中/粗)| SN: XXXXXX |
---------------------------------------
"""
# 1. 创建画布
img = Image.new('RGB', (LabelPrintService.LABEL_WIDTH, LabelPrintService.LABEL_HEIGHT), color='white')
d = ImageDraw.Draw(img)
# 2. 字体配置 (字号再次加大)
# [修改] 通用字体加大到 28
font_text = LabelPrintService._get_font(28)
# [修改] SKU字体加大到 34 (特大)
font_sku = LabelPrintService._get_font(34)
# 3. 数据准备
sku_code = str(data.get('sku') or data.get('serial_number') or '000000')
name = str(data.get('material_name', '') or '-')
spec = str(data.get('spec_model', '') or '-')
loc = str(data.get('warehouse_loc', '') or '-')
cat = str(data.get('category', '') or '')
typ = str(data.get('material_type', '') or '')
attr = f"{cat}/{typ}" if (cat or typ) else "-"
# 底部编号逻辑
bottom_val = ""
bottom_label = "NO"
if data.get('print_no'):
bottom_val = str(data.get('print_no'))
l_type = data.get('print_label', '')
bottom_label = 'SN' if l_type == '' else 'BN' if l_type == '' else 'NO'
elif data.get('serial_number'):
bottom_label = "SN"
bottom_val = str(data.get('serial_number'))
elif data.get('batch_number'):
bottom_label = "BN"
bottom_val = str(data.get('batch_number'))
else:
bottom_val = sku_code
bottom_text_full = f"{bottom_label}:{bottom_val}"
# ==================== 绘制区域划分 ====================
# --- A. 左侧区域 (二维码 + SKU + 库位) ---
qr_x = LabelPrintService.MARGIN_LEFT
qr_y = LabelPrintService.TOP_MARGIN
# 1. 绘制二维码
qr_img = LabelPrintService._generate_qr_image(sku_code, LabelPrintService.QR_SIZE_PX)
img.paste(qr_img, (qr_x, qr_y))
# 计算中心点,用于 SKU 和 库位 居中
qr_center_x = qr_x + (LabelPrintService.QR_SIZE_PX // 2)
# 2. 绘制 SKU (特大 + 特粗)
# 位于二维码下方,留 6px 间距
current_left_y = qr_y + LabelPrintService.QR_SIZE_PX + 6
sku_w = font_sku.getlength(sku_code)
sku_x = int(qr_center_x - (sku_w // 2))
if sku_x < 2: sku_x = 2 # 边界保护
d.text(
(sku_x, current_left_y),
sku_code,
font=font_sku,
fill='black',
stroke_width=2, # [修改] SKU 增加到 2px 描边,更粗
stroke_fill='black'
)
# 3. 绘制 库位 (放在 SKU 下方)
# 位于 SKU 下方,留 6px 间距
current_left_y += 34 + 6 # 34是字号大致高度
loc_text = f"库:{loc}"
loc_w = font_text.getlength(loc_text)
loc_x = int(qr_center_x - (loc_w // 2))
if loc_x < 2: loc_x = 2
d.text(
(loc_x, current_left_y),
loc_text,
font=font_text,
fill='black',
stroke_width=1, # 普通加粗
stroke_fill='black'
)
# --- B. 右侧区域 (名称、规格、属性、编号) ---
# 右侧起始 X
right_start_x = LabelPrintService.MARGIN_LEFT + LabelPrintService.QR_SIZE_PX + LabelPrintService.GAP_COLUMNS
# 右侧最大宽度
right_max_width = LabelPrintService.LABEL_WIDTH - right_start_x - LabelPrintService.MARGIN_RIGHT
current_right_y = LabelPrintService.TOP_MARGIN
# [修改] 增大行间距 line_spacing=8
LINE_SPACING = 8
# 1. 名称
current_right_y = LabelPrintService.draw_text_wrap(
d, f"名:{name}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
)
current_right_y += LINE_SPACING
# 2. 规格
current_right_y = LabelPrintService.draw_text_wrap(
d, f"规:{spec}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
)
current_right_y += LINE_SPACING
# 3. 属性
current_right_y = LabelPrintService.draw_text_wrap(
d, f"属:{attr}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
)
current_right_y += LINE_SPACING
# 4. 序列号/批号
LabelPrintService.draw_text_wrap(
d, bottom_text_full, right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
)
return img
@staticmethod
def generate_preview_image(data):
"""生成 Base64 预览图"""
img = LabelPrintService._create_image_object(data)
output_buffer = BytesIO()
img.save(output_buffer, format='JPEG', quality=95)
base64_str = base64.b64encode(output_buffer.getvalue()).decode('utf-8')
return f"data:image/jpeg;base64,{base64_str}"
@staticmethod
def send_to_printer(data):
ip = LabelPrintService.PRINTER_IP
port = LabelPrintService.PRINTER_PORT
try:
# 1. 获取 RGB 图像
img_rgb = LabelPrintService._create_image_object(data)
# 2. 转换为灰度
img_gray = img_rgb.convert('L')
# 3. 二值化处理
img_bw = img_gray.point(lambda x: 0 if x < 128 else 255, '1')
# 4. 生成打印指令
bitmap_data = img_bw.tobytes()
width_bytes = (img_bw.width + 7) // 8
height_dots = img_bw.height
# TSPL 协议头
header = (
f"SIZE {LabelPrintService.LABEL_WIDTH_MM} mm, {LabelPrintService.LABEL_HEIGHT_MM} mm\r\n"
"GAP 2 mm, 0 mm\r\n"
"CLS\r\n"
"DIRECTION 1\r\n"
"REFERENCE 0, 0\r\n"
).encode('gbk')
# 位图指令
bitmap_cmd = f"BITMAP 0,0,{width_bytes},{height_dots},0,".encode('gbk')
footer = b"\r\nPRINT 1,1\r\n"
# 5. 发送 socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5)
s.connect((ip, port))
s.sendall(header + bitmap_cmd + bitmap_data + footer)
s.close()
return True
except Exception as e:
print(f"❌ 打印异常: {e}")
raise Exception(f"打印机连接失败: {str(e)}")
if __name__ == "__main__":
pass

View File

@ -0,0 +1,114 @@
import socket # .material -> .base refactor checked
import datetime
class NetworkPrintService:
def __init__(self, ip='192.168.9.205', port=9100):
"""
初始化网络打印机服务
:param ip: 打印机IP默认 192.168.9.205
:param port: 端口,默认 9100
"""
self.ip = ip
self.port = port
def _send_to_printer(self, content):
"""底层发送方法"""
try:
# 建立 Socket 连接
with socket.socket(socket.socket.AF_INET, socket.socket.SOCK_STREAM) as s:
s.settimeout(5) # 设置5秒超时
s.connect((self.ip, self.port))
# 发送内容,使用 GB18030 编码以支持中文
s.sendall(content.encode('gb18030'))
# 发送切纸指令 (ESC/POS: GS V m)
# 十六进制: 1D 56 42 00
s.sendall(b'\x1d\x56\x42\x00')
return True, "打印成功"
except Exception as e:
print(f"[NetworkPrint Error] {str(e)}")
return False, f"打印失败: {str(e)}"
def print_outbound_selection(self, items):
"""
打印出库选单 (拣货单)
:param items: 选中的物品列表
"""
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lines = []
lines.append("\n")
lines.append("********************************")
lines.append(" 出库拣货确认单 ")
lines.append("********************************")
lines.append(f"打印时间: {timestamp}")
lines.append(f"待出库总数: {len(items)}")
lines.append("--------------------------------")
lines.append(f"{'名称':<14}{'规格/批号':<10}")
lines.append("--------------------------------")
for item in items:
# 获取名称,优先取 material_name, 其次 product_name
name = item.get('material_name') or item.get('product_name') or "未知物品"
if len(name) > 14: name = name[:13] + "." # 名称过长截断
standard = item.get('standard', '')
batch = item.get('batch_no', '')
uuid = item.get('uuid', '')[-6:] # 只显示UUID后6位
lines.append(f"{name:<14} {standard}")
lines.append(f"批号: {batch} | 尾号: {uuid}")
lines.append("- - - - - - - - - - - - - - - -")
lines.append("\n")
lines.append("库管员签字: ______________")
lines.append("领料人签字: ______________")
lines.append("\n\n\n") # 走纸
content = "\n".join(lines)
return self._send_to_printer(content)
def print_stocktake_report(self, data):
"""
打印盘点统计报告
:param data: 包含 total, scanned, missing, missing_items
"""
total = data.get('total', 0)
scanned = data.get('scanned', 0)
missing = data.get('missing', 0)
missing_items = data.get('missing_items', [])
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lines = []
lines.append("\n")
lines.append("================================")
lines.append(" 库存盘点统计报告 ")
lines.append("================================")
lines.append(f"盘点时间: {timestamp}")
lines.append(f"应盘总数: {total}")
lines.append(f"实盘(已扫): {scanned}")
lines.append(f"差异(未扫): {missing}")
lines.append("--------------------------------")
if missing == 0:
lines.append("【结果】: 账实相符,库存完美!")
else:
lines.append("【差异明细 (未扫码物品)】:")
for item in missing_items:
name = item.get('material_name') or item.get('product_name') or "未知"
batch = item.get('batch_no', '-')
# 兼容不同模型的字段
code = item.get('uuid', item.get('bar_code', 'N/A'))[-6:]
lines.append(f"[ ] {name}")
lines.append(f" 批:{batch} 码:{code}")
lines.append("\n")
lines.append("监盘人: ______________")
lines.append("\n\n\n")
content = "\n".join(lines)
return self._send_to_printer(content)

View File

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

View File

@ -0,0 +1,185 @@
import uuid # .material -> .base refactor checked
from datetime import datetime
from app.extensions import db
from app.models.transaction import TransBorrow
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from sqlalchemy import desc, func
class TransService:
@staticmethod
def generate_borrow_no():
"""
生成借用单号: BOR-yyyyMMdd-0001 (按日流水)
逻辑:统计当天已存在的不同借用单号数量,+1 作为新序号
"""
now = datetime.now()
date_str = now.strftime('%Y%m%d')
prefix = f"BOR-{date_str}-"
# 使用 count distinct 来计算当天有多少个不同的借用单 (因为一单多货会占多行)
count = db.session.query(func.count(func.distinct(TransBorrow.borrow_no))) \
.filter(TransBorrow.borrow_no.like(f"{prefix}%")).scalar()
sequence = count + 1
return f"{prefix}{sequence:04d}"
@staticmethod
def create_borrow(data, operator_name='System'):
"""
借库逻辑:减少可用库存,不减总库存
"""
items = data.get('items', [])
borrower_name = data.get('borrower_name')
signature = data.get('signature_path') # 借用人签字
if not items: raise ValueError("物品列表为空")
if not borrower_name: raise ValueError("请输入借用人")
if not signature: raise ValueError("借用人必须签字")
borrow_no = TransService.generate_borrow_no()
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
try:
for item in items:
source_table = item.get('source_table')
stock_id = item.get('id')
qty = float(item.get('out_quantity', 0))
ModelClass = model_map.get(source_table)
if not ModelClass: continue
stock = ModelClass.query.with_for_update().get(stock_id)
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
if float(stock.available_quantity) < qty:
raise ValueError(f"SKU {stock.sku} 可用库存不足")
# 1. 冻结库存 (只减可用)
stock.available_quantity = float(stock.available_quantity) - qty
# 2. 创建借用单
record = TransBorrow(
borrow_no=borrow_no,
sku=stock.sku,
source_table=source_table,
stock_id=stock.id,
barcode=stock.barcode,
quantity=qty,
borrower_name=borrower_name,
borrow_signature=signature,
remark=data.get('remark'),
expected_return_time=data.get('expected_return_time'),
status='borrowed',
is_returned=False
)
db.session.add(record)
db.session.commit()
return borrow_no
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def scan_for_return(barcode):
"""
扫码还库:查找未归还记录,并返回当前物品的库位
"""
records = TransBorrow.query.filter_by(barcode=barcode, is_returned=False).all()
if not records:
return None
# 取第一条未还记录
record = records[0]
# 获取当前库存表中的实时库位
current_location = ""
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
ModelClass = model_map.get(record.source_table)
if ModelClass:
stock = ModelClass.query.get(record.stock_id)
if stock:
current_location = stock.warehouse_location
res_dict = record.to_dict()
res_dict['current_location'] = current_location # 用于前端对比和预填
return res_dict
@staticmethod
def process_return(data, operator_name):
"""
还库逻辑:
1. 恢复可用库存
2. 更新库位 (如果有变动)
3. 记录库管签字
"""
items = data.get('items', [])
signature = data.get('signature_path') # 库管签字
if not items: raise ValueError("还库列表为空")
if not signature: raise ValueError("库管必须签字确认")
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
try:
for item in items:
borrow_id = item.get('id')
# 前端如果没有填 return_location应该在提交前处理好或者这里做 fallback
# 这里假设前端传来的 return_location 就是最终要保存的库位
final_location = item.get('return_location')
record = TransBorrow.query.with_for_update().get(borrow_id)
if not record or record.is_returned:
continue
ModelClass = model_map.get(record.source_table)
if ModelClass:
stock = ModelClass.query.with_for_update().get(record.stock_id)
if stock:
# 1. 恢复可用库存
stock.available_quantity = float(stock.available_quantity) + float(record.quantity)
# 2. 更新库位 (如果提供了有效值)
if final_location:
stock.warehouse_location = final_location
# 3. 更新借用单状态
record.is_returned = True
record.status = 'returned'
record.return_time = datetime.now()
record.return_operator = operator_name
record.return_signature = signature
record.return_location = final_location
db.session.commit()
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def get_records(page=1, limit=10, status='all', keyword=None):
q = TransBorrow.query
if status == 'borrowed':
q = q.filter(TransBorrow.is_returned == False)
elif status == 'returned':
q = q.filter(TransBorrow.is_returned == True)
if keyword:
q = q.filter(TransBorrow.borrower_name.ilike(f'%{keyword}%') |
TransBorrow.sku.ilike(f'%{keyword}%') |
TransBorrow.borrow_no.ilike(f'%{keyword}%'))
q = q.order_by(desc(TransBorrow.borrow_time))
pagination = q.paginate(page=page, per_page=limit, error_out=False)
return {
'items': [r.to_dict() for r in pagination.items],
'total': pagination.total,
'page': page,
'limit': limit
}

View File

@ -0,0 +1,23 @@
# app/utils/constants.py
class UserRole:
SUPER_ADMIN = 'super_admin' # 超级管理员 (IRIS)
SUPERVISOR = 'supervisor' # 主管
FINANCE = 'finance' # 财务
WAREHOUSE_MGR = 'warehouse_manager' # 库管
INBOUND = 'inbound' # 入库员
OUTBOUND = 'outbound' # 出库员
PURCHASER = 'purchaser' # 采购员
SALES = 'sales' # 销售
# 角色中文映射(用于前端展示或日志)
ROLE_MAP = {
SUPER_ADMIN: '超级管理员',
SUPERVISOR: '主管',
FINANCE: '财务',
WAREHOUSE_MGR: '库管',
INBOUND: '入库员',
OUTBOUND: '出库员',
PURCHASER: '采购员',
SALES: '销售'
}

View File

@ -0,0 +1,30 @@
# app/utils/decorators.py
from functools import wraps
from flask_jwt_extended import get_jwt
from flask import jsonify
def role_required(*roles):
"""
自定义装饰器:检查用户角色
使用方法: @role_required('super_admin', 'finance')
"""
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
claims = get_jwt()
user_role = claims.get('role')
# 如果是超级管理员,拥有上帝视角,直接放行 (可选)
if user_role == 'super_admin':
return fn(*args, **kwargs)
if user_role not in roles:
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
return fn(*args, **kwargs)
return decorator
return wrapper

View File

@ -1,14 +1,43 @@
import os
from datetime import timedelta
class Config:
# 数据库连接配置
# 请务必将 '你的密码' 替换为你 PostgreSQL 的真实密码
# 如果数据库不在本地,请将 localhost 替换为 IP 地址
SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:1234@localhost:5432/inventory_system'
# =========================================================
# 1. 基础路径与安全配置
# =========================================================
# 获取当前文件所在目录的绝对路径 (用于定位 uploads 文件夹等)
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
# 关闭 SQLAlchemy 的事件追踪,减少内存消耗
# Flask 的基础密钥 (用于 Session, Flash 消息等安全签名)
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-1234')
# =========================================================
# 2. 数据库配置
# =========================================================
# 优先读取 .env 中的 'DATABASE_URL'。
# 如果读不到,才回退使用默认的 localhost 连接字符串。
SQLALCHEMY_DATABASE_URI = os.getenv(
'DATABASE_URL',
'postgresql://postgres:1234@localhost:5432/inventory_system'
)
# 关闭 SQLAlchemy 的事件追踪,减少内存消耗 (推荐设为 False)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Flask 的密钥,用于 Session 加密等,开发环境随便写一个即可
SECRET_KEY = 'dev-secret-key-1234'
# =========================================================
# 3. JWT 配置 (修复 500 报错的核心区域)
# =========================================================
# 【核心】必须设置 JWT_SECRET_KEY否则 create_access_token 会报错
# 逻辑:优先读环境变量,读不到就用默认字符串
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'default-jwt-secret-key-if-missing')
# 设置 Token 过期时间 (这里设为 1 天)
JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=1)
# =========================================================
# 4. 文件上传配置
# =========================================================
# 上传文件存储路径
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
# 限制最大上传 16MB
MAX_CONTENT_LENGTH = 16 * 1024 * 1024

View File

@ -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 等其他服务

View 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 = '-'

View File

@ -5,4 +5,12 @@ Flask-Marshmallow==1.1.0
marshmallow-sqlalchemy==1.0.0
psycopg2-binary==2.9.9
python-dotenv==1.0.0
flask-cors==4.0.0
flask-cors==4.0.0
# 图片处理核心库
Pillow>=10.0.0
# [旧] 条形码生成库 (建议保留,防止旧代码报错)
python-barcode>=0.14.0
# [新增] 二维码生成库 (标签打印必需包含PIL支持)
qrcode[pil]>=7.4.2
# [新增] 必须添加,用于处理 token 登录
Flask-JWT-Extended==4.6.0

View File

@ -1,6 +1,27 @@
# inventory-backend/run.py
from app import create_app
# Gunicorn 或 uWSGI 会寻找名为 'app' 的实例
app = create_app()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
# =================================================
# 路由打印调试 (启动时会在控制台列出所有 URL)
# 这一步能帮你确认 /api/inbound/base/list 是否存在
# =================================================
print("\n====== 当前生效的路由映射 ======")
try:
# 按 URL 排序打印,方便查找
sorted_rules = sorted(app.url_map.iter_rules(), key=lambda x: str(x))
for rule in sorted_rules:
# 过滤掉一些系统自带的 static 路由,只显示 API
if 'api' in str(rule):
methods = ','.join(rule.methods - {'OPTIONS', 'HEAD'})
print(f"{str(rule):<50} | {methods:<10} | {rule.endpoint}")
except Exception:
pass
print("==============================\n")
# 启动开发服务器
# 端口设置为 5000 (Flask 默认) 或 8000请确保与前端 Vite 代理一致
app.run(host='0.0.0.0', port=8000, debug=True)

Binary file not shown.

View 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
View 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"]

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/jetbrains://idea/navigate/reference?project=inventory-web&path=public%2Firis.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>inventory-web</title>
</head>

26
inventory-web/nginx.conf Normal file
View 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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -11,14 +11,20 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.3",
"cropperjs": "^1.6.2",
"element-plus": "^2.13.1",
"html5-qrcode": "^2.3.8",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"pinia": "^3.0.4",
"sass": "^1.97.3",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-basic-ssl": "^1.1.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
@ -28,4 +34,4 @@
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,30 +1,200 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
import { InfoFilled, SwitchButton, UserFilled } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute() // [新增] 获取当前路由对象
const userStore = useUserStore()
// [新增] 计算属性:判断当前是否是登录页
const isLoginPage = computed(() => {
return route.path === '/login'
})
// --- 退出登录逻辑 Start ---
const handleLogout = () => {
ElMessageBox.confirm(
'确定要退出系统吗?',
'提示',
{
confirmButtonText: '确定退出',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(async () => {
// 1. 调用 Store 的 logout 清除状态
userStore.logout()
// 2. 提示消息
ElMessage({
type: 'success',
message: '已安全退出',
})
// 3. 强制跳转回登录页
await router.replace('/login')
})
.catch(() => {
// 取消操作
})
}
// --- 退出登录逻辑 End ---
</script>
<template>
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
<div class="app-wrapper">
<header v-if="!isLoginPage" class="app-header">
<div class="logo-container">
<router-link to="/" class="home-link">
<img src="@/assets/iris.png" class="logo" alt="Logo" />
<span class="system-title">IRIS 库存管理系统</span>
</router-link>
</div>
<div class="header-right">
<div class="user-profile">
<el-avatar :size="32" :icon="UserFilled" class="user-avatar" />
<span class="user-name">{{ userStore.username || '管理员' }}</span>
</div>
<el-divider direction="vertical" />
<el-button
type="danger"
link
@click="handleLogout"
class="logout-btn"
>
<el-icon style="margin-right: 4px; font-size: 16px"><SwitchButton /></el-icon>
退出
</el-button>
</div>
</header>
<main class="app-content">
<router-view />
</main>
<footer v-if="!isLoginPage" 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>
<HelloWorld msg="Vite + Vue" />
</template>
<style scoped>
<style>
.app-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
background-color: #f5f7fa;
}
.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 1px 4px rgba(0, 21, 41, 0.08);
flex-shrink: 0;
z-index: 1000;
}
.logo-container {
display: flex;
align-items: center;
height: 100%;
}
.home-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
cursor: pointer;
height: 100%;
user-select: none;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
height: 32px;
width: auto;
object-fit: contain;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
.system-title {
font-size: 18px;
font-weight: 600;
color: #303133;
letter-spacing: 0.5px;
white-space: nowrap;
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
</style>
.user-profile {
display: flex;
align-items: center;
gap: 8px;
cursor: default;
}
.user-avatar {
background-color: #409eff;
}
.user-name {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.logout-btn {
font-weight: 400;
padding: 4px 8px;
}
.logout-btn:hover {
color: #f56c6c !important;
}
.app-content {
flex: 1;
min-height: 0;
width: 100%;
position: relative;
overflow: hidden;
}
.app-footer {
height: 30px;
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;
}
</style>

View File

@ -0,0 +1,52 @@
import request from '@/utils/request'
// 登录
export function login(data: any) {
return request({
url: '/v1/auth/login',
method: 'post',
data
})
}
// 创建用户 (管理员专用)
export function createUser(data: any) {
return request({
url: '/v1/auth/user/create',
method: 'post',
data
})
}
// [新增] 更新用户
export function updateUser(id: number, data: any) {
return request({
url: `/v1/auth/user/${id}`,
method: 'put',
data
})
}
// 获取当前登录用户信息
export function getUserInfo() {
return request({
url: '/v1/auth/me',
method: 'get'
})
}
// 获取所有用户列表
export function getUserList() {
return request({
url: '/v1/auth/users',
method: 'get'
})
}
// 删除用户
export function deleteUser(id: number) {
return request({
url: `/v1/auth/user/${id}`,
method: 'delete'
})
}

View File

@ -0,0 +1,17 @@
import request from '@/utils/request'
export function getLabelPreview(data: any) {
return request({
url: '/common/print/preview',
method: 'post',
data
})
}
export function executePrint(data: any) {
return request({
url: '/common/print/execute',
method: 'post',
data
})
}

View File

@ -0,0 +1,41 @@
import request from '@/utils/request'
/**
* 上传文件通用接口
* @param data File 对象 或 FormData 对象
* 适配说明list.vue 中 customUpload 已经封装了 FormData所以这里支持直接传 FormData
*/
export function uploadFile(data: File | FormData) {
let formData: FormData
if (data instanceof FormData) {
formData = data
} else {
// 如果传入的是原始 File 对象,则手动封装
formData = new FormData()
// @ts-ignore
formData.append('file', data)
}
return request({
// 注意:这里 /v1/common/upload 需要与后端 BluePrint 注册的 url_prefix 对应
url: '/v1/common/upload',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 删除文件通用接口 (新增)
* @param filename 文件名 (例如: a1b2c3d4.jpg)
*/
export function deleteFile(filename: string) {
return request({
// 对应后端路由: @upload_bp.route('/files/<filename>', methods=['DELETE'])
url: `/v1/common/files/${filename}`,
method: 'delete'
})
}

View File

@ -0,0 +1,81 @@
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 }
})
}
// 6. 文件上传 (用于图片/拍照)
export function uploadFile(data: FormData) {
return request({
url: '/common/upload', // 对应后端 /api/v1/common/upload
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 7. [新增] 文件删除
export function deleteFile(filename: string) {
return request({
url: `/common/files/${filename}`, // 对应后端 /api/v1/common/files/<filename>
method: 'delete'
})
}
// 8. 供应商建议
export function getSupplierSuggestions(params: any) {
return request({
url: '/inbound/buy/suggestions/suppliers',
method: 'get',
params
})
}
// 9. 用户建议
export function getUserSuggestions(params: any) {
return request({
url: '/inbound/buy/suggestions/users',
method: 'get',
params
})
}

View File

@ -0,0 +1,18 @@
import request from '@/utils/request'
export interface InboundSummaryQuery {
page: number
per_page: number
keyword?: string
start_date?: string
end_date?: string
source_type?: string
}
export function getInboundSummaryList(params: InboundSummaryQuery) {
return request({
url: '/v1/inbound/summary/list',
method: 'get',
params
})
}

View File

@ -0,0 +1,51 @@
import request from '@/utils/request'
// 注意 URL 已变为 /inbound/product/...
export function getProductList(params: any) {
return request({
url: '/inbound/product/list',
method: 'get',
params
})
}
export function createProductInbound(data: any) {
return request({
url: '/inbound/product/submit',
method: 'post',
data
})
}
export function updateProductInbound(id: number, data: any) {
return request({
url: `/inbound/product/${id}`,
method: 'put',
data
})
}
export function deleteProductInbound(id: number) {
return request({
url: `/inbound/product/${id}`,
method: 'delete'
})
}
export function searchMaterialBase(keyword: string) {
return request({
url: '/inbound/product/search-base',
method: 'get',
params: { keyword }
})
}
// 用户建议
export function getUserSuggestions(params: any) {
return request({
url: '/inbound/product/suggestions/users',
method: 'get',
params
})
}

View File

@ -0,0 +1,54 @@
import request from '@/utils/request'
// 1. 获取列表
export function getSemiList(params: any) {
return request({
url: '/inbound/semi/list',
method: 'get',
params
})
}
// 2. 新增入库
export function createSemiInbound(data: any) {
return request({
url: '/inbound/semi/submit',
method: 'post',
data
})
}
// 3. 更新入库
export function updateSemiInbound(id: number, data: any) {
return request({
url: `/inbound/semi/${id}`,
method: 'put',
data
})
}
// 4. 删除入库
export function deleteSemiInbound(id: number) {
return request({
url: `/inbound/semi/${id}`,
method: 'delete'
})
}
// 5. 搜索基础物料
export function searchMaterialBase(keyword: string) {
return request({
url: '/inbound/semi/search-base',
method: 'get',
params: { keyword }
})
}
// 用户建议
export function getUserSuggestions(params: any) {
return request({
url: '/inbound/semi/suggestions/users',
method: 'get',
params
})
}

View File

@ -0,0 +1,131 @@
import request from '@/utils/request'
export interface ServiceItem {
id: number
base_id: number
sku: string
sale_price: number
provider_name: string
description: string
created_at: string
updated_at: string
material_name?: string
spec_model?: string
unit?: string
}
export interface ServiceListResponse {
code: number
msg: string
data: {
items: ServiceItem[]
total: number
page: number
per_page: number
}
}
export interface ServiceQueryParams {
page?: number
per_page?: number
keyword?: string
start_date?: string
end_date?: string
provider_name?: string
}
export interface ServiceCreateRequest {
base_id: number
sale_price: number
provider_name: string
description?: string
}
export interface ServiceUpdateRequest {
sale_price?: number
provider_name?: string
description?: string
}
export interface MaterialBaseItem {
id: number
name: string
spec: string
category: string
unit: string
type: string
}
// 获取服务权益列表
export function getServiceList(params: ServiceQueryParams) {
return request<ServiceListResponse>({
url: '/v1/inbound/service',
method: 'get',
params
})
}
// 创建服务权益
export function createService(data: ServiceCreateRequest) {
return request({
url: '/v1/inbound/service',
method: 'post',
data
})
}
// 获取服务权益详情
export function getServiceDetail(id: number) {
return request<ServiceListResponse>({
url: `/v1/inbound/service/${id}`,
method: 'get'
})
}
// 更新服务权益
export function updateService(id: number, data: ServiceUpdateRequest) {
return request({
url: `/v1/inbound/service/${id}`,
method: 'put',
data
})
}
// 搜索基础物料
export function searchMaterialBase(keyword: string) {
return request<{
code: number
msg: string
data: MaterialBaseItem[]
}>({
url: '/v1/inbound/service/search-base',
method: 'get',
params: { keyword }
})
}
// 供应商建议
export function getProviderSuggestions(params: any) {
return request({
url: '/v1/inbound/service/suggestions/providers',
method: 'get',
params
})
}
// 用户建议
export function getUserSuggestions(params: any) {
return request({
url: '/v1/inbound/service/suggestions/users',
method: 'get',
params
})
}
// 删除服务权益
export function deleteService(id: number) {
return request({
url: `/v1/inbound/service/${id}`,
method: 'delete'
})
}

View File

@ -0,0 +1,65 @@
import request from '@/utils/request'
// 获取全量库存
// 修改前: url: '/api/v1/inbound/stock/all'
// 修改后: url: '/v1/inbound/stock/all'
export function getAllStock() {
return request({
url: '/v1/inbound/stock/all',
method: 'get'
})
}
// 打印出库选单
// 修改后: 去掉开头的 /api
export function printSelectionList(items: any[]) {
return request({
url: '/v1/inbound/stock/print/selection',
method: 'post',
data: { items }
})
}
// 打印盘点报告
// 修改后: 去掉开头的 /api
export function printStocktakeReport(data: any) {
return request({
url: '/v1/inbound/stock/print/stocktake',
method: 'post',
data
})
}
// 保存 BOM 结构
export function saveBom(data: { parent_id: number; children: any[] }) {
return request({
url: '/v1/bom',
method: 'post',
data
})
}
// 获取基础物料列表
export function getMaterialBaseList(params?: any) {
return request({
url: '/v1/bom/base/list',
method: 'get',
params
})
}
// 获取 BOM 父件列表
export function getBomParents() {
return request({
url: '/v1/bom/parents',
method: 'get'
})
}
// 获取指定BOM详情
export function getBom(parentId: number) {
return request({
url: `/v1/bom/${parentId}`,
method: 'get'
})
}

View File

@ -0,0 +1,45 @@
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. 修改基础信息 (包含状态启用/禁用)
// 【修复点】: 必须在 URL 中拼接 data.id否则后端会报 405 Method Not Allowed
export function updateMaterialBase(data: any) {
return request({
url: `/inbound/base/${data.id}`,
method: 'put',
data
})
}
// 4. 删除基础信息
export function delMaterialBase(id: number) {
return request({
url: `/inbound/base/${id}`,
method: 'delete'
})
}
// 5. 获取详情 (可选,用于编辑回显)
export function getMaterialBase(id: number) {
return request({
url: `/inbound/base/${id}`,
method: 'get'
})
}

View File

@ -0,0 +1,80 @@
import request from '@/utils/request'
// 购物车商品项接口
export interface CartItem {
id: number
sku: string
name: string
spec_model: string
source_table: string
stock_quantity: number
available_quantity: number
barcode: string
price: number // 单价
out_quantity: number // 本次出库数量
}
// 提交出库单的数据结构
export interface OutboundSubmitData {
items: Array<{
sku: string
source_table: string
stock_id: number
barcode: string
quantity: number
price: number
}>
outbound_type: string
consumer_name: string
operator_name: string
signature_path: string // 上传后返回的图片路径
remark?: string
}
export interface ScanResult {
id: number
sku: string
name: string
spec_model: string
source_table: string // 'stock_buy' | 'stock_product' ...
stock_quantity: number
available_quantity: number
batch_number?: string
warehouse_location?: string
barcode?: string
price?: number // 扫描返回的价格
}
/**
* 根据条码获取库存物品详情
* @param barcode 扫描到的条码
*/
export function getStockByBarcode(barcode: string) {
return request<any, ScanResult>({
url: '/v1/outbound/scan',
method: 'get',
params: { barcode }
})
}
/**
* 提交出库单 (批量)
*/
export function submitOutbound(data: OutboundSubmitData) {
return request({
url: '/v1/outbound',
method: 'post',
data
})
}
/**
* 获取出库记录列表
*/
export function getOutboundList(params: any) {
return request({
url: '/v1/outbound',
method: 'get',
params
})
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

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

View File

@ -0,0 +1,553 @@
<template>
<div class="camera-container is-fullscreen">
<div v-if="error" class="error-message">
{{ error }}
<el-button style="margin-top: 20px" @click="handleCancel">关闭</el-button>
</div>
<div v-else class="media-box">
<video
v-show="!imgSrc"
ref="videoRef"
autoplay
playsinline
class="media-content video-feed"
:style="{ transform: `scale(${cameraZoom})` }"
></video>
<div v-show="imgSrc" class="editor-container">
<img
ref="previewImgRef"
:src="imgSrc"
class="media-content preview-img"
alt="Photo Preview"
/>
</div>
<canvas ref="canvasRef" style="display: none;"></canvas>
</div>
<div v-if="!imgSrc && isCameraReady" class="zoom-slider-container">
<el-icon class="zoom-icon"><Remove /></el-icon>
<el-slider
v-model="cameraZoom"
:min="1"
:max="3"
:step="0.1"
:show-tooltip="false"
class="custom-slider"
/>
<el-icon class="zoom-icon"><CirclePlus /></el-icon>
</div>
<div class="camera-actions">
<template v-if="!imgSrc">
<el-button circle size="large" @click="handleCancel" class="action-btn icon-btn">
<el-icon><Close /></el-icon>
</el-button>
<el-button
circle
type="danger"
size="large"
@click="capture"
:disabled="!isCameraReady"
class="shutter-btn"
>
<div class="shutter-inner"></div>
</el-button>
<div class="placeholder-btn"></div>
</template>
<template v-else>
<div v-if="isEditing" class="edit-mode-bar">
<div class="edit-tools">
<el-tooltip content="切换移动图片/调整裁剪框" placement="top" :show-after="1000">
<el-button
circle
@click="toggleDragMode"
class="tool-btn"
:class="{ 'is-active': isMoveMode }"
>
<el-icon><Rank /></el-icon>
</el-button>
</el-tooltip>
<el-divider direction="vertical" border-style="dashed" />
<el-button circle @click="zoomCropper(0.1)" class="tool-btn"><el-icon><ZoomIn /></el-icon></el-button>
<el-button circle @click="zoomCropper(-0.1)" class="tool-btn"><el-icon><ZoomOut /></el-icon></el-button>
<el-button circle @click="rotateLeft" class="tool-btn"><el-icon><RefreshLeft /></el-icon></el-button>
<el-button circle @click="rotateRight" class="tool-btn"><el-icon><RefreshRight /></el-icon></el-button>
<el-button circle @click="resetCrop" class="tool-btn"><el-icon><Refresh /></el-icon></el-button>
</div>
<div class="edit-confirm">
<el-button circle size="large" @click="handleCancel" class="action-btn icon-btn">
<el-icon><Close /></el-icon>
</el-button>
<el-button @click="stopEdit" class="text-btn">取消编辑</el-button>
<el-button type="success" @click="confirmUse" class="confirm-btn">
完成并上传
</el-button>
</div>
</div>
<div v-else class="preview-mode-bar">
<el-button circle size="large" @click="handleCancel" class="action-btn icon-btn">
<el-icon><Close /></el-icon>
</el-button>
<el-button @click="retake" size="large" class="text-btn" style="min-width: 80px;">重拍</el-button>
<el-button @click="startEdit" size="large" class="text-btn" style="min-width: 80px;">
<el-icon style="margin-right: 4px"><Edit /></el-icon>编辑
</el-button>
<el-button
type="success"
@click="confirmUse"
size="large"
class="confirm-btn"
>
确认使用 <el-icon class="el-icon--right"><Check /></el-icon>
</el-button>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import {
Camera, RefreshLeft, RefreshRight, Check, Close, Refresh, Edit,
ZoomIn, ZoomOut, Remove, CirclePlus, Rank
} from '@element-plus/icons-vue'
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
const emit = defineEmits(['photo-submit', 'cancel'])
const videoRef = ref<HTMLVideoElement>()
const canvasRef = ref<HTMLCanvasElement>()
const previewImgRef = ref<HTMLImageElement>()
const mediaStream = ref<MediaStream>()
const error = ref('')
const isCameraReady = ref(false)
const imgSrc = ref('')
const currentFile = ref<File | null>(null)
const isEditing = ref(false)
const isMoveMode = ref(false)
const cameraZoom = ref(1) // 控制拍摄时的变焦倍数
let cropper: Cropper | null = null
const startCamera = async () => {
stopCamera()
error.value = ''
imgSrc.value = ''
isEditing.value = false
currentFile.value = null
cameraZoom.value = 1
try {
const constraints = {
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
}
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
mediaStream.value = stream
if (videoRef.value) {
videoRef.value.srcObject = stream
await videoRef.value.play()
isCameraReady.value = true
}
} catch (err: any) {
console.error(err)
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
error.value = '无法访问摄像头: 请使用 HTTPS 环境。'
} else {
error.value = '无法访问摄像头: ' + (err.message || '请检查权限')
}
ElMessage.error(error.value)
}
}
const stopCamera = () => {
if (mediaStream.value) {
mediaStream.value.getTracks().forEach(track => track.stop())
mediaStream.value = undefined
}
if (videoRef.value) {
videoRef.value.srcObject = null
}
isCameraReady.value = false
}
// ----------------------------------------------------
// 核心修复:拍照时应用数码变焦(裁剪+拉伸)
// ----------------------------------------------------
const capture = () => {
const video = videoRef.value
const canvas = canvasRef.value
if (!video || !canvas) return
// 1. 获取视频原始尺寸
const vW = video.videoWidth
const vH = video.videoHeight
if (vW === 0 || vH === 0) return
// 2. 设置画布为全尺寸(保持清晰度)
canvas.width = vW
canvas.height = vH
const ctx = canvas.getContext('2d')
if (!ctx) return
// 3. 计算基于 zoomLevel 的裁剪区域
// zoom = 1: 裁剪宽 = vW
// zoom = 2: 裁剪宽 = vW / 2
const zoom = cameraZoom.value
const cropW = vW / zoom
const cropH = vH / zoom
// 4. 计算裁剪的起始点 (居中裁剪)
const cropX = (vW - cropW) / 2
const cropY = (vH - cropH) / 2
// 5. 将裁剪区域绘制到全尺寸画布上 (drawImage(source, sx, sy, sw, sh, dx, dy, dw, dh))
ctx.drawImage(
video,
cropX, cropY, cropW, cropH, // 源:截取中心部分
0, 0, vW, vH // 目标:铺满整个画布
)
canvas.toBlob((blob) => {
if (!blob) {
ElMessage.error('拍照失败,请重试')
return
}
const timestamp = new Date().getTime()
const filename = `photo_${timestamp}.jpg`
currentFile.value = new File([blob], filename, { type: 'image/jpeg' })
imgSrc.value = URL.createObjectURL(blob)
stopCamera()
}, 'image/jpeg', 0.95)
}
const retake = () => {
destroyCropper()
isEditing.value = false
if (imgSrc.value) URL.revokeObjectURL(imgSrc.value)
imgSrc.value = ''
currentFile.value = null
startCamera()
}
const startEdit = () => {
if (!imgSrc.value || !previewImgRef.value) return
isEditing.value = true
isMoveMode.value = false
nextTick(() => {
if (cropper) cropper.destroy()
cropper = new Cropper(previewImgRef.value!, {
viewMode: 1,
dragMode: 'none',
autoCropArea: 0.8,
background: false,
modal: true,
guides: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
movable: true,
zoomable: true,
rotatable: true,
scalable: true,
})
})
}
const toggleDragMode = () => {
if (!cropper) return
isMoveMode.value = !isMoveMode.value
cropper.setDragMode(isMoveMode.value ? 'move' : 'none')
}
const stopEdit = () => {
destroyCropper()
isEditing.value = false
}
const destroyCropper = () => {
if (cropper) {
cropper.destroy()
cropper = null
}
}
const rotateLeft = () => cropper?.rotate(-90)
const rotateRight = () => cropper?.rotate(90)
const resetCrop = () => {
cropper?.reset()
isMoveMode.value = false
cropper?.setDragMode('none')
}
const zoomCropper = (ratio: number) => cropper?.zoom(ratio)
const confirmUse = () => {
console.log('👆 确认使用')
if (isEditing.value && cropper) {
const croppedCanvas = cropper.getCroppedCanvas({
imageSmoothingQuality: 'high'
})
if (!croppedCanvas) {
ElMessage.error('图片处理失败')
return
}
croppedCanvas.toBlob((blob) => {
if (!blob) {
ElMessage.error('文件生成失败')
return
}
const timestamp = new Date().getTime()
const filename = `photo_crop_${timestamp}.jpg`
const file = new File([blob], filename, { type: 'image/jpeg' })
emitFile(file)
}, 'image/jpeg', 0.9)
}
else if (currentFile.value) {
emitFile(currentFile.value)
}
else {
ElMessage.warning('没有可用的照片')
}
}
const emitFile = (file: File) => {
try {
console.log('📤 提交文件:', file.name, (file.size/1024).toFixed(1)+'KB')
emit('photo-submit', file)
} catch (err) {
console.error('父组件处理事件失败:', err)
}
}
const handleCancel = () => {
destroyCropper()
stopCamera()
emit('cancel')
}
onMounted(() => startCamera())
onBeforeUnmount(() => {
destroyCropper()
stopCamera()
if (imgSrc.value) URL.revokeObjectURL(imgSrc.value)
})
defineExpose({ startCamera, stopCamera })
</script>
<style scoped>
.camera-container.is-fullscreen {
position: fixed;
top: 0; left: 0; width: 100vw; height: 100vh;
background-color: #000;
z-index: 9999;
display: flex; flex-direction: column;
}
.error-message {
color: #fff;
padding: 40px;
text-align: center;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
/* 媒体显示区 */
.media-box {
flex: 1;
width: 100%;
position: relative;
overflow: hidden;
background: #000;
display: flex;
justify-content: center;
align-items: center;
}
.media-content {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease-out; /* 变焦平滑动画 */
transform-origin: center center;
}
.editor-container { width: 100%; height: 100%; }
.preview-img { display: block; max-width: 100%; max-height: 100%; }
/* 变焦滑块 */
.zoom-slider-container {
position: absolute;
bottom: 150px;
left: 50%;
transform: translateX(-50%);
width: 70%;
max-width: 300px;
display: flex;
align-items: center;
gap: 10px;
z-index: 20;
background: rgba(0, 0, 0, 0.4);
padding: 5px 15px;
border-radius: 20px;
}
.zoom-icon { color: #fff; font-size: 18px; }
.custom-slider { flex: 1; }
:deep(.el-slider__runway) { background-color: #555; }
:deep(.el-slider__bar) { background-color: #fff; }
:deep(.el-slider__button) { border-color: #fff; }
/* 底部操作栏 */
.camera-actions {
height: 140px;
width: 100%;
background: rgba(0, 0, 0, 0.85);
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 10px;
position: relative;
z-index: 30;
}
/* 拍照按钮布局 */
.camera-actions:has(.shutter-btn) {
flex-direction: row;
justify-content: space-around;
align-items: center;
}
.placeholder-btn { width: 40px; }
/* 按钮样式 */
.action-btn { background: rgba(255, 255, 255, 0.15); border: none; color: #fff; }
.text-btn {
color: #fff;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
font-size: 14px;
}
.confirm-btn { min-width: 120px; font-weight: bold; }
/* 快门按钮 */
.shutter-btn {
width: 72px;
height: 72px;
border: 4px solid #fff;
background: transparent !important;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
}
.shutter-inner {
width: 58px;
height: 58px;
background-color: #fff;
border-radius: 50%;
transition: transform 0.1s;
}
.shutter-btn:active .shutter-inner {
transform: scale(0.9);
background-color: #ccc;
}
/* 预览模式操作栏 */
.preview-mode-bar {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
gap: 10px;
}
/* 编辑模式操作栏 */
.edit-mode-bar {
width: 100%;
display: flex;
flex-direction: column;
gap: 15px;
}
.edit-tools {
display: flex;
justify-content: center;
gap: 12px;
align-items: center;
}
.tool-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
font-size: 16px;
transition: all 0.3s;
}
.tool-btn.is-active {
background-color: #409EFF;
color: #fff;
}
.edit-confirm {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
gap: 10px;
}
/* Cropper 样式定制 */
:deep(.cropper-view-box) {
outline: 3px solid #409EFF;
outline-color: #409EFF;
}
:deep(.cropper-point) {
width: 8px;
height: 8px;
background-color: #409EFF;
opacity: 0.9;
}
:deep(.cropper-line) {
background-color: rgba(64, 158, 255, 0.5);
}
</style>

View File

@ -0,0 +1,357 @@
<template>
<div class="qr-scanner-container">
<div id="qr-reader" class="scanner-box"></div>
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
<div class="focus-tip" v-if="!errorMsg && !isPaused">
<div class="scan-line"></div>
<div class="scan-text">将条码置于镜头范围内即可</div>
</div>
<div class="focus-tip success" v-if="isPaused">
<div class="scan-text-success">
<el-icon><CircleCheckFilled /></el-icon>
扫描成功2秒后继续...
</div>
</div>
<div v-if="hasZoom" class="zoom-control">
<span class="zoom-icon">-</span>
<input
type="range"
:min="zoomMin"
:max="zoomMax"
step="0.1"
v-model="currentZoom"
@input="handleZoom"
/>
<span class="zoom-icon">+</span>
<div class="zoom-value">{{ currentZoom }}x</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
import { CircleCheckFilled } from '@element-plus/icons-vue'
const emit = defineEmits(['decode', 'error'])
const errorMsg = ref('')
const isPaused = ref(false)
let html5QrCode: Html5Qrcode | null = null
const scannerElementId = "qr-reader"
// 变焦控制状态
const hasZoom = ref(false)
const zoomMin = ref(1)
const zoomMax = ref(5)
const currentZoom = ref(1)
// 音频上下文
let audioCtx: AudioContext | null = null;
// 提示音播放函数
const playBeep = () => {
try {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
if (!AudioContext) return;
if (!audioCtx) {
audioCtx = new AudioContext();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
// 三角波,清脆响亮
oscillator.type = 'triangle';
oscillator.frequency.value = 1500;
gainNode.gain.setValueAtTime(1.0, audioCtx.currentTime);
oscillator.start();
oscillator.stop(audioCtx.currentTime + 0.1);
} catch (e) {
console.error("播放提示音失败:", e);
}
};
const startScanning = async () => {
try {
html5QrCode = new Html5Qrcode(scannerElementId, {
useBarCodeDetectorIfSupported: true,
formatsToSupport: [
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.QR_CODE
],
verbose: false
})
const config = {
fps: 20,
disableFlip: false,
videoConstraints: {
facingMode: "environment",
// ★★★ 核心修改:设置为 2K (QHD) 分辨率 ★★★
// min: 1280x720 (保证低端机能启动)
// ideal: 2560x1440 (2K QHD清晰度与性能的平衡点)
width: { min: 1280, ideal: 2560, max: 3840 },
height: { min: 720, ideal: 1440, max: 2160 },
// 16:9 的比例
aspectRatio: { ideal: 1.7777777778 },
focusMode: "continuous",
advanced: [{ focusMode: "macro" }, { zoom: 2.0 }]
}
}
await html5QrCode.start(
{ facingMode: "environment" },
config,
(decodedText) => {
if (isPaused.value) return
console.log(`Scan: ${decodedText}`)
isPaused.value = true
playBeep();
emit('decode', decodedText)
if (navigator.vibrate) navigator.vibrate(200);
setTimeout(() => {
isPaused.value = false
}, 2000)
},
(errorMessage) => {
// ignore
}
)
checkZoomCapability()
} catch (err: any) {
let msg = '无法启动摄像头'
console.error("Scanner Error:", err)
if (err.name === 'OverconstrainedError') {
msg = '摄像头不支持 2K 分辨率,请尝试降低配置'
}
errorMsg.value = msg
emit('error', msg)
}
}
const checkZoomCapability = () => {
if (!html5QrCode) return
try {
const videoTrack = html5QrCode.getRunningTrackCameraCapabilities() as MediaTrackCapabilities;
// @ts-ignore
if (videoTrack && 'zoom' in videoTrack) {
hasZoom.value = true
// @ts-ignore
zoomMin.value = videoTrack.zoom.min || 1
// @ts-ignore
zoomMax.value = videoTrack.zoom.max || 5
// @ts-ignore
currentZoom.value = videoTrack.zoom.min || 1
}
} catch (e) {
console.warn("无法获取变焦能力", e)
}
}
const handleZoom = () => {
if (!html5QrCode) return
try {
html5QrCode.applyVideoConstraints({
advanced: [{ zoom: Number(currentZoom.value) }]
})
} catch (e) {
console.error("变焦失败", e)
}
}
const stopScanning = async () => {
if (html5QrCode) {
try {
if (html5QrCode.isScanning) {
await html5QrCode.stop()
}
html5QrCode.clear()
} catch (e) {
console.error("Stop failed", e)
}
}
if (audioCtx) {
audioCtx.close();
audioCtx = null;
}
}
onMounted(() => {
try {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
if (AudioContext) audioCtx = new AudioContext();
} catch(e) {}
setTimeout(() => {
startScanning()
}, 500)
})
onUnmounted(() => {
stopScanning()
})
</script>
<style scoped>
.qr-scanner-container {
width: 100%;
height: 100%;
position: relative;
background-color: #000;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
overflow: hidden;
border-radius: 0;
}
.scanner-box {
width: 100%;
height: 100%;
}
:deep(#qr-reader) {
width: 100%;
height: 100%;
border: none !important;
padding: 0 !important;
}
:deep(#qr-reader video) {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
display: block !important;
}
.error-msg {
position: absolute;
top: 50%;
left: 0;
width: 100%;
text-align: center;
color: #fff;
background: rgba(245, 108, 108, 0.85);
padding: 15px;
transform: translateY(-50%);
z-index: 20;
}
.focus-tip {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
display: flex;
justify-content: center;
align-items: center;
}
.focus-tip.success {
background: rgba(103, 194, 58, 0.2);
}
.scan-line {
width: 100%;
height: 2px;
background: rgba(255, 0, 0, 0.5);
box-shadow: 0 0 4px rgba(255, 0, 0, 0.8);
position: absolute;
animation: scan-move 2.5s infinite linear;
}
.scan-text {
position: absolute;
bottom: 150px;
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
background: rgba(0,0,0,0.3);
padding: 4px 10px;
border-radius: 4px;
}
.scan-text-success {
color: #fff;
font-size: 20px;
font-weight: bold;
display: flex;
align-items: center;
gap: 10px;
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
background: rgba(103, 194, 58, 0.9);
padding: 15px 30px;
border-radius: 50px;
}
@keyframes scan-move {
0% { top: 0%; opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { top: 100%; opacity: 0; }
}
.zoom-control {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
width: 80%;
max-width: 300px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(0, 0, 0, 0.5);
padding: 10px 20px;
border-radius: 30px;
z-index: 50;
color: white;
}
.zoom-control input[type=range] {
flex: 1;
height: 4px;
}
.zoom-icon {
font-size: 20px;
font-weight: bold;
}
.zoom-value {
font-size: 14px;
width: 30px;
text-align: right;
}
</style>

View File

@ -0,0 +1,135 @@
<template>
<div class="signature-container">
<canvas
ref="canvasRef"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart.prevent="startDrawing"
@touchmove.prevent="draw"
@touchend.prevent="stopDrawing"
></canvas>
<div class="actions">
<el-button size="small" @click="clear">重签</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
const isDrawing = ref(false)
const ctx = ref<CanvasRenderingContext2D | null>(null)
// 初始化 Canvas
onMounted(() => {
if (canvasRef.value) {
const canvas = canvasRef.value
// 设置画布大小 (可以根据父容器调整)
canvas.width = canvas.offsetWidth
canvas.height = 300 // 固定高度
ctx.value = canvas.getContext('2d')
if (ctx.value) {
ctx.value.lineWidth = 3
ctx.value.lineCap = 'round'
ctx.value.strokeStyle = '#000'
}
}
})
// 获取坐标 (兼容鼠标和触摸)
const getPos = (e: MouseEvent | TouchEvent) => {
const canvas = canvasRef.value
if (!canvas) return { x: 0, y: 0 }
const rect = canvas.getBoundingClientRect()
let clientX, clientY
if ('touches' in e) {
clientX = e.touches[0].clientX
clientY = e.touches[0].clientY
} else {
clientX = (e as MouseEvent).clientX
clientY = (e as MouseEvent).clientY
}
return {
x: clientX - rect.left,
y: clientY - rect.top
}
}
const startDrawing = (e: MouseEvent | TouchEvent) => {
isDrawing.value = true
const { x, y } = getPos(e)
ctx.value?.beginPath()
ctx.value?.moveTo(x, y)
}
const draw = (e: MouseEvent | TouchEvent) => {
if (!isDrawing.value) return
const { x, y } = getPos(e)
ctx.value?.lineTo(x, y)
ctx.value?.stroke()
}
const stopDrawing = () => {
isDrawing.value = false
}
const clear = () => {
if (canvasRef.value && ctx.value) {
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
}
}
/**
* 导出签名为 File 对象
*/
const generateFile = (): Promise<File | null> => {
return new Promise((resolve) => {
if (!canvasRef.value) {
resolve(null)
return
}
canvasRef.value.toBlob((blob) => {
if (blob) {
const file = new File([blob], `sign_${Date.now()}.png`, { type: 'image/png' })
resolve(file)
} else {
resolve(null)
}
}, 'image/png')
})
}
// 暴露方法给父组件
defineExpose({
clear,
generateFile
})
</script>
<style scoped>
.signature-container {
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #f5f7fa;
position: relative;
width: 100%;
}
canvas {
display: block;
width: 100%; /* 响应式宽度 */
height: 300px;
cursor: crosshair;
background: #fff;
}
.actions {
position: absolute;
bottom: 10px;
right: 10px;
}
</style>

View 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>

View 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>

View 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: 180px; /* 固定侧边栏宽度 */
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: 10px; /* 给内部页面留出边距 */
box-sizing: border-box;
}
</style>

View File

@ -1,5 +1,44 @@
import { createApp } from 'vue'
import './style.css'
import { createPinia } from 'pinia' // [新增] 引入 Pinia
import App from './App.vue'
createApp(App).mount('#app')
// 1. 引入路由配置
import router from './router'
// 2. 引入 Element Plus
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'
// 4. 引入全局样式 (通常建议加上,如果没有可忽略)
import './style.css'
const app = createApp(App)
// =========================================================
// [关键修复] 注册顺序非常重要!
// 1. 必须先注册 Pinia因为 Router 的守卫中会用到 Store
// =========================================================
const pinia = createPinia()
app.use(pinia)
// =========================================================
// 2. 然后注册 Router
// =========================================================
app.use(router)
// 3. 注册 Element Plus
app.use(ElementPlus, {
locale: zhCn, // 设置为中文
})
// 4. 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

View File

@ -0,0 +1,217 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/layout/index.vue'
import { useUserStore } from '@/stores/user'
const routes: Array<RouteRecordRaw> = [
// 1. 登录页
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { hidden: true }
},
// 2. 首页 Dashboard
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页', icon: 'HomeFilled' }
}
]
},
// 3. 基础信息
{
path: '/material',
component: Layout,
redirect: '/material/index',
children: [
{
path: 'index',
name: 'MaterialBase',
component: () => import('@/views/material/list.vue'),
meta: { title: '基础信息', icon: 'Box' }
}
]
},
// 4. 库存管理 (入库)
{
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: '服务权益' }
},
// [原有] 入库记录整合
{
path: 'summary',
name: 'InventorySummary',
component: () => import('@/views/stock/inbound/inbound_summary.vue'),
meta: { title: '入库记录' }
},
// ★ [新增] 库存盘点页面 (查库/消除)
{
path: 'stocktake',
name: 'InventoryStocktake',
component: () => import('@/views/stock/stocktake/index.vue'),
meta: { title: '库存盘点' }
}
]
},
// 5. 出库管理
{
path: '/outbound',
component: Layout,
meta: { title: '出库管理', icon: 'Van' },
redirect: '/outbound/index',
children: [
// ★ [新增] 出库选单打印页面
{
path: 'selection',
name: 'OutboundSelection',
component: () => import('@/views/outbound/Selection.vue'),
meta: { title: '出库选单' }
},
{
path: 'create',
name: 'OutboundCreate',
component: () => import('@/views/outbound/create.vue'),
meta: { title: '扫码出库' }
},
{
path: 'index',
name: 'OutboundList',
component: () => import('@/views/outbound/index.vue'),
meta: { title: '出库记录' }
}
]
},
// 6. 业务操作
{
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',
component: () => import('@/views/transaction/return.vue'),
meta: { title: '返还' }
},
{
path: 'records',
name: 'OpRecords',
component: () => import('@/views/transaction/records.vue'),
meta: { title: '借还记录' }
}
]
},
// 7. 系统管理
{
path: '/system',
component: Layout,
meta: {
title: '系统管理',
icon: 'Setting',
roles: ['super_admin', 'supervisor']
},
children: [
{
path: 'user-create',
name: 'UserCreate',
component: () => import('@/views/system/UserCreate.vue'),
meta: { title: '账号开通', icon: 'User' }
}
]
},
// 404 路由
{
path: '/:pathMatch(.*)*',
redirect: '/dashboard',
meta: { hidden: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// ==========================================
// 全局路由守卫
// ==========================================
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
const token = userStore.token || localStorage.getItem('token')
const userRole = userStore.role || localStorage.getItem('role') || 'user'
if (to.path === '/login') {
if (token) {
next('/')
} else {
next()
}
return
}
if (!token) {
next({ path: '/login', replace: true })
return
}
if (to.meta.roles && Array.isArray(to.meta.roles)) {
if (to.meta.roles.includes(userRole)) {
next()
} else {
next('/dashboard')
}
} else {
next()
}
})
export default router

View File

View File

View File

@ -0,0 +1,77 @@
import { defineStore } from 'pinia'
import { login } from '@/api/auth'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
// 1. State: 初始化时优先从 localStorage 获取,防止刷新丢失
const token = ref(localStorage.getItem('token') || '')
const role = ref(localStorage.getItem('role') || '')
const username = ref(localStorage.getItem('username') || '')
// 2. Actions
// 登录逻辑
const handleLogin = async (loginForm: any) => {
const res = await login(loginForm)
// [调试日志] 查看实际返回的数据结构
console.log('Login API Response:', res)
// ============================================================
// [关键修复] 兼容 Axios 拦截器的不同处理方式
// 如果拦截器已经返回了 response.data那么 res 本身就是数据对象
// ============================================================
const data = res.data || res
// 安全检查:确保 data 存在且包含 access_token
if (!data || !data.access_token) {
console.error('Login Error: 响应数据中缺少 access_token', data)
throw new Error('登录失败: 响应数据异常')
}
// 更新 Pinia 状态 (内存)
token.value = data.access_token
// 处理用户信息 (确保后端返回结构中有 user 字段)
if (data.user) {
role.value = data.user.role || 'user' // 默认给个 user 角色防止空
username.value = data.user.username || '用户'
// 持久化存储用户信息
localStorage.setItem('role', role.value)
localStorage.setItem('username', username.value)
}
// 持久化存储 Token
localStorage.setItem('token', data.access_token)
return true // 返回 true 表示登录成功
}
// 退出逻辑
const logout = () => {
// 1. 清空 Pinia 状态 (内存)
token.value = ''
role.value = ''
username.value = ''
// 2. 清空 LocalStorage (硬盘)
localStorage.removeItem('token')
localStorage.removeItem('role')
localStorage.removeItem('username')
}
// 3. Getters / Helpers
// 判断当前用户是否拥有某些角色
const hasRole = (roles: string[]) => {
return roles.includes(role.value)
}
return {
token,
role,
username,
handleLogin,
logout,
hasRole
}
})

View File

@ -1,18 +1,31 @@
/* inventory-web/src/style.css */
/* 1. 保留原有的字体定义,确保文字清晰好看 */
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
/* 颜色方案配置 */
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
/* 字体渲染优化 */
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 2. 针对亮色模式的颜色适配 (保留) */
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
}
/* 3. 链接的基本样式 (保留,但通常 RouterLink 会覆盖) */
a {
font-weight: 500;
color: #646cff;
@ -22,58 +35,44 @@ a:hover {
color: #535bf2;
}
body {
/* -------------------------------------------------
【重要修改区域】
下面的代码是为了修复“无法铺满全屏”的问题
-------------------------------------------------
*/
/* 4. 全局盒模型修复:防止 padding 撑大元素 */
*, *::before, *::after {
box-sizing: border-box;
}
/* 5. 重置 body 和 html */
html, body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
padding: 0;
width: 100%;
height: 100%; /* 强制高度占满 */
/* !!! 删除了原有的 display: flex; place-items: center;
这是导致你页面缩在中间的罪魁祸首
*/
display: block;
overflow: hidden; /* 防止最外层出现双滚动条 */
}
/* 6. 重置 #app 挂载点 */
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
/* !!! 删除了 max-width: 1280px; padding: 2rem; text-align: center;
这是导致你页面两边留白、无法全屏的原因
*/
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
/* 注意:原文件中关于 button, .card 的样式已被删除,
因为你的项目中引入了 Element Plus
保留原生 button 样式会和 Element Plus 组件产生冲突。
*/

View File

View File

@ -0,0 +1,89 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user' // 引入 Store 获取 Token
// 1. 创建 axios 实例
const service = axios.create({
// 【关键修改】
// 设置为 '/api',请求会自动拼接成 http://localhost:5173/api/...
// 然后被 Vite 代理转发到 http://127.0.0.1:8000/api/...
baseURL: '/api',
timeout: 5000
})
// 2. 请求拦截器
service.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么
// 注意:这里需要确保 Pinia 已经初始化,但在拦截器运行时组件早已加载,通常没问题
// 为了安全起见,也可以直接读 localStorage或者在函数内调用 store
const token = localStorage.getItem('token')
if (token && config.headers) {
// Flask-JWT-Extended 默认需要 'Bearer <token>' 格式
config.headers['Authorization'] = 'Bearer ' + token
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 3. 响应拦截器
service.interceptors.response.use(
(response) => {
// Axios 默认包了一层 data所以这里取 response.data
const res = response.data
// 如果后端返回的是标准 Flask jsonify 结果,通常没有 code 字段(除非你自己封装了)
// 如果你使用了标准 HTTP 状态码200, 201等Axios 会直接进入这里
// 只有当业务逻辑明确返回错误码时才报错 (根据你的后端封装调整)
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) // for debug
let message = error.message || '请求失败'
// 处理 HTTP 状态码错误
const isLoginEndpoint = error.config && error.config.url.includes('/login')
if (error.response) {
const status = error.response.status
const data = error.response.data
if (status === 401) {
// 对于登录接口的401错误不执行登出重定向仅提示错误
if (!isLoginEndpoint) {
message = '登录已过期,请重新登录'
localStorage.clear()
window.location.href = '/login'
}
// 如果是登录接口message会被后面的data.msg覆盖
} else if (status === 403) {
message = '权限不足'
} else if (status === 404) {
message = '请求的资源不存在'
} else if (status === 500) {
message = '服务器内部错误'
} else if (data && data.msg) {
// 优先显示后端返回的错误信息
message = data.msg
}
}
// 登录接口的错误由调用方单独处理,不再显示全局提示
if (!isLoginEndpoint) {
ElMessage.error(message)
}
return Promise.reject(error)
}
)
export default service

View File

View File

@ -0,0 +1,112 @@
<template>
<div class="dashboard-container">
<el-card class="welcome-card">
<template #header>
<div class="card-header">
<span class="title">👋 欢迎回来{{ userStore.username }}</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'
// 1. 引入 User Store
import { useUserStore } from '@/stores/user'
// 引入需要的图标
import { Box, TrendCharts, ShoppingCart, Operation } from '@element-plus/icons-vue'
const router = useRouter()
// 2. 实例化 store
const userStore = useUserStore()
// 统一跳转函数
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>

Some files were not shown because too many files have changed in this diff Show More