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