362 lines
15 KiB
Python
362 lines
15 KiB
Python
"""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)
|