feat: initialize inventory profit and loss adjustment module

This commit is contained in:
DXC
2026-03-19 12:06:32 +08:00
parent 7a4717ce21
commit d8a57ab66e
6 changed files with 634 additions and 1 deletions

View File

@ -162,7 +162,17 @@ def create_app():
print(f"⚠️ 审计日志菜单初始化跳过: {e}")
# -----------------------------------------------------
# 2.10 注册库位管理模块 (Warehouse)
# 2.10 注册盘盈盘亏管理模块 (Stock Adjustment)
# -----------------------------------------------------
try:
from app.api.v1.stock.adjustment import adjustment_bp
app.register_blueprint(adjustment_bp, url_prefix='/api/v1/stock/adjustment')
print("✅ Stock Adjustment 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Stock Adjustment 模块导入失败: {e}")
# -----------------------------------------------------
# 2.11 注册库位管理模块 (Warehouse)
# -----------------------------------------------------
try:
from app.api.v1.warehouse import warehouse_bp

View File

@ -0,0 +1,223 @@
# inventory-backend/app/api/v1/stock/adjustment.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
from app.extensions import db
from app.models.stock.adjustment import StockAdjustment
from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from datetime import datetime
import random
import string
adjustment_bp = Blueprint('adjustment', __name__, url_prefix='/stock/adjustment')
def generate_order_no():
"""生成单号 ADJ-YYYYMMDD-XXXX"""
today = datetime.now().strftime('%Y%m%d')
suffix = ''.join(random.choices(string.digits, k=4))
return f'ADJ-{today}-{suffix}'
def get_stock_model(source_table):
"""根据source_table获取对应的库存模型"""
if source_table == 'stock_buy':
return StockBuy
elif source_table == 'stock_semi':
return StockSemi
elif source_table == 'stock_product':
return StockProduct
return None
# --------------------------------------------------------
# 1. 获取调整单列表
# GET /api/v1/stock/adjustment/list
# --------------------------------------------------------
@adjustment_bp.route('/list', methods=['GET'])
@jwt_required()
@permission_required('stock_adjustment:list')
def get_list():
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
keyword = request.args.get('keyword', '')
adjust_type = request.args.get('adjust_type', '')
status = request.args.get('status', '')
query = StockAdjustment.query
if keyword:
query = query.filter(
db.or_(
StockAdjustment.order_no.ilike(f'%{keyword}%'),
StockAdjustment.sku.ilike(f'%{keyword}%'),
StockAdjustment.material_name.ilike(f'%{keyword}%')
)
)
if adjust_type:
query = query.filter(StockAdjustment.adjust_type == adjust_type)
if status:
query = query.filter(StockAdjustment.status == status)
# 按创建时间降序
query = query.order_by(StockAdjustment.create_time.desc())
pagination = query.paginate(page=page, per_page=limit, error_out=False)
return jsonify({
'code': 200,
'data': {
'items': [item.to_dict() for item in pagination.items],
'total': pagination.total,
'page': page,
'limit': limit
}
})
# --------------------------------------------------------
# 2. 创建调整单
# POST /api/v1/stock/adjustment/create
# --------------------------------------------------------
@adjustment_bp.route('/create', methods=['POST'])
@jwt_required()
@permission_required('stock_adjustment:operation')
def create():
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '请求参数不能为空'}), 400
# 必填字段验证
required_fields = ['source_table', 'stock_id', 'adjust_type', 'adjust_quantity', 'reason']
for field in required_fields:
if field not in data or not data.get(field):
return jsonify({'code': 400, 'msg': f'{field} 为必填项'}), 400
source_table = data['source_table']
stock_id = int(data['stock_id'])
adjust_type = data['adjust_type'] # 'profit' or 'loss'
adjust_quantity = float(data['adjust_quantity'])
reason = data['reason']
operator = get_jwt_identity() or 'system'
# 获取库存记录
StockModel = get_stock_model(source_table)
if not StockModel:
return jsonify({'code': 400, 'msg': '无效的库存类型'}), 400
stock = StockModel.query.get(stock_id)
if not stock:
return jsonify({'code': 404, 'msg': '库存记录不存在'}), 404
# 获取物料信息
base_id = getattr(stock, 'base_id', None)
material = MaterialBase.query.get(base_id) if base_id else None
# 计算库存变动
if adjust_type == 'profit':
# 盘盈:增加库存
new_stock_qty = float(stock.stock_quantity or 0) + adjust_quantity
new_avail_qty = float(stock.available_quantity or 0) + adjust_quantity
elif adjust_type == 'loss':
# 盘亏:减少库存
new_stock_qty = float(stock.stock_quantity or 0) - adjust_quantity
new_avail_qty = float(stock.available_quantity or 0) - adjust_quantity
if new_stock_qty < 0 or new_avail_qty < 0:
return jsonify({'code': 400, 'msg': '库存不足,无法盘亏'}), 400
else:
return jsonify({'code': 400, 'msg': '无效的调整类型'}), 400
try:
# 创建调整单
adjustment = StockAdjustment(
order_no=generate_order_no(),
base_id=base_id,
stock_id=stock_id,
source_table=source_table,
sku=getattr(stock, 'sku', None) or getattr(stock, 'SKU', None),
material_name=material.name if material else getattr(stock, 'sku', '未知'),
spec_model=getattr(material, 'spec_model', None) if material else None,
warehouse_location=getattr(stock, 'warehouse_location', None),
adjust_type=adjust_type,
adjust_quantity=adjust_quantity,
reason=reason,
status='completed',
operator=operator
)
db.session.add(adjustment)
# 更新库存
stock.stock_quantity = new_stock_qty
stock.available_quantity = new_avail_qty
db.session.commit()
return jsonify({
'code': 200,
'msg': '调整成功',
'data': adjustment.to_dict()
})
except Exception as e:
db.session.rollback()
return jsonify({'code': 500, 'msg': f'调整失败: {str(e)}'}), 500
# --------------------------------------------------------
# 3. 获取库存列表(用于选择物料)
# GET /api/v1/stock/adjustment/stocks
# --------------------------------------------------------
@adjustment_bp.route('/stocks', methods=['GET'])
@jwt_required()
@permission_required('stock_adjustment:list')
def get_stocks():
"""获取可用于调整的库存列表"""
source_table = request.args.get('source_table', 'stock_buy')
keyword = request.args.get('keyword', '')
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 20))
StockModel = get_stock_model(source_table)
if not StockModel:
return jsonify({'code': 400, 'msg': '无效的库存类型'}), 400
query = StockModel.query.filter(StockModel.stock_quantity > 0)
if keyword:
query = query.filter(
db.or_(
StockModel.sku.ilike(f'%{keyword}%'),
StockModel.barcode.ilike(f'%{keyword}%')
)
)
pagination = query.paginate(page=page, per_page=limit, error_out=False)
items = []
for stock in pagination.items:
base_id = getattr(stock, 'base_id', None)
material = MaterialBase.query.get(base_id) if base_id else None
items.append({
'stock_id': stock.id,
'source_table': source_table,
'sku': getattr(stock, 'sku', None) or getattr(stock, 'SKU', None),
'material_name': material.name if material else getattr(stock, 'sku', '未知'),
'spec_model': getattr(material, 'spec_model', None) if material else None,
'stock_quantity': float(stock.stock_quantity or 0),
'available_quantity': float(stock.available_quantity or 0),
'warehouse_location': getattr(stock, 'warehouse_location', None),
})
return jsonify({
'code': 200,
'data': {
'items': items,
'total': pagination.total,
'page': page,
'limit': limit
}
})

View File

@ -0,0 +1,61 @@
# app/models/stock/adjustment.py
from app.extensions import db, beijing_time
from datetime import datetime
class StockAdjustment(db.Model):
"""
盘盈盘亏调整表
用于记录财务/主管手动发起的库存修正
"""
__tablename__ = 'stock_adjustment'
id = db.Column(db.Integer, primary_key=True)
# 单号,如 ADJ-YYYYMMDD-XXXX
order_no = db.Column(db.String(50), unique=True, nullable=False, index=True)
# 关联物料基础表
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'))
# 关联具体库存行ID
stock_id = db.Column(db.Integer)
# 库存类型 (stock_buy/stock_semi/stock_product)
source_table = db.Column(db.String(50))
# 物料冗余信息
sku = db.Column(db.String(100))
material_name = db.Column(db.String(255))
spec_model = db.Column(db.String(255))
# 库位
warehouse_location = db.Column(db.String(100))
# 调整类型:'profit' 盘盈 / 'loss' 盘亏
adjust_type = db.Column(db.String(20), nullable=False)
# 调整数量(绝对值)
adjust_quantity = db.Column(db.Numeric(19, 4), nullable=False)
# 原因说明(必填)
reason = db.Column(db.String(500), nullable=False)
# 状态:'pending' 待处理 / 'completed' 已完成 / 'cancelled' 已取消
status = db.Column(db.String(20), default='pending')
# 操作人/经办人
operator = db.Column(db.String(100))
# 创建时间
create_time = db.Column(db.DateTime, default=beijing_time)
# 更新时间
update_time = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time)
def to_dict(self):
return {
'id': self.id,
'order_no': self.order_no,
'base_id': self.base_id,
'stock_id': self.stock_id,
'source_table': self.source_table,
'sku': self.sku,
'material_name': self.material_name,
'spec_model': self.spec_model,
'warehouse_location': self.warehouse_location,
'adjust_type': self.adjust_type,
'adjust_quantity': float(self.adjust_quantity or 0),
'reason': self.reason,
'status': self.status,
'operator': self.operator,
'create_time': self.create_time.strftime('%Y-%m-%d %H:%M:%S') if self.create_time else None,
'update_time': self.update_time.strftime('%Y-%m-%d %H:%M:%S') if self.update_time else None,
}

View File

@ -99,6 +99,13 @@ const routes: Array<RouteRecordRaw> = [
name: 'InventoryStocktake',
component: () => import('@/views/stock/stocktake/index.vue'),
meta: { title: '库存盘点' }
},
// ★ [新增] 盘盈盘亏管理页面
{
path: 'adjustment',
name: 'StockAdjustment',
component: () => import('@/views/stock/adjustment/index.vue'),
meta: { title: '盘盈盘亏管理' }
}
]
},

View File

@ -0,0 +1,307 @@
<template>
<div class="app-container">
<!-- 筛选条件 -->
<div class="filter-container">
<el-input v-model="keyword" placeholder="搜索单号/SKU/物料名称" style="width: 220px" @keyup.enter="fetchData" clearable />
<el-select v-model="searchAdjustType" placeholder="调整类型" style="width: 120px" clearable>
<el-option label="盘盈" value="profit" />
<el-option label="盘亏" value="loss" />
</el-select>
<el-select v-model="searchStatus" placeholder="状态" style="width: 120px" clearable>
<el-option label="待处理" value="pending" />
<el-option label="已完成" value="completed" />
<el-option label="已取消" value="cancelled" />
</el-select>
<el-button type="primary" @click="fetchData">查询</el-button>
<el-button v-if="userStore.hasPermission('stock_adjustment:operation')" type="success" @click="showDialog = true">
新增调整单
</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="list" border stripe v-loading="loading" style="margin-top: 20px">
<el-table-column prop="order_no" label="单号" width="180" />
<el-table-column prop="sku" label="SKU" width="140" />
<el-table-column prop="material_name" label="物料名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" width="120" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="100" />
<el-table-column label="调整类型" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.adjust_type === 'profit' ? 'success' : 'danger'">
{{ row.adjust_type === 'profit' ? '盘盈' : '盘亏' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="adjust_quantity" label="调整数量" width="100" align="center">
<template #default="{ row }">
<span :style="{ color: row.adjust_type === 'profit' ? '#67C23A' : '#F56C6C', fontWeight: 'bold' }">
{{ row.adjust_type === 'profit' ? '+' : '-' }}{{ row.adjust_quantity }}
</span>
</template>
</el-table-column>
<el-table-column prop="reason" label="调整原因" min-width="150" show-overflow-tooltip />
<el-table-column prop="operator" label="操作人" width="100" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === 'completed'" type="success" size="small">已完成</el-tag>
<el-tag v-else-if="row.status === 'pending'" type="warning" size="small">待处理</el-tag>
<el-tag v-else type="info" size="small">已取消</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="160" />
</el-table>
<!-- 分页 -->
<el-pagination
background
layout="prev, pager, next, total"
:total="total"
:page-size="limit"
v-model:current-page="page"
@current-change="fetchData"
style="margin-top: 20px; justify-content: center"
/>
<!-- 新增调整单弹窗 -->
<el-dialog v-model="showDialog" title="新增盘盈盘亏调整单" width="700px" :close-on-click-modal="false">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="选择物料" prop="stock_id">
<el-select
v-model="form.source_table"
placeholder="库存类型"
style="width: 150px; margin-right: 10px"
@change="handleSourceTableChange"
>
<el-option label="采购库存" value="stock_buy" />
<el-option label="半成品库存" value="stock_semi" />
<el-option label="成品库存" value="stock_product" />
</el-select>
<el-button @click="openStockSelector" type="primary" plain>选择物料</el-button>
<span v-if="selectedStock" style="margin-left: 10px">
{{ selectedStock.sku }} - {{ selectedStock.material_name }} (库存: {{ selectedStock.stock_quantity }})
</span>
</el-form-item>
<el-form-item label="调整类型" prop="adjust_type">
<el-radio-group v-model="form.adjust_type">
<el-radio label="profit">盘盈 (加库存)</el-radio>
<el-radio label="loss">盘亏 (减库存)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="调整数量" prop="adjust_quantity">
<el-input-number v-model="form.adjust_quantity" :min="1" :max="form.adjust_type === 'loss' ? (selectedStock?.stock_quantity || 9999) : 99999" style="width: 200px" />
</el-form-item>
<el-form-item label="调整原因" prop="reason">
<el-input v-model="form.reason" type="textarea" :rows="3" placeholder="请输入调整原因(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading">提交</el-button>
</template>
</el-dialog>
<!-- 物料选择弹窗 -->
<el-dialog v-model="showStockDialog" title="选择物料" width="800px">
<div class="filter-container" style="margin-bottom: 15px">
<el-input v-model="stockKeyword" placeholder="搜索SKU/条码" style="width: 200px" @keyup.enter="fetchStocks" clearable />
<el-button type="primary" @click="fetchStocks">搜索</el-button>
</div>
<el-table :data="stockList" border stripe v-loading="stockLoading" @row-click="selectStock" highlight-current-row style="cursor: pointer">
<el-table-column prop="sku" label="SKU" width="140" />
<el-table-column prop="material_name" label="物料名称" min-width="150" />
<el-table-column prop="spec_model" label="规格型号" width="120" />
<el-table-column prop="stock_quantity" label="当前库存" width="100" align="center" />
<el-table-column prop="warehouse_location" label="库位" width="100" />
</el-table>
<el-pagination
background
layout="prev, pager, next"
:total="stockTotal"
:page-size="stockLimit"
v-model:current-page="stockPage"
@current-change="fetchStocks"
style="margin-top: 15px; justify-content: center"
/>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
// 列表数据
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const limit = ref(10)
// 筛选条件
const keyword = ref('')
const searchAdjustType = ref('')
const searchStatus = ref('')
// 新增表单
const showDialog = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const form = ref({
source_table: 'stock_buy',
stock_id: null,
adjust_type: 'profit',
adjust_quantity: 1,
reason: ''
})
const rules = {
stock_id: [{ required: true, message: '请选择物料', trigger: 'change' }],
adjust_type: [{ required: true, message: '请选择调整类型', trigger: 'change' }],
adjust_quantity: [{ required: true, message: '请输入调整数量', trigger: 'blur' }],
reason: [{ required: true, message: '请输入调整原因', trigger: 'blur' }]
}
// 物料选择
const showStockDialog = ref(false)
const stockLoading = ref(false)
const stockList = ref([])
const stockTotal = ref(0)
const stockPage = ref(1)
const stockLimit = ref(20)
const stockKeyword = ref('')
const selectedStock = ref<any>(null)
// 获取列表
async function fetchData() {
loading.value = true
try {
const params = new URLSearchParams({
page: page.value.toString(),
limit: limit.value.toString()
})
if (keyword.value) params.append('keyword', keyword.value)
if (searchAdjustType.value) params.append('adjust_type', searchAdjustType.value)
if (searchStatus.value) params.append('status', searchStatus.value)
const res = await fetch(`/api/v1/stock/adjustment/list?${params}`)
const json = await res.json()
if (json.code === 200) {
list.value = json.data.items
total.value = json.data.total
}
} catch (e) {
ElMessage.error('获取数据失败')
} finally {
loading.value = false
}
}
// 获取库存列表
async function fetchStocks() {
stockLoading.value = true
try {
const params = new URLSearchParams({
source_table: form.value.source_table,
page: stockPage.value.toString(),
limit: stockLimit.value.toString()
})
if (stockKeyword.value) params.append('keyword', stockKeyword.value)
const res = await fetch(`/api/v1/stock/adjustment/stocks?${params}`)
const json = await res.json()
if (json.code === 200) {
stockList.value = json.data.items
stockTotal.value = json.data.total
}
} catch (e) {
ElMessage.error('获取库存失败')
} finally {
stockLoading.value = false
}
}
// 打开物料选择
function openStockSelector() {
if (!form.value.source_table) {
ElMessage.warning('请先选择库存类型')
return
}
stockKeyword.value = ''
stockPage.value = 1
fetchStocks()
showStockDialog.value = true
}
// 选择物料
function selectStock(row: any) {
selectedStock.value = row
form.value.stock_id = row.stock_id
showStockDialog.value = false
// 盘亏时自动限制最大数量
if (form.value.adjust_type === 'loss') {
form.value.adjust_quantity = Math.min(form.value.adjust_quantity, row.stock_quantity)
}
}
// 库存类型变化时清空选择
function handleSourceTableChange() {
selectedStock.value = null
form.value.stock_id = null
}
// 提交表单
async function submitForm() {
if (!formRef.value) return
await formRef.value.validate()
submitLoading.value = true
try {
const res = await fetch('/api/v1/stock/adjustment/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value)
})
const json = await res.json()
if (json.code === 200) {
ElMessage.success('提交成功')
showDialog.value = false
fetchData()
// 重置表单
form.value = {
source_table: 'stock_buy',
stock_id: null,
adjust_type: 'profit',
adjust_quantity: 1,
reason: ''
}
selectedStock.value = null
} else {
ElMessage.error(json.msg || '提交失败')
}
} catch (e) {
ElMessage.error('提交失败')
} finally {
submitLoading.value = false
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.filter-container {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
</style>

25
stock_adjustment.sql Normal file
View File

@ -0,0 +1,25 @@
-- 盘盈盘亏调整表
-- 用于记录财务/主管手动发起的库存修正
CREATE TABLE IF NOT EXISTS `stock_adjustment` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`order_no` VARCHAR(50) NOT NULL COMMENT '单号,如 ADJ-YYYYMMDD-XXXX',
`base_id` INT DEFAULT NULL COMMENT '关联物料基础表',
`stock_id` INT DEFAULT NULL COMMENT '关联具体库存行ID',
`source_table` VARCHAR(50) DEFAULT NULL COMMENT '库存类型 (stock_buy/stock_semi/stock_product)',
`sku` VARCHAR(100) DEFAULT NULL COMMENT '物料SKU',
`material_name` VARCHAR(255) DEFAULT NULL COMMENT '物料名称',
`spec_model` VARCHAR(255) DEFAULT NULL COMMENT '规格型号',
`warehouse_location` VARCHAR(100) DEFAULT NULL COMMENT '库位',
`adjust_type` VARCHAR(20) NOT NULL COMMENT '调整类型profit 盘盈 / loss 盘亏',
`adjust_quantity` DECIMAL(19,4) NOT NULL COMMENT '调整数量(绝对值)',
`reason` VARCHAR(500) NOT NULL COMMENT '原因说明(必填)',
`status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态pending 待处理 / completed 已完成 / cancelled 已取消',
`operator` VARCHAR(100) DEFAULT NULL COMMENT '操作人/经办人',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_create_time` (`create_time`),
KEY `idx_adjust_type` (`adjust_type`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='盘盈盘亏调整表';