1487 lines
76 KiB
Python
1487 lines
76 KiB
Python
"""现货模式相关接口。"""
|
||
import os
|
||
import re
|
||
import json
|
||
import uuid
|
||
from pathlib import Path
|
||
from datetime import date, datetime, timedelta
|
||
|
||
from flask import Blueprint, current_app, jsonify, request, send_file, session
|
||
from werkzeug.utils import secure_filename
|
||
|
||
from routes.auth import login_required, role_required, operation_required
|
||
from services.db import add_operation_log, execute, get_db, query
|
||
from services.cache import bump_data_version
|
||
from services.field_perms import field_perms_for_page
|
||
from services.spot_confirmation_edit import (
|
||
EDITABLE, confirmation_reminder_state, validate_confirmation_values,
|
||
)
|
||
from services.spot_sales_cache import sales_snapshot
|
||
from services.forecast import is_new_product
|
||
from services.spot_confirmation_import import (
|
||
ImportValidationError, MAX_FILE_BYTES, confirmation_template,
|
||
import_confirmations, parse_confirmation_import,
|
||
)
|
||
from services.inventory_calc import (
|
||
compute_product_inventory_cached,
|
||
compute_product_in_transit_cached,
|
||
resolve_cost_price,
|
||
_determine_trend,
|
||
)
|
||
|
||
|
||
spot_bp = Blueprint('spot', __name__)
|
||
|
||
UPLOAD_FOLDER = os.path.join(
|
||
os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads', 'spot_application'
|
||
)
|
||
AUTHORIZATION_UPLOAD_FOLDER = os.path.join(
|
||
os.path.dirname(os.path.dirname(__file__)), 'var', 'spot_authorization'
|
||
)
|
||
os.makedirs(AUTHORIZATION_UPLOAD_FOLDER, exist_ok=True)
|
||
ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'}
|
||
ALLOWED_REPORT_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS | {'pdf', 'doc', 'docx', 'xls', 'xlsx'}
|
||
ALLOWED_DOCUMENT_EXTENSIONS = {'pdf', 'doc', 'docx', 'xls', 'xlsx'}
|
||
AUTHORIZATION_MAX_FILE_BYTES = 10 * 1024 * 1024
|
||
SPOT_APPLICATION_GROUPS = ('京东1组', '京东2组', '阿里1组', '阿里2组', '阿里3组(广州)', '无')
|
||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||
|
||
|
||
def _allowed_image(filename):
|
||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
|
||
|
||
|
||
def _authorization_file_url(filename):
|
||
if not filename:
|
||
return ''
|
||
return f"/api/spot/authorization/file/{filename}"
|
||
|
||
|
||
def _authorization_filenames(value):
|
||
if not value:
|
||
return []
|
||
try:
|
||
parsed = json.loads(value)
|
||
if isinstance(parsed, list):
|
||
return [str(name) for name in parsed if name]
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return [str(value)]
|
||
|
||
|
||
def _store_authorization_filenames(filenames):
|
||
return json.dumps(filenames, ensure_ascii=False) if len(filenames) > 1 else (filenames[0] if filenames else '')
|
||
|
||
|
||
@spot_bp.route('/api/spot/authorization/list')
|
||
@login_required
|
||
def authorization_list():
|
||
if not role_required('spot_authorization'):
|
||
return jsonify({'code': 403, 'msg': '无访问权限'}), 403
|
||
page, size = _page_params()
|
||
search = (request.args.get('search') or '').strip()
|
||
where = ''
|
||
params = []
|
||
if search:
|
||
where = "WHERE merchant_code LIKE %s OR product_name LIKE %s OR platform LIKE %s OR operator LIKE %s"
|
||
params = [f'%{search}%'] * 4
|
||
count = query(f"SELECT COUNT(*) AS total FROM spot_authorization_materials {where}", params)
|
||
rows = query(
|
||
f"SELECT * FROM spot_authorization_materials {where} ORDER BY updated_at DESC, id DESC LIMIT %s OFFSET %s",
|
||
params + [size, (page - 1) * size],
|
||
)
|
||
file_fields = ('authorization_letter', 'testing_report', 'factory_qualification', 'trademark')
|
||
for row in rows:
|
||
for field in file_fields:
|
||
names = _authorization_filenames(row.get(field))
|
||
row[field + '_files'] = [{'name': name, 'url': _authorization_file_url(name)} for name in names]
|
||
row[field + '_url'] = _authorization_file_url(names[0]) if names else ''
|
||
return jsonify({'code': 0, 'data': rows, 'total': int(count[0]['total'] if count else 0),
|
||
'page': page, 'size': size})
|
||
|
||
|
||
@spot_bp.route('/api/spot/authorization/create', methods=['POST'])
|
||
@spot_bp.route('/api/spot/authorization/<int:record_id>/update', methods=['POST'])
|
||
@login_required
|
||
def authorization_create(record_id=None):
|
||
editing = record_id is not None
|
||
if not role_required('spot_authorization') or not operation_required('spot_authorization', 'edit' if editing else 'create'):
|
||
return jsonify({'code': 403, 'msg': '无编辑资料权限' if editing else '无新增资料权限'}), 403
|
||
merchant_code = (request.form.get('merchant_code') or '').strip()
|
||
product_name = (request.form.get('product_name') or '').strip()
|
||
if not merchant_code or not product_name:
|
||
return jsonify({'code': 400, 'msg': '商家编码和货品名称为必填项'})
|
||
|
||
upload_specs = {
|
||
'authorization_letter': (ALLOWED_REPORT_EXTENSIONS, 1, '授权书仅支持文件或图片格式'),
|
||
'testing_report': (ALLOWED_DOCUMENT_EXTENSIONS, 1, '检测报告仅支持PDF、Word或Excel文件格式'),
|
||
'factory_qualification': (ALLOWED_IMAGE_EXTENSIONS, 2, '营业执照+生产许可证仅支持图片格式'),
|
||
'trademark': (ALLOWED_IMAGE_EXTENSIONS, 2, '商标注册证+商标转让证明仅支持图片格式'),
|
||
}
|
||
saved_paths = []
|
||
filenames = {field: [] for field in upload_specs}
|
||
total_bytes = 0
|
||
try:
|
||
upload_contents = []
|
||
for field, (extensions, limit, error_message) in upload_specs.items():
|
||
uploads = [upload for upload in request.files.getlist(field) if upload and upload.filename]
|
||
if len(uploads) > limit:
|
||
return jsonify({'code': 400, 'msg': f'{field}最多上传{limit}个文件'})
|
||
if not uploads:
|
||
continue
|
||
for upload in uploads:
|
||
safe_original = secure_filename(upload.filename)
|
||
ext = safe_original.rsplit('.', 1)[-1].lower() if '.' in safe_original else ''
|
||
if ext not in extensions:
|
||
return jsonify({'code': 400, 'msg': error_message})
|
||
content = upload.stream.read(AUTHORIZATION_MAX_FILE_BYTES + 1)
|
||
if len(content) > AUTHORIZATION_MAX_FILE_BYTES:
|
||
return jsonify({'code': 400, 'msg': '单个附件不能超过10MB'})
|
||
total_bytes += len(content)
|
||
if total_bytes > 20 * 1024 * 1024:
|
||
return jsonify({'code': 400, 'msg': '附件总大小不能超过20MB'})
|
||
upload_contents.append((field, ext, content))
|
||
|
||
for field, ext, content in upload_contents:
|
||
filename = f'{uuid.uuid4().hex}.{ext}'
|
||
path = os.path.join(AUTHORIZATION_UPLOAD_FOLDER, filename)
|
||
with open(path, 'wb') as target:
|
||
target.write(content)
|
||
saved_paths.append(path)
|
||
filenames[field].append(filename)
|
||
|
||
if editing:
|
||
conn = get_db()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute('SELECT * FROM spot_authorization_materials WHERE id=%s FOR UPDATE', (record_id,))
|
||
existing = cur.fetchone()
|
||
if not existing:
|
||
raise LookupError('资料不存在或已被删除')
|
||
for field in upload_specs:
|
||
if not filenames[field]:
|
||
names = [] if request.form.get('remove_' + field) == '1' else _authorization_filenames(existing.get(field))
|
||
filenames[field] = names
|
||
filenames[field] = _store_authorization_filenames(filenames[field])
|
||
cur.execute('''UPDATE spot_authorization_materials SET merchant_code=%s,
|
||
product_name=%s, platform=%s, operator=%s, authorization_letter=%s,
|
||
testing_report=%s, factory_qualification=%s, trademark=%s, remark=%s,
|
||
updated_at=CURRENT_TIMESTAMP WHERE id=%s''',
|
||
(merchant_code, product_name, (request.form.get('platform') or '').strip(),
|
||
(request.form.get('operator') or '').strip(), filenames['authorization_letter'],
|
||
filenames['testing_report'], filenames['factory_qualification'], filenames['trademark'],
|
||
(request.form.get('remark') or '').strip(), record_id))
|
||
conn.commit()
|
||
row_id = record_id
|
||
except Exception:
|
||
conn.rollback()
|
||
raise
|
||
finally:
|
||
conn.close()
|
||
else:
|
||
filenames = {field: _store_authorization_filenames(names) for field, names in filenames.items()}
|
||
row_id = query(
|
||
"""INSERT INTO spot_authorization_materials
|
||
(merchant_code, product_name, platform, operator, authorization_letter,
|
||
testing_report, factory_qualification, trademark, remark, created_by)
|
||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||
(merchant_code, product_name, (request.form.get('platform') or '').strip(),
|
||
(request.form.get('operator') or '').strip(), filenames['authorization_letter'],
|
||
filenames['testing_report'], filenames['factory_qualification'], filenames['trademark'],
|
||
(request.form.get('remark') or '').strip(), session.get('user_id')),
|
||
)
|
||
except Exception as exc:
|
||
for path in saved_paths:
|
||
try:
|
||
os.unlink(path)
|
||
except OSError:
|
||
pass
|
||
if isinstance(exc, LookupError):
|
||
return jsonify({'code': 404, 'msg': '资料不存在或已被删除'}), 404
|
||
current_app.logger.exception('保存授权资料失败')
|
||
return jsonify({'code': 500, 'msg': '保存失败,请稍后重试'})
|
||
try:
|
||
add_operation_log('编辑授权资料' if editing else '新增授权资料',
|
||
f'授权资料 #{row_id}:{merchant_code} {product_name}', page='授权资料管理')
|
||
except Exception:
|
||
current_app.logger.exception('授权资料已保存,操作日志写入失败')
|
||
return jsonify({'code': 0, 'msg': '授权资料已保存', 'id': row_id})
|
||
|
||
|
||
@spot_bp.route('/api/spot/authorization/<int:record_id>/delete', methods=['POST'])
|
||
@login_required
|
||
def authorization_delete(record_id):
|
||
if not role_required('spot_authorization') or not operation_required('spot_authorization', 'delete'):
|
||
return jsonify({'code': 403, 'msg': '无删除权限'}), 403
|
||
conn = get_db()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute('SELECT * FROM spot_authorization_materials WHERE id=%s FOR UPDATE', (record_id,))
|
||
row = cur.fetchone()
|
||
if not row:
|
||
conn.rollback()
|
||
return jsonify({'code': 404, 'msg': '资料不存在或已被删除'}), 404
|
||
cur.execute('DELETE FROM spot_authorization_materials WHERE id=%s', (record_id,))
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
current_app.logger.exception('删除授权资料失败')
|
||
return jsonify({'code': 500, 'msg': '删除失败,请稍后重试'}), 500
|
||
finally:
|
||
conn.close()
|
||
# Keep stored attachments for recovery; deleting a record never removes files.
|
||
try:
|
||
add_operation_log('删除授权资料', f'授权资料 #{record_id}:{row.get("merchant_code", "")} {row.get("product_name", "")}',
|
||
page='授权资料管理')
|
||
except Exception:
|
||
current_app.logger.exception('授权资料已删除,操作日志写入失败')
|
||
return jsonify({'code': 0, 'msg': '已删除'})
|
||
|
||
|
||
@spot_bp.route('/api/spot/authorization/file/<path:filename>')
|
||
@login_required
|
||
def authorization_file(filename):
|
||
if not role_required('spot_authorization'):
|
||
return jsonify({'code': 403, 'msg': '无访问权限'}), 403
|
||
safe_name = os.path.basename(filename)
|
||
if safe_name != filename or not re.fullmatch(r'[0-9a-f]{32}\.[a-z0-9]{1,8}', safe_name):
|
||
return jsonify({'code': 404, 'msg': '文件不存在'}), 404
|
||
path = os.path.join(AUTHORIZATION_UPLOAD_FOLDER, safe_name)
|
||
if not os.path.isfile(path):
|
||
return jsonify({'code': 404, 'msg': '文件不存在'}), 404
|
||
return send_file(path, as_attachment=False, download_name=safe_name)
|
||
|
||
|
||
def _page_params():
|
||
try:
|
||
page = max(1, int(request.args.get('page', 1)))
|
||
except (TypeError, ValueError):
|
||
page = 1
|
||
try:
|
||
size = min(100, max(1, int(request.args.get('size', 20))))
|
||
except (TypeError, ValueError):
|
||
size = 20
|
||
return page, size
|
||
|
||
|
||
def _format_date(value):
|
||
"""统一格式化列表中的日期字段。"""
|
||
if isinstance(value, (datetime, date)):
|
||
return value.strftime('%Y-%m-%d')
|
||
return str(value or '')[:10]
|
||
|
||
|
||
def _month_bounds(value, fallback):
|
||
"""解析 YYYY-MM 月份,返回月初、下月月初和标准月份文本。"""
|
||
try:
|
||
month_start = datetime.strptime(str(value or '')[:7], '%Y-%m').date()
|
||
except (TypeError, ValueError):
|
||
month_start = fallback.replace(day=1)
|
||
next_month = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||
return month_start, next_month, month_start.strftime('%Y-%m')
|
||
|
||
|
||
def _spot_sales_map():
|
||
"""按现货商品索引实时统计,避免全量扫描及商品分类变更后的旧缓存。"""
|
||
latest_rows = query("SELECT MAX(日期) AS d FROM daily_outbound")
|
||
latest = latest_rows[0]['d'] if latest_rows else None
|
||
if not latest:
|
||
return {}, None
|
||
if hasattr(latest, 'date'):
|
||
latest = latest.date()
|
||
elif not isinstance(latest, date):
|
||
latest = datetime.strptime(str(latest)[:10], '%Y-%m-%d').date()
|
||
|
||
start_30 = latest - timedelta(days=29)
|
||
rows = query(
|
||
"""
|
||
SELECT o.货品编号,
|
||
SUM(CASE WHEN 日期 = %s THEN 货品数量 ELSE 0 END) AS yesterday_sales,
|
||
SUM(CASE WHEN 日期 = %s THEN 货品数量 ELSE 0 END) AS ante_sales,
|
||
SUM(CASE WHEN 日期 BETWEEN %s AND %s THEN 货品数量 ELSE 0 END) AS sales_3,
|
||
SUM(CASE WHEN 日期 BETWEEN %s AND %s THEN 货品数量 ELSE 0 END) AS sales_7,
|
||
SUM(CASE WHEN 日期 BETWEEN %s AND %s THEN 货品数量 ELSE 0 END) AS sales_14,
|
||
SUM(CASE WHEN 日期 BETWEEN %s AND %s THEN 货品数量 ELSE 0 END) AS sales_30,
|
||
SUM(货品数量) AS total_sales
|
||
FROM products p
|
||
INNER JOIN daily_outbound o FORCE INDEX (idx_product_date)
|
||
ON o.货品编号 = p.product_code
|
||
WHERE p.brand LIKE %s
|
||
AND p.product_type NOT IN ('包材','其他')
|
||
AND p.product_code NOT IN (SELECT product_code FROM plan_hidden)
|
||
AND p.deleted_at IS NULL
|
||
AND o.日期 <= %s
|
||
AND o.货品编号 NOT LIKE %s
|
||
AND o.货品名称 NOT LIKE %s
|
||
GROUP BY o.货品编号
|
||
""",
|
||
(
|
||
latest, latest - timedelta(days=1),
|
||
latest - timedelta(days=2), latest,
|
||
latest - timedelta(days=6), latest,
|
||
latest - timedelta(days=13), latest,
|
||
start_30, latest,
|
||
'%现货%', latest, '%-NL', '%内料%',
|
||
),
|
||
)
|
||
result = {}
|
||
for row in rows:
|
||
result[row['货品编号']] = {
|
||
'昨日销量': int(row['yesterday_sales'] or 0),
|
||
'前日销量': int(row['ante_sales'] or 0),
|
||
'3天销量': int(row['sales_3'] or 0),
|
||
'7天销量': int(row['sales_7'] or 0),
|
||
'14天销量': int(row['sales_14'] or 0),
|
||
'30天销量': int(row['sales_30'] or 0),
|
||
'总销量': int(row['total_sales'] or 0),
|
||
}
|
||
return result, latest
|
||
|
||
|
||
def _spot_first_inventory_map():
|
||
"""只统计有效现货,仍从首批日期的库存记录读取首次成本。"""
|
||
rows = query(
|
||
"""
|
||
SELECT i.product_code,
|
||
MIN(i.data_date) AS first_inventory_date,
|
||
ANY_VALUE(i.cost_price) AS first_cost_price
|
||
FROM inventory_detail i
|
||
INNER JOIN (
|
||
SELECT i2.product_code, MIN(i2.data_date) AS first_inventory_date
|
||
FROM inventory_detail i2
|
||
INNER JOIN products p2 ON p2.product_code = i2.product_code
|
||
WHERE p2.product_type = '现货' AND p2.deleted_at IS NULL
|
||
GROUP BY i2.product_code
|
||
) first_batch ON first_batch.product_code = i.product_code
|
||
AND first_batch.first_inventory_date = i.data_date
|
||
GROUP BY i.product_code
|
||
"""
|
||
)
|
||
return {row['product_code']: row for row in rows}
|
||
|
||
|
||
def _spot_domestic_completion_map():
|
||
"""现货销量列表的首批入库时间取国内订单最早的有效完结日期。"""
|
||
rows = query(
|
||
"""
|
||
SELECT `货品编号` AS product_code, MIN(`完结时间`) AS completion_date
|
||
FROM domestic_orders
|
||
WHERE TRIM(COALESCE(`状态`, '')) = '已完结'
|
||
AND `完结时间` IS NOT NULL
|
||
AND TRIM(COALESCE(`货品编号`, '')) <> ''
|
||
GROUP BY `货品编号`
|
||
"""
|
||
)
|
||
return {row['product_code']: row['completion_date'] for row in rows}
|
||
|
||
|
||
def _first_no_sales_days(value):
|
||
try:
|
||
return min(60, max(20, int(value)))
|
||
except (TypeError, ValueError):
|
||
return 20
|
||
|
||
|
||
def _spot_effective_inbound_map(products, completion_map):
|
||
result = dict(completion_map)
|
||
for row in products:
|
||
if row.get('spot_first_inbound_override'):
|
||
result[row.get('product_code') or row.get('货品编号')] = row['spot_first_inbound_override']
|
||
return result
|
||
|
||
|
||
def _spot_dashboard_summary(month):
|
||
if month == 'all':
|
||
total = query('SELECT COUNT(*) AS cnt FROM spot_applications')[0]['cnt']
|
||
stats = query('''SELECT
|
||
SUM(TRIM(COALESCE(c.sample_status,'')) IN ('已到货','样品已到货')) AS sampled_count,
|
||
SUM(TRIM(COALESCE(c.sample_status,''))='不授权') AS unauthorized_count
|
||
FROM spot_confirmations c
|
||
INNER JOIN spot_applications a ON a.id=c.application_id''')[0]
|
||
new_products = query('''SELECT COUNT(DISTINCT NULLIF(TRIM(p.product_code),'')) AS cnt
|
||
FROM products p
|
||
WHERE p.product_type='现货' AND p.deleted_at IS NULL
|
||
AND TRIM(COALESCE(p.planning_new_old,'')) IN ('新品','是')''')[0]['cnt']
|
||
return {
|
||
'total_forms': int(total or 0),
|
||
'total_sample_quantity': int(stats.get('sampled_count') or 0),
|
||
'unauthorized_count': int(stats.get('unauthorized_count') or 0),
|
||
'listed_count': int(new_products or 0),
|
||
}, 'all'
|
||
start, end, selected = _month_bounds(month, date.today())
|
||
total = query('''SELECT COUNT(*) AS cnt FROM spot_applications
|
||
WHERE application_date >= %s AND application_date < %s''', (start, end))[0]['cnt']
|
||
stats = query('''SELECT
|
||
SUM(TRIM(COALESCE(c.sample_status,'')) IN ('已到货','样品已到货')
|
||
AND COALESCE(c.arrival_date,DATE(c.updated_at),a.application_date) >= %s
|
||
AND COALESCE(c.arrival_date,DATE(c.updated_at),a.application_date) < %s) AS sampled_count,
|
||
SUM(TRIM(COALESCE(c.sample_status,''))='不授权'
|
||
AND a.application_date >= %s AND a.application_date < %s) AS unauthorized_count
|
||
FROM spot_confirmations c
|
||
INNER JOIN spot_applications a ON a.id=c.application_id''', (start, end, start, end))[0]
|
||
new_products = query('''SELECT COUNT(DISTINCT NULLIF(TRIM(p.product_code),'')) AS cnt
|
||
FROM products p
|
||
WHERE p.product_type='现货' AND p.deleted_at IS NULL
|
||
AND TRIM(COALESCE(p.planning_new_old,'')) IN ('新品','是')
|
||
AND p.created_at >= %s AND p.created_at < %s''', (start, end))[0]['cnt']
|
||
return {
|
||
'total_forms': int(total or 0),
|
||
'total_sample_quantity': int(stats.get('sampled_count') or 0),
|
||
'unauthorized_count': int(stats.get('unauthorized_count') or 0),
|
||
'listed_count': int(new_products or 0),
|
||
}, selected
|
||
|
||
|
||
def _spot_sample_department(value):
|
||
"""规范现货看板采样部门筛选,仅允许业务上存在的两个部门。"""
|
||
value = str(value or '').strip()
|
||
return value if value in ('洁维塔', '泊紫汀兰') else ''
|
||
|
||
|
||
def _spot_sample_group(value):
|
||
return str(value or '').strip()
|
||
|
||
|
||
def _first_no_sales_rows(sales_rows, products, completion_map, as_of, days):
|
||
new_codes = {row['product_code'] for row in products
|
||
if str(row.get('planning_new_old') or '').strip() in ('新品', '是')}
|
||
result = []
|
||
for row in sales_rows:
|
||
if row['product_code'] not in new_codes:
|
||
continue
|
||
first_value = completion_map.get(row['product_code'])
|
||
try:
|
||
first_date = date.fromisoformat(_format_date(first_value))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
age = (as_of - first_date).days
|
||
if age >= days and row['total_sales'] == 0:
|
||
result.append({**row, 'name': row['product_name'], 'sales': row['total_sales'],
|
||
'first_inventory_date': _format_date(first_date),
|
||
'days_since_inventory': age})
|
||
result.sort(key=lambda row: (-row['days_since_inventory'], row['name']))
|
||
return result
|
||
|
||
|
||
|
||
@spot_bp.route('/api/spot/dashboard')
|
||
@login_required
|
||
def spot_dashboard():
|
||
"""现货数据看板:采样按到货月份统计,销量按所选自然月统计。"""
|
||
if not role_required('spot_dashboard'):
|
||
return jsonify({'code': 403, 'msg': '无访问权限'}), 403
|
||
|
||
sales_map, latest_sales_date = _spot_sales_map()
|
||
products = query("""SELECT id,goods_code,product_code,product_name,platform,
|
||
category1,category2,category3,operator,brand,created_at,spot_first_inbound_override
|
||
FROM products WHERE product_type='现货' AND deleted_at IS NULL
|
||
ORDER BY product_code""")
|
||
# 首批无销量使用与现货销量管理完全一致的商品来源及新旧品字段。
|
||
no_sales_products = query("""SELECT product_code, goods_code, product_name, brand, operator,
|
||
created_at, spot_first_inbound_override, planning_new_old
|
||
FROM products
|
||
WHERE brand LIKE %s
|
||
AND product_type NOT IN ('包材','其他')
|
||
AND product_code NOT IN (SELECT product_code FROM plan_hidden)
|
||
AND deleted_at IS NULL
|
||
ORDER BY product_code""", ('%现货%',))
|
||
no_sales_codes = {row['product_code'] for row in no_sales_products}
|
||
no_sales_rows = []
|
||
for product in no_sales_products:
|
||
code = product.get('product_code')
|
||
sales = sales_map.get(code) or {}
|
||
no_sales_rows.append({
|
||
'product_code': code,
|
||
'goods_code': product.get('goods_code') or '',
|
||
'product_name': product.get('product_name') or code,
|
||
'brand': product.get('brand') or '',
|
||
'operator': product.get('operator') or '未分配',
|
||
'total_sales': int(sales.get('总销量') or 0),
|
||
})
|
||
|
||
metrics, selected_summary_month = _spot_dashboard_summary(request.args.get('summary_month'))
|
||
|
||
latest_fallback = latest_sales_date or date.today()
|
||
sales_start, _, selected_sales_start_month = _month_bounds(
|
||
request.args.get('sales_start_month'), latest_fallback
|
||
)
|
||
sales_end_start, sales_end, selected_sales_end_month = _month_bounds(
|
||
request.args.get('sales_end_month'), latest_fallback
|
||
)
|
||
if sales_end_start < sales_start:
|
||
sales_start = sales_end_start
|
||
selected_sales_start_month = selected_sales_end_month
|
||
monthly_sales = {
|
||
str(row['货品编号'] or '').strip(): int(row['sales'] or 0)
|
||
for row in query("""SELECT o.货品编号,COALESCE(SUM(o.货品数量),0) AS sales
|
||
FROM daily_outbound o
|
||
WHERE o.日期 >= %s AND o.日期 < %s
|
||
AND o.货品编号 NOT LIKE %s AND o.货品名称 NOT LIKE %s
|
||
AND EXISTS (
|
||
SELECT 1 FROM products p
|
||
WHERE p.product_code = o.货品编号
|
||
AND p.product_type = '现货'
|
||
AND p.deleted_at IS NULL
|
||
)
|
||
GROUP BY o.货品编号""",
|
||
(sales_start, sales_end, '%-NL', '%内料%'))
|
||
}
|
||
sales_rows = []
|
||
for row in products:
|
||
code = str(row.get('product_code') or '').strip()
|
||
sales = sales_map.get(code) or {}
|
||
sales_rows.append({
|
||
'product_code': code,
|
||
'goods_code': row.get('goods_code') or '',
|
||
'product_name': row.get('product_name') or code,
|
||
'platform': row.get('platform') or '',
|
||
'brand': row.get('brand') or '',
|
||
'category': ' / '.join(
|
||
str(row.get(key) or '').strip()
|
||
for key in ('category1', 'category2', 'category3')
|
||
if str(row.get(key) or '').strip()
|
||
),
|
||
'operator': row.get('operator') or '未分配',
|
||
'total_sales': int(sales.get('总销量') or 0),
|
||
'monthly_sales': int(monthly_sales.get(code) or 0),
|
||
})
|
||
top10 = sorted(
|
||
(row for row in sales_rows if row['monthly_sales'] > 0),
|
||
key=lambda x: (-x['monthly_sales'], x['product_name'])
|
||
)[:10]
|
||
|
||
latest_arrival_rows = query("""SELECT MAX(COALESCE(c.arrival_date,DATE(c.updated_at),a.application_date)) AS d
|
||
FROM spot_confirmations c
|
||
INNER JOIN spot_applications a ON a.id=c.application_id
|
||
WHERE TRIM(COALESCE(c.sample_status,'')) IN ('已到货','样品已到货')""")
|
||
latest_arrival = latest_arrival_rows[0].get('d') if latest_arrival_rows else None
|
||
if isinstance(latest_arrival, datetime):
|
||
latest_arrival = latest_arrival.date()
|
||
elif not isinstance(latest_arrival, date):
|
||
try:
|
||
latest_arrival = datetime.strptime(str(latest_arrival)[:10], '%Y-%m-%d').date()
|
||
except (TypeError, ValueError):
|
||
latest_arrival = date.today()
|
||
sample_start, sample_end, selected_sample_month = _month_bounds(
|
||
request.args.get('sample_month'), latest_arrival
|
||
)
|
||
selected_sample_department = _spot_sample_department(request.args.get('sample_department'))
|
||
selected_sample_group = _spot_sample_group(request.args.get('sample_group'))
|
||
sample_group_expr = ("COALESCE(NULLIF(TRIM(a.group_name),''), "
|
||
"NULLIF(TRIM(a.department),''), '未维护组别')")
|
||
sampling_where = [
|
||
"TRIM(COALESCE(c.sample_status,'')) = '样品已到货'",
|
||
"COALESCE(c.arrival_date,DATE(c.updated_at),a.application_date) >= %s",
|
||
"COALESCE(c.arrival_date,DATE(c.updated_at),a.application_date) < %s",
|
||
]
|
||
sampling_params = [sample_start, sample_end]
|
||
if selected_sample_department:
|
||
sampling_where.append("TRIM(COALESCE(a.department,'')) = %s")
|
||
sampling_params.append(selected_sample_department)
|
||
group_option_where = [sample_group_expr + " <> '未维护组别'"]
|
||
group_option_params = []
|
||
if selected_sample_department:
|
||
group_option_where.append("TRIM(COALESCE(a.department,'')) = %s")
|
||
group_option_params.append(selected_sample_department)
|
||
group_options_rows = query("""SELECT DISTINCT """ + sample_group_expr + """ AS group_name
|
||
FROM spot_applications a
|
||
WHERE """ + ' AND '.join(group_option_where) + """
|
||
ORDER BY group_name""", tuple(group_option_params))
|
||
sample_group_options = [row['group_name'] for row in group_options_rows if row.get('group_name')]
|
||
if selected_sample_group:
|
||
sampling_where.append(sample_group_expr + " = %s")
|
||
sampling_params.append(selected_sample_group)
|
||
sampling_rows = query("""SELECT COALESCE(NULLIF(TRIM(c.platform),''),'未维护平台') AS name,
|
||
COUNT(*) AS forms,
|
||
COALESCE(SUM(a.sample_quantity),0) AS sample_quantity
|
||
FROM spot_confirmations c
|
||
INNER JOIN spot_applications a ON a.id=c.application_id
|
||
WHERE """ + ' AND '.join(sampling_where) + """
|
||
GROUP BY COALESCE(NULLIF(TRIM(c.platform),''),'未维护平台')
|
||
ORDER BY forms DESC,name""",
|
||
tuple(sampling_params))
|
||
operator_sampling = [
|
||
{'name': row['name'], 'forms': int(row['forms'] or 0), 'sample_quantity': int(row['sample_quantity'] or 0)}
|
||
for row in sampling_rows
|
||
]
|
||
|
||
# 与现货销量管理共用国内订单完结日期;仅新品,入库满所选天数且累计销量为0。
|
||
as_of = latest_sales_date or date.today()
|
||
first_no_sales_days = _first_no_sales_days(request.args.get('first_no_sales_days', 20))
|
||
first_no_sales = _first_no_sales_rows(
|
||
no_sales_rows, no_sales_products,
|
||
_spot_effective_inbound_map(no_sales_products, _spot_domestic_completion_map()),
|
||
as_of, first_no_sales_days)
|
||
try:
|
||
first_no_sales_page = max(1, int(request.args.get('first_no_sales_page', 1)))
|
||
except (TypeError, ValueError):
|
||
first_no_sales_page = 1
|
||
first_no_sales_size = 20
|
||
first_no_sales_total = len(first_no_sales)
|
||
first_no_sales_pages = max(1, (first_no_sales_total + first_no_sales_size - 1) // first_no_sales_size)
|
||
first_no_sales_page = min(first_no_sales_page, first_no_sales_pages)
|
||
first_no_sales_offset = (first_no_sales_page - 1) * first_no_sales_size
|
||
first_no_sales_rows = first_no_sales[first_no_sales_offset:first_no_sales_offset + first_no_sales_size]
|
||
|
||
return jsonify({'code': 0, 'data': {
|
||
'metrics': metrics,
|
||
'selected_summary_month': selected_summary_month,
|
||
'operator_sampling': operator_sampling,
|
||
'first_no_sales': first_no_sales_rows,
|
||
'first_no_sales_total': first_no_sales_total,
|
||
'first_no_sales_page': first_no_sales_page,
|
||
'first_no_sales_size': first_no_sales_size,
|
||
'first_no_sales_days': first_no_sales_days,
|
||
'top10_sales': top10,
|
||
'latest_sales_date': _format_date(latest_sales_date),
|
||
'selected_sample_month': selected_sample_month,
|
||
'selected_sample_department': selected_sample_department,
|
||
'selected_sample_group': selected_sample_group,
|
||
'sample_group_options': sample_group_options,
|
||
'selected_sales_start_month': selected_sales_start_month,
|
||
'selected_sales_end_month': selected_sales_end_month,
|
||
'definitions': {
|
||
'total_forms': '全部现货采样申请记录数' if selected_summary_month == 'all' else '所选月份提交的现货采样申请记录数(按申请日期)',
|
||
'total_sample_quantity': '全部当前样品状态为已到货或样品已到货的记录数' if selected_summary_month == 'all' else '所选月份到样且当前样品状态为已到货或样品已到货的记录数;缺少到样日期时沿用更新时间、申请日期',
|
||
'unauthorized_count': '全部当前样品状态为不授权的记录数' if selected_summary_month == 'all' else '所选月份申请且当前样品状态为不授权的记录数',
|
||
'listed_count': '计划预测中新旧品=新品、产品类型=现货的未删除商品,按货品编号去重' + (',不限创建月份' if selected_summary_month == 'all' else '、按商品创建月份统计'),
|
||
},
|
||
}})
|
||
|
||
|
||
def _can_edit_spot_sales_inbound():
|
||
user_id = session.get('user_id')
|
||
if not user_id or not role_required('spot_sales'):
|
||
return False
|
||
permissions = field_perms_for_page(
|
||
user_id, session.get('role', ''), 'spot_sales', page_view=True,
|
||
page_edit=operation_required('spot_sales', 'edit'),
|
||
).get('首批入库时间', {})
|
||
return bool(permissions.get('view') and permissions.get('edit'))
|
||
|
||
|
||
@spot_bp.route('/api/spot/sales/list')
|
||
@login_required
|
||
def sales_list():
|
||
"""现货模式销量管理列表。"""
|
||
if not role_required('spot_sales'):
|
||
return jsonify({'code': 403, 'msg': '无访问权限'}), 403
|
||
|
||
page, size = _page_params()
|
||
search = (request.args.get('search') or '').strip()
|
||
products = query(
|
||
"""
|
||
SELECT p.goods_code AS 商家编码, p.product_code AS 货品编号,
|
||
p.product_name AS 货品名称, p.brand AS 品牌,
|
||
p.spot_first_inbound_override, p.cost_price AS product_cost,
|
||
p.operator AS 负责运营, p.planning_new_old
|
||
FROM products p
|
||
WHERE p.brand LIKE %s AND p.deleted_at IS NULL
|
||
AND p.product_type NOT IN ('包材','其他')
|
||
AND p.product_code NOT IN (SELECT product_code FROM plan_hidden)
|
||
ORDER BY p.goods_code ASC, p.product_code ASC
|
||
""", ('%现货%',),
|
||
)
|
||
rows, latest_sales_date = sales_snapshot(products, _build_spot_sales_rows)
|
||
if search:
|
||
term = search.casefold()
|
||
rows = [row for row in rows if any(term in str(row.get(key) or '').casefold()
|
||
for key in ('商家编码','货品编号','货品名称','品牌','负责运营'))]
|
||
sales_fields = ['新旧品','货品编号','货品名称','品牌','首批成本价','库存','采购在途',
|
||
'昨日销量','前日销量','7天销量','14天销量','30天销量','总销量',
|
||
'销量趋势','首批入库时间','负责运营']
|
||
filter_field = (request.args.get('filter_field') or '').strip()
|
||
filter_value = request.args.get('filter_value')
|
||
if filter_field in sales_fields and filter_value is not None:
|
||
value = '' if filter_value == '__EMPTY__' else filter_value
|
||
rows = [row for row in rows if str(row.get(filter_field) or '') == value]
|
||
sort_by = (request.args.get('sort_by') or '').strip()
|
||
sort_order = (request.args.get('sort_order') or '').lower()
|
||
if sort_by in sales_fields:
|
||
numeric = sort_by in {'首批成本价','库存','采购在途','昨日销量','前日销量','7天销量','14天销量','30天销量','总销量'}
|
||
def sort_key(row):
|
||
value = row.get(sort_by)
|
||
if numeric:
|
||
try: return float(value or 0)
|
||
except (TypeError, ValueError): return 0
|
||
return str(value or '').casefold()
|
||
rows.sort(key=sort_key, reverse=sort_order == 'desc')
|
||
total = len(rows)
|
||
offset = (page - 1) * size
|
||
internal = {'category1', 'category2', 'category3', 'product_cost', 'spot_first_inbound_override', 'planning_new_old'}
|
||
return jsonify({
|
||
'code': 0,
|
||
'data': [{key: value for key, value in row.items() if key not in internal}
|
||
for row in rows[offset:offset + size]],
|
||
'total': total,
|
||
'page': page,
|
||
'size': size,
|
||
'latest_sales_date': _format_date(latest_sales_date),
|
||
'can_edit_inbound_date': _can_edit_spot_sales_inbound(),
|
||
'fields': sales_fields,
|
||
'filter_options': {field: sorted({str(row.get(field) or '') for row in rows}) for field in sales_fields},
|
||
})
|
||
|
||
|
||
@spot_bp.route('/api/spot/sales/inbound-date', methods=['POST'])
|
||
@login_required
|
||
def sales_inbound_date_save():
|
||
if not _can_edit_spot_sales_inbound():
|
||
return jsonify({'code': 403, 'msg': '无编辑权限'}), 403
|
||
data = request.get_json(silent=True)
|
||
if not isinstance(data, dict) or not isinstance(data.get('product_code'), str) or 'date' not in data:
|
||
return jsonify({'code': 400, 'msg': '货品编号不能为空'}), 400
|
||
code = data['product_code'].strip()
|
||
value = data.get('date')
|
||
try:
|
||
if value not in (None, ''):
|
||
if not isinstance(value, str) or not re.fullmatch(r'\d{4}-\d{2}-\d{2}', value):
|
||
raise ValueError()
|
||
value = date.fromisoformat(value)
|
||
else:
|
||
value = None
|
||
except (ValueError, TypeError):
|
||
return jsonify({'code': 400, 'msg': '请填写有效日期(YYYY-MM-DD)'}), 400
|
||
conn = get_db()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT id FROM products WHERE product_code=%s AND brand LIKE %s AND product_type NOT IN ('包材','其他') AND product_code NOT IN (SELECT product_code FROM plan_hidden) AND deleted_at IS NULL FOR UPDATE", (code, '%现货%'))
|
||
if not code or not cur.fetchone():
|
||
conn.rollback()
|
||
return jsonify({'code': 404, 'msg': '现货商品不存在'}), 404
|
||
cur.execute("UPDATE products SET spot_first_inbound_override=%s WHERE product_code=%s AND brand LIKE %s AND product_type NOT IN ('包材','其他') AND product_code NOT IN (SELECT product_code FROM plan_hidden) AND deleted_at IS NULL", (value, code, '%现货%'))
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
current_app.logger.exception('首批入库时间保存失败')
|
||
return jsonify({'code': 500, 'msg': '保存失败,请稍后重试'}), 500
|
||
finally:
|
||
conn.close()
|
||
for action in (bump_data_version, lambda: add_operation_log(
|
||
'现货首批入库时间编辑', f'{code}:{value or "恢复自动抓取"}', page='现货销量管理', field='首批入库时间')):
|
||
try:
|
||
action()
|
||
except Exception:
|
||
current_app.logger.exception('首批入库时间已保存,缓存或日志更新失败')
|
||
return jsonify({'code': 0, 'msg': '保存成功' if value else '已恢复自动抓取'})
|
||
|
||
|
||
def _build_spot_sales_rows(products):
|
||
# Work on copies: cached rows and the fresh product fingerprint must stay separate.
|
||
rows = [dict(row) for row in products]
|
||
missing = [row['货品编号'] for row in rows if not str(row.get('商家编码') or '').strip()]
|
||
code_map = {}
|
||
if missing:
|
||
placeholders = ','.join(['%s'] * len(missing))
|
||
matches = query(f"""SELECT product_code, MIN(TRIM(goods_code)) AS goods_code
|
||
FROM inventory_detail
|
||
WHERE product_code IN ({placeholders})
|
||
AND NULLIF(TRIM(goods_code),'') IS NOT NULL
|
||
GROUP BY product_code
|
||
HAVING COUNT(DISTINCT TRIM(goods_code)) = 1""", missing)
|
||
code_map = {row['product_code']: row['goods_code'] for row in matches}
|
||
for row in rows:
|
||
original = str(row.get('商家编码') or '').strip()
|
||
row['商家编码'] = original or code_map.get(row['货品编号'], '')
|
||
row['商家编码来源'] = ('产品基础信息' if original
|
||
else '库存数据' if row['商家编码'] else '未找到唯一对应编码')
|
||
|
||
sales_map, latest_sales_date = _spot_sales_map()
|
||
inventory_map = compute_product_inventory_cached()
|
||
transit_map = compute_product_in_transit_cached()
|
||
first_map = _spot_first_inventory_map()
|
||
completion_map = _spot_domestic_completion_map()
|
||
|
||
for row in rows:
|
||
code = row['货品编号']
|
||
sales = sales_map.get(code, {})
|
||
first = first_map.get(code, {})
|
||
avg_3 = sales.get('3天销量', 0) / 3
|
||
avg_7 = sales.get('7天销量', 0) / 7
|
||
avg_14 = sales.get('14天销量', 0) / 14
|
||
total_sales = sales.get('总销量', 0)
|
||
sales_30 = sales.get('30天销量', 0)
|
||
row.update({
|
||
'新旧品': {'是': '新品', '否': '旧品'}.get(row.get('planning_new_old'), row.get('planning_new_old') or '旧品'),
|
||
'首批成本价': round(resolve_cost_price(
|
||
code, float(first.get('first_cost_price') or row.get('product_cost') or 0)
|
||
), 4),
|
||
'库存': int(inventory_map.get(code, 0) or 0),
|
||
'采购在途': int((transit_map.get(code) or {}).get('采购在途', 0) or 0),
|
||
'昨日销量': sales.get('昨日销量', 0),
|
||
'前日销量': sales.get('前日销量', 0),
|
||
'7天销量': sales.get('7天销量', 0),
|
||
'14天销量': sales.get('14天销量', 0),
|
||
'30天销量': sales_30,
|
||
'总销量': total_sales,
|
||
'销量趋势': _determine_trend(avg_3, avg_7, avg_14),
|
||
'首批入库时间': _format_date(row.get('spot_first_inbound_override') or completion_map.get(code)),
|
||
'自动首批入库时间': _format_date(completion_map.get(code)),
|
||
'首批入库时间来源': '手动填写' if row.get('spot_first_inbound_override') else '自动抓取',
|
||
})
|
||
rows.sort(key=lambda row: (-row['30天销量'], -row['昨日销量'], row['商家编码'] or ''))
|
||
return rows, latest_sales_date
|
||
|
||
|
||
@spot_bp.route('/api/spot/application/list')
|
||
@login_required
|
||
def application_list():
|
||
"""分页查询现货采样申请,支持现货采样申请/已到货/已驳回三个Tab。"""
|
||
if not role_required('spot_application'):
|
||
return jsonify({'code': 403, 'msg': '无访问权限'}), 403
|
||
page, size = _page_params()
|
||
tab = (request.args.get('tab') or 'application').strip()
|
||
if tab not in ('application', 'arrived', 'rejected'):
|
||
tab = 'application'
|
||
search = (request.args.get('search') or '').strip()
|
||
params = []
|
||
common = "FROM spot_applications a LEFT JOIN dashboard_users u ON u.id=a.applicant_user_id LEFT JOIN spot_confirmations c ON c.application_id=a.id"
|
||
# 到货数据与采样确认菜单一致;驳回记录独立来自申请,避免确认原因覆盖申请驳回原因。
|
||
platform = """COALESCE(NULLIF(TRIM(c.platform),''),
|
||
CASE WHEN a.group_name LIKE '京东%%' THEN '京东'
|
||
WHEN a.group_name LIKE '阿里%%' THEN '阿里' ELSE '' END) AS platform"""
|
||
operator = "COALESCE(NULLIF(u.real_name,''),u.username,'') AS operator"
|
||
where = []
|
||
if tab == 'application':
|
||
where.append("COALESCE(a.status,'待处理') NOT IN ('通过','驳回')")
|
||
if tab == 'arrived':
|
||
where.append("TRIM(COALESCE(c.sample_status,'')) IN ('已到货','样品已到货') AND a.status='通过'")
|
||
elif tab == 'rejected':
|
||
common = "FROM spot_applications a LEFT JOIN dashboard_users u ON u.id=a.applicant_user_id"
|
||
platform = """CASE WHEN a.group_name LIKE '京东%%' THEN '京东'
|
||
WHEN a.group_name LIKE '阿里%%' THEN '阿里' ELSE '' END AS platform"""
|
||
where.append("a.status='驳回'")
|
||
if search:
|
||
like = f'%{search}%'
|
||
search_fields = ['a.brand', 'a.product_name', 'a.barcode', 'a.manufacturer', 'a.applicant_name',
|
||
'u.real_name', 'u.username', 'a.group_name', 'a.reason']
|
||
if tab != 'rejected':
|
||
search_fields.extend(['c.sample_status', 'c.platform'])
|
||
where.append('(' + ' OR '.join(f'{field} LIKE %s' for field in search_fields) + ')')
|
||
params.extend([like] * len(search_fields))
|
||
# 结果页表头筛选与排序使用白名单字段,避免把前端参数拼入任意 SQL。
|
||
operator_expr = "COALESCE(NULLIF(u.real_name,''),u.username,'')"
|
||
result_filter_fields = {
|
||
'brand': 'a.brand',
|
||
'product_name': 'a.product_name',
|
||
'department': 'a.department',
|
||
'group_name': 'a.group_name',
|
||
'operator': operator_expr,
|
||
'barcode': 'a.barcode',
|
||
'arrival_time': 'c.arrival_date',
|
||
'rejected': "CASE WHEN a.status='驳回' THEN '是' ELSE '否' END",
|
||
'reject_reason': "COALESCE(a.reason,'')",
|
||
}
|
||
filter_field = (request.args.get('filter_field') or '').strip()
|
||
filter_value = (request.args.get('filter_value') or '').strip()
|
||
filter_values = []
|
||
try:
|
||
raw_filter_values = json.loads(request.args.get('filter_values') or '[]')
|
||
if isinstance(raw_filter_values, list):
|
||
filter_values = [str(value) for value in raw_filter_values]
|
||
except (TypeError, ValueError, json.JSONDecodeError):
|
||
filter_values = []
|
||
if tab == 'rejected' and filter_field == 'arrival_time':
|
||
filter_field = ''
|
||
if filter_field in result_filter_fields and filter_values:
|
||
filter_expr = result_filter_fields[filter_field]
|
||
placeholders = ','.join(['%s'] * len(filter_values))
|
||
where.append(f"COALESCE(CAST(({filter_expr}) AS CHAR),'') IN ({placeholders})")
|
||
params.extend(filter_values)
|
||
elif filter_field in result_filter_fields and filter_value:
|
||
filter_expr = result_filter_fields[filter_field]
|
||
where.append(f"COALESCE(CAST(({filter_expr}) AS CHAR),'') LIKE %s")
|
||
params.append(f'%{filter_value}%')
|
||
sort_fields = {
|
||
'brand': 'a.brand',
|
||
'product_name': 'a.product_name',
|
||
'department': 'a.department',
|
||
'group_name': 'a.group_name',
|
||
'operator': operator_expr,
|
||
'barcode': 'a.barcode',
|
||
'arrival_time': 'c.arrival_date',
|
||
'rejected': "CASE WHEN a.status='驳回' THEN '是' ELSE '否' END",
|
||
'reject_reason': "COALESCE(a.reason,'')",
|
||
'application_date': 'a.application_date',
|
||
}
|
||
sort_by = (request.args.get('sort_by') or '').strip()
|
||
sort_expr = sort_fields.get(sort_by)
|
||
sort_order = 'DESC' if (request.args.get('sort_order') or '').lower() == 'desc' else 'ASC'
|
||
if not sort_expr:
|
||
sort_expr = 'a.application_date'
|
||
sort_order = 'DESC'
|
||
where_sql = (' WHERE ' + ' AND '.join(where)) if where else ''
|
||
total = query(f"SELECT COUNT(*) AS cnt {common}{where_sql}", params)[0]['cnt']
|
||
if tab == 'application':
|
||
select = """a.id,a.applicant_user_id,COALESCE(NULLIF(a.applicant_name,''),NULLIF(u.real_name,''),u.username,'') AS applicant,
|
||
a.application_date,a.department,a.group_name,a.brand,a.product_name,a.manufacturer,a.image_path,a.product_link,
|
||
a.sample_quantity,a.barcode,a.recipient_info,COALESCE(a.status,'待处理') AS status,
|
||
COALESCE(a.reason,'') AS reason,a.created_at"""
|
||
elif tab == 'arrived':
|
||
select = f"""a.id,a.brand,a.product_name,a.image_path,a.barcode,
|
||
COALESCE(a.department,'') AS department,COALESCE(a.group_name,'') AS group_name,{operator},
|
||
c.arrival_date AS arrival_time"""
|
||
else:
|
||
select = f"""a.id,a.brand,a.product_name,COALESCE(a.department,'') AS department,
|
||
COALESCE(a.group_name,'') AS group_name,{operator},
|
||
CASE WHEN a.status='驳回' THEN '是' ELSE '否' END AS rejected,
|
||
COALESCE(a.reason,'') AS reject_reason"""
|
||
rows = query(f"SELECT {select} {common}{where_sql} ORDER BY {sort_expr} {sort_order},a.id DESC LIMIT %s OFFSET %s", params + [size,(page-1)*size])
|
||
for row in rows:
|
||
for key in ('application_date','created_at','arrival_time'):
|
||
value=row.get(key)
|
||
if isinstance(value,(datetime,date)): row[key]=value.strftime('%Y-%m-%d')
|
||
data = {'rows':rows,'total':total,'page':page,'size':size,'tab':tab}
|
||
if tab in ('arrived', 'rejected') and request.args.get('include_filter_options') == '1':
|
||
option_fields = {
|
||
'brand': 'a.brand', 'product_name': 'a.product_name',
|
||
'department': 'a.department', 'group_name': 'a.group_name',
|
||
'operator': operator_expr,
|
||
'barcode': 'a.barcode', 'rejected': "CASE WHEN a.status='驳回' THEN '是' ELSE '否' END",
|
||
'reject_reason': "COALESCE(a.reason,'')",
|
||
}
|
||
options = {}
|
||
# 选项按当前Tab的完整数据集生成,不能只取当前分页,确保跨页筛选可见。
|
||
for key, expression in option_fields.items():
|
||
option_rows = query(
|
||
f"SELECT DISTINCT COALESCE(CAST(({expression}) AS CHAR),'') AS value {common}{where_sql} ORDER BY value",
|
||
params,
|
||
)
|
||
options[key] = [str(item.get('value') or '') for item in option_rows]
|
||
data['filter_options'] = options
|
||
return jsonify({'code':0,'data':data})
|
||
|
||
|
||
@spot_bp.route('/api/spot/application/decision', methods=['POST'])
|
||
@login_required
|
||
def application_decision():
|
||
"""审核现货采样申请:通过同步到现货采样确认,驳回保留驳回原因。"""
|
||
if not operation_required('spot_application', 'edit'):
|
||
return jsonify({'code':403,'msg':'无审核权限'}),403
|
||
data=request.get_json(silent=True) or {}
|
||
try: application_id=int(data.get('id') or 0)
|
||
except (TypeError,ValueError): application_id=0
|
||
decision=str(data.get('decision') or '').strip()
|
||
reason=str(data.get('reason') or '').strip()
|
||
if application_id<=0 or decision not in ('通过','驳回'):
|
||
return jsonify({'code':400,'msg':'参数错误'})
|
||
if decision=='驳回' and not reason:
|
||
return jsonify({'code':400,'msg':'请填写驳回原因'})
|
||
from services.db import get_db
|
||
conn=get_db()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT id,status FROM spot_applications WHERE id=%s FOR UPDATE",(application_id,))
|
||
app_row=cur.fetchone()
|
||
if not app_row:
|
||
return jsonify({'code':404,'msg':'申请记录不存在'})
|
||
if app_row.get('status') in ('通过','驳回'):
|
||
return jsonify({'code':409,'msg':'该申请已处理,请刷新列表后重试'})
|
||
cur.execute("UPDATE spot_applications SET status=%s,reason=%s WHERE id=%s",(decision,reason if decision=='驳回' else '',application_id))
|
||
if decision=='通过':
|
||
cur.execute("INSERT IGNORE INTO spot_confirmations(application_id) VALUES (%s)",(application_id,))
|
||
conn.commit()
|
||
except Exception as exc:
|
||
conn.rollback()
|
||
return jsonify({'code':500,'msg':str(exc)})
|
||
finally:
|
||
conn.close()
|
||
add_operation_log('现货采样申请审核',f'现货采样申请 #{application_id} 审核{decision}'+(f';驳回原因:{reason}' if reason else ''),page='现货采样申请',field='状态')
|
||
return jsonify({'code':0,'msg':'已通过并同步到现货采样确认' if decision=='通过' else '已驳回并留档'})
|
||
|
||
|
||
@spot_bp.route('/api/spot/application/delete', methods=['POST'])
|
||
@login_required
|
||
def application_delete():
|
||
"""删除现货采样申请及其关联确认记录。"""
|
||
if not operation_required('spot_application', 'delete'):
|
||
return jsonify({'code': 403, 'msg': '无删除权限'}), 403
|
||
data = request.get_json(silent=True) or {}
|
||
try:
|
||
application_id = int(data.get('id'))
|
||
except (TypeError, ValueError):
|
||
return jsonify({'code': 400, 'msg': '参数错误'})
|
||
if application_id <= 0:
|
||
return jsonify({'code': 400, 'msg': '参数错误'})
|
||
# 外键可能阻止直接删除,先清理关联的确认记录;失败时保留原申请。
|
||
try:
|
||
query('DELETE FROM spot_confirmations WHERE application_id=%s', (application_id,))
|
||
except Exception:
|
||
pass
|
||
affected = execute('DELETE FROM spot_applications WHERE id=%s', (application_id,))
|
||
if not affected:
|
||
return jsonify({'code': 404, 'msg': '申请记录不存在'})
|
||
add_operation_log('现货采样申请删除', f'删除现货采样申请 #{application_id}', page='现货采样申请')
|
||
return jsonify({'code': 0, 'msg': '删除成功'})
|
||
|
||
|
||
@spot_bp.route('/api/spot/confirmation/<int:application_id>/delete', methods=['POST'])
|
||
@login_required
|
||
def confirmation_delete(application_id):
|
||
if not role_required('spot_confirmation') or not operation_required('spot_confirmation', 'delete'):
|
||
return jsonify({'code': 403, 'msg': '无删除权限'}), 403
|
||
conn = get_db()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT id, product_name FROM spot_applications WHERE id=%s AND status='通过' FOR UPDATE", (application_id,))
|
||
row = cur.fetchone()
|
||
if not row:
|
||
conn.rollback()
|
||
return jsonify({'code': 404, 'msg': '确认记录不存在或已被删除'}), 404
|
||
cur.execute('DELETE FROM spot_confirmations WHERE application_id=%s', (application_id,))
|
||
cur.execute('DELETE FROM spot_applications WHERE id=%s', (application_id,))
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
current_app.logger.exception('删除现货采样确认失败')
|
||
return jsonify({'code': 500, 'msg': '删除失败,请稍后重试'}), 500
|
||
finally:
|
||
conn.close()
|
||
try:
|
||
bump_data_version()
|
||
add_operation_log('删除现货采样确认', f"删除申请及确认 #{application_id}:{row['product_name']}", page='现货采样确认')
|
||
except Exception:
|
||
current_app.logger.exception('现货采样确认已删除,缓存或日志更新失败')
|
||
return jsonify({'code': 0, 'msg': '申请及确认记录已删除'})
|
||
|
||
|
||
SPOT_CONFIRMATION_ALL_FIELDS = {
|
||
'application_id': "CAST(a.id AS UNSIGNED)",
|
||
'application_date': "COALESCE(a.application_date, '')",
|
||
'brand': "COALESCE(a.brand, '')",
|
||
'product_name': "COALESCE(a.product_name, '')",
|
||
'image_path': "COALESCE(a.image_path, '')",
|
||
'barcode': "COALESCE(NULLIF(c.barcode, ''), a.barcode, '')",
|
||
'sample_quantity': "COALESCE(a.sample_quantity, '')",
|
||
'product_link': "COALESCE(a.product_link, '')",
|
||
'department': "COALESCE(a.department, '')",
|
||
'group_name': "COALESCE(a.group_name, '')",
|
||
'manufacturer': "COALESCE(a.manufacturer, '')",
|
||
'recipient_info': "COALESCE(a.recipient_info, '')",
|
||
'sample_status': "COALESCE(c.sample_status, '')",
|
||
'product_unit_price': "COALESCE(c.product_unit_price, '')",
|
||
'oa_approval_no': "COALESCE(c.oa_approval_no, '')",
|
||
'express_tracking_no': "COALESCE(c.express_tracking_no, '')",
|
||
'arrival_date': "COALESCE(c.arrival_date, '')",
|
||
'remark': "COALESCE(c.remark, '')",
|
||
'receiving_department': "COALESCE(c.receiving_department, '')",
|
||
'recipient': "COALESCE(NULLIF(a.applicant_name, ''), NULLIF(u.real_name, ''), u.username, '') COLLATE utf8mb4_unicode_ci",
|
||
'sample_confirmed': "COALESCE(c.sample_confirmed, '')",
|
||
'sample_confirmation_reason': "COALESCE(c.sample_confirmation_reason, '')",
|
||
'product_code': "COALESCE(c.product_code, '')",
|
||
'final_product_name': "COALESCE(c.final_product_name, '')",
|
||
'platform': "COALESCE(c.platform, '')",
|
||
# Historical imported names and account names can use different collations.
|
||
'applicant': "COALESCE(NULLIF(a.applicant_name, ''), NULLIF(u.real_name, ''), u.username, '') COLLATE utf8mb4_unicode_ci",
|
||
}
|
||
# Keep the original four option queries stable for compatibility with existing
|
||
# clients/tests; the remaining fields use the same whitelist for filtering and sorting.
|
||
SPOT_CONFIRMATION_FILTERS = {key: SPOT_CONFIRMATION_ALL_FIELDS[key] for key in ('brand', 'product_name', 'sample_status', 'applicant')}
|
||
SPOT_CONFIRMATION_EXPORT_LABELS = {
|
||
'application_id': '编号', 'application_date': '日期', 'brand': '品牌',
|
||
'product_name': '产品名称', 'image_path': '图片', 'barcode': '条形码',
|
||
'sample_quantity': '采样数量', 'product_link': '产品链接', 'department': '部门',
|
||
'group_name': '组别', 'applicant': '申请人', 'manufacturer': '厂家名称',
|
||
'recipient_info': '收件信息', 'sample_status': '样品状态',
|
||
'product_unit_price': '产品单价', 'oa_approval_no': 'OA审批单号',
|
||
'express_tracking_no': '寄样快递单号', 'arrival_date': '到样日期',
|
||
'remark': '备注', 'receiving_department': '领样部门', 'recipient': '领样人',
|
||
'sample_confirmed': '样品确认', 'sample_confirmation_reason': '样品确认原因',
|
||
'product_code': '产品编码', 'final_product_name': '货品名称(最终名称)',
|
||
'platform': '平台',
|
||
}
|
||
|
||
|
||
@spot_bp.route('/api/spot/confirmation/list')
|
||
@login_required
|
||
def confirmation_list():
|
||
"""分页查询现货采样确认列表:申请信息自动带出,确认字段暂为空或读取已保存值。"""
|
||
if not role_required('spot_confirmation'):
|
||
return jsonify({'code': 403, 'msg': '无访问权限'}), 403
|
||
|
||
page, size = _page_params()
|
||
search = (request.args.get('search') or '').strip()
|
||
where = ["a.status='通过'"]
|
||
params = []
|
||
if search:
|
||
like = f'%{search}%'
|
||
where.append(
|
||
'(a.brand LIKE %s OR a.product_name LIKE %s OR a.manufacturer LIKE %s '
|
||
'OR a.barcode LIKE %s OR a.product_link LIKE %s OR a.department LIKE %s '
|
||
'OR a.applicant_name LIKE %s OR u.real_name LIKE %s OR u.username LIKE %s OR c.product_code LIKE %s '
|
||
'OR c.final_product_name LIKE %s OR c.platform LIKE %s)'
|
||
)
|
||
params.extend([like] * 12)
|
||
for key, expression in SPOT_CONFIRMATION_ALL_FIELDS.items():
|
||
value = request.args.get('filter_' + key, '')
|
||
if value:
|
||
where.append(f'{expression}=%s')
|
||
params.append('' if value == '__EMPTY__' else value)
|
||
sort_by = (request.args.get('sort_by') or '').strip()
|
||
sort_expr = SPOT_CONFIRMATION_ALL_FIELDS.get(sort_by)
|
||
sort_order = 'DESC' if (request.args.get('sort_order') or '').lower() == 'desc' else 'ASC'
|
||
if not sort_expr:
|
||
sort_expr, sort_order = 'a.application_date', 'DESC'
|
||
where_sql = ('WHERE ' + ' AND '.join(where)) if where else ''
|
||
|
||
total = query(
|
||
f"""SELECT COUNT(*) AS cnt
|
||
FROM spot_applications a
|
||
LEFT JOIN dashboard_users u ON u.id=a.applicant_user_id
|
||
LEFT JOIN spot_confirmations c ON c.application_id=a.id
|
||
{where_sql}""",
|
||
params,
|
||
)[0]['cnt']
|
||
rows = query(
|
||
f"""
|
||
SELECT a.id AS application_id, a.application_date, a.brand, a.product_name,
|
||
a.image_path, COALESCE(NULLIF(c.barcode, ''), a.barcode, '') AS barcode,
|
||
a.sample_quantity, a.product_link, COALESCE(a.department, '') AS department,
|
||
COALESCE(a.group_name, '') AS group_name,
|
||
COALESCE(NULLIF(a.applicant_name, ''), NULLIF(u.real_name, ''), u.username, '') AS applicant,
|
||
a.manufacturer, a.recipient_info,
|
||
COALESCE(c.sample_status, '') AS sample_status,
|
||
c.product_unit_price,
|
||
COALESCE(c.oa_approval_no, '') AS oa_approval_no,
|
||
COALESCE(c.express_tracking_no, '') AS express_tracking_no,
|
||
c.arrival_date,
|
||
COALESCE(c.remark, '') AS remark,
|
||
COALESCE(c.receiving_department, '') AS receiving_department,
|
||
COALESCE(NULLIF(a.applicant_name, ''), NULLIF(u.real_name, ''), u.username, '') AS recipient,
|
||
COALESCE(c.sample_confirmed, '') AS sample_confirmed,
|
||
COALESCE(c.sample_confirmation_reason, '') AS sample_confirmation_reason,
|
||
COALESCE(c.product_code, '') AS product_code,
|
||
COALESCE(c.final_product_name, '') AS final_product_name,
|
||
COALESCE(c.platform, '') AS platform
|
||
FROM spot_applications a
|
||
LEFT JOIN dashboard_users u ON u.id=a.applicant_user_id
|
||
LEFT JOIN spot_confirmations c ON c.application_id=a.id
|
||
{where_sql}
|
||
ORDER BY {sort_expr} {sort_order}, a.id DESC
|
||
LIMIT %s OFFSET %s
|
||
""",
|
||
params + [size, (page - 1) * size],
|
||
)
|
||
for row in rows:
|
||
if row.get('product_unit_price') is not None:
|
||
row['product_unit_price'] = str(row['product_unit_price'])
|
||
for key in ('application_date', 'arrival_date'):
|
||
value = row.get(key)
|
||
if isinstance(value, (datetime, date)):
|
||
row[key] = value.strftime('%Y-%m-%d')
|
||
reminder = confirmation_reminder_state(row.get('arrival_date'), row.get('sample_confirmed'))
|
||
row['confirmation_reminder_due_date'] = reminder['due_date']
|
||
row['confirmation_reminder_pending'] = reminder['pending']
|
||
data = {'rows': rows, 'total': total, 'page': page, 'size': size}
|
||
if request.args.get('include_filter_options') == '1':
|
||
data['filter_options'] = {
|
||
key: [row['value'] for row in query(
|
||
f"""SELECT DISTINCT {expression} AS value FROM spot_applications a
|
||
LEFT JOIN dashboard_users u ON u.id=a.applicant_user_id
|
||
LEFT JOIN spot_confirmations c ON c.application_id=a.id
|
||
WHERE a.status='通过' ORDER BY value""")]
|
||
for key, expression in (SPOT_CONFIRMATION_ALL_FIELDS.items() if request.args.get('include_all_filter_options') == '1' else SPOT_CONFIRMATION_FILTERS.items())
|
||
}
|
||
return jsonify({'code': 0, 'data': data})
|
||
|
||
|
||
@spot_bp.route('/api/spot/confirmation/export')
|
||
@login_required
|
||
def confirmation_export():
|
||
if not role_required('spot_confirmation') or not operation_required('spot_confirmation', 'export'):
|
||
return jsonify({'code': 403, 'msg': '无导出权限'}), 403
|
||
fields = [key for key in (request.args.get('fields') or '').split(',')
|
||
if key in SPOT_CONFIRMATION_EXPORT_LABELS]
|
||
if not fields:
|
||
fields = list(SPOT_CONFIRMATION_EXPORT_LABELS)
|
||
search = (request.args.get('search') or '').strip()
|
||
where = ["a.status='通过'"]
|
||
params = []
|
||
if search:
|
||
like = f'%{search}%'
|
||
search_fields = ['a.brand', 'a.product_name', 'a.manufacturer', 'a.barcode',
|
||
'a.product_link', 'a.department', 'a.applicant_name',
|
||
'u.real_name', 'u.username', 'c.product_code',
|
||
'c.final_product_name', 'c.platform']
|
||
where.append('(' + ' OR '.join(f'{field} LIKE %s' for field in search_fields) + ')')
|
||
params.extend([like] * len(search_fields))
|
||
for key, expression in SPOT_CONFIRMATION_ALL_FIELDS.items():
|
||
value = request.args.get('filter_' + key, '')
|
||
if value:
|
||
where.append(f'{expression}=%s')
|
||
params.append('' if value == '__EMPTY__' else value)
|
||
sort_by = (request.args.get('sort_by') or '').strip()
|
||
sort_expr = SPOT_CONFIRMATION_ALL_FIELDS.get(sort_by) or 'a.application_date'
|
||
sort_order = 'DESC' if (request.args.get('sort_order') or '').lower() == 'desc' else 'ASC'
|
||
where_sql = ' WHERE ' + ' AND '.join(where)
|
||
select_map = {
|
||
'application_id': 'a.id AS application_id', 'application_date': 'a.application_date',
|
||
'brand': 'a.brand', 'product_name': 'a.product_name', 'image_path': 'a.image_path',
|
||
'barcode': "COALESCE(NULLIF(c.barcode, ''), a.barcode, '') AS barcode",
|
||
'sample_quantity': 'a.sample_quantity',
|
||
'product_link': 'a.product_link', 'department': "COALESCE(a.department,'') AS department",
|
||
'group_name': "COALESCE(a.group_name,'') AS group_name",
|
||
'applicant': "COALESCE(NULLIF(a.applicant_name,''),NULLIF(u.real_name,''),u.username,'') AS applicant",
|
||
'manufacturer': 'a.manufacturer', 'recipient_info': 'a.recipient_info',
|
||
'sample_status': "COALESCE(c.sample_status,'') AS sample_status",
|
||
'product_unit_price': 'c.product_unit_price', 'oa_approval_no': "COALESCE(c.oa_approval_no,'') AS oa_approval_no",
|
||
'express_tracking_no': "COALESCE(c.express_tracking_no,'') AS express_tracking_no", 'arrival_date': 'c.arrival_date',
|
||
'remark': "COALESCE(c.remark,'') AS remark", 'receiving_department': "COALESCE(c.receiving_department,'') AS receiving_department",
|
||
'recipient': "COALESCE(NULLIF(a.applicant_name,''),NULLIF(u.real_name,''),u.username,'') AS recipient",
|
||
'sample_confirmed': "COALESCE(c.sample_confirmed,'') AS sample_confirmed",
|
||
'sample_confirmation_reason': "COALESCE(c.sample_confirmation_reason,'') AS sample_confirmation_reason",
|
||
'product_code': "COALESCE(c.product_code,'') AS product_code", 'final_product_name': "COALESCE(c.final_product_name,'') AS final_product_name",
|
||
'platform': "COALESCE(c.platform,'') AS platform",
|
||
}
|
||
rows = query(f"SELECT {','.join(select_map[key] for key in fields)} FROM spot_applications a LEFT JOIN dashboard_users u ON u.id=a.applicant_user_id LEFT JOIN spot_confirmations c ON c.application_id=a.id{where_sql} ORDER BY {sort_expr} {sort_order},a.id DESC", params)
|
||
import io, openpyxl
|
||
from openpyxl.styles import Font, PatternFill, Alignment
|
||
wb = openpyxl.Workbook(); ws = wb.active; ws.title = '现货采样确认'
|
||
ws.append([SPOT_CONFIRMATION_EXPORT_LABELS[key] for key in fields])
|
||
for cell in ws[1]:
|
||
cell.font = Font(bold=True, color='FFFFFF'); cell.fill = PatternFill('solid', fgColor='344454'); cell.alignment = Alignment(horizontal='center')
|
||
for row in rows:
|
||
values = []
|
||
for key in fields:
|
||
value = row.get(key, '')
|
||
if isinstance(value, (datetime, date)): value = value.strftime('%Y-%m-%d')
|
||
values.append(value if value is not None else '')
|
||
ws.append(values)
|
||
for column in ws.columns:
|
||
ws.column_dimensions[column[0].column_letter].width = min(42, max(12, max(len(str(cell.value or '')) for cell in column) + 2))
|
||
output = io.BytesIO(); wb.save(output); output.seek(0)
|
||
return send_file(output, as_attachment=True, download_name='现货采样确认.xlsx', mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||
|
||
|
||
@spot_bp.route('/api/spot/confirmation/reminders/pending')
|
||
@login_required
|
||
def confirmation_pending_reminders():
|
||
"""List confirmations whose 10-day deadline has passed; delivery is configured separately."""
|
||
if not role_required('spot_confirmation'):
|
||
return jsonify({'code': 403, 'msg': '无访问权限'}), 403
|
||
rows = query(
|
||
"""SELECT a.id AS application_id, a.product_name, a.department,
|
||
a.applicant_user_id,
|
||
COALESCE(NULLIF(a.applicant_name,''),NULLIF(u.real_name,''),u.username,'') AS recipient,
|
||
c.arrival_date, DATE_ADD(c.arrival_date, INTERVAL 10 DAY) AS reminder_due_date
|
||
FROM spot_confirmations c
|
||
INNER JOIN spot_applications a ON a.id=c.application_id
|
||
LEFT JOIN dashboard_users u ON u.id=a.applicant_user_id
|
||
WHERE a.status='通过'
|
||
AND c.arrival_date IS NOT NULL
|
||
AND TRIM(COALESCE(c.sample_confirmed,''))=''
|
||
AND DATE_ADD(c.arrival_date, INTERVAL 10 DAY) <= CURRENT_DATE
|
||
ORDER BY reminder_due_date,a.id"""
|
||
)
|
||
for row in rows:
|
||
for key in ('arrival_date', 'reminder_due_date'):
|
||
value = row.get(key)
|
||
if isinstance(value, (datetime, date)):
|
||
row[key] = value.strftime('%Y-%m-%d')
|
||
return jsonify({'code': 0, 'data': {'rows': rows, 'total': len(rows)}})
|
||
|
||
|
||
@spot_bp.route('/api/spot/confirmation/save', methods=['POST'])
|
||
@login_required
|
||
def confirmation_save():
|
||
if not role_required('spot_confirmation') or not operation_required('spot_confirmation', 'edit'):
|
||
return jsonify({'code': 403, 'msg': '无编辑权限'}), 403
|
||
data = request.get_json(silent=True)
|
||
if not isinstance(data, dict):
|
||
return jsonify({'code': 400, 'msg': '参数错误'}), 400
|
||
try:
|
||
application_id = int(data.get('application_id'))
|
||
if application_id <= 0 or set(data) - EDITABLE - {'application_id', 'other_reason'}:
|
||
raise ValueError('包含不可编辑字段或申请编号错误')
|
||
values = validate_confirmation_values(data)
|
||
if not values:
|
||
raise ValueError('没有可保存的字段')
|
||
except (ValueError, TypeError) as exc:
|
||
return jsonify({'code': 400, 'msg': str(exc)}), 400
|
||
conn = get_db()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute('SELECT id,status FROM spot_applications WHERE id=%s FOR UPDATE', (application_id,))
|
||
row = cur.fetchone()
|
||
if not row or row['status'] != '通过':
|
||
conn.rollback()
|
||
return jsonify({'code': 404, 'msg': '申请不存在或未审核通过'}), 404
|
||
cur.execute('INSERT IGNORE INTO spot_confirmations(application_id) VALUES (%s)', (application_id,))
|
||
assignments = ','.join(f'`{key}`=%s' for key in values)
|
||
cur.execute(f'UPDATE spot_confirmations SET {assignments} WHERE application_id=%s',
|
||
tuple(values.values()) + (application_id,))
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
current_app.logger.exception('采样确认保存失败')
|
||
return jsonify({'code': 500, 'msg': '保存失败,请稍后重试'}), 500
|
||
finally:
|
||
conn.close()
|
||
for action in (bump_data_version, lambda: add_operation_log('现货采样确认编辑',
|
||
f'更新采样确认 #{application_id}', page='现货采样确认')):
|
||
try:
|
||
action()
|
||
except Exception:
|
||
current_app.logger.exception('采样确认已保存,缓存或日志更新失败')
|
||
return jsonify({'code': 0, 'msg': '保存成功'})
|
||
|
||
|
||
@spot_bp.route('/api/spot/confirmation/import_template')
|
||
@login_required
|
||
def confirmation_import_template():
|
||
if not role_required('spot_confirmation'):
|
||
return jsonify({'code': 403, 'msg': '无访问权限'}), 403
|
||
return send_file(confirmation_template(), as_attachment=True,
|
||
download_name='现货采样确认导入模板.xlsx',
|
||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||
|
||
|
||
@spot_bp.route('/api/spot/confirmation/import', methods=['POST'])
|
||
@login_required
|
||
def confirmation_import():
|
||
if not role_required('spot_confirmation') or not operation_required('spot_confirmation', 'import'):
|
||
return jsonify({'code': 403, 'msg': '无导入权限'}), 403
|
||
upload = request.files.get('file')
|
||
if upload is None or not upload.filename:
|
||
return jsonify({'code': 400, 'msg': '请选择.xlsx或.csv文件'}), 400
|
||
conn = None
|
||
try:
|
||
rows = parse_confirmation_import(upload.read(MAX_FILE_BYTES + 1), upload.filename)
|
||
conn = get_db()
|
||
result = import_confirmations(rows, session['user_id'], conn)
|
||
except ImportValidationError as exc:
|
||
return jsonify({'code': 400, 'msg': '校验失败,整份文件未导入', 'errors': exc.errors[:20]}), 400
|
||
except Exception:
|
||
current_app.logger.exception('现货采样确认导入失败')
|
||
return jsonify({'code': 500, 'msg': '导入失败,请联系管理员检查'}), 500
|
||
finally:
|
||
if conn is not None:
|
||
conn.close()
|
||
# 数据已提交;附属操作失败不能误报为导入失败,避免用户重复导入。
|
||
for action in (
|
||
bump_data_version,
|
||
lambda: add_operation_log('现货采样确认导入',
|
||
f"新增{result['added']}条,更新{result['updated']}条",
|
||
page='现货采样确认'),
|
||
):
|
||
try:
|
||
action()
|
||
except Exception:
|
||
current_app.logger.exception('采样确认导入成功,但缓存刷新或操作日志写入失败')
|
||
return jsonify({'code': 0, 'data': result,
|
||
'msg': f"导入成功:新增{result['added']}条,更新{result['updated']}条"})
|
||
|
||
|
||
@spot_bp.route('/api/spot/application/create', methods=['POST'])
|
||
@login_required
|
||
def application_create():
|
||
"""新增一条现货采样申请,申请人和日期只能由服务端根据登录会话生成。"""
|
||
if not role_required('spot_application'):
|
||
return jsonify({'code': 403, 'msg': '无访问权限'}), 403
|
||
|
||
product_name = (request.form.get('product_name') or '').strip()
|
||
barcode = (request.form.get('barcode') or '').strip()
|
||
recipient_info = (request.form.get('recipient_info') or '').strip()
|
||
group_name = (request.form.get('group_name') or '').strip()
|
||
brand = (request.form.get('brand') or '').strip()
|
||
department = (request.form.get('department') or '').strip()
|
||
if not department:
|
||
return jsonify({'code': 400, 'msg': '请选择部门'})
|
||
if department not in ('洁维塔', '泊紫汀兰'):
|
||
return jsonify({'code': 400, 'msg': '请选择有效的部门'})
|
||
if len(brand) > 100:
|
||
return jsonify({'code': 400, 'msg': '品牌不能超过100个字符'})
|
||
if not group_name:
|
||
return jsonify({'code': 400, 'msg': '请选择组别'})
|
||
if 'recipient_template' in request.form:
|
||
template = request.form.get('recipient_template')
|
||
if template == 'shenzhen':
|
||
address = '深圳市龙岗区坂田街道发达路6号云里智能园7栋3楼蝶雅东侧'
|
||
recipient, phone = '一部样品', '17771977816'
|
||
elif template == 'guangzhou':
|
||
address = '广州市白云区鹤龙街道广云路238号思成未来城市总部5楼'
|
||
recipient = (request.form.get('recipient_name') or '').strip()
|
||
phone = (request.form.get('recipient_phone') or '').strip()
|
||
if not recipient or not phone:
|
||
return jsonify({'code': 400, 'msg': '请填写收件人和电话'})
|
||
else:
|
||
return jsonify({'code': 400, 'msg': '请选择收件模板'})
|
||
recipient_info = f'{address}\n收件人:{recipient}\n电话:{phone}'
|
||
if not product_name:
|
||
return jsonify({'code': 400, 'msg': '产品名称不能为空'})
|
||
if not barcode:
|
||
return jsonify({'code': 400, 'msg': '条形码不能为空'})
|
||
if not re.fullmatch(r'[0-9]{13}', barcode):
|
||
return jsonify({'code': 400, 'msg': '请填写正确的条形码(必须为13位数字)'})
|
||
if not recipient_info:
|
||
return jsonify({'code': 400, 'msg': '收件信息不能为空'})
|
||
if group_name and group_name not in SPOT_APPLICATION_GROUPS:
|
||
return jsonify({'code': 400, 'msg': '请选择有效的组别'})
|
||
|
||
quantity_text = (request.form.get('sample_quantity') or '').strip()
|
||
try:
|
||
sample_quantity = int(quantity_text)
|
||
if sample_quantity < 0 or sample_quantity > 2147483647:
|
||
raise ValueError
|
||
except (TypeError, ValueError):
|
||
return jsonify({'code': 400, 'msg': '寄样数量请输入不小于 0 的整数'})
|
||
|
||
image = request.files.get('image')
|
||
if image and image.filename and not _allowed_image(image.filename):
|
||
return jsonify({'code': 400, 'msg': '图片仅支持 png、jpg、jpeg、gif、webp、bmp 格式'})
|
||
|
||
conn = get_db()
|
||
locked = False
|
||
saved_image = None
|
||
try:
|
||
with conn.cursor() as cur:
|
||
# 同一数据库中的新增操作串行查重,避免两个 worker 同时检查通过。
|
||
cur.execute("SELECT GET_LOCK('dy.spot_application.create', 5) AS acquired")
|
||
locked = (cur.fetchone() or {}).get('acquired') == 1
|
||
if not locked:
|
||
return jsonify({'code': 409, 'msg': '申请正在保存,请稍后重试'})
|
||
cur.execute("""SELECT id FROM spot_applications
|
||
WHERE TRIM(product_name)=%s OR TRIM(barcode)=%s LIMIT 1""",
|
||
(product_name, barcode))
|
||
if cur.fetchone():
|
||
return jsonify({'code': 409, 'msg': '无法保存,存在重复值:产品名称或条形码已存在'})
|
||
|
||
image_path = ''
|
||
if image and image.filename:
|
||
ext = image.filename.rsplit('.', 1)[1].lower()
|
||
safe_name = f'{uuid.uuid4().hex}.{ext}'
|
||
saved_image = os.path.join(UPLOAD_FOLDER, safe_name)
|
||
image.save(saved_image)
|
||
image_path = f'/static/uploads/spot_application/{safe_name}'
|
||
cur.execute(
|
||
"""INSERT INTO spot_applications
|
||
(applicant_user_id, application_date, department, group_name, brand, product_name,
|
||
manufacturer, image_path, product_link, sample_quantity, barcode, recipient_info)
|
||
VALUES (%s, CURDATE(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||
(session['user_id'], department, group_name, brand,
|
||
product_name, (request.form.get('manufacturer') or '').strip(), image_path,
|
||
(request.form.get('product_link') or '').strip(), sample_quantity,
|
||
barcode, recipient_info),
|
||
)
|
||
row_id = cur.lastrowid
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
if saved_image and os.path.isfile(saved_image):
|
||
os.unlink(saved_image)
|
||
current_app.logger.exception('现货采样申请保存失败')
|
||
return jsonify({'code': 500, 'msg': '保存失败,请稍后重试'})
|
||
finally:
|
||
try:
|
||
conn.rollback() # 结束未保存/重复申请的只读事务,连接归还池前释放命名锁。
|
||
if locked:
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT RELEASE_LOCK('dy.spot_application.create')")
|
||
finally:
|
||
conn.close()
|
||
try:
|
||
add_operation_log('现货采样申请', f'新增现货采样申请 #{row_id}', page='现货采样申请')
|
||
except Exception:
|
||
current_app.logger.exception('现货采样申请已保存,但操作日志写入失败')
|
||
return jsonify({'code': 0, 'msg': '申请已添加', 'id': row_id})
|