Compare commits
5 Commits
ae63748060
...
5510bae3b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5510bae3b2 | |||
| 5842042db6 | |||
| fa8b113f9d | |||
| b37049a4d7 | |||
| 6cc3d1b6e0 |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user