|
|
@@ -5,9 +5,14 @@
|
|
|
Extract vehicle prices and config from Wuling overseas quotation xlsx,
|
|
|
replacing the API-based price lookup path.
|
|
|
|
|
|
-Supports dual-sheet format:
|
|
|
- - Chinese: 中文报价表 (Chinese headers + descriptions)
|
|
|
- - English: English Quotation (English headers + descriptions, optional)
|
|
|
+Supports two formats:
|
|
|
+ 1. Table format (legacy):
|
|
|
+ - Chinese: 中文报价表 (Chinese headers + descriptions)
|
|
|
+ - English: English Quotation (English headers + descriptions, optional)
|
|
|
+ 2. Form format (new):
|
|
|
+ - One vehicle per sheet, bilingual labels (e.g. 报价单-中文版-* / Quotation-EN-*)
|
|
|
+ - Price per Unit already includes freight and add-on fees
|
|
|
+ - Quantity Sum = Price per Unit × Quantity
|
|
|
"""
|
|
|
|
|
|
import re
|
|
|
@@ -18,7 +23,7 @@ import openpyxl
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
-# Chinese header -> internal field mapping
|
|
|
+# Chinese header -> internal field mapping (legacy table format)
|
|
|
CN_COL_MAP = {
|
|
|
'序号': 'seq_no',
|
|
|
'推广命名': 'promotion_name',
|
|
|
@@ -50,7 +55,7 @@ CN_COL_MAP = {
|
|
|
'EXW小计(USD)': 'exw_subtotal_usd',
|
|
|
}
|
|
|
|
|
|
-# English header -> internal field mapping
|
|
|
+# English header -> internal field mapping (legacy table format)
|
|
|
EN_COL_MAP = {
|
|
|
'No.': 'seq_no',
|
|
|
'Promotion Name': 'promotion_name',
|
|
|
@@ -82,13 +87,60 @@ EN_COL_MAP = {
|
|
|
'EXW Subtotal(USD)': 'exw_subtotal_usd',
|
|
|
}
|
|
|
|
|
|
+# Form-style label -> internal field mapping
|
|
|
+# Labels are bilingual: e.g. "Model / 型号"
|
|
|
+FORM_LABEL_MAP = {
|
|
|
+ 'model': 'model_code',
|
|
|
+ '型号': 'model_code',
|
|
|
+ 'model code': 'model_code2',
|
|
|
+ '车型代码': 'model_code2',
|
|
|
+ 'version': 'version',
|
|
|
+ '版本': 'version',
|
|
|
+ 'engine code': 'engine_code',
|
|
|
+ '发动机': 'engine_code',
|
|
|
+ 'emission standard': 'emission_std',
|
|
|
+ '排放标准': 'emission_std',
|
|
|
+ 'vehicle type': 'vehicle_type',
|
|
|
+ '车辆类型': 'vehicle_type',
|
|
|
+ 'quantity': 'quantity',
|
|
|
+ '采购数量': 'quantity',
|
|
|
+ 'major configurations description': 'config_desc',
|
|
|
+ '主要配置描述': 'config_desc',
|
|
|
+ 'configuration': 'config_desc',
|
|
|
+ '配置信息': 'config_desc',
|
|
|
+}
|
|
|
+
|
|
|
+# Form-style price section markers
|
|
|
+PRICE_SECTION_MARKERS = {
|
|
|
+ 'price per unit': 'unit_price',
|
|
|
+ '每台价格': 'unit_price',
|
|
|
+ 'freight per unit': 'freight',
|
|
|
+ '每台运费': 'freight',
|
|
|
+ 'add-on per unit': 'add_on',
|
|
|
+ '每台加装费': 'add_on',
|
|
|
+ 'quantity sum': 'quantity_sum',
|
|
|
+ '数量总和': 'quantity_sum',
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+def _normalize_text(value) -> str:
|
|
|
+ """Normalize cell value to string."""
|
|
|
+ if value is None:
|
|
|
+ return ''
|
|
|
+ return str(value).strip()
|
|
|
+
|
|
|
+
|
|
|
+def _has_chinese(text: str) -> bool:
|
|
|
+ """Check if text contains Chinese characters."""
|
|
|
+ return bool(re.search(r'[\u4e00-\u9fff]', text))
|
|
|
+
|
|
|
|
|
|
def _parse_meta_row(value: str) -> dict:
|
|
|
- """Parse Row 3 export settings line."""
|
|
|
+ """Parse export settings line."""
|
|
|
meta = {}
|
|
|
if not value:
|
|
|
return meta
|
|
|
- value = re.sub(r'^(\u5bfc\u51fa\u8bbe\u7f6e:|Export Settings:)\s*', '', value).strip()
|
|
|
+ value = re.sub(r'^(导出设置:|Export Settings:)\s*', '', value, flags=re.IGNORECASE).strip()
|
|
|
for pair in value.split('|'):
|
|
|
pair = pair.strip()
|
|
|
if '=' not in pair:
|
|
|
@@ -96,13 +148,14 @@ def _parse_meta_row(value: str) -> dict:
|
|
|
k, _, v = pair.partition('=')
|
|
|
k, v = k.strip(), v.strip()
|
|
|
key_map = {
|
|
|
- '\u57fa\u5730': 'base', 'Base': 'base',
|
|
|
- '\u6e2f\u53e3': 'port', 'Port': 'port',
|
|
|
- '\u4ef7\u683c\u7c7b\u578b': 'price_type', 'Price Type': 'price_type',
|
|
|
- '\u5bfc\u51fa\u4eba': 'exporter', 'Exporter': 'exporter',
|
|
|
- '\u5ba2\u6237': 'customer', 'Customer': 'customer',
|
|
|
- '\u552e\u5356\u56fd\u5bb6': 'country', 'Sales Country': 'country',
|
|
|
- '\u6c47\u7387': 'exchange_rate', 'Exchange Rate': 'exchange_rate',
|
|
|
+ '基地': 'base', 'Base': 'base',
|
|
|
+ '港口': 'port', 'Port': 'port',
|
|
|
+ '价格类型': 'price_type', 'Price Type': 'price_type',
|
|
|
+ '导出人': 'exporter', 'Exporter': 'exporter',
|
|
|
+ '客户': 'customer', 'Customer': 'customer',
|
|
|
+ '售卖国家': 'country', 'Sales Country': 'country',
|
|
|
+ '汇率': 'exchange_rate', 'Exchange Rate': 'exchange_rate',
|
|
|
+ '运输方式': 'shipping_method', 'Shipping Method': 'shipping_method',
|
|
|
}
|
|
|
field = key_map.get(k, k)
|
|
|
if field == 'exchange_rate':
|
|
|
@@ -110,12 +163,15 @@ def _parse_meta_row(value: str) -> dict:
|
|
|
v = float(v)
|
|
|
except ValueError:
|
|
|
pass
|
|
|
+ if field == 'price_type':
|
|
|
+ # Support "FCA,FOB" or "FCA" or "FCA/FOB"
|
|
|
+ v = [p.strip().upper() for p in re.split(r'[,,/\\]+', v) if p.strip()]
|
|
|
meta[field] = v
|
|
|
return meta
|
|
|
|
|
|
|
|
|
def _detect_col_map(headers: list) -> Optional[dict]:
|
|
|
- """Detect Chinese or English column map from header row."""
|
|
|
+ """Detect Chinese or English column map from header row (legacy table)."""
|
|
|
for h in headers:
|
|
|
hs = (h or '').strip()
|
|
|
if hs in CN_COL_MAP:
|
|
|
@@ -136,8 +192,200 @@ def _build_col_index(headers: list, col_map: dict) -> dict:
|
|
|
return idx
|
|
|
|
|
|
|
|
|
-def _parse_sheet(ws) -> tuple:
|
|
|
- """Parse one sheet. Returns (data_rows, meta, sheet_type)."""
|
|
|
+def _is_form_style_sheet(ws) -> bool:
|
|
|
+ """Detect whether a sheet uses the new form-style layout."""
|
|
|
+ max_row = min(ws.max_row, 20)
|
|
|
+ for ri in range(1, max_row + 1):
|
|
|
+ for ci in range(1, ws.max_column + 1):
|
|
|
+ val = _normalize_text(ws.cell(row=ri, column=ci).value)
|
|
|
+ if not val:
|
|
|
+ continue
|
|
|
+ lower = val.lower()
|
|
|
+ # Look for bilingual labels that only appear in form layout
|
|
|
+ if 'model / 型号' in lower or 'vehicle information' in lower or 'price information' in lower:
|
|
|
+ return True
|
|
|
+ if 'price per unit' in lower or 'quantity sum' in lower:
|
|
|
+ return True
|
|
|
+ return False
|
|
|
+
|
|
|
+
|
|
|
+def _extract_form_label(text: str) -> Optional[str]:
|
|
|
+ """Match form label to internal field name."""
|
|
|
+ text = _normalize_text(text).lower()
|
|
|
+ if not text:
|
|
|
+ return None
|
|
|
+ # Remove common bilingual separator patterns
|
|
|
+ text = re.sub(r'\s*/\s*', ' ', text)
|
|
|
+ text = text.strip()
|
|
|
+ for key, field in FORM_LABEL_MAP.items():
|
|
|
+ if key in text:
|
|
|
+ return field
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def _extract_price_type_labels(row_values: list) -> dict:
|
|
|
+ """From a price section header row, extract price type -> column index.
|
|
|
+
|
|
|
+ Example row: ['Price per Unit / 每台价格', None, 'FOB', None, 'FCA']
|
|
|
+ Returns: {'FOB': 2, 'FCA': 4} (0-based column indices)
|
|
|
+ """
|
|
|
+ labels = {}
|
|
|
+ for ci, val in enumerate(row_values):
|
|
|
+ val = _normalize_text(val).upper()
|
|
|
+ if val in ('FCA', 'FOB', 'EXW', 'CIF', 'CFR'):
|
|
|
+ labels[val] = ci
|
|
|
+ return labels
|
|
|
+
|
|
|
+
|
|
|
+def _to_float(value) -> Optional[float]:
|
|
|
+ """Convert a cell value to float, return None on failure."""
|
|
|
+ if value is None:
|
|
|
+ return None
|
|
|
+ if isinstance(value, (int, float)):
|
|
|
+ return float(value)
|
|
|
+ s = str(value).replace(',', '').strip()
|
|
|
+ if not s:
|
|
|
+ return None
|
|
|
+ try:
|
|
|
+ return float(s)
|
|
|
+ except ValueError:
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def _parse_form_sheet(ws) -> tuple:
|
|
|
+ """Parse one form-style sheet. Returns (data_rows, meta, sheet_lang).
|
|
|
+
|
|
|
+ sheet_lang is 'cn' or 'en'.
|
|
|
+ """
|
|
|
+ max_row = ws.max_row
|
|
|
+ if max_row < 3:
|
|
|
+ return [], {}, 'cn'
|
|
|
+
|
|
|
+ # Determine language by row 3 promotion name
|
|
|
+ promotion_name = _normalize_text(ws.cell(row=3, column=2).value)
|
|
|
+ sheet_lang = 'cn' if _has_chinese(promotion_name) else 'en'
|
|
|
+
|
|
|
+ def _first_non_empty(values: list):
|
|
|
+ """Return (column_index, value) of first non-empty cell (0-based)."""
|
|
|
+ for ci, v in enumerate(values):
|
|
|
+ nv = _normalize_text(v)
|
|
|
+ if nv:
|
|
|
+ return ci, nv
|
|
|
+ return None, ''
|
|
|
+
|
|
|
+ def _next_value(values: list, start_index: int):
|
|
|
+ """Return next non-empty cell value to the right of start_index."""
|
|
|
+ for ci in range(start_index + 1, len(values)):
|
|
|
+ v = values[ci]
|
|
|
+ if v is not None and str(v).strip():
|
|
|
+ return v
|
|
|
+ return None
|
|
|
+
|
|
|
+ # Build key-value map from label rows
|
|
|
+ kv = {}
|
|
|
+ for ri in range(1, max_row + 1):
|
|
|
+ row_values = [ws.cell(row=ri, column=ci).value for ci in range(1, ws.max_column + 1)]
|
|
|
+ label_ci, label = _first_non_empty(row_values)
|
|
|
+ if not label:
|
|
|
+ continue
|
|
|
+ field = _extract_form_label(label)
|
|
|
+ if field and field not in kv:
|
|
|
+ val = _next_value(row_values, label_ci)
|
|
|
+ if val is not None:
|
|
|
+ kv[field] = val
|
|
|
+
|
|
|
+ # Parse price sections
|
|
|
+ prices = {} # { 'FCA': {'unit_price_usd': x, 'unit_price_rmb': y, ...}, ... }
|
|
|
+ current_section = None
|
|
|
+ current_price_types = {}
|
|
|
+ for ri in range(1, max_row + 1):
|
|
|
+ row_values = [ws.cell(row=ri, column=ci).value for ci in range(1, ws.max_column + 1)]
|
|
|
+ first_col_ci, first_col = _first_non_empty(row_values)
|
|
|
+ if not first_col:
|
|
|
+ current_section = None
|
|
|
+ current_price_types = {}
|
|
|
+ continue
|
|
|
+ lower = first_col.lower()
|
|
|
+
|
|
|
+ # Detect price section header
|
|
|
+ section_found = False
|
|
|
+ for marker_key, section in PRICE_SECTION_MARKERS.items():
|
|
|
+ if marker_key in lower:
|
|
|
+ current_section = section
|
|
|
+ current_price_types = _extract_price_type_labels(row_values)
|
|
|
+ for pt in current_price_types:
|
|
|
+ if pt not in prices:
|
|
|
+ prices[pt] = {}
|
|
|
+ section_found = True
|
|
|
+ break
|
|
|
+ if section_found:
|
|
|
+ continue
|
|
|
+
|
|
|
+ # Currency rows
|
|
|
+ if current_section and first_col.upper() in ('CNY', 'USD', 'RMB'):
|
|
|
+ currency = 'rmb' if first_col.upper() in ('CNY', 'RMB') else 'usd'
|
|
|
+ for pt, ci in current_price_types.items():
|
|
|
+ if ci < len(row_values):
|
|
|
+ val = _to_float(row_values[ci])
|
|
|
+ if val is not None:
|
|
|
+ suffix = f'_{currency}'
|
|
|
+ if current_section == 'unit_price':
|
|
|
+ prices[pt][f'unit_price{suffix}'] = val
|
|
|
+ elif current_section == 'freight':
|
|
|
+ prices[pt][f'freight{suffix}'] = val
|
|
|
+ elif current_section == 'add_on':
|
|
|
+ prices[pt][f'add_on{suffix}'] = val
|
|
|
+ elif current_section == 'quantity_sum':
|
|
|
+ prices[pt][f'subtotal{suffix}'] = val
|
|
|
+
|
|
|
+ # Parse export settings row
|
|
|
+ meta = {}
|
|
|
+ for ri in range(max_row, 0, -1):
|
|
|
+ val = _normalize_text(ws.cell(row=ri, column=2).value)
|
|
|
+ if val.startswith('导出设置:') or val.lower().startswith('export settings:'):
|
|
|
+ meta = _parse_meta_row(val)
|
|
|
+ break
|
|
|
+
|
|
|
+ # Build vehicle row
|
|
|
+ quantity_raw = kv.get('quantity')
|
|
|
+ try:
|
|
|
+ quantity = int(quantity_raw) if quantity_raw is not None else 1
|
|
|
+ except (ValueError, TypeError):
|
|
|
+ quantity = 1
|
|
|
+
|
|
|
+ model_code = _normalize_text(kv.get('model_code', '')).upper()
|
|
|
+ entry = {
|
|
|
+ 'model_code': model_code,
|
|
|
+ 'promotion_name': promotion_name,
|
|
|
+ 'version': _normalize_text(kv.get('version', '')),
|
|
|
+ 'engine_code': _normalize_text(kv.get('engine_code', '')),
|
|
|
+ 'emission_std': _normalize_text(kv.get('emission_std', '')),
|
|
|
+ 'vehicle_type': _normalize_text(kv.get('vehicle_type', '')),
|
|
|
+ 'quantity': quantity,
|
|
|
+ 'config': _normalize_text(kv.get('config_desc', '')),
|
|
|
+ '_prices': prices,
|
|
|
+ }
|
|
|
+
|
|
|
+ # Add legacy-style price fields for the first/default price type
|
|
|
+ # so legacy consumers still see a price
|
|
|
+ default_pt = meta.get('price_type', ['FCA'])[0] if meta.get('price_type') else 'FCA'
|
|
|
+ if default_pt in prices:
|
|
|
+ entry['unit_price_usd'] = prices[default_pt].get('unit_price_usd')
|
|
|
+ entry['unit_price_rmb'] = prices[default_pt].get('unit_price_rmb')
|
|
|
+ entry['freight_usd'] = prices[default_pt].get('freight_usd')
|
|
|
+ entry['installation_usd'] = prices[default_pt].get('add_on_usd')
|
|
|
+
|
|
|
+ # If no prices found, fall back to any available price type
|
|
|
+ if entry.get('unit_price_usd') is None and prices:
|
|
|
+ first_pt = next(iter(prices))
|
|
|
+ entry['unit_price_usd'] = prices[first_pt].get('unit_price_usd')
|
|
|
+ entry['unit_price_rmb'] = prices[first_pt].get('unit_price_rmb')
|
|
|
+
|
|
|
+ return [entry], meta, sheet_lang
|
|
|
+
|
|
|
+
|
|
|
+def _parse_table_sheet(ws) -> tuple:
|
|
|
+ """Parse one legacy table-style sheet. Returns (data_rows, meta, sheet_type)."""
|
|
|
max_row = ws.max_row
|
|
|
if max_row < 2:
|
|
|
return [], {}, 'cn'
|
|
|
@@ -159,7 +407,7 @@ def _parse_sheet(ws) -> tuple:
|
|
|
continue
|
|
|
# skip rows that are export settings (Row 3 style)
|
|
|
first_val = str(rv[0] or '')
|
|
|
- if '\u5bfc\u51fa\u8bbe\u7f6e' in first_val or 'Export Settings' in first_val:
|
|
|
+ if '导出设置' in first_val or 'Export Settings' in first_val:
|
|
|
continue
|
|
|
entry = {}
|
|
|
for field, ci in col_idx.items():
|
|
|
@@ -169,68 +417,147 @@ def _parse_sheet(ws) -> tuple:
|
|
|
continue
|
|
|
if entry.get('model_code'):
|
|
|
entry['model_code'] = str(entry['model_code']).strip().upper()
|
|
|
+ # Normalize quantity
|
|
|
+ qty = entry.get('quantity', 1)
|
|
|
+ try:
|
|
|
+ entry['quantity'] = int(qty) if qty is not None else 1
|
|
|
+ except (ValueError, TypeError):
|
|
|
+ entry['quantity'] = 1
|
|
|
rows.append(entry)
|
|
|
return rows, meta, sheet_type
|
|
|
|
|
|
|
|
|
+def _parse_sheet(ws) -> tuple:
|
|
|
+ """Parse one sheet. Returns (data_rows, meta, sheet_type/lang)."""
|
|
|
+ if _is_form_style_sheet(ws):
|
|
|
+ return _parse_form_sheet(ws)
|
|
|
+ return _parse_table_sheet(ws)
|
|
|
+
|
|
|
+
|
|
|
def load_quotation(excel_path: str) -> dict:
|
|
|
"""Load quotation xlsx. Returns {'meta': {...}, 'rows': [...]}.
|
|
|
|
|
|
- Merges data from both Chinese and English sheets by model_code.
|
|
|
+ Supports both legacy table format and new form format.
|
|
|
+ Merges data from Chinese and English sheets by model_code.
|
|
|
"""
|
|
|
wb = openpyxl.load_workbook(excel_path, data_only=True)
|
|
|
- cn_rows, meta, _ = _parse_sheet(wb['\u4e2d\u6587\u62a5\u4ef7\u8868'])
|
|
|
- en_rows, _, _ = _parse_sheet(wb['English Quotation'])
|
|
|
- en_by_model = {r['model_code']: r for r in en_rows if r.get('model_code')}
|
|
|
+
|
|
|
+ all_rows = []
|
|
|
+ all_meta = {}
|
|
|
+
|
|
|
+ for sheet_name in wb.sheetnames:
|
|
|
+ ws = wb[sheet_name]
|
|
|
+ rows, meta, sheet_type = _parse_sheet(ws)
|
|
|
+ if meta:
|
|
|
+ all_meta.update(meta)
|
|
|
+ for r in rows:
|
|
|
+ r['_sheet_type'] = sheet_type
|
|
|
+ all_rows.extend(rows)
|
|
|
+
|
|
|
+ # Group rows by model_code and merge Chinese/English fields
|
|
|
+ grouped = {}
|
|
|
+ for r in all_rows:
|
|
|
+ mc = r.get('model_code')
|
|
|
+ if not mc:
|
|
|
+ continue
|
|
|
+ if mc not in grouped:
|
|
|
+ grouped[mc] = {
|
|
|
+ 'model_code': mc,
|
|
|
+ 'name_cn': '',
|
|
|
+ 'name_en': '',
|
|
|
+ 'version': r.get('version', ''),
|
|
|
+ '_prices': r.get('_prices', {}),
|
|
|
+ 'unit_price_usd': r.get('unit_price_usd'),
|
|
|
+ 'unit_price_rmb': r.get('unit_price_rmb'),
|
|
|
+ 'freight_usd': r.get('freight_usd'),
|
|
|
+ 'installation_usd': r.get('installation_usd'),
|
|
|
+ 'config_desc_cn': '',
|
|
|
+ 'config_desc_en': '',
|
|
|
+ 'engine_code': r.get('engine_code', ''),
|
|
|
+ 'quantity': r.get('quantity', 1),
|
|
|
+ }
|
|
|
+ st = r.get('_sheet_type', 'cn')
|
|
|
+ if st == 'cn':
|
|
|
+ grouped[mc]['name_cn'] = r.get('promotion_name', '') or grouped[mc]['name_cn']
|
|
|
+ grouped[mc]['config_desc_cn'] = r.get('config', '') or grouped[mc]['config_desc_cn']
|
|
|
+ # Prefer Chinese version/engine if English missing
|
|
|
+ if not grouped[mc]['engine_code']:
|
|
|
+ grouped[mc]['engine_code'] = r.get('engine_code', '')
|
|
|
+ if not grouped[mc]['version']:
|
|
|
+ grouped[mc]['version'] = r.get('version', '')
|
|
|
+ else:
|
|
|
+ grouped[mc]['name_en'] = r.get('promotion_name', '') or grouped[mc]['name_en']
|
|
|
+ grouped[mc]['config_desc_en'] = r.get('config', '') or grouped[mc]['config_desc_en']
|
|
|
+ # Prefer English version/engine if available
|
|
|
+ if r.get('engine_code'):
|
|
|
+ grouped[mc]['engine_code'] = r.get('engine_code')
|
|
|
+ if r.get('version'):
|
|
|
+ grouped[mc]['version'] = r.get('version')
|
|
|
+
|
|
|
+ # Merge prices: take union of all available price types
|
|
|
+ if r.get('_prices'):
|
|
|
+ for pt, vals in r['_prices'].items():
|
|
|
+ if pt not in grouped[mc]['_prices']:
|
|
|
+ grouped[mc]['_prices'][pt] = vals
|
|
|
+
|
|
|
merged = []
|
|
|
- price_type = meta.get('price_type', 'FCA').upper()
|
|
|
- price_field = {
|
|
|
- 'FOB': 'fob_price_usd',
|
|
|
- 'EXW': 'exw_price_usd',
|
|
|
- }.get(price_type, 'fca_price_usd')
|
|
|
- for cr in cn_rows:
|
|
|
- mc = cr.get('model_code')
|
|
|
- er = en_by_model.get(mc, {})
|
|
|
- merged.append({
|
|
|
- 'model_code': mc,
|
|
|
- 'name_cn': cr.get('promotion_name', ''),
|
|
|
- 'name_en': er.get('promotion_name', ''),
|
|
|
- 'version': cr.get('version', ''),
|
|
|
- 'unit_price_usd': cr.get(price_field) or cr.get('fca_price_usd'),
|
|
|
- 'unit_price_rmb': cr.get(price_field.replace('usd', 'rmb')) or cr.get('fca_price_rmb'),
|
|
|
- 'freight_usd': cr.get('freight_usd'),
|
|
|
- 'installation_usd': cr.get('installation_usd', 0),
|
|
|
- 'config_desc_cn': cr.get('config', ''),
|
|
|
- 'config_desc_en': er.get('config', ''),
|
|
|
- 'engine_code': cr.get('engine', ''),
|
|
|
- 'quantity': cr.get('quantity', 1),
|
|
|
- })
|
|
|
+ for mc, r in grouped.items():
|
|
|
+ # Recompute default unit price from available price types
|
|
|
+ available_pts = list(r['_prices'].keys())
|
|
|
+ default_pt = all_meta.get('price_type', ['FCA'])[0] if all_meta.get('price_type') and all_meta['price_type'][0] in available_pts else (available_pts[0] if available_pts else None)
|
|
|
+ if default_pt:
|
|
|
+ r['unit_price_usd'] = r['_prices'][default_pt].get('unit_price_usd')
|
|
|
+ r['unit_price_rmb'] = r['_prices'][default_pt].get('unit_price_rmb')
|
|
|
+ r['freight_usd'] = r['_prices'][default_pt].get('freight_usd')
|
|
|
+ r['installation_usd'] = r['_prices'][default_pt].get('add_on_usd')
|
|
|
+ merged.append(r)
|
|
|
+
|
|
|
wb.close()
|
|
|
- return {'meta': meta, 'rows': merged}
|
|
|
+ return {'meta': all_meta, 'rows': merged}
|
|
|
|
|
|
|
|
|
-def match_vehicle_from_quotation(model_code: str, quotation: dict) -> Optional[dict]:
|
|
|
+def match_vehicle_from_quotation(model_code: str, quotation: dict, trade_term: str = 'FCA') -> Optional[dict]:
|
|
|
"""Match vehicle in quotation data. Returns dict compatible with match_vehicle().
|
|
|
|
|
|
- Returns None if not found.
|
|
|
+ Args:
|
|
|
+ model_code: e.g. 'LZW6500BEVA9'
|
|
|
+ quotation: output of load_quotation()
|
|
|
+ trade_term: 'FCA', 'FOB', or 'EXW' (determines which price to return)
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ dict with vehicle info, or None if not found.
|
|
|
"""
|
|
|
if not model_code or not quotation:
|
|
|
return None
|
|
|
mc = model_code.strip().upper()
|
|
|
+ trade_term = trade_term.upper()
|
|
|
+
|
|
|
for r in quotation.get('rows', []):
|
|
|
if r.get('model_code') == mc:
|
|
|
config = r.get('config_desc_en') or r.get('config_desc_cn', '')
|
|
|
+
|
|
|
+ # Pick price for requested trade term; fallback to any available
|
|
|
+ prices = r.get('_prices', {})
|
|
|
+ pt = trade_term if trade_term in prices else next(iter(prices), None)
|
|
|
+ unit_price = None
|
|
|
+ if pt:
|
|
|
+ unit_price = prices[pt].get('unit_price_usd')
|
|
|
+ if unit_price is None:
|
|
|
+ unit_price = r.get('unit_price_usd')
|
|
|
+
|
|
|
return {
|
|
|
- 'unit_price_usd': r.get('unit_price_usd'),
|
|
|
+ 'unit_price_usd': unit_price,
|
|
|
'model_code': mc,
|
|
|
'engine_code': r.get('engine_code', ''),
|
|
|
'description_cn': r.get('name_cn', ''),
|
|
|
'description_en': r.get('name_en', ''),
|
|
|
- 'series_cn': '',
|
|
|
- 'series_en': '',
|
|
|
+ 'series_cn': r.get('vehicle_type', ''),
|
|
|
+ 'series_en': r.get('vehicle_type', ''),
|
|
|
'version': r.get('version', ''),
|
|
|
'config_desc': config,
|
|
|
'sheet_name': 'Quotation',
|
|
|
+ '_prices': prices,
|
|
|
+ '_matched_price_type': pt,
|
|
|
}
|
|
|
return None
|
|
|
|
|
|
@@ -239,12 +566,13 @@ if __name__ == '__main__':
|
|
|
import sys, json
|
|
|
sys.stdout.reconfigure(encoding='utf-8')
|
|
|
if len(sys.argv) < 2:
|
|
|
- print('Usage: python parse_quotation.py <quotation.xlsx>')
|
|
|
+ print('Usage: python parse_quotation.py <quotation.xlsx> [trade_term]')
|
|
|
sys.exit(1)
|
|
|
q = load_quotation(sys.argv[1])
|
|
|
print(json.dumps(q, ensure_ascii=False, indent=2, default=str))
|
|
|
if q['rows']:
|
|
|
- m = match_vehicle_from_quotation(q['rows'][0]['model_code'], q)
|
|
|
+ term = sys.argv[2].upper() if len(sys.argv) > 2 else 'FCA'
|
|
|
+ m = match_vehicle_from_quotation(q['rows'][0]['model_code'], q, term)
|
|
|
print()
|
|
|
print('--- Match Result ---')
|
|
|
- print(json.dumps(m, ensure_ascii=False, indent=2, default=str))
|
|
|
+ print(json.dumps(m, ensure_ascii=False, indent=2, default=str))
|