Merge remote-tracking branch 'origin/2.0权限管理' into 2.0权限管理

This commit is contained in:
dxc
2026-03-19 10:50:47 +08:00
23 changed files with 3723 additions and 0 deletions

1
commit_msg.txt Normal file
View File

@ -0,0 +1 @@
feat: sort outbound selection list by warehouse location to optimize pick path

BIN
db_sync.sql.gz Normal file

Binary file not shown.

BIN
deploy.tar.gz Normal file

Binary file not shown.

0
deploy_code.sh Executable file → Normal file
View File

0
deploy_full.sh Executable file → Normal file
View File

BIN
deploy_full.tar.gz Normal file

Binary file not shown.

49
docker-compose.prod.yml Normal file
View 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

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

View File

@ -0,0 +1,10 @@
{
"label_printer": {
"ip": "172.16.0.119",
"port": 9100
},
"network_printer": {
"ip": "192.168.9.250",
"port": 9100
}
}

View 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(".")

View File

@ -0,0 +1,9 @@
from app import create_app, db
# 1. 创建应用实例
app = create_app()
# 2. 在应用上下文中创建表
with app.app_context():
db.create_all()
print("✅ 数据库表结构已成功创建!")

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

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

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

View 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

File diff suppressed because it is too large Load Diff

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

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

@ -0,0 +1 @@
fix: send correct numeric user_id to stocktake draft api to prevent 500 error

39
nginx/default.conf Normal file
View 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

Binary file not shown.

77
sync_db.sh Normal file
View 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
View 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()