Compare commits

5 Commits

6 changed files with 337 additions and 14 deletions

View File

@ -1,5 +1,5 @@
from flask import Blueprint, jsonify, request, send_file
from app.extensions import db
from app.extensions import db, beijing_time
# ★★★ 修复点:必须引入 datetime否则下方更新时间时会报错 500 ★★★
from datetime import datetime, timedelta
from app.utils.decorators import permission_required
@ -889,3 +889,95 @@ def export_stocktake():
import traceback
traceback.print_exc()
return jsonify({"message": f"导出失败: {str(e)}"}), 500
# --------------------------------------------------------
# 生成漏盘数据 - 将未扫描的库存标记为全额盘亏
# POST /api/v1/inbound/stocktake/generate-missing
# --------------------------------------------------------
@bp.route('/stocktake/generate-missing', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def generate_missing_stocktake():
"""
生成漏盘数据:
找出所有真实库存 > 0但未被盘点扫描到的物料
自动生成盘点草稿,标记为盘亏(实盘=0差异=-库存数)
"""
try:
# 1. 获取所有已有盘点记录的 (source_table, stock_id) 集合
existing_records = db.session.query(
StocktakeDraft.source_table,
StocktakeDraft.stock_id
).distinct().all()
scanned_keys = set()
for src_table, stock_id in existing_records:
if stock_id:
scanned_keys.add((src_table, stock_id))
# 2. 获取所有真实库存 > 0 的记录
all_stock = []
# 采购库存
for item in StockBuy.query.filter(StockBuy.stock_quantity > 0).all():
all_stock.append({
'source_table': 'stock_buy',
'stock_id': item.id,
'base_id': item.base_id,
'stock_qty': float(item.stock_quantity or 0)
})
# 半成品库存
if StockSemi:
for item in StockSemi.query.filter(StockSemi.stock_quantity > 0).all():
all_stock.append({
'source_table': 'stock_semi',
'stock_id': item.id,
'base_id': item.base_id,
'stock_qty': float(item.stock_quantity or 0)
})
# 成品库存
if StockProduct:
for item in StockProduct.query.filter(StockProduct.stock_quantity > 0).all():
all_stock.append({
'source_table': 'stock_product',
'stock_id': item.id,
'base_id': item.base_id,
'stock_qty': float(item.stock_quantity or 0)
})
# 3. 找出漏盘记录(库存中有但盘点中没有的)
missing_count = 0
for stock in all_stock:
key = (stock['source_table'], stock['stock_id'])
if key not in scanned_keys:
# 生成漏盘草稿
draft = StocktakeDraft(
user_id='system',
uuid=f'MISSING-{stock["source_table"]}-{stock["stock_id"]}',
quantity=0, # 实盘数为0
scan_time=beijing_time(),
session_id='AUTO_GENERATED',
source_table=stock['source_table'],
stock_id=stock['stock_id'],
stock_qty=stock['stock_qty'],
diff_qty=-stock['stock_qty'], # 差异 = 0 - 库存数 = 负数
remark='未盘点到,系统自动标记为盘亏'
)
db.session.add(draft)
missing_count += 1
db.session.commit()
return jsonify({
'code': 200,
'msg': f'成功生成 {missing_count} 条漏盘记录',
'data': {'count': missing_count}
})
except Exception as e:
db.session.rollback()
import traceback
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'生成漏盘数据失败: {str(e)}'}), 500

View File

@ -9,18 +9,39 @@ from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from app.models.inbound.stocktake import StocktakeDraft
from datetime import datetime
import random
import string
from datetime import datetime, timedelta
from sqlalchemy import func
adjustment_bp = Blueprint('adjustment', __name__, url_prefix='/stock/adjustment')
def get_beijing_time():
"""获取北京时间 (UTC+8)"""
return datetime.utcnow() + timedelta(hours=8)
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}'
"""生成单号 ADJ-YYYYMMDD-XXXX,按天严格自增"""
bj_time = get_beijing_time()
date_str = bj_time.strftime('%Y%m%d')
prefix = f"ADJ-{date_str}-"
# 查询今天已有的最大单号
last_order = StockAdjustment.query.filter(
StockAdjustment.order_no.like(f"{prefix}%")
).order_by(StockAdjustment.order_no.desc()).first()
if last_order:
# 解析最后的 4 位流水号并 +1
try:
last_seq = int(last_order.order_no.split('-')[-1])
new_seq = last_seq + 1
except (ValueError, IndexError):
new_seq = 1
else:
new_seq = 1
return f"{prefix}{new_seq:04d}" # 补齐 4 位,如 0001
def get_stock_model(source_table):
@ -134,7 +155,8 @@ def create():
return jsonify({'code': 400, 'msg': '无效的调整类型'}), 400
try:
# 创建调整单
# 创建调整单(强制使用北京时间)
bj_time = get_beijing_time()
adjustment = StockAdjustment(
order_no=generate_order_no(),
base_id=base_id,
@ -148,7 +170,9 @@ def create():
adjust_quantity=adjust_quantity,
reason=reason,
status='completed',
operator=operator
operator=operator,
create_time=bj_time,
update_time=bj_time
)
db.session.add(adjustment)
@ -351,7 +375,8 @@ def import_from_stocktake():
# 生成调整单号
order_no = generate_order_no()
# 创建调整单
# 创建调整单(强制使用北京时间)
bj_time = get_beijing_time()
adjustment = StockAdjustment(
order_no=order_no,
base_id=base_id,
@ -363,7 +388,9 @@ def import_from_stocktake():
adjust_quantity=adjust_quantity,
reason=reason,
status='pending',
operator=operator
operator=operator,
create_time=bj_time,
update_time=bj_time
)
db.session.add(adjustment)
count += 1
@ -374,3 +401,54 @@ def import_from_stocktake():
except Exception as e:
db.session.rollback()
return jsonify({'code': 500, 'msg': f'导入失败: {str(e)}'}), 500
# --------------------------------------------------------
# 7. 处理调整单关联入库SKU或出库单号
# POST /api/v1/stock/adjustment/<id>/process
# --------------------------------------------------------
@adjustment_bp.route('/<int:id>/process', methods=['POST'])
@jwt_required()
@permission_required('stock_adjustment:operation')
def process_adjustment(id):
"""处理待处理的调整单关联入库SKU或出库单号"""
identity = get_jwt_identity()
operator = identity.get('username', 'system') if isinstance(identity, dict) else str(identity)
if not operator or operator == '0':
operator = identity if identity else 'system'
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '缺少参数'}), 400
linked_sku = data.get('linked_sku', '')
linked_outbound_no = data.get('linked_outbound_no', '')
try:
adjustment = StockAdjustment.query.get(id)
if not adjustment:
return jsonify({'code': 404, 'msg': '调整单不存在'}), 404
if adjustment.status != 'pending':
return jsonify({'code': 400, 'msg': '只能处理待处理状态的调整单'}), 400
# 根据调整类型校验必填项
if adjustment.adjust_type == 'profit':
if not linked_sku:
return jsonify({'code': 400, 'msg': '盘盈调整必须关联入库SKU'}), 400
adjustment.linked_sku = linked_sku
elif adjustment.adjust_type == 'loss':
if not linked_outbound_no:
return jsonify({'code': 400, 'msg': '盘亏调整必须关联出库单号'}), 400
adjustment.linked_outbound_no = linked_outbound_no
adjustment.status = 'completed'
adjustment.operator = operator
adjustment.update_time = get_beijing_time()
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

View File

@ -34,6 +34,10 @@ class StockAdjustment(db.Model):
reason = db.Column(db.String(500), nullable=False)
# 状态:'pending' 待处理 / 'completed' 已完成 / 'cancelled' 已取消
status = db.Column(db.String(20), default='pending')
# 关联入库SKU盘盈时填写
linked_sku = db.Column(db.String(100), comment='关联入库SKU盘盈时填写')
# 关联出库单号(盘亏时填写)
linked_outbound_no = db.Column(db.String(100), comment='关联出库单号(盘亏时填写)')
# 操作人/经办人
operator = db.Column(db.String(100))
# 创建时间
@ -56,6 +60,8 @@ class StockAdjustment(db.Model):
'adjust_quantity': float(self.adjust_quantity or 0),
'reason': self.reason,
'status': self.status,
'linked_sku': self.linked_sku,
'linked_outbound_no': self.linked_outbound_no,
'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

@ -95,6 +95,7 @@
<el-option label="销售出库" value="SALES" />
<el-option label="内部领用" value="USE" />
<el-option label="调拨出库" value="TRANSFER" />
<el-option label="盘亏出库" value="LOSS" />
</el-select>
</el-form-item>
</el-col>

View File

@ -43,6 +43,18 @@
</template>
</el-table-column>
<el-table-column prop="reason" label="调整原因" min-width="150" show-overflow-tooltip />
<el-table-column prop="linked_sku" label="关联SKU" width="120" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.adjust_type === 'profit'">{{ row.linked_sku || '-' }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="linked_outbound_no" label="关联出库单" width="140" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.adjust_type === 'loss'">{{ row.linked_outbound_no || '-' }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="100" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
@ -52,6 +64,14 @@
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="160" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'pending' && userStore.hasPermission('stock_adjustment:operation')" type="primary" link @click="openProcessDialog(row)">
处理
</el-button>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
@ -173,6 +193,30 @@
</el-button>
</template>
</el-dialog>
<!-- 处理调整单弹窗 -->
<el-dialog v-model="showProcessDialog" title="处理调整单" width="500px" :close-on-click-modal="false">
<el-form label-width="120px">
<el-form-item label="调整单号">
<el-input v-model="processForm.order_no" disabled />
</el-form-item>
<el-form-item label="调整类型">
<el-tag :type="processForm.adjust_type === 'profit' ? 'success' : 'danger'">
{{ processForm.adjust_type === 'profit' ? '盘盈' : '盘亏' }}
</el-tag>
</el-form-item>
<el-form-item :label="processForm.adjust_type === 'profit' ? '关联入库SKU' : '关联出库单号'">
<el-input
v-model="processForm.linked_value"
:placeholder="processForm.adjust_type === 'profit' ? '请输入已入库的SKU' : '请输入对应的出库单号'"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showProcessDialog = false">取消</el-button>
<el-button type="primary" @click="handleProcessSubmit" :loading="processLoading">确认处理</el-button>
</template>
</el-dialog>
</div>
</template>
@ -233,6 +277,68 @@ const selectedReviewRows = ref<any[]>([])
const reviewTableRef = ref()
const importLoading = ref(false)
// 处理调整单
const showProcessDialog = ref(false)
const processLoading = ref(false)
const processForm = ref({
id: null as number | null,
order_no: '',
adjust_type: '',
linked_value: ''
})
// 打开处理弹窗
function openProcessDialog(row: any) {
processForm.value = {
id: row.id,
order_no: row.order_no,
adjust_type: row.adjust_type,
linked_value: ''
}
showProcessDialog.value = true
}
// 提交处理
async function handleProcessSubmit() {
if (!processForm.value.id) return
if (processForm.value.adjust_type === 'profit' && !processForm.value.linked_value) {
ElMessage.warning('请输入已入库的SKU')
return
}
if (processForm.value.adjust_type === 'loss' && !processForm.value.linked_value) {
ElMessage.warning('请输入对应的出库单号')
return
}
processLoading.value = true
try {
const data: any = {}
if (processForm.value.adjust_type === 'profit') {
data.linked_sku = processForm.value.linked_value
} else {
data.linked_outbound_no = processForm.value.linked_value
}
const res = await request({
url: `/v1/stock/adjustment/${processForm.value.id}/process`,
method: 'post',
data
})
if (res.code === 200) {
ElMessage.success('处理成功')
showProcessDialog.value = false
fetchData()
} else {
ElMessage.error(res.msg || '处理失败')
}
} catch (e) {
ElMessage.error('处理失败')
} finally {
processLoading.value = false
}
}
// 获取盘点差异列表
async function fetchReviewList() {
reviewLoading.value = true

View File

@ -103,8 +103,8 @@
</el-button>
</el-col>
<el-col :span="12">
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="danger" size="large" class="w-100 action-btn" @click="openFinishDialog" :icon="Checked">
结束盘点
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="danger" size="large" class="w-100 action-btn" @click="handleGenerateMissing" :icon="Checked">
结束盘点并生成差异
</el-button>
</el-col>
</el-row>
@ -993,6 +993,46 @@ const openFinishDialog = () => {
finishStocktake()
}
// ★ 新增:结束盘点(计算漏盘)- 将未扫描的库存标记为全额盘亏
const handleGenerateMissing = async () => {
if (stats.value.total === 0) {
ElMessage.warning('暂无盘点数据')
return
}
try {
await ElMessageBox.confirm(
'确认结束当前盘点吗?系统将自动把所有未扫描到的库存标记为全额盘亏!',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
btnLoading.value = true
const res = await request({
url: '/v1/inbound/stock/stocktake/generate-missing',
method: 'post'
})
if (res.code === 200) {
ElMessage.success(`成功生成 ${res.data.count} 条漏盘记录`)
// 刷新差异列表
await checkServerDraft()
} else {
ElMessage.error(res.msg || '生成漏盘数据失败')
}
} catch (e) {
if (e !== 'cancel') {
ElMessage.error('生成漏盘数据失败')
}
} finally {
btnLoading.value = false
}
}
// ★ 重写: 结束盘点 - 纯前端状态流转,不再调用后端
const finishStocktake = async () => {
try {