2026-10-28 11:14:17 +08:00

1487 lines
76 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""现货模式相关接口。"""
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})