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