Merge remote-tracking branch 'origin/2.0权限管理' into 2.0权限管理
This commit is contained in:
1
commit_msg.txt
Normal file
1
commit_msg.txt
Normal file
@ -0,0 +1 @@
|
||||
feat: sort outbound selection list by warehouse location to optimize pick path
|
||||
BIN
db_sync.sql.gz
Normal file
BIN
db_sync.sql.gz
Normal file
Binary file not shown.
BIN
deploy.tar.gz
Normal file
BIN
deploy.tar.gz
Normal file
Binary file not shown.
0
deploy_code.sh
Executable file → Normal file
0
deploy_code.sh
Executable file → Normal file
0
deploy_full.sh
Executable file → Normal file
0
deploy_full.sh
Executable file → Normal file
BIN
deploy_full.tar.gz
Normal file
BIN
deploy_full.tar.gz
Normal file
Binary file not shown.
49
docker-compose.prod.yml
Normal file
49
docker-compose.prod.yml
Normal file
@ -0,0 +1,49 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# --- 数据库 (保持不变) ---
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: inventory_db_prod
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: prod_user
|
||||
POSTGRES_PASSWORD: StrongPassword123!
|
||||
POSTGRES_DB: inventory_system
|
||||
volumes:
|
||||
- ./pgdata_prod:/var/lib/postgresql/data
|
||||
|
||||
# --- 后端 (Flask) (保持不变) ---
|
||||
backend:
|
||||
build:
|
||||
context: ./inventory-backend
|
||||
container_name: inventory_api_prod
|
||||
restart: always
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
- ./uploads_prod:/app/uploads
|
||||
command: gunicorn -c gunicorn.conf.py run:app
|
||||
environment:
|
||||
DATABASE_URL: postgresql://prod_user:StrongPassword123!@db:5432/inventory_system
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
# --- 前端 (Nginx + Vue) (这是需要修改的部分) ---
|
||||
frontend:
|
||||
build:
|
||||
context: ./inventory-web
|
||||
dockerfile: Dockerfile.prod
|
||||
container_name: inventory_ui_prod
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80" # HTTP 端口
|
||||
- "443:443" # 【新增】HTTPS 端口
|
||||
volumes:
|
||||
# 【新增】挂载 SSL 证书
|
||||
# 左边是服务器路径 ./ssl/nginx.crt
|
||||
# 右边是容器内路径 /etc/nginx/ssl/nginx.crt (必须和报错日志里的一致)
|
||||
- ./ssl/nginx.crt:/etc/nginx/ssl/nginx.crt
|
||||
- ./ssl/nginx.key:/etc/nginx/ssl/nginx.key
|
||||
depends_on:
|
||||
- backend
|
||||
287
inventory-backend/app/api/v1/scrap.py
Normal file
287
inventory-backend/app/api/v1/scrap.py
Normal file
@ -0,0 +1,287 @@
|
||||
# inventory-backend/app/api/v1/scrap.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
from app.utils.decorators import permission_required, audit_log
|
||||
from app.services.auth_service import AuthService
|
||||
from app.extensions import db
|
||||
from app.models.transaction import TransScrap
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
import traceback
|
||||
import math
|
||||
|
||||
scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap')
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
||||
# ==============================================================================
|
||||
def get_current_user_permissions():
|
||||
from flask_jwt_extended import get_jwt
|
||||
from app.services.auth_service import AuthService
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
if not user_role:
|
||||
return []
|
||||
if user_role.upper() == 'SUPER_ADMIN':
|
||||
return ['scrap_list:*']
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
return perms
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 1. 扫码查询库存接口 (关联三个库存表)
|
||||
# GET /api/v1/scrap/scan?barcode=...
|
||||
# --------------------------------------------------------
|
||||
@scrap_bp.route('/scan', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('scrap_selection')
|
||||
def scan_barcode():
|
||||
barcode = request.args.get('barcode')
|
||||
if not barcode:
|
||||
return jsonify({'code': 400, 'msg': '请提供条码'}), 400
|
||||
|
||||
try:
|
||||
result = ScrapService.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/scrap
|
||||
# --------------------------------------------------------
|
||||
@scrap_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@audit_log(
|
||||
module='报废管理',
|
||||
action='报废出库',
|
||||
get_target_name_fn=lambda: request.get_json().get('items')[0].get('sku') if request.get_json() and request.get_json().get('items') else None
|
||||
)
|
||||
def create_scrap():
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
if not user_role:
|
||||
return jsonify({'code': 403, 'msg': '未授权'}), 403
|
||||
|
||||
# 超级管理员直接放行
|
||||
if user_role.upper() != 'SUPER_ADMIN':
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
if 'scrap_create:operation' not in perms:
|
||||
return jsonify({'code': 403, 'msg': '权限不足'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
|
||||
|
||||
current_user_name = get_jwt_identity() or 'Unknown'
|
||||
|
||||
# items 必填
|
||||
if 'items' not in data or not data['items']:
|
||||
return jsonify({'code': 400, 'msg': '报废商品列表不能为空'}), 400
|
||||
|
||||
try:
|
||||
result = ScrapService.process_scrap(data, operator_name=current_user_name)
|
||||
return jsonify({'code': 200, 'msg': '报废成功', 'data': result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
db.session.rollback()
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 3. 报废记录查询接口
|
||||
# GET /api/v1/scrap/records
|
||||
# --------------------------------------------------------
|
||||
@scrap_bp.route('/records', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('scrap_list')
|
||||
def get_scrap_records():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
page_size = request.args.get('pageSize', 50, type=int)
|
||||
sku = request.args.get('sku', '')
|
||||
start_date = request.args.get('start_date', '')
|
||||
end_date = request.args.get('end_date', '')
|
||||
|
||||
try:
|
||||
result = ScrapService.query_records(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
sku=sku,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Service 层:报废核心逻辑
|
||||
# ============================================================
|
||||
class ScrapService:
|
||||
|
||||
@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.pre_tax_unit_price) if item.pre_tax_unit_price else 0
|
||||
return 0
|
||||
|
||||
# 1. 查询成品
|
||||
prod = StockProduct.query.filter(
|
||||
db.or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code)
|
||||
).first()
|
||||
if prod:
|
||||
res = ScrapService._format_stock(prod, 'stock_product')
|
||||
res['price'] = get_price(prod, 'stock_product')
|
||||
return res
|
||||
|
||||
# 2. 查询半成品
|
||||
semi = StockSemi.query.filter(
|
||||
db.or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code)
|
||||
).first()
|
||||
if semi:
|
||||
res = ScrapService._format_stock(semi, 'stock_semi')
|
||||
res['price'] = 0
|
||||
return res
|
||||
|
||||
# 3. 查询原材料
|
||||
buy = StockBuy.query.filter(
|
||||
db.or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code)
|
||||
).first()
|
||||
if buy:
|
||||
res = ScrapService._format_stock(buy, 'stock_buy')
|
||||
res['price'] = get_price(buy, 'stock_buy')
|
||||
return res
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_stock(item, table_type):
|
||||
"""格式化库存查询结果"""
|
||||
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,
|
||||
'barcode': item.barcode,
|
||||
'name': item.material_base.name if item.material_base else '',
|
||||
'spec': item.material_base.spec_model if item.material_base else '',
|
||||
'category': item.material_base.category if item.material_base else '',
|
||||
'material_type': item.material_base.material_type if item.material_base else '',
|
||||
'warehouse_loc': item.warehouse_loc or '',
|
||||
'stock_quantity': stock_qty,
|
||||
'available_quantity': avail_qty,
|
||||
'source_table': table_type,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def process_scrap(data, operator_name='System'):
|
||||
"""处理报废:扣减库存并记录报废单"""
|
||||
items = data.get('items', [])
|
||||
reason = data.get('reason', '')
|
||||
|
||||
if not reason:
|
||||
raise ValueError('请填写报废原因')
|
||||
|
||||
created_records = []
|
||||
|
||||
for item in items:
|
||||
stock_id = item.get('id')
|
||||
source_table = item.get('source_table')
|
||||
scrap_qty = float(item.get('quantity', 0))
|
||||
|
||||
if not stock_id or not source_table or scrap_qty <= 0:
|
||||
continue
|
||||
|
||||
# 获取库存记录
|
||||
stock_record = None
|
||||
if source_table == 'stock_product':
|
||||
stock_record = StockProduct.query.get(stock_id)
|
||||
elif source_table == 'stock_semi':
|
||||
stock_record = StockSemi.query.get(stock_id)
|
||||
elif source_table == 'stock_buy':
|
||||
stock_record = StockBuy.query.get(stock_id)
|
||||
|
||||
if not stock_record:
|
||||
raise ValueError(f'库存记录不存在: ID={stock_id}')
|
||||
|
||||
# 检查可用数量
|
||||
avail_qty = float(stock_record.available_quantity) if stock_record.available_quantity else 0
|
||||
if avail_qty < scrap_qty:
|
||||
raise ValueError(f"SKU {stock_record.sku} 可用库存不足,当前可用: {avail_qty}")
|
||||
|
||||
# 计算损失金额
|
||||
unit_price = 0.0
|
||||
if source_table == 'stock_product':
|
||||
unit_price = float(stock_record.sale_price) if stock_record.sale_price else 0
|
||||
elif source_table == 'stock_buy':
|
||||
unit_price = float(stock_record.pre_tax_unit_price) if stock_record.pre_tax_unit_price else 0
|
||||
|
||||
total_loss = round(unit_price * scrap_qty, 2)
|
||||
|
||||
# 扣减库存
|
||||
stock_record.stock_quantity = float(stock_record.stock_quantity) - scrap_qty
|
||||
stock_record.available_quantity = float(stock_record.available_quantity) - scrap_qty
|
||||
|
||||
# 创建报废记录
|
||||
scrap_record = TransScrap(
|
||||
sku=stock_record.sku,
|
||||
source_table=source_table,
|
||||
stock_id=stock_id,
|
||||
quantity=scrap_qty,
|
||||
reason=reason,
|
||||
operator_name=operator_name,
|
||||
approval_status='approved',
|
||||
cost_at_scrap=unit_price,
|
||||
total_loss=total_loss
|
||||
)
|
||||
db.session.add(scrap_record)
|
||||
created_records.append(scrap_record)
|
||||
|
||||
db.session.commit()
|
||||
return {'count': len(created_records)}
|
||||
|
||||
@staticmethod
|
||||
def query_records(page=1, page_size=50, sku='', start_date='', end_date=''):
|
||||
"""分页查询报废记录"""
|
||||
query = TransScrap.query
|
||||
|
||||
if sku:
|
||||
query = query.filter(TransScrap.sku.like(f'%{sku}%'))
|
||||
|
||||
if start_date:
|
||||
query = query.filter(TransScrap.operation_time >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(TransScrap.operation_time <= end_date + ' 23:59:59')
|
||||
|
||||
# 按时间倒序
|
||||
query = query.order_by(TransScrap.operation_time.desc())
|
||||
|
||||
total = query.count()
|
||||
records = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
'list': [r.to_dict() for r in records],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'pageSize': page_size
|
||||
}
|
||||
10
inventory-backend/app/services/print/printer_config.json
Normal file
10
inventory-backend/app/services/print/printer_config.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"label_printer": {
|
||||
"ip": "172.16.0.119",
|
||||
"port": 9100
|
||||
},
|
||||
"network_printer": {
|
||||
"ip": "192.168.9.250",
|
||||
"port": 9100
|
||||
}
|
||||
}
|
||||
50
inventory-backend/find_error.py
Normal file
50
inventory-backend/find_error.py
Normal file
@ -0,0 +1,50 @@
|
||||
import os
|
||||
|
||||
|
||||
def find_bad_imports(root_dir):
|
||||
# 我们要查找的错误引用字符串
|
||||
targets = [
|
||||
"app.models.material",
|
||||
"from app.models.material"
|
||||
]
|
||||
|
||||
print(f"🚀 开始扫描目录: {os.path.abspath(root_dir)}")
|
||||
print(f"🔍 正在寻找残留代码: {targets} ...\n")
|
||||
|
||||
found_count = 0
|
||||
|
||||
# 排除的目录(避免扫描虚拟环境或缓存)
|
||||
exclude_dirs = {'.git', '__pycache__', 'venv', '.idea', '.vscode'}
|
||||
|
||||
for root, dirs, files in os.walk(root_dir):
|
||||
# 修改 dirs 列表以跳过排除的目录
|
||||
dirs[:] = [d for d in dirs if d not in exclude_dirs]
|
||||
|
||||
for file in files:
|
||||
if file.endswith(".py"):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
for i, line in enumerate(lines, 1):
|
||||
for target in targets:
|
||||
if target in line:
|
||||
print(f"❌ 发现错误文件: {file_path}")
|
||||
print(f" ➡️ 第 {i} 行: {line.strip()}")
|
||||
found_count += 1
|
||||
except Exception as e:
|
||||
# 忽略无法读取的文件(如二进制文件)
|
||||
pass
|
||||
|
||||
print("-" * 50)
|
||||
if found_count == 0:
|
||||
print("✅ 恭喜!未发现任何 'app.models.material' 的残留引用。")
|
||||
print("💡 如果依然报错,请尝试完全重启 Docker 或清理 __pycache__。")
|
||||
else:
|
||||
print(f"⚠️ 共发现 {found_count} 处错误引用,请照上方路径逐一修改。")
|
||||
print("👉 修改方法: 将 'app.models.material' 改为 'app.models.base'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 扫描当前目录
|
||||
find_bad_imports(".")
|
||||
9
inventory-backend/init_db.py
Normal file
9
inventory-backend/init_db.py
Normal file
@ -0,0 +1,9 @@
|
||||
from app import create_app, db
|
||||
|
||||
# 1. 创建应用实例
|
||||
app = create_app()
|
||||
|
||||
# 2. 在应用上下文中创建表
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
print("✅ 数据库表结构已成功创建!")
|
||||
139
inventory-backend/test_barcode_limit.py
Normal file
139
inventory-backend/test_barcode_limit.py
Normal file
@ -0,0 +1,139 @@
|
||||
import socket
|
||||
import os
|
||||
from io import BytesIO
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# 引入条形码生成库
|
||||
try:
|
||||
import barcode
|
||||
from barcode.writer import ImageWriter
|
||||
except ImportError:
|
||||
print("❌ 错误: 请先安装 python-barcode (pip install python-barcode)")
|
||||
exit()
|
||||
|
||||
|
||||
class BarcodeStackTester:
|
||||
# 打印机配置
|
||||
PRINTER_IP = "192.168.9.205"
|
||||
PRINTER_PORT = 9100
|
||||
|
||||
# 纸张配置: 40mm x 30mm @ 300 DPI
|
||||
DOTS_PER_MM = 12
|
||||
CANVAS_W = int(40 * DOTS_PER_MM) # 480px
|
||||
CANVAS_H = int(30 * DOTS_PER_MM) # 360px
|
||||
|
||||
@staticmethod
|
||||
def _generate_raw_barcode(content, bar_height_mm=4):
|
||||
"""
|
||||
生成原始条码图片 (不强制缩放宽度,让其自然生长)
|
||||
"""
|
||||
try:
|
||||
# Code128 自动优化 (数字会被压缩,所以20位数字其实不长)
|
||||
writer = ImageWriter()
|
||||
|
||||
# module_width: 条码黑条的最小宽度(mm)。
|
||||
# 0.2mm 在 300DPI 下约等于 2.4像素。这是一个比较清晰且节省空间的数值。
|
||||
# 如果设得太大(如0.3),20位可能会超出40mm纸张。
|
||||
options = {
|
||||
"module_width": 0.2,
|
||||
"module_height": bar_height_mm, # 条码高度
|
||||
"quiet_zone": 2.0, # 两侧留白(mm)
|
||||
"write_text": False, # 【关键】不写文字
|
||||
"dpi": 300 # 对应打印机DPI
|
||||
}
|
||||
|
||||
code_img = barcode.get('code128', content, writer=writer)
|
||||
|
||||
# 渲染到内存
|
||||
buffer = BytesIO()
|
||||
code_img.write(buffer, options=options)
|
||||
buffer.seek(0)
|
||||
|
||||
return Image.open(buffer)
|
||||
except Exception as e:
|
||||
print(f"生成失败: {e}")
|
||||
return None
|
||||
|
||||
def run_test(self):
|
||||
# 1. 创建白色底图 (40x30mm)
|
||||
canvas = Image.new('RGB', (self.CANVAS_W, self.CANVAS_H), color='white')
|
||||
|
||||
# 测试用例: 长度从小到大
|
||||
test_lengths = [13, 15, 17, 20]
|
||||
|
||||
# 布局参数
|
||||
current_y = 20 # 顶部起始留白 (px)
|
||||
gap = 10 # 间距 (px)
|
||||
|
||||
print("-" * 50)
|
||||
print("🚀 开始生成堆叠条码测试图...")
|
||||
|
||||
for length in test_lengths:
|
||||
# 生成全7的内容
|
||||
content = "7" * length
|
||||
print(f" 正在处理: {length}位 -> {content}")
|
||||
|
||||
# 生成条码 (高度设为5mm左右,方便在一张纸上放下4个)
|
||||
bar_img = self._generate_raw_barcode(content, bar_height_mm=5)
|
||||
|
||||
if bar_img:
|
||||
# 计算居中位置
|
||||
# bar_img.width 是条码生成的自然宽度
|
||||
x_pos = (self.CANVAS_W - bar_img.width) // 2
|
||||
|
||||
# 如果宽度超出了纸张 (x_pos < 0),说明40mm纸打不下这么多位
|
||||
if x_pos < 0:
|
||||
print(f" ⚠️ 警告: {length}位条码过宽 ({bar_img.width}px > {self.CANVAS_W}px),可能会被裁切!")
|
||||
x_pos = 0
|
||||
|
||||
# 粘贴到画布
|
||||
canvas.paste(bar_img, (x_pos, current_y))
|
||||
|
||||
# 更新Y坐标,准备画下一个
|
||||
current_y += bar_img.height + gap
|
||||
else:
|
||||
print(" 生成失败")
|
||||
|
||||
# 2. 保存预览图
|
||||
preview_file = "test_stack_preview.jpg"
|
||||
canvas.save(preview_file)
|
||||
print(f"✅ 图片已生成: {preview_file} (请打开查看宽度是否超出)")
|
||||
|
||||
# 3. 发送打印 (二值化处理)
|
||||
self._send_to_printer(canvas)
|
||||
|
||||
def _send_to_printer(self, img_rgb):
|
||||
try:
|
||||
# 转二值化 (白=1, 黑=0)
|
||||
img_bw = img_rgb.convert('L').convert('1', dither=Image.Dither.NONE)
|
||||
bitmap_data = img_bw.tobytes()
|
||||
|
||||
width_bytes = (img_bw.width + 7) // 8
|
||||
height_dots = img_bw.height
|
||||
|
||||
# TSPL 指令
|
||||
header = (
|
||||
f"SIZE 40 mm, 30 mm\r\n"
|
||||
"GAP 2 mm, 0 mm\r\n"
|
||||
"CLS\r\n"
|
||||
"DIRECTION 1\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"
|
||||
|
||||
print("🖨️ 正在发送打印指令...")
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(5)
|
||||
s.connect((self.PRINTER_IP, self.PRINTER_PORT))
|
||||
s.sendall(header + bitmap_cmd + bitmap_data + footer)
|
||||
s.close()
|
||||
print("✅ 指令已发送")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 打印失败: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tester = BarcodeStackTester()
|
||||
tester.run_test()
|
||||
56
inventory-backend/打印测试代码.py
Normal file
56
inventory-backend/打印测试代码.py
Normal file
@ -0,0 +1,56 @@
|
||||
import socket
|
||||
|
||||
# ================= 配置区域 =================
|
||||
PRINTER_IP = '192.168.9.89'
|
||||
PRINTER_PORT = 9100
|
||||
|
||||
|
||||
def print_barcode_no_text():
|
||||
try:
|
||||
# 1. 建立连接
|
||||
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
client.settimeout(5)
|
||||
client.connect((PRINTER_IP, PRINTER_PORT))
|
||||
print(f"✅ 已连接 {PRINTER_IP}")
|
||||
|
||||
# 2. 准备 TSPL 指令
|
||||
commands = (
|
||||
"SIZE 40 mm, 30 mm\r\n"
|
||||
"GAP 2 mm, 0 mm\r\n"
|
||||
"CLS\r\n"
|
||||
"DIRECTION 1\r\n"
|
||||
|
||||
# 保持之前的居中设置 (向右偏移 3mm)
|
||||
"REFERENCE 25, 0\r\n"
|
||||
|
||||
# --- 条形码 (不显示数字) ---
|
||||
# 关键修改:第5个参数从 1 改为了 0
|
||||
# BARCODE x,y,"128",height,human_readable(0=不显),rot,narrow,wide,"content"
|
||||
"BARCODE 20,10,\"128\",80,0,0,2,2,\"00000002\"\r\n"
|
||||
|
||||
# --- 文本内容 (保持字号 3) ---
|
||||
# 第一行 Y=110
|
||||
"TEXT 0,110,\"3\",0,1,1,\"N:test01\"\r\n"
|
||||
"TEXT 150,110,\"3\",0,1,1,\"S:test01\"\r\n"
|
||||
|
||||
# 第二行 Y=140
|
||||
"TEXT 0,140,\"3\",0,1,1,\"T:test01\"\r\n"
|
||||
"TEXT 150,140,\"3\",0,1,1,\"K:test01\"\r\n"
|
||||
|
||||
# 第三行 Y=170
|
||||
"TEXT 0,170,\"3\",0,1,1,\"L:test01\"\r\n"
|
||||
"TEXT 150,170,\"3\",0,1,1,\"B:test01\"\r\n"
|
||||
|
||||
"PRINT 1,1\r\n"
|
||||
)
|
||||
|
||||
client.sendall(commands.encode('gbk'))
|
||||
client.close()
|
||||
print("✅ 条码数字已隐藏,指令发送成功")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 失败: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print_barcode_no_text()
|
||||
18
inventory-web/Dockerfile.prod
Normal file
18
inventory-web/Dockerfile.prod
Normal file
@ -0,0 +1,18 @@
|
||||
# 第一阶段:构建 (Build Stage)
|
||||
FROM node:20-alpine as build-stage
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
# 执行构建,生成 dist 目录
|
||||
RUN npm run build
|
||||
|
||||
# 第二阶段:生产服务 (Production Stage)
|
||||
FROM nginx:stable-alpine as production-stage
|
||||
# 复制构建好的 dist 文件到 Nginx 目录
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
# 复制我们自定义的 nginx 配置
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
49
inventory-web/docker-compose.prod.yml
Normal file
49
inventory-web/docker-compose.prod.yml
Normal file
@ -0,0 +1,49 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# --- 数据库 (保持不变) ---
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: inventory_db_prod
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: prod_user
|
||||
POSTGRES_PASSWORD: StrongPassword123!
|
||||
POSTGRES_DB: inventory_system
|
||||
volumes:
|
||||
- ./pgdata_prod:/var/lib/postgresql/data
|
||||
|
||||
# --- 后端 (Flask) (保持不变) ---
|
||||
backend:
|
||||
build:
|
||||
context: ./inventory-backend
|
||||
container_name: inventory_api_prod
|
||||
restart: always
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
- ./uploads_prod:/app/uploads
|
||||
command: gunicorn -c gunicorn.conf.py run:app
|
||||
environment:
|
||||
DATABASE_URL: postgresql://prod_user:StrongPassword123!@db:5432/inventory_system
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
# --- 前端 (Nginx + Vue) (这是需要修改的部分) ---
|
||||
frontend:
|
||||
build:
|
||||
context: ./inventory-web
|
||||
dockerfile: Dockerfile.prod
|
||||
container_name: inventory_ui_prod
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80" # HTTP 端口
|
||||
- "443:443" # 【新增】HTTPS 端口
|
||||
volumes:
|
||||
# 【新增】挂载 SSL 证书
|
||||
# 左边是服务器路径 ./ssl/nginx.crt
|
||||
# 右边是容器内路径 /etc/nginx/ssl/nginx.crt (必须和报错日志里的一致)
|
||||
- ./ssl/nginx.crt:/etc/nginx/ssl/nginx.crt
|
||||
- ./ssl/nginx.key:/etc/nginx/ssl/nginx.key
|
||||
depends_on:
|
||||
- backend
|
||||
2650
inventory-web/package-lock.json
generated
Normal file
2650
inventory-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
inventory-web/src/api/scrap.ts
Normal file
28
inventory-web/src/api/scrap.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 1. 扫码查询库存
|
||||
export function scanBarcode(barcode: string) {
|
||||
return request({
|
||||
url: '/v1/scrap/scan',
|
||||
method: 'get',
|
||||
params: { barcode }
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 提交报废单
|
||||
export function createScrap(data: any) {
|
||||
return request({
|
||||
url: '/v1/scrap',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 报废记录列表查询
|
||||
export function getScrapRecords(params: any) {
|
||||
return request({
|
||||
url: '/v1/scrap/records',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
138
inventory-web/src/views/operation/scrap/index.vue
Normal file
138
inventory-web/src/views/operation/scrap/index.vue
Normal file
@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="filter-container">
|
||||
<el-input v-model="sku" placeholder="SKU" style="width: 200px" @keyup.enter="fetchData" />
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="margin-left: 10px; width: 240px"
|
||||
/>
|
||||
<el-button type="primary" @click="fetchData">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="list"
|
||||
border
|
||||
stripe
|
||||
style="margin-top: 20px"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
|
||||
<el-table-column label="物料名称" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.material_name || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="规格型号" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.material_spec || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="报废数量" width="100" align="right" />
|
||||
<el-table-column prop="reason" label="报废原因" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="operator_name" label="操作人" width="100" />
|
||||
<el-table-column prop="operation_time" label="报废时间" width="160" />
|
||||
<el-table-column label="损失金额" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.total_loss ? `¥${row.total_loss}` : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="approval_status" label="审批状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.approval_status)">
|
||||
{{ getStatusText(row.approval_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="handlePage"
|
||||
style="margin-top: 10px; text-align: right"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getScrapRecords } from '@/api/scrap'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const sku = ref('')
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
sku: sku.value
|
||||
}
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
}
|
||||
const res = await getScrapRecords(params)
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePage = (val: number) => {
|
||||
page.value = val
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const resetFilter = () => {
|
||||
sku.value = ''
|
||||
dateRange.value = null
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
pending: 'warning'
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
approved: '已审批',
|
||||
rejected: '已拒绝',
|
||||
pending: '待审批'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
1
msg.txt
Normal file
1
msg.txt
Normal file
@ -0,0 +1 @@
|
||||
fix: send correct numeric user_id to stocktake draft api to prevent 500 error
|
||||
39
nginx/default.conf
Normal file
39
nginx/default.conf
Normal file
@ -0,0 +1,39 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 静态文件访问:匹配 /api/v1/common/files/ 路径
|
||||
# 使用 alias 直接映射到本地文件系统
|
||||
location /api/v1/common/files/ {
|
||||
# 去掉前缀 /api/v1/common/files/,映射到 /app/uploads/
|
||||
alias /app/uploads/;
|
||||
|
||||
# 尝试文件,找不到返回 404
|
||||
try_files $uri =404;
|
||||
|
||||
# 关闭日志以提升性能(可选)
|
||||
# access_log off;
|
||||
|
||||
# 设置合适的 MIME 类型
|
||||
include /etc/nginx/mime.types;
|
||||
}
|
||||
|
||||
# 其他所有请求,反向代理到 Flask 后端
|
||||
location / {
|
||||
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;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# 启用 HTTP/1.1
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
}
|
||||
BIN
product.template.xlsx
Normal file
BIN
product.template.xlsx
Normal file
Binary file not shown.
77
sync_db.sh
Normal file
77
sync_db.sh
Normal file
@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# 1. 本地 WSL 数据库配置 (根据你之前的数据)
|
||||
# ==========================================
|
||||
LOCAL_CONTAINER="inventory_db"
|
||||
LOCAL_DB_USER="test"
|
||||
LOCAL_DB_NAME="inventory_system"
|
||||
|
||||
# ==========================================
|
||||
# 2. 远程服务器 SSH 配置 (根据你的截图)
|
||||
# ==========================================
|
||||
REMOTE_USER="dxc"
|
||||
REMOTE_HOST="172.16.0.198"
|
||||
REMOTE_PORT="22"
|
||||
|
||||
# ==========================================
|
||||
# 3. 远程服务器 Docker 配置 (根据你的 docker-compose.prod.yml)
|
||||
# ==========================================
|
||||
REMOTE_CONTAINER="inventory_db_prod"
|
||||
REMOTE_DB_USER="prod_user"
|
||||
REMOTE_DB_PASS="StrongPassword123!"
|
||||
REMOTE_DB_NAME="inventory_system"
|
||||
|
||||
# --- 临时文件变量 ---
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
DUMP_FILE="db_sync_${TIMESTAMP}.sql.gz"
|
||||
LOCAL_DUMP_PATH="/tmp/${DUMP_FILE}"
|
||||
|
||||
echo "========================================================"
|
||||
echo " 🔄 开始同步 WSL 数据库到远程服务器 (${REMOTE_HOST})"
|
||||
echo "========================================================"
|
||||
|
||||
# --- 步骤 1: 本地导出 ---
|
||||
echo -e "\n[1/4] 📦 正在本地打包数据库..."
|
||||
# 注意:这里使用 pg_dump 导出,为了兼容性,排除可能引起冲突的权限所有者信息 (-O -x)
|
||||
docker exec ${LOCAL_CONTAINER} pg_dump -U ${LOCAL_DB_USER} -d ${LOCAL_DB_NAME} -O -x | gzip > ${LOCAL_DUMP_PATH}
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ 本地数据库导出失败!请检查本地 inventory_db 容器是否正常运行。"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ 本地打包完成: ${DUMP_FILE}"
|
||||
|
||||
# --- 步骤 2: 传输文件 ---
|
||||
echo -e "\n[2/4] 🚀 正在通过 SCP 传输文件到服务器..."
|
||||
scp -P ${REMOTE_PORT} ${LOCAL_DUMP_PATH} ${REMOTE_USER}@${REMOTE_HOST}:/tmp/${DUMP_FILE}
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ 文件传输失败!请检查网络或密码。"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ 传输成功!"
|
||||
|
||||
# --- 步骤 3: 远程服务器执行替换 ---
|
||||
echo -e "\n[3/4] ⚠️ 正在服务器上清空旧数据并导入新数据..."
|
||||
ssh -p ${REMOTE_PORT} ${REMOTE_USER}@${REMOTE_HOST} << EOF
|
||||
echo " -> 将备份文件复制进服务器容器 [${REMOTE_CONTAINER}]..."
|
||||
docker cp /tmp/${DUMP_FILE} ${REMOTE_CONTAINER}:/tmp/${DUMP_FILE}
|
||||
|
||||
echo " -> 危险操作:清空服务器旧数据环境..."
|
||||
# 传入 PGPASSWORD 环境变量以防密码拦截
|
||||
docker exec -e PGPASSWORD="${REMOTE_DB_PASS}" ${REMOTE_CONTAINER} psql -U ${REMOTE_DB_USER} -d ${REMOTE_DB_NAME} -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO ${REMOTE_DB_USER};"
|
||||
|
||||
echo " -> 正在导入最新数据..."
|
||||
docker exec -e PGPASSWORD="${REMOTE_DB_PASS}" ${REMOTE_CONTAINER} sh -c "gunzip -c /tmp/${DUMP_FILE} | psql -U ${REMOTE_DB_USER} -d ${REMOTE_DB_NAME}"
|
||||
|
||||
echo " -> 清理服务器端临时文件..."
|
||||
docker exec ${REMOTE_CONTAINER} rm /tmp/${DUMP_FILE}
|
||||
rm /tmp/${DUMP_FILE}
|
||||
EOF
|
||||
|
||||
# --- 步骤 4: 清理本地 ---
|
||||
echo -e "\n[4/4] 🧹 清理本地临时文件..."
|
||||
rm ${LOCAL_DUMP_PATH}
|
||||
|
||||
echo -e "\n========================================================"
|
||||
echo "🎉 数据库全量替换成功!快去刷新你的线上系统看看吧!"
|
||||
echo "========================================================"
|
||||
122
导入基础信息.py
Normal file
122
导入基础信息.py
Normal file
@ -0,0 +1,122 @@
|
||||
import pandas as pd
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
|
||||
# 1. 数据库配置 (根据您的 docker-compose 配置)
|
||||
DB_CONFIG = {
|
||||
'dbname': 'inventory_system',
|
||||
'user': 'test',
|
||||
'password': '1234',
|
||||
'host': 'localhost', # 脚本在宿主机运行,连接映射出的端口
|
||||
'port': '5434'
|
||||
}
|
||||
|
||||
# 2. Excel 文件路径
|
||||
EXCEL_FILE = 'product.template.xlsx'
|
||||
|
||||
|
||||
def process_excel_to_db():
|
||||
try:
|
||||
# 读取 Excel 文件
|
||||
# dtype=str 确保所有数据读取为字符串,避免数字被自动转换为浮点数(如条码)
|
||||
df = pd.read_excel(EXCEL_FILE, dtype=str)
|
||||
|
||||
# 将 NaN (空值) 替换为 None,方便插入数据库时转为 NULL
|
||||
df = df.where(pd.notnull(df), None)
|
||||
|
||||
print(f"成功读取 Excel 文件,共 {len(df)} 行数据。")
|
||||
|
||||
# 连接数据库
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
cur = conn.cursor()
|
||||
|
||||
success_count = 0
|
||||
|
||||
for index, row in df.iterrows():
|
||||
# --- 数据提取与处理逻辑 ---
|
||||
|
||||
# 1. 名称 -> name, common_name
|
||||
name = row.get('名称')
|
||||
common_name = row.get('名称') # 题目要求写入到俗名中也用名称
|
||||
|
||||
# 2. 产品类别 -> category
|
||||
category = row.get('产品类别')
|
||||
|
||||
# 3. 产品类型 -> material_type
|
||||
material_type = row.get('产品类型')
|
||||
|
||||
# 4. 规格型号 -> spec_model (逻辑:条码/内部参考,如果没有内部参考则没有/)
|
||||
barcode = row.get('条码')
|
||||
internal_ref = row.get('内部参考')
|
||||
|
||||
spec_model = ""
|
||||
if barcode and internal_ref:
|
||||
spec_model = f"{barcode}/{internal_ref}"
|
||||
elif barcode:
|
||||
spec_model = f"{barcode}"
|
||||
elif internal_ref: # 以防万一只有内部参考
|
||||
spec_model = f"{internal_ref}"
|
||||
else:
|
||||
spec_model = None
|
||||
|
||||
# 5. 采购计量单位 -> unit
|
||||
unit = row.get('采购计量单位')
|
||||
|
||||
# 6. 其他固定值或空值
|
||||
visibility_level = 0
|
||||
manual_link = "" # 空值
|
||||
product_image = "" # 空值 (或者根据您的JSON需求,如果是存JSON字符串可以是 '[]')
|
||||
is_enabled = True
|
||||
|
||||
# 简单的非空检查 (根据数据库 NOT NULL 约束)
|
||||
if not name:
|
||||
print(f"跳过第 {index + 2} 行:名称为空")
|
||||
continue
|
||||
|
||||
# --- 执行插入 SQL ---
|
||||
insert_query = """
|
||||
INSERT INTO material_base (name, \
|
||||
common_name, \
|
||||
category, \
|
||||
material_type, \
|
||||
spec_model, \
|
||||
unit, \
|
||||
visibility_level, \
|
||||
manual_link, \
|
||||
product_image, \
|
||||
is_enabled) \
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) \
|
||||
"""
|
||||
|
||||
cur.execute(insert_query, (
|
||||
name,
|
||||
common_name,
|
||||
category,
|
||||
material_type,
|
||||
spec_model,
|
||||
unit,
|
||||
visibility_level,
|
||||
manual_link,
|
||||
product_image,
|
||||
is_enabled
|
||||
))
|
||||
|
||||
success_count += 1
|
||||
|
||||
# 提交事务
|
||||
conn.commit()
|
||||
print(f"导入完成!成功插入 {success_count} 条数据。")
|
||||
|
||||
except Exception as e:
|
||||
print(f"发生错误: {e}")
|
||||
if 'conn' in locals() and conn:
|
||||
conn.rollback()
|
||||
finally:
|
||||
if 'cur' in locals() and cur:
|
||||
cur.close()
|
||||
if 'conn' in locals() and conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
process_excel_to_db()
|
||||
Reference in New Issue
Block a user