出库进行修改,确保可以进行多个样例的出库以及出库的记录展示

This commit is contained in:
dxc
2026-02-05 16:54:11 +08:00
parent 3f6ab3e607
commit c1ddb8093f
6 changed files with 608 additions and 385 deletions

View File

@ -18,7 +18,7 @@ def scan_barcode():
return jsonify({'code': 400, 'msg': '请提供条码'}), 400
try:
# 调用 Service 层去三个表中查找
# 调用 Service 层去三个表中查找 (Service已更新会返回价格)
result = OutboundService.get_stock_by_barcode(barcode)
if result:
@ -39,7 +39,7 @@ def scan_barcode():
# --------------------------------------------------------
# 2. 提交出库单接口
# 2. 提交出库单接口 (批量)
# POST /api/v1/outbound
# --------------------------------------------------------
@outbound_bp.route('', methods=['POST'])
@ -54,26 +54,26 @@ def create_outbound():
if not current_user_name:
current_user_name = 'Unknown'
# ★ [修改] 获取最终的操作员名称
# 优先取前端传来的 operator_name (你在前端下拉框选的人)
# 如果前端没传,则回退使用当前登录用户的名字
# 获取最终的操作员名称
final_operator = data.get('operator_name')
if not final_operator:
final_operator = current_user_name
# 必填校验
required_fields = ['stock_id', 'source_table', 'quantity', 'consumer_name', 'signature_path']
for field in required_fields:
if field not in data or not data[field]:
return jsonify({'code': 400, 'msg': f'缺少必填字段: {field}'}), 400
# 必填校验 (针对整个单据)
# items 必须是列表且不为空consumer_name 和 signature_path 必填
if 'items' not in data or not data['items']:
return jsonify({'code': 400, 'msg': '出库商品列表不能为空'}), 400
if not data.get('consumer_name') or not data.get('signature_path'):
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400
try:
# ★ [修改] 将确认后的操作员名称传给 Service
outbound_record = OutboundService.create_outbound(data, operator_name=final_operator)
# ★ [修改] 调用批量创建服务
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
return jsonify({
'code': 200,
'msg': '出库成功',
'data': outbound_record.to_dict()
'data': {'outbound_no': outbound_no}
})
except ValueError as e:
# 业务逻辑错误 (如库存不足)
@ -84,7 +84,7 @@ def create_outbound():
# --------------------------------------------------------
# 3. 获取出库记录列表
# 3. 获取出库记录列表 (分组展示)
# GET /api/v1/outbound
# --------------------------------------------------------
@outbound_bp.route('', methods=['GET'])
@ -94,8 +94,10 @@ def get_outbound_list():
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
keyword = request.args.get('keyword', '')
# 如果前端传了日期范围,可以解析处理,这里暂略
result = OutboundService.get_list(page, limit, keyword)
# ★ [修改] 调用分组查询服务
result = OutboundService.get_grouped_list(page, limit, keyword)
return jsonify({
'code': 200,

View File

@ -6,7 +6,8 @@ class TransOutbound(db.Model):
__tablename__ = 'trans_outbound'
id = db.Column(db.Integer, primary_key=True)
outbound_no = db.Column(db.String(100), unique=True, nullable=False) # 出库单号
# 修改:不再唯一,因为批量出库时多个商品共用一个单号
outbound_no = db.Column(db.String(100), nullable=False)
# 关联源库存信息
sku = db.Column(db.String(100))
@ -18,6 +19,9 @@ class TransOutbound(db.Model):
outbound_type = db.Column(db.String(50), default='SALES') # SALES(销售), USE(领用), TRANSFER(调拨)
quantity = db.Column(db.Numeric(19, 4), nullable=False)
# [新增] 出库时的单价,用于计算金额
unit_price = db.Column(db.Numeric(19, 2), default=0)
# 签字与追溯
consumer_name = db.Column(db.String(100)) # 领用人/客户
signature_path = db.Column(db.Text) # 电子签名图片路径
@ -34,6 +38,7 @@ class TransOutbound(db.Model):
'source_table': self.source_table,
'outbound_type': self.outbound_type,
'quantity': float(self.quantity) if self.quantity else 0,
'unit_price': float(self.unit_price) if self.unit_price else 0,
'consumer_name': self.consumer_name,
'signature_path': self.signature_path,
'outbound_time': self.outbound_time.strftime('%Y-%m-%d %H:%M:%S') if self.outbound_time else None,

View File

@ -1,6 +1,6 @@
import uuid
from datetime import datetime, timezone, timedelta # [修改] 引入 timezone 和 timedelta
from sqlalchemy import or_
from datetime import datetime, timezone, timedelta
from sqlalchemy import or_, func, desc
from app.extensions import db
from app.models.outbound import TransOutbound
@ -8,7 +8,7 @@ from app.models.outbound import TransOutbound
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
# ★★★ [关键] 引入基础信息表,用于手动补全名称
# 引入基础信息表
from app.models.base import MaterialBase
@ -17,82 +17,90 @@ class OutboundService:
@staticmethod
def generate_outbound_no():
"""
生成出库单号: OUT-yyyyMMdd-随机码
[修改] 强制使用北京时间生成日期前缀
生成出库单号: OUT-yyyyMMdd-HHmm-当日流水(4位)
例如: OUT-20260205-1558-0001
"""
# 获取北京时间
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz)
now = datetime.now(beijing_tz)
date_str = current_time.strftime('%Y%m%d')
short_uuid = uuid.uuid4().hex[:6].upper()
return f"OUT-{date_str}-{short_uuid}"
date_str = now.strftime('%Y%m%d')
time_str = now.strftime('%H%M')
prefix = f"OUT-{date_str}-"
existing_count = db.session.query(func.count(func.distinct(TransOutbound.outbound_no))) \
.filter(TransOutbound.outbound_no.like(f"{prefix}%")).scalar()
sequence = existing_count + 1
return f"OUT-{date_str}-{time_str}-{sequence:04d}"
@staticmethod
def get_stock_by_barcode(barcode):
"""
[核心逻辑] 根据扫码内容查找对应的库存物品
查找顺序: 成品 (StockProduct) -> 半成品 (StockSemi) -> 采购件 (StockBuy)
匹配逻辑: 匹配 barcode 字段 OR sku 字段
根据扫码内容查找对应的库存物品,并附带价格信息
"""
if not barcode:
return None
clean_code = barcode.strip()
# --- 1. 查找成品表 (StockProduct) ---
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.unit_price) if item.unit_price else 0
return 0
prod = StockProduct.query.filter(
or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code)
).first()
if prod:
return OutboundService._format_scan_result(prod, 'stock_product')
res = OutboundService._format_scan_result(prod, 'stock_product')
res['price'] = get_price(prod, 'stock_product')
return res
# --- 2. 查找半成品表 (StockSemi) ---
semi = StockSemi.query.filter(
or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code)
).first()
if semi:
return OutboundService._format_scan_result(semi, 'stock_semi')
res = OutboundService._format_scan_result(semi, 'stock_semi')
res['price'] = 0
return res
# --- 3. 查找采购件表 (StockBuy) ---
buy = StockBuy.query.filter(
or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code)
).first()
if buy:
return OutboundService._format_scan_result(buy, 'stock_buy')
res = OutboundService._format_scan_result(buy, 'stock_buy')
res['price'] = get_price(buy, 'stock_buy')
return res
return None
@staticmethod
def _format_scan_result(item, table_name):
"""
[核心修复] 格式化返回数据,确保名称和规格一定能取到
"""
base_name = ""
base_spec = ""
base_cat = ""
base_type = ""
# -------------------------------------------------------
# 修复逻辑:强制获取基础信息
# -------------------------------------------------------
# 步骤 1: 尝试通过 ORM 关联获取 (如果有定义 relationship)
if hasattr(item, 'base') and item.base:
base_name = item.base.name
base_spec = item.base.spec_model
base_cat = item.base.category
base_type = item.base.material_type
# 步骤 2: [关键] 如果步骤1失败但有 base_id则手动查询 MaterialBase 表
# 这能解决“扫码有库存但显示未知物品”的问题
if not base_name and hasattr(item, 'base_id') and item.base_id:
try:
# 手动查基础表
base_info = MaterialBase.query.get(item.base_id)
if base_info:
base_name = base_info.name
base_spec = base_info.spec_model
except Exception as e:
print(f"基础信息查询失败: {e}")
base_cat = base_info.category
base_type = base_info.material_type
except Exception:
pass
# 步骤 3: 兜底逻辑,某些旧表可能直接存了 material_name 字段
if not base_name and hasattr(item, 'material_name'):
base_name = item.material_name
@ -102,107 +110,205 @@ class OutboundService:
return {
'id': item.id,
'sku': item.sku,
'name': base_name or "未知物品", # 此时应该能正确显示名称了
'spec_model': base_spec or "", # 此时应该能正确显示规格了
'source_table': table_name, # 标记来源表
'stock_quantity': stock_qty, # 当前库存
'available_quantity': avail_qty, # 可用库存
'name': base_name or "未知物品",
'spec_model': base_spec or "",
'category': base_cat or "",
'material_type': base_type or "",
'source_table': table_name,
'stock_quantity': stock_qty,
'available_quantity': avail_qty,
'batch_number': getattr(item, 'batch_number', ''),
'warehouse_location': getattr(item, 'warehouse_location', ''),
'barcode': getattr(item, 'barcode', '')
}
@staticmethod
def create_outbound(data, operator_name='System'):
"""
[核心逻辑] 执行出库:扣减对应表的库存 + 创建记录
"""
source_table = data.get('source_table')
stock_id = data.get('stock_id')
quantity = float(data.get('quantity', 0))
def create_outbound_batch(data, operator_name='System'):
items = data.get('items', [])
if not items:
raise ValueError("出库商品列表不能为空")
if quantity <= 0:
raise ValueError("出库数量必须大于0")
outbound_no = OutboundService.generate_outbound_no()
common_data = {
'outbound_no': outbound_no,
'consumer_name': data.get('consumer_name'),
'outbound_type': data.get('outbound_type', 'SALES'),
'signature_path': data.get('signature_path'),
'operator_name': operator_name,
'remark': data.get('remark')
}
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
# 1. 动态映射表模型
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct
}
ModelClass = model_map.get(source_table)
if not ModelClass:
raise ValueError(f"无效的数据来源表: {source_table}")
# 2. 锁定并查询库存 (使用 with_for_update 防止并发超卖)
stock_item = ModelClass.query.with_for_update().get(stock_id)
if not stock_item:
raise ValueError("库存记录不存在或已被删除")
# 3. 校验库存充足
current_avail = float(stock_item.available_quantity)
if current_avail < quantity:
raise ValueError(f"库存不足!当前可用: {current_avail}, 请求出库: {quantity}")
try:
# 4. 扣减库存
stock_item.stock_quantity = float(stock_item.stock_quantity) - quantity
stock_item.available_quantity = float(stock_item.available_quantity) - quantity
for item in items:
source_table = item.get('source_table')
stock_id = item.get('stock_id')
quantity = float(item.get('quantity', 0))
unit_price = float(item.get('price', 0))
# [新增] 计算北京时间
beijing_tz = timezone(timedelta(hours=8))
# replace(tzinfo=None) 是为了让存入数据库的时间变为 naive time (不带时区信息的本地时间)
# 这样数据库看起来就是 "2023-10-27 15:00:00" 而不是 UTC 时间
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
if quantity <= 0:
raise ValueError(f"SKU {item.get('sku')} 的出库数量必须大于0")
# 5. 创建出库记录
new_outbound = TransOutbound(
outbound_no=OutboundService.generate_outbound_no(),
sku=data.get('sku'),
source_table=source_table,
stock_id=stock_id,
barcode=data.get('barcode'),
outbound_type=data.get('outbound_type', 'SALES'),
quantity=quantity,
consumer_name=data.get('consumer_name'),
signature_path=data.get('signature_path'), # 存储签名的 URL
ModelClass = model_map.get(source_table)
if not ModelClass:
continue
# [关键] 显式设置北京时间,覆盖 Model 中的 default=datetime.now (UTC)
outbound_time=current_time,
stock_record = ModelClass.query.with_for_update().get(stock_id)
if not stock_record:
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
operator_name=operator_name,
remark=data.get('remark')
)
if float(stock_record.available_quantity) < quantity:
raise ValueError(f"SKU {stock_record.sku} 库存不足,当前可用: {stock_record.available_quantity}")
stock_record.stock_quantity = float(stock_record.stock_quantity) - quantity
stock_record.available_quantity = float(stock_record.available_quantity) - quantity
new_record = TransOutbound(
sku=item.get('sku'),
source_table=source_table,
stock_id=stock_id,
barcode=item.get('barcode'),
quantity=quantity,
unit_price=unit_price,
outbound_time=current_time,
**common_data
)
db.session.add(new_record)
db.session.add(new_outbound)
db.session.commit()
return new_outbound
return outbound_no
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def get_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None):
"""查询出库历史记录"""
query = TransOutbound.query.order_by(TransOutbound.outbound_time.desc())
def get_grouped_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None):
"""
查询出库记录(按出库单号分组),包含详细物品信息
"""
# 1. 查询分页单号
stmt = db.session.query(
TransOutbound.outbound_no,
func.max(TransOutbound.outbound_time).label('max_time')
).group_by(TransOutbound.outbound_no)
if keyword:
query = query.filter(or_(
stmt = stmt.filter(or_(
TransOutbound.outbound_no.ilike(f'%{keyword}%'),
TransOutbound.consumer_name.ilike(f'%{keyword}%'),
TransOutbound.sku.ilike(f'%{keyword}%')
))
if start_date and end_date:
query = query.filter(TransOutbound.outbound_time.between(start_date, end_date))
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
stmt = stmt.order_by(desc('max_time'))
pagination = stmt.paginate(page=page, per_page=per_page, error_out=False)
outbound_nos = [row.outbound_no for row in pagination.items]
if not outbound_nos:
return {
'items': [],
'total': 0,
'pages': 0,
'current_page': page
}
# 2. 查询详细记录
details = TransOutbound.query.filter(TransOutbound.outbound_no.in_(outbound_nos)).all()
# 3. 组装数据并查询物品详情
grouped_map = {}
# 映射表模型以便查询
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct
}
for d in details:
ono = d.outbound_no
if ono not in grouped_map:
grouped_map[ono] = {
'outbound_no': ono,
'outbound_time': d.outbound_time.strftime('%Y-%m-%d %H:%M:%S'),
'outbound_type': d.outbound_type,
'consumer_name': d.consumer_name,
'operator_name': d.operator_name,
'signature_path': d.signature_path,
'remark': d.remark,
'total_amount': 0.0,
'items': []
}
# --- 查询物品详细信息 (名称, 规格, 类型, 类别) ---
item_name = "未知物品"
item_spec = ""
item_cat = ""
item_type = ""
ModelClass = model_map.get(d.source_table)
if ModelClass and d.stock_id:
# 注意这里在循环中查询可能会有N+1问题但考虑到单页数据量通常每单条目不多暂时可接受
# 生产环境建议优化为预加载或批量查询
try:
stock_item = ModelClass.query.get(d.stock_id)
if stock_item and stock_item.base:
item_name = stock_item.base.name
item_spec = stock_item.base.spec_model
item_cat = stock_item.base.category
item_type = stock_item.base.material_type
elif stock_item and hasattr(stock_item, 'base_id') and stock_item.base_id:
base_info = MaterialBase.query.get(stock_item.base_id)
if base_info:
item_name = base_info.name
item_spec = base_info.spec_model
item_cat = base_info.category
item_type = base_info.material_type
except Exception as e:
print(f"Error fetching detail for stock_id {d.stock_id}: {e}")
# 计算金额
price = float(d.unit_price) if d.unit_price else 0
qty = float(d.quantity)
subtotal = price * qty
grouped_map[ono]['total_amount'] += subtotal
grouped_map[ono]['items'].append({
'sku': d.sku,
'name': item_name,
'spec_model': item_spec,
'category': item_cat,
'material_type': item_type,
'quantity': qty,
'unit_price': price,
'subtotal': subtotal
})
# 4. 排序输出
result_list = []
for ono in outbound_nos:
if ono in grouped_map:
obj = grouped_map[ono]
obj['items'].sort(key=lambda x: x['unit_price'], reverse=True)
obj['total_amount'] = round(obj['total_amount'], 2)
result_list.append(obj)
return {
'items': [item.to_dict() for item in pagination.items],
'items': result_list,
'total': pagination.total,
'pages': pagination.pages,
'current_page': page

View File

@ -1,13 +1,32 @@
import request from '@/utils/request'
export interface OutboundSubmitData {
// 购物车商品项接口
export interface CartItem {
id: number
sku: string
name: string
spec_model: string
source_table: string
stock_id: number
stock_quantity: number
available_quantity: number
barcode: string
price: number // 单价
out_quantity: number // 本次出库数量
}
// 提交出库单的数据结构
export interface OutboundSubmitData {
items: Array<{
sku: string
source_table: string
stock_id: number
barcode: string
quantity: number
price: number
}>
outbound_type: string
quantity: number
consumer_name: string
operator_name: string
signature_path: string // 上传后返回的图片路径
remark?: string
}
@ -23,6 +42,7 @@ export interface ScanResult {
batch_number?: string
warehouse_location?: string
barcode?: string
price?: number // 扫描返回的价格
}
/**
@ -31,7 +51,6 @@ export interface ScanResult {
*/
export function getStockByBarcode(barcode: string) {
return request<any, ScanResult>({
// ★★★ [修改] 去掉开头的 /apiAxios 会自动拼接 baseURL
url: '/v1/outbound/scan',
method: 'get',
params: { barcode }
@ -39,11 +58,10 @@ export function getStockByBarcode(barcode: string) {
}
/**
* 提交出库单
* 提交出库单 (批量)
*/
export function submitOutbound(data: OutboundSubmitData) {
return request({
// ★★★ [修改] 去掉开头的 /api
url: '/v1/outbound',
method: 'post',
data
@ -55,7 +73,6 @@ export function submitOutbound(data: OutboundSubmitData) {
*/
export function getOutboundList(params: any) {
return request({
// ★★★ [修改] 去掉开头的 /api
url: '/v1/outbound',
method: 'get',
params

View File

@ -3,14 +3,20 @@
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>扫码出库作业台</span>
<el-button type="primary" @click="toggleCamera">
{{ showCamera ? '关闭摄像头' : '打开摄像头扫码' }}
</el-button>
<span>批量扫码出库作业台</span>
<div class="header-right">
<span v-if="cartItems.length > 0" class="summary-text">
已选: <span class="num">{{ cartItems.length }}</span> |
总金额: <span class="price">¥{{ totalAmount.toFixed(2) }}</span>
</span>
<el-button type="primary" @click="toggleCamera" style="margin-left: 10px;">
{{ showCamera ? '关闭摄像头' : '打开摄像头扫码' }}
</el-button>
</div>
</div>
</template>
<div v-if="!currentItem" class="scan-area">
<div class="scan-area">
<div v-if="showCamera" id="reader" class="camera-box"></div>
<div v-if="isHttpAndNotLocal" class="http-warning">
注意当前为 HTTP 环境摄像头可能无法启动请使用 HTTPS localhost或配置浏览器安全白名单
@ -19,58 +25,76 @@
<div class="input-box">
<el-input
v-model="barcodeInput"
placeholder="请扫描条码或手动输入后回车"
placeholder="请连续扫描条码或手动输入后回车"
@keyup.enter="handleScan"
clearable
ref="barcodeRef"
class="scan-input"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="handleScan">确定</el-button>
<el-button @click="handleScan">添加商品</el-button>
</template>
</el-input>
</div>
</div>
<div v-else class="confirm-area">
<el-descriptions title="货物信息" border :column="1">
<el-descriptions-item label="名称">{{ currentItem.name }}</el-descriptions-item>
<el-descriptions-item label="规格/型号">{{ currentItem.spec_model }}</el-descriptions-item>
<el-descriptions-item label="当前库存">
<el-tag :type="currentItem.available_quantity > 0 ? 'success' : 'danger'">
{{ currentItem.stock_quantity }} (可用: {{ currentItem.available_quantity }})
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="库位">{{ currentItem.warehouse_location || '暂无' }}</el-descriptions-item>
</el-descriptions>
<div class="cart-area mt-20" v-if="cartItems.length > 0">
<el-table :data="cartItems" border stripe style="width: 100%">
<el-table-column prop="name" label="名称" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="150" />
<el-table-column label="单价" width="120">
<template #default="{row}">
¥{{ row.price }}
</template>
</el-table-column>
<el-table-column label="库存" width="100">
<template #default="{row}">
<el-tag :type="row.available_quantity > 0 ? 'success' : 'danger'">{{ row.available_quantity }}</el-tag>
</template>
</el-table-column>
<el-table-column label="出库数量" width="160">
<template #default="{row}">
<el-input-number v-model="row.out_quantity" :min="1" :max="row.available_quantity" size="small" />
</template>
</el-table-column>
<el-table-column label="小计" width="120">
<template #default="{row}">
<span style="color: #F56C6C; font-weight: bold;">¥{{ (row.price * row.out_quantity).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无扫描商品,请扫描上方条码进行添加" />
<el-form :model="form" ref="formRef" :rules="rules" label-position="top" class="mt-20">
<div v-if="cartItems.length > 0" class="confirm-area mt-20">
<el-divider content-position="left">单据信息</el-divider>
<el-form :model="form" ref="formRef" :rules="rules" label-position="top">
<el-row :gutter="20">
<el-col :span="12">
<el-col :span="8">
<el-form-item label="出库类型" prop="outbound_type">
<el-select v-model="form.outbound_type" placeholder="请选择">
<el-select v-model="form.outbound_type" placeholder="请选择" style="width: 100%">
<el-option label="销售出库" value="SALES" />
<el-option label="内部领用" value="USE" />
<el-option label="调拨" value="TRANSFER" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="出库数量" prop="quantity">
<el-input-number v-model="form.quantity" :min="1" :max="currentItem.available_quantity" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-col :span="8">
<el-form-item label="领用人/客户姓名" prop="consumer_name">
<el-input v-model="form.consumer_name" placeholder="请输入姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :span="8">
<el-form-item label="操作员 (库管)" prop="operator_name">
<el-select
v-model="form.operator_name"
@ -108,8 +132,8 @@
</el-form-item>
<div class="form-actions">
<el-button @click="resetScan">取消 / 重新扫码</el-button>
<el-button type="primary" :loading="loading" @click="submitForm">确认出库</el-button>
<el-button @click="clearAll">清空重置</el-button>
<el-button type="primary" size="large" :loading="loading" @click="submitForm">确认批量出库</el-button>
</div>
</el-form>
</div>
@ -154,16 +178,16 @@
<script setup lang="ts">
import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
import { Scissor, EditPen } from '@element-plus/icons-vue'
import { getStockByBarcode, submitOutbound, getOutboundList, type ScanResult } from '@/api/outbound'
import { Scissor, EditPen, Delete } from '@element-plus/icons-vue'
import { getStockByBarcode, submitOutbound, getOutboundList } from '@/api/outbound'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
// --- 状态定义 ---
const barcodeInput = ref('')
const currentItem = ref<ScanResult | null>(null)
const cartItems = ref<any[]>([]) // 购物车列表
const loading = ref(false)
const showCamera = ref(false)
const html5QrCode = ref<Html5Qrcode | null>(null)
@ -188,7 +212,6 @@ const operatorOptions = ref<string[]>([])
const form = reactive({
outbound_type: 'SALES',
quantity: 1,
consumer_name: '',
operator_name: '',
remark: ''
@ -196,7 +219,6 @@ const form = reactive({
const rules = {
consumer_name: [{ required: true, message: '请输入领用人姓名', trigger: 'blur' }],
quantity: [{ required: true, message: '请输入数量', trigger: 'blur' }],
operator_name: [{ required: true, message: '请指定操作员', trigger: 'change' }]
}
@ -206,6 +228,11 @@ const isHttpAndNotLocal = computed(() => {
return !isHttps && !isLocal
})
// 计算总金额
const totalAmount = computed(() => {
return cartItems.value.reduce((sum, item) => sum + (item.price * item.out_quantity), 0)
})
// --- 初始化逻辑 ---
onMounted(() => {
if (userStore.username) {
@ -221,9 +248,10 @@ const loadHistoryOperators = async () => {
if (res.data && res.data.items) {
const names = new Set<string>()
if (userStore.username) names.add(userStore.username)
res.data.items.forEach((item: any) => {
if (item.operator_name) {
names.add(item.operator_name)
// 注意现在的列表结构变了operator_name 在外层
res.data.items.forEach((group: any) => {
if (group.operator_name) {
names.add(group.operator_name)
}
})
operatorOptions.value = Array.from(names)
@ -233,149 +261,139 @@ const loadHistoryOperators = async () => {
}
}
// --- 签名逻辑 ---
const openSignatureDialog = () => {
showSignatureDialog.value = true
}
const initCanvas = async () => {
await nextTick()
const canvas = nativeCanvasRef.value
const container = canvasContainerRef.value
if (canvas && container) {
// 强制设置宽高,确保在手机横屏竖屏切换后也能占满
canvas.width = container.clientWidth
canvas.height = container.clientHeight
ctx.value = canvas.getContext('2d')
if (ctx.value) {
ctx.value.lineWidth = 4
ctx.value.lineCap = 'round'
ctx.value.lineJoin = 'round'
ctx.value.strokeStyle = '#000000'
// 填充白色背景
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, canvas.width, canvas.height)
}
}
}
// 获取坐标(兼容鼠标和触摸)
const getPos = (e: MouseEvent | TouchEvent) => {
if (!nativeCanvasRef.value) return { x: 0, y: 0 }
const rect = nativeCanvasRef.value.getBoundingClientRect()
let clientX, clientY
if (e.type.startsWith('touch')) {
clientX = (e as TouchEvent).touches[0].clientX
clientY = (e as TouchEvent).touches[0].clientY
} else {
clientX = (e as MouseEvent).clientX
clientY = (e as MouseEvent).clientY
}
return {
x: clientX - rect.left,
y: clientY - rect.top
}
}
const startDrawing = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
isDrawing.value = true
const { x, y } = getPos(e)
lastX.value = x
lastY.value = y
if(ctx.value) {
ctx.value.beginPath()
ctx.value.moveTo(x, y)
ctx.value.lineTo(x, y)
ctx.value.stroke()
}
}
const draw = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
if (!isDrawing.value || !ctx.value) return
const { x, y } = getPos(e)
ctx.value.beginPath()
ctx.value.moveTo(lastX.value, lastY.value)
ctx.value.lineTo(x, y)
ctx.value.stroke()
lastX.value = x
lastY.value = y
}
const stopDrawing = () => {
isDrawing.value = false
if (ctx.value) ctx.value.beginPath()
}
const clearCanvas = () => {
if (!ctx.value || !nativeCanvasRef.value) return
ctx.value.clearRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
}
const handleSignConfirm = () => {
if (!nativeCanvasRef.value) return
nativeCanvasRef.value.toBlob((blob) => {
if (blob) {
const file = new File([blob], `signature_${Date.now()}.png`, { type: 'image/png' })
signatureFile.value = file
signaturePreviewUrl.value = URL.createObjectURL(file)
showSignatureDialog.value = false
} else {
ElMessage.error('生成签名失败')
}
}, 'image/png')
}
const handleSignCancel = () => {
showSignatureDialog.value = false
}
// --- 扫码逻辑 ---
// --- 扫码逻辑 (加入购物车) ---
const handleScan = async () => {
if (!barcodeInput.value) return
const code = barcodeInput.value.trim()
if (!code) return
try {
loading.value = true
const res = await getStockByBarcode(barcodeInput.value)
// 1. 检查是否已经在购物车中
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
if (existIndex > -1) {
// 存在则数量+1
const item = cartItems.value[existIndex]
if (item.out_quantity < item.available_quantity) {
item.out_quantity++
ElMessage.success(`商品 ${item.name} 数量+1`)
} else {
ElMessage.warning(`库存不足 (余: ${item.available_quantity})`)
}
barcodeInput.value = ''
return
}
// 2. 新商品调用 API
const res = await getStockByBarcode(code)
if (res.data) {
const item = res.data
if (item.available_quantity <= 0) {
ElMessage.warning(`该商品库存不足或已出库!(当前库存: ${item.available_quantity})`)
barcodeInput.value = ''
return
}
currentItem.value = item
if (html5QrCode.value && html5QrCode.value.isScanning) {
await stopCamera()
} else {
// 加入购物车,默认出库 1
cartItems.value.push({
...item,
out_quantity: 1,
price: item.price || 0
})
ElMessage.success('添加成功')
}
barcodeInput.value = ''
}
} catch (error: any) {
if (error.response && error.response.status === 404) {
ElMessage.error(`未找到条码 ${barcodeInput.value} 的入库记录,请先入库!`)
ElMessage.error(`未找到条码 ${barcodeInput.value} 的入库记录`)
} else {
console.error(error)
ElMessage.error('查询出错,请重试')
}
} finally {
loading.value = false
// 聚焦回输入框
nextTick(() => { barcodeRef.value?.focus() })
}
}
const removeFromCart = (index: number) => {
cartItems.value.splice(index, 1)
}
const clearAll = () => {
ElMessageBox.confirm('确定清空所有已扫商品和填写信息吗?', '提示', {
type: 'warning'
}).then(() => {
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
})
}
// --- 提交逻辑 (批量提交) ---
const submitForm = async () => {
if (!formRef.value) return
if (cartItems.value.length === 0) {
ElMessage.warning('购物车为空,请先扫码')
return
}
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
if (!signatureFile.value) {
ElMessage.error('请进行电子签名')
return
}
try {
loading.value = true
// 1. 上传签名
const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url
// 2. 构造 items 数据
const itemsPayload = cartItems.value.map(item => ({
stock_id: item.id,
source_table: item.source_table,
sku: item.sku,
barcode: item.barcode,
quantity: item.out_quantity,
price: item.price
}))
// 3. 提交业务单据
await submitOutbound({
items: itemsPayload,
outbound_type: form.outbound_type,
consumer_name: form.consumer_name,
operator_name: form.operator_name,
remark: form.remark,
signature_path: signatureUrl
})
ElMessage.success('批量出库成功')
// 清空
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
// 刷新操作员列表
loadHistoryOperators()
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
})
}
// --- 摄像头控制 ---
const toggleCamera = async () => {
if (showCamera.value) {
@ -435,64 +453,106 @@ const stopCamera = async () => {
}
}
// --- 提交逻辑 ---
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
if (!currentItem.value) return
if (!signatureFile.value) {
ElMessage.error('请进行电子签名')
return
}
try {
loading.value = true
const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url
await submitOutbound({
sku: currentItem.value.sku,
source_table: currentItem.value.source_table,
stock_id: currentItem.value.id,
barcode: barcodeInput.value,
outbound_type: form.outbound_type,
quantity: form.quantity,
consumer_name: form.consumer_name,
operator_name: form.operator_name,
remark: form.remark,
signature_path: signatureUrl
})
ElMessage.success('出库成功')
resetScan()
loadHistoryOperators()
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
})
// --- 签名核心逻辑 ---
const openSignatureDialog = () => {
showSignatureDialog.value = true
}
const resetScan = () => {
currentItem.value = null
barcodeInput.value = ''
form.consumer_name = ''
form.quantity = 1
form.remark = ''
if (userStore.username) {
form.operator_name = userStore.username
const initCanvas = async () => {
// 必须等待 DOM 更新完成,确保 dialog 里的 ref 已经挂载
await nextTick()
const canvas = nativeCanvasRef.value
const container = canvasContainerRef.value
if (canvas && container) {
// 设置物理像素等于显示像素,避免模糊
canvas.width = container.clientWidth
canvas.height = container.clientHeight
ctx.value = canvas.getContext('2d')
if (ctx.value) {
ctx.value.lineWidth = 4
ctx.value.lineCap = 'round'
ctx.value.lineJoin = 'round'
ctx.value.strokeStyle = '#000000'
// 填充白色背景,防止生成透明图片
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, canvas.width, canvas.height)
}
} else {
console.error('Canvas 初始化失败:无法获取 DOM 元素')
}
signatureFile.value = null
signaturePreviewUrl.value = ''
nextTick(() => {
barcodeRef.value?.focus()
})
}
const getPos = (e: MouseEvent | TouchEvent) => {
if (!nativeCanvasRef.value) return { x: 0, y: 0 }
const rect = nativeCanvasRef.value.getBoundingClientRect()
let clientX, clientY
if (e.type.startsWith('touch')) {
clientX = (e as TouchEvent).touches[0].clientX
clientY = (e as TouchEvent).touches[0].clientY
} else {
clientX = (e as MouseEvent).clientX
clientY = (e as MouseEvent).clientY
}
return { x: clientX - rect.left, y: clientY - rect.top }
}
const startDrawing = (e: MouseEvent | TouchEvent) => {
// 阻止默认事件(如页面滚动)
e.preventDefault()
isDrawing.value = true
const { x, y } = getPos(e)
lastX.value = x
lastY.value = y
if(ctx.value) {
ctx.value.beginPath()
ctx.value.moveTo(x, y)
ctx.value.lineTo(x, y)
ctx.value.stroke()
}
}
const draw = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
if (!isDrawing.value || !ctx.value) return
const { x, y } = getPos(e)
ctx.value.beginPath()
ctx.value.moveTo(lastX.value, lastY.value)
ctx.value.lineTo(x, y)
ctx.value.stroke()
lastX.value = x
lastY.value = y
}
const stopDrawing = () => {
isDrawing.value = false
if (ctx.value) ctx.value.beginPath()
}
const clearCanvas = () => {
if (!ctx.value || !nativeCanvasRef.value) return
ctx.value.clearRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
}
const handleSignConfirm = () => {
if (!nativeCanvasRef.value) return
nativeCanvasRef.value.toBlob((blob) => {
if (blob) {
const file = new File([blob], `signature_${Date.now()}.png`, { type: 'image/png' })
signatureFile.value = file
signaturePreviewUrl.value = URL.createObjectURL(file)
showSignatureDialog.value = false
} else {
ElMessage.error('生成签名失败')
}
}, 'image/png')
}
const handleSignCancel = () => {
showSignatureDialog.value = false
}
onUnmounted(() => {
@ -504,7 +564,6 @@ onUnmounted(() => {
</script>
<style scoped>
/* 原有样式保持不变 */
.scan-area {
text-align: center;
padding: 20px;
@ -526,7 +585,7 @@ onUnmounted(() => {
font-size: 12px;
}
.confirm-area {
max-width: 600px;
max-width: 800px;
margin: 0 auto;
}
.mt-20 {
@ -537,7 +596,6 @@ onUnmounted(() => {
display: flex;
justify-content: space-between;
}
.signature-display-area {
border: 1px dashed #dcdfe6;
border-radius: 4px;
@ -549,9 +607,6 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
}
.signed-image-box {
text-align: center;
}
.signed-image-box img {
max-height: 100px;
max-width: 100%;
@ -559,23 +614,32 @@ onUnmounted(() => {
margin-bottom: 5px;
border: 1px solid #eee;
}
/* 新增:头部右侧信息 */
.header-right {
display: flex;
align-items: center;
}
.summary-text {
margin-right: 15px;
font-size: 14px;
color: #606266;
}
.summary-text .num { color: #409EFF; font-weight: bold; margin: 0 4px; }
.summary-text .price { color: #F56C6C; font-weight: bold; margin: 0 4px; font-size: 16px; }
/* --- 响应式全屏弹窗样式 --- */
/* 响应式签名弹窗 */
:deep(.fullscreen-signature-dialog .el-dialog__body) {
padding: 0;
height: 100%;
overflow: hidden;
display: flex;
}
/* 默认布局(电脑/平板):左右结构 */
.signature-wrapper {
display: flex;
width: 100%;
height: 100%; /* 使用 100% 适应弹窗 body */
height: 100%;
background-color: #fff;
}
.signature-canvas-container {
flex: 1;
position: relative;
@ -584,7 +648,6 @@ onUnmounted(() => {
width: 100%;
overflow: hidden;
}
.native-canvas {
display: block;
width: 100%;
@ -592,8 +655,6 @@ onUnmounted(() => {
cursor: crosshair;
touch-action: none;
}
/* 右侧边栏(默认) */
.signature-sidebar {
width: 150px;
background: #333;
@ -607,7 +668,6 @@ onUnmounted(() => {
z-index: 10;
box-shadow: -2px 0 5px rgba(0,0,0,0.1);
}
.sidebar-title {
writing-mode: vertical-rl;
letter-spacing: 4px;
@ -615,69 +675,50 @@ onUnmounted(() => {
font-weight: bold;
opacity: 0.8;
}
.sidebar-actions {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
}
.sidebar-actions .el-button {
width: 100%;
margin-left: 0;
height: 50px;
}
.confirm-btn {
margin-top: 20px;
font-weight: bold;
}
/* --- ★ 手机端适配 (屏幕宽度小于 768px) --- */
@media screen and (max-width: 768px) {
/* 1. 改为上下布局:画布在上,按钮在下 */
.signature-wrapper {
flex-direction: column;
}
/* 2. 画布区域自动填充剩余空间 */
.signature-canvas-container {
flex: 1;
height: auto; /* 高度自适应 */
height: auto;
}
/* 3. 底部工具栏样式重写 */
.signature-sidebar {
width: 100%; /* 宽度占满 */
height: auto; /* 高度由内容撑开 */
flex-direction: row; /* 内容横向排列 */
padding: 15px; /* 增加内边距 */
width: 100%;
height: auto;
flex-direction: row;
padding: 15px;
gap: 10px;
/* 放在底部 */
order: 2;
}
/* 4. 隐藏竖排文字标题,节省空间 */
.sidebar-title {
display: none;
}
/* 5. 按钮组改为横向排列 */
.sidebar-actions {
flex-direction: row;
gap: 10px;
width: 100%;
}
/* 6. 按钮大小调整,均匀分布 */
.sidebar-actions .el-button {
flex: 1; /* 三个按钮平分宽度 */
height: 44px; /* 适合手指点击的高度 */
flex: 1;
height: 44px;
font-size: 14px;
}
/* 去掉确认按钮原本的顶部外边距 */
.confirm-btn {
margin-top: 0;
}

View File

@ -30,7 +30,33 @@
style="width: 100%; margin-top: 20px;"
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
>
<el-table-column prop="outbound_no" label="出库单号" min-width="180" show-overflow-tooltip />
<el-table-column type="expand">
<template #default="props">
<div style="padding: 10px 40px; background: #fafafa;">
<h4 style="margin: 0 0 10px 0; color: #409EFF;">商品明细 (按单价降序)</h4>
<el-table :data="props.row.items" border size="small">
<el-table-column prop="sku" label="SKU" width="150" />
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="material_type" label="类型" width="120" show-overflow-tooltip />
<el-table-column prop="category" label="类别" width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="100" />
<el-table-column prop="unit_price" label="单价" width="120">
<template #default="{row}">¥{{ row.unit_price }}</template>
</el-table-column>
<el-table-column prop="subtotal" label="小计">
<template #default="{row}">
<span style="color: #F56C6C; font-weight: bold;">¥{{ row.subtotal.toFixed(2) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="outbound_no" label="出库单号" min-width="200" show-overflow-tooltip />
<el-table-column prop="outbound_time" label="出库时间" width="170" align="center">
<template #default="{ row }">
@ -44,9 +70,11 @@
</template>
</el-table-column>
<el-table-column prop="sku" label="SKU" min-width="140" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="90" align="center" />
<el-table-column prop="total_amount" label="总金额" width="120" align="right">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold; font-size: 15px;">¥{{ row.total_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="consumer_name" label="领用/客户" min-width="120" show-overflow-tooltip />
@ -76,18 +104,31 @@
</template>
</el-table-column>
</el-table>
<div style="margin-top: 20px; text-align: right;">
<el-pagination
background
layout="prev, pager, next"
:total="total"
:page-size="listQuery.limit"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue'
import { getOutboundList } from '@/api/outbound'
import { Picture } from '@element-plus/icons-vue' // 引入图标用于图片加载失败显示
import { Picture } from '@element-plus/icons-vue'
const list = ref([])
const total = ref(0)
const loading = ref(false)
const listQuery = reactive({
page: 1,
limit: 10,
keyword: '',
dateRange: []
})
@ -95,8 +136,15 @@ const listQuery = reactive({
const fetchData = async () => {
loading.value = true
try {
const res = await getOutboundList(listQuery)
const params = {
...listQuery,
start_date: listQuery.dateRange && listQuery.dateRange[0] ? listQuery.dateRange[0] : null,
end_date: listQuery.dateRange && listQuery.dateRange[1] ? listQuery.dateRange[1] : null
}
const res = await getOutboundList(params)
list.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
@ -104,6 +152,11 @@ const fetchData = async () => {
}
}
const handlePageChange = (val: number) => {
listQuery.page = val
fetchData()
}
const formatType = (type: string) => {
const map: any = {
'SALES': '销售出库',
@ -114,7 +167,6 @@ const formatType = (type: string) => {
return map[type] || type
}
// 辅助函数:根据类型返回 Tag 颜色
const getTagType = (type: string) => {
const map: any = {
'SALES': 'success',