Files

362 lines
15 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.
"""Extracted pricing rules with unchanged business behavior."""
import math
import re
try:
from . import rates_extra as RX
except Exception:
RX = None
RATES = {
"sea": {
"name": "欧洲海运快线(双清/递延)",
"tiers": [(2000, 10.0), (500, 10.2), (101, 10.3), (21, 12.3)],
"divisor": 6000,
"min_kg": 21,
"time": "开船后 38-45 天提取",
"min_pcs_kg": None,
"remote": 7,
"note": "德国 UPS/DHL/DPD/CWE 统配;欧洲递延 -1.7元/kg;樱花快线 +0.3元/kg;因查验/罢工/节假日顺延",
},
"rail": {
"name": "义乌-中欧铁路专线",
"tiers": [(1000, 13.5), (100, 13.5), (21, 15.5)],
"divisor": 6000,
"min_kg": 21,
"time": "发车后 36-40 天提取",
"min_pcs_kg": 13,
"light_fee": 5,
"remote": 7,
"note": "义乌装柜;VAT 递延 -1元/kg;单件计费重不足 13kg 按超轻费加收",
},
"truck": {
"name": "中欧卡航",
"tiers": [(4000, 17.3), (2000, 17.3), (500, 17.4), (101, 17.4), (21, 19.4)],
"divisor": 6000,
"min_kg": 21,
"time": "发车后欧盟 16-20 天提取",
"min_pcs_kg": 13,
"light_fee": 5,
"remote": 7,
"note": "递延 -1.5元/kg;纯纺织品 +5元/kg;普卡时效 18-22 天",
},
"air": {
"name": "欧洲空运专线(空派)",
"tiers": [(101, 36), (71, 37), (45, 38), (21, 39)],
"divisor": 6000,
"min_kg": 21,
"time": "10-13 天左右提取",
"min_pcs_kg": 12,
"light_fee": None,
"remote": 7,
"note": "一票多件单件不足 12kg 按 12kg 计费;快线 8-13 天;直飞快线 12-15 天",
},
"sea_uk": {
"name": "英国海运(双清)",
"tiers": [(301, 10.9), (101, 10.9), (21, 12.8)],
"divisor": 6000,
"min_kg": 21,
"time": "参考时效 40 天提取",
"remote": None,
"note": "VAT自税/递延 10.0/8.1 元/kg;按KG卡派 12.2/10.3;限时达 13.5/11.6;北极航线 25-28 天(义乌装)",
},
"rail_uk": {
"name": "英国铁路(双清)",
"tiers": [(301, 16.2), (101, 16.2), (21, 18.0)],
"divisor": 6000,
"min_kg": 21,
"time": "发车后 25-30 天提取",
"remote": None,
"note": "VAT自税/递延 15/13.2 元/kg;限时达 19.2/17.4(38天);按KG卡派 17.3/15.5",
},
"truck_uk": {
"name": "中英卡航(双清)",
"tiers": [(301, 19.4), (101, 19.4), (21, 21.4)],
"divisor": 6000,
"min_kg": 21,
"time": "发车后 20-22 天提取",
"remote": None,
"note": "VAT自税/递延 18.4/16.4 元/kg;卡派 20.9/18.9;单件不超30kg,尺寸≤98×68×59cm",
},
"air_uk": {
"name": "英国空运专线(双清)",
"tiers": [(101, 42), (51, 44), (22, 48)],
"divisor": 6000,
"min_kg": 22,
"time": "8-10 个自然日提取",
"min_pcs_kg": 12,
"light_fee": None,
"remote": None,
"note": "不包税 VAT 自税/递延 44/40/38;带电 47.5/42.5/41.5;拒收北爱尔兰/苏格兰高地偏远地址",
},
}
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
def parse_dims(t):
"""解析长x宽x高,支持小数、空格及 x/×/* 分隔。
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
"""
match = re.search(r'(?<![\d.+-])(\d+(?:\.\d+)?)\s*[*xX×]\s*'
r'(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?![\d.])', t)
return [float(value) for value in match.groups()] if match else None
SHIPPING_REGION_PATTERNS = tuple((re.compile(pattern, re.I), region) for region, pattern in (
('unsupported', r'美国|加拿大|澳大利亚|澳洲|新西兰|日本|韩国|新加坡|马来西亚|泰国|越南|印度|阿联酋|迪拜|沙特|巴西|墨西哥|俄罗斯|'
r'(?<![A-Za-z0-9_])(?-i:US)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?<![A-Za-z0-9_])(?:USA|United States|America|Canada|Australia|Japan|Singapore|Russia)(?![A-Za-z])'),
('uk', r'英国|伦敦|英格兰|(?<![A-Za-z0-9_])(?:UK|GB)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?<![A-Za-z0-9_])(?:United Kingdom|Britain)(?![A-Za-z])'),
('eu', r'欧洲|欧盟|德国|法国|意大利|西班牙|波兰|捷克|荷兰|比利时|奥地利|葡萄牙|瑞典|丹麦|芬兰|爱尔兰|'
r'(?<![A-Za-z0-9_])(?:Europe|EU|Germany|France|Italy|Spain|Poland|Netherlands)(?![A-Za-z])|'
r'(?<![A-Za-z0-9_])(?-i:DE|FR|PL|CZ|ES|NL)(?=\s*(?:仓|FBA|warehouse))'),
('unsupported', r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|\bto\s+us\b'),
))
def shipping_region(text):
return next((region for pattern, region in SHIPPING_REGION_PATTERNS if pattern.search(text)), None)
def is_shipping_destination_reply(text):
text = text.strip().strip(',,。.!!??、:: ')
for pattern, _ in SHIPPING_REGION_PATTERNS:
match = pattern.search(text)
if not match:
continue
prefix = re.fullmatch(r'\s*(?:(?:目的地|目的国家|国家)(?:是|为|:|:)?|(?:发|寄|运|送)(?:往|到)?|到|往|去|to)?\s*', text[:match.start()], re.I)
suffix = re.fullmatch(r'\s*(?:的)?\s*(?:FBA\s*)?(?:(?:海外)?仓(?:库)?|warehouse)?(?:呢)?\s*', text[match.end():], re.I)
if prefix and suffix:
return True
return False
def parse_shipping_intent(text):
"""解析重量、渠道、尺寸和费率区域;尺寸单位由调用方识别。"""
t = text.replace(" ", "")
intent = {"weight": None, "channel": None, "dims": None, "region": shipping_region(text)}
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
intent["dims"] = parse_dims(text)
if not intent["dims"]:
# 支持“长100宽80高60”
dl = re.search(r"长(\d+(?:\.\d+)?)宽(\d+(?:\.\d+)?)高(\d+(?:\.\d+)?)", t)
if dl:
intent["dims"] = [float(x) for x in dl.groups()]
# 重量:带单位的数字。去掉空格后在原始文本上匹配,
# 但不能把尺寸里的数字(如 120*100*100 的第三个数)当成重量
m = re.search(r"(\d+(?:\.\d+)?)\s*(吨|kg|KG|公斤|千克|tons?|kilo(?:grams?)?|lbs?)", text, re.I)
if m:
before = text[:m.start()].rstrip()
adjacent_sep = bool(before) and before[-1] in "*xX×"
if not adjacent_sep:
n = float(m.group(1))
unit_w = m.group(2).lower()
if unit_w in ("吨", "ton", "tons"):
intent["weight"] = n * 1000
elif unit_w.startswith("lb"):
intent["weight"] = round(n * 0.4536, 2) # 磅转公斤
else:
intent["weight"] = n
intent["weight_raw"] = m.group(0).replace(" ", "")
# 渠道:空运 / 铁路 / 卡航 / 海运(按优先级匹配)
if re.search(r"空运|空派|航空", t) or re.search(r"\bair\b|air ?freight|air ?cargo", text, re.I):
intent["channel"] = "air_uk" if re.search(r"英国|UK|伦敦|英格兰", text, re.I) else "air"
elif re.search(r"铁路|班列|火车", t) or re.search(r"\brail\b|train", text, re.I):
intent["channel"] = "rail"
elif re.search(r"卡航|卡车|卡派", t) or re.search(r"\btruck\b|road", text, re.I):
intent["channel"] = "truck"
elif re.search(r"海运|海派|船运|快船|海运快线|樱花快线", t) or re.search(r"\bsea\b|ocean|shipping", text, re.I):
intent["channel"] = "sea"
return intent
def detect_dim_unit(text):
"""识别尺寸单位:cm(默认)/ m / mm"""
low = text.lower()
if re.search(r"毫米|\d\s*mm", low):
return "mm"
if re.search(r"米", low) and not re.search(r"厘米", low):
return "m"
if re.search(r"\d\s*cm|\d\s*厘米", low):
return "cm"
if re.search(r"\d\s*m", low): # 注意:cm 里也含 m,所以放在 cm 之后判断
return "m"
return "cm"
def dims_to_cm(dims, unit):
"""统一换算成厘米,体积重公式按 cm 计算"""
if unit == "m":
return [d * 100 for d in dims]
if unit == "mm":
return [d / 10 for d in dims]
return list(dims)
def pick_rate(weight_kg, tiers):
"""从阶梯价里选档:tiers 为 [(最低重量, 单价), ...],从高到低匹配"""
for min_w, price in tiers:
if weight_kg >= min_w:
return price, min_w
return tiers[-1][1], tiers[-1][0]
def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None):
"""海外仓卡派:按方(CBM)计费,1CBM+ 和 0.5CBM+ 两档
country 传入国家代码(DE/FR/PL/CZ/ES)时,仅选择包含该后缀的仓点组;无匹配不报价。
"""
if not RX or not RX.WAREHOUSE_CARD:
return None
keys = list(RX.WAREHOUSE_CARD)
key = warehouse_key
if key and key not in RX.WAREHOUSE_CARD:
return None
if not key and country:
for k in keys:
if ("-" + country) in k.upper():
key = k
break
if not key:
return None
if not key:
key = keys[0]
row = RX.WAREHOUSE_CARD.get(key) or RX.WAREHOUSE_CARD[keys[0]]
p = row.get(channel) or row["海运"]
price = p["cbm1"] if cbm >= 1 else p["cbm05"]
if price is None:
return None
tier = "1CBM+" if cbm >= 1 else "0.5CBM+"
stores = [s for s in key.splitlines() if s.strip()]
if country:
stores = [s for s in stores if ("-" + country) in s.upper()]
if not stores:
return None
else:
stores = stores[:1]
note = "报价表中的仓点分组:" + "、".join(stores)
return {
"warehouse": "\n".join(stores), "stores": stores,
"channel": channel, "cbm": cbm, "note": note,
"price_cbm": price, "tier": tier,
"total": round(cbm * price, 2),
"formula": f"{cbm:g} CBM × {price:,.0f} 元/CBM = {cbm * price:,.0f} 元",
}
def detect_surcharge(text):
"""识别产品类型,返回报价表中的附加费条目"""
if not RX:
return None
best = None
for product, fee in RX.PRODUCT_SURCHARGES:
# 用产品名里的前几个字做匹配(如“手机”“电池”“玩具”)
for seg in re.split(r"[,,、\s()()]", product):
seg = seg.strip()
if len(seg) >= 2 and seg in text:
if best is None or len(seg) > len(best[2]):
best = (product, fee, seg)
if best:
return best[0], best[1]
return None
def check_island(postcode):
"""按邮编判断是否属于 DPD 岛屿偏远地区(附加费 35 欧/件)"""
if not RX:
return None
pc = re.sub(r"[^0-9]", "", str(postcode))
if not pc:
return None
for country, info in RX.DPD_ISLAND_POSTCODES.items():
seg = info["text"]
# 直接匹配的 4~5 位邮编
for m in re.finditer(r"\d{4,5}", seg):
if pc.startswith(m.group(0)):
return country, info["code"], m.group(0)
# 区间形式 07000–07999
for m in re.finditer(r"(\d{4,5})\s*[–\-]\s*(\d{4,5})", seg):
lo, hi = int(m.group(1)), int(m.group(2))
if len(m.group(1)) == len(pc) and lo <= int(pc) <= hi:
return country, info["code"], m.group(0)
return None
def surcharge_reply(product, fee, weight_kg=None):
"""附加费说明(能算就给出金额)"""
txt = f"【产品附加费】{product}:{fee}"
if weight_kg and "/KG" in fee.upper():
m = re.search(r"\+?(\d+(?:\.\d+)?)", fee)
if m:
extra = float(m.group(1)) * weight_kg
txt += f"\n 按 {weight_kg:g} kg 计:约 {extra:,.0f} 元(以最终计费重为准)"
return txt
def calc_freight(weight_kg, channel, dims=None, unit="cm"):
"""按报价表规则计算运费(数字全部由代码算)
报价表规则:
- 阶梯单价:按总重量落入的档位取价
- 材积:长×宽×高(cm) ÷ 6000,与实重取大值作为计费重量
- 单件最低计费重 / 超轻费:见 RATES 里的 min_pcs_kg、light_fee
- 偏远地址:+7元/kg(不含油),最低 250 元/票
"""
r = RATES[channel]
cm = dims_to_cm(dims, unit) if dims else None
divisor = r.get("divisor", 6000)
vol = round(cm[0] * cm[1] * cm[2] / divisor, 2) if cm else None
real = weight_kg or 0
charge = round(max(real, vol) if vol else real, 2) # 实重与体积重取大
min_kg = r.get("min_kg") or 0
charge = max(charge, min_kg) # 报价表起收档
price, tier = pick_rate(charge, r["tiers"])
total = round(charge * price, 2)
unit_note = ""
if dims and unit != "cm":
unit_note = "(原尺寸 %g×%g×%g %s,已换算为 %g×%g×%g cm)" % (*dims, unit, *cm)
return {
"channel": channel, "name": r["name"],
"real": real, "vol": vol, "charge": charge,
"dims": dims, "dims_cm": cm, "unit": unit, "unit_note": unit_note,
"dim_rule": ("长×宽×高÷%d = %g×%g×%g÷%d = %g kg" % (divisor, *cm, divisor, vol)) if cm else None,
"price": price, "tier": tier,
"formula": f"{charge:g} kg × {price} 元/kg = {total:,.0f} 元",
"total": total,
"time": r["time"],
"min_pcs_kg": r.get("min_pcs_kg"),
"light_fee": r.get("light_fee"),
"remote": r.get("remote"),
"note": r.get("note"),
}
def freight_reply(intent):
"""根据已知信息拼出可读的计算说明(给模型当参考,也直接展示)"""
w = intent["weight"] or 0
dims = intent["dims"]
unit = intent.get("dim_unit") or "cm"
lines = []
default_channels = ['sea_uk', 'air_uk'] if intent.get('region') == 'uk' else ['sea', 'air']
channels = [intent["channel"]] if intent["channel"] else default_channels
for ch in channels:
c = calc_freight(w, ch, dims, unit)
parts = [f"实重 {w:g} kg" if w else "实重:未提供"]
if c["vol"]:
parts.append(f"体积重 {c['vol']:g} kg")
body = (
f"【{c['name']}】按时效 {c['time']},计费方式:阶梯单价 × 计费重量\n"
+ (f" 尺寸:{c['unit_note']}\n" if c["unit_note"] else "")
+ f" 计费重量 {c['charge']:g} kg(" + ",".join(parts) + ",取较大值)\n"
+ (f" 材积算法:{c['dim_rule']}\n" if c["dim_rule"] else "")
+ f" 适用档位:{c['tier']}KG+ 档,单价 {c['price']} 元/kg\n"
+ f" 运费:{c['formula']}\n"
)
if c["min_pcs_kg"] and c["charge"] < c["min_pcs_kg"]:
body += f" 注意:单件计费重不足 {c['min_pcs_kg']}kg,需按 {c['min_pcs_kg']}kg 起算或加收超轻费 {c['light_fee']} 元/kg\n"
if c["remote"]:
body += f" 偏远地址:额外 +{c['remote']} 元/kg,单票最低 250 元\n"
if c["note"]:
body += f" 其他:{c['note']}\n"
lines.append(body.rstrip("\n"))
return "\n\n".join(lines)