408 lines
18 KiB
Python
408 lines
18 KiB
Python
"""状态 / 分类常量(前后端共用语义)。"""
|
||
|
||
import re
|
||
|
||
# (code, 中文名, 主题色)
|
||
STATUSES = [
|
||
("not_started", "未开始", "#94a3b8"),
|
||
("in_progress", "进行中", "#2563eb"),
|
||
("done", "已完成", "#16a34a"),
|
||
("at_risk", "风险", "#d97706"),
|
||
("blocked", "阻塞", "#dc2626"),
|
||
("paused", "暂停/终止", "#6b7280"),
|
||
]
|
||
|
||
STATUS_MAP = {code: (name, color) for code, name, color in STATUSES}
|
||
STATUS_CODES = [c for c, _, _ in STATUSES]
|
||
|
||
NEGATIVE_STATUS = ("at_risk", "blocked", "paused")
|
||
|
||
# 项目优先级
|
||
PRIORITIES = [
|
||
("p0", "P0 最高", "#dc2626"),
|
||
("p1", "P1 高", "#d97706"),
|
||
("p2", "P2 中", "#2563eb"),
|
||
("p3", "P3 低", "#6b7280"),
|
||
]
|
||
PRIORITY_MAP = {code: (name, color) for code, name, color in PRIORITIES}
|
||
|
||
# 台账类别(来自《项目明细表》)
|
||
LEDGER_CATEGORIES = [
|
||
"新增项目",
|
||
"重点项目",
|
||
"验收项目",
|
||
"交付项目",
|
||
"提前交付",
|
||
"异常项目",
|
||
]
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 定制项目(交付制,与平台产品类项目分开管理)
|
||
# (code, 名称, 颜色, 收入确认基准比例)
|
||
# 开发中阶段:base + 0.4 * progress/100
|
||
# 其余阶段:直接使用 base
|
||
# 手动比例 manual_ratio 非空时优先使用
|
||
# --------------------------------------------------------------------------
|
||
CUSTOM_STAGES = [
|
||
("lead", "商机/报备", "#94a3b8", 0.00),
|
||
("signed", "已签单/下单", "#0891b2", 0.10),
|
||
("developing", "开发中", "#2563eb", 0.10),
|
||
("delivered", "交付完成", "#7c3aed", 0.70),
|
||
("accepted", "已验收", "#d97706", 0.90),
|
||
("revenue", "已计收", "#16a34a", 1.00),
|
||
("paused", "暂停/异常", "#dc2626", 0.30),
|
||
("closed", "已关闭", "#6b7280", 1.00),
|
||
]
|
||
CUSTOM_STAGE_MAP = {c: (n, col, r) for c, n, col, r in CUSTOM_STAGES}
|
||
CUSTOM_STAGE_CODES = [c for c, _, _, _ in CUSTOM_STAGES]
|
||
# 阶段先后顺序(用于看板列排序与阶段流转判断)
|
||
CUSTOM_STAGE_ORDER = {c: i for i, (c, _, _, _) in enumerate(CUSTOM_STAGES)}
|
||
|
||
|
||
def custom_revenue_ratio(stage: str, progress: float | None, manual_ratio=None) -> float:
|
||
"""按阶段 + 开发进度计算收入确认比例(0~1)。"""
|
||
if manual_ratio is not None:
|
||
try:
|
||
return max(0.0, min(1.0, float(manual_ratio)))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
base = CUSTOM_STAGE_MAP.get(stage, ("", "#94a3b8", 0.0))[2]
|
||
if stage == "developing":
|
||
p = float(progress or 0)
|
||
return round(min(1.0, base + 0.4 * (p / 100.0)), 4)
|
||
return round(base, 4)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 定制项目:文本 → 阶段 / 金额 识别
|
||
# --------------------------------------------------------------------------
|
||
CUSTOM_STAGE_RULES = [
|
||
(("暂停", "终止", "未达成一致", "搁置"), "paused"),
|
||
(("已计收", "已核销", "完成计收", "计收完成"), "revenue"),
|
||
(("验收报告", "已验收", "完成验收", "待验收"), "accepted"),
|
||
(("交付完成", "已交付", "需求交付完成"), "delivered"),
|
||
(("开发中", "开发交付中", "启动开发", "开发进行"), "developing"),
|
||
(("已下单", "已报单", "已签订", "合同签订", "合同已签"), "signed"),
|
||
(("商机", "报备", "投标"), "lead"),
|
||
]
|
||
|
||
CUSTOM_CAT_STAGE = {
|
||
"验收项目": "accepted",
|
||
"交付项目": "delivered",
|
||
"提前交付": "delivered",
|
||
"异常项目": "paused",
|
||
"新增项目": "signed",
|
||
"重点项目": "developing",
|
||
}
|
||
|
||
_CUSTOM_AMT_TAIL = re.compile(r"[((]\s*([0-9][0-9,,]*\.?[0-9]*)\s*[))]?\s*$")
|
||
_CUSTOM_WS = re.compile(r"\s+")
|
||
|
||
|
||
def normalize_custom_name(name: str) -> tuple[str, float | None]:
|
||
"""『龙江交投高速大模型分析项目(39,400.00 )』→ ('龙江交投高速大模型分析项目', 39400.0)"""
|
||
s = _CUSTOM_WS.sub(" ", (name or "").strip())
|
||
m = _CUSTOM_AMT_TAIL.search(s)
|
||
amount = None
|
||
if m:
|
||
try:
|
||
amount = float(m.group(1).replace(",", "").replace(",", ""))
|
||
except ValueError:
|
||
amount = None
|
||
s = s[: m.start()].strip().strip("(( ")
|
||
return s.strip(" 、-—"), amount
|
||
|
||
|
||
_PROGRESS_RE = re.compile(r"(\d{1,3}(?:\.\d+)?)\s*%")
|
||
|
||
|
||
def extract_percent(text: str | None) -> int | None:
|
||
"""取文本中最大的百分比数值(0-100)。"""
|
||
nums = [float(x) for x in _PROGRESS_RE.findall(text or "")]
|
||
nums = [n for n in nums if 0 <= n <= 100]
|
||
return int(round(max(nums))) if nums else None
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 周期标签解析(『9月第1周』『8月第四周』『2025/08-第1周』)
|
||
# --------------------------------------------------------------------------
|
||
_CN_NUM = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10}
|
||
_PERIOD_LABEL_RE = re.compile(
|
||
r"(?:(\d{4})\s*[/\-年]\s*)?(\d{1,2})\s*月?\s*[-—~~至]?\s*第?\s*([一二三四五六七八九十]{1,2}|\d{1,2})\s*周"
|
||
)
|
||
|
||
|
||
def parse_period_label(label: str, year_hint: int = 0) -> tuple[int, int, int] | None:
|
||
"""解析周期标签 → (year, month, week)。
|
||
|
||
year_hint 为 0 表示调用方希望区分『标签里到底有没有写年份』:
|
||
没写时返回的 year 就是 0,由调用方决定用什么年份。
|
||
"""
|
||
m = _PERIOD_LABEL_RE.search(str(label or ""))
|
||
if not m:
|
||
return None
|
||
year = int(m.group(1)) if m.group(1) else int(year_hint or 0)
|
||
month = int(m.group(2))
|
||
w = m.group(3)
|
||
if w.isdigit():
|
||
week = int(w)
|
||
elif w == "十":
|
||
week = 10
|
||
elif w.startswith("十"):
|
||
week = 10 + _CN_NUM.get(w[1:], 0)
|
||
elif w.endswith("十"):
|
||
week = _CN_NUM.get(w[:-1], 1) * 10
|
||
else:
|
||
week = _CN_NUM.get(w, 1)
|
||
return year, month, week
|
||
|
||
|
||
def guess_custom_stage(text: str | None, category: str | None = None) -> str:
|
||
"""按『负面 → 完成 → 在制 → 商务』的顺序判定阶段。
|
||
|
||
关键点:文本里同时出现『已计收』和『进度 60%』时,应判定为开发中,
|
||
计收金额另由 revenue_delta 字段承载。
|
||
"""
|
||
t = text or ""
|
||
if any(w in t for w in ("暂停", "终止", "未达成一致", "搁置")):
|
||
return "paused"
|
||
if any(w in t for w in ("验收报告", "已验收", "完成验收")):
|
||
return "accepted"
|
||
if any(w in t for w in ("交付完成", "已交付", "需求交付完成")):
|
||
return "delivered"
|
||
|
||
pct = extract_percent(t)
|
||
in_progress_words = ("进度", "开发", "联调", "部署", "实施", "测试中", "未完成", "待完成")
|
||
if pct is not None and pct < 100 and any(w in t for w in in_progress_words):
|
||
return "developing"
|
||
|
||
for words, code in CUSTOM_STAGE_RULES:
|
||
if any(w in t for w in words):
|
||
return code
|
||
|
||
if pct is not None and pct < 100:
|
||
return "developing"
|
||
if category and category in CUSTOM_CAT_STAGE:
|
||
return CUSTOM_CAT_STAGE[category]
|
||
return "signed"
|
||
|
||
|
||
# 省/直辖市 → 下辖市县关键词(用于从项目名反推区域)
|
||
REGION_KEYWORDS: list[tuple[str, list[str]]] = [
|
||
("重庆", ["重庆", "渝北", "渝中", "南岸", "江北", "巴南", "黔江", "青白江", "天宫殿", "两江", "大足", "綦江", "长寿", "垫江"]),
|
||
("四川", ["四川", "成都", "乐山", "德阳", "荥经", "简阳", "名山", "绵阳", "宜宾", "泸州", "广元", "眉山", "资阳", "内江", "自贡", "攀枝花", "达州", "南充", "遂宁", "广安", "雅安", "凉山", "宣汉", "沙湾"]),
|
||
("内蒙古", ["内蒙古", "内蒙", "呼和浩特", "赤峰", "包头", "鄂尔多斯", "呼伦贝尔", "兴安盟", "阿拉善", "巴彦淖尔", "通辽", "乌兰察布", "锡林郭勒", "乌海", "满洲里", "杭锦旗"]),
|
||
("江苏", ["江苏", "南京", "南通", "苏州", "无锡", "常州", "徐州", "扬州", "镇江", "盐城", "淮安", "连云港", "泰州", "宿迁", "昆山", "花桥", "江宁", "通州湾"]),
|
||
("山东", ["山东", "青岛", "济南", "烟台", "潍坊", "临沂", "淄博", "济宁", "威海", "东营", "泰安", "德州", "聊城", "菏泽", "枣庄", "日照", "滨州"]),
|
||
("黑龙江", ["黑龙江", "哈尔滨", "大庆", "齐齐哈尔", "牡丹江", "佳木斯", "绥化", "鸡西", "双鸭山", "伊春", "七台河", "鹤岗", "黑河", "抚远", "龙江"]),
|
||
("吉林", ["吉林", "长春", "四平", "辽源", "通化", "白山", "松原", "白城", "延边"]),
|
||
("辽宁", ["辽宁", "沈阳", "大连", "鞍山", "抚顺", "本溪", "丹东", "锦州", "营口", "阜新", "辽阳", "盘锦", "铁岭", "朝阳", "葫芦岛"]),
|
||
("甘肃", ["甘肃", "兰州", "金昌", "天水", "白银", "酒泉", "张掖", "武威", "定西", "平凉", "庆阳", "陇南"]),
|
||
("青海", ["青海", "西宁", "海东", "格尔木", "化隆"]),
|
||
("宁夏", ["宁夏", "银川", "石嘴山", "吴忠", "固原", "中卫"]),
|
||
("陕西", ["陕西", "西安", "咸阳", "宝鸡", "渭南", "延安", "汉中", "榆林", "安康", "商洛"]),
|
||
("广西", ["广西", "南宁", "柳州", "桂林", "梧州", "北海", "贺州", "玉林", "百色", "河池", "来宾", "崇左", "钦州", "贵港", "防城港"]),
|
||
("云南", ["云南", "昆明", "曲靖", "玉溪", "昭通", "楚雄", "红河", "文山", "普洱", "大理", "丽江", "临沧", "林草", "滇东南"]),
|
||
("江西", ["江西", "南昌", "赣州", "九江", "上饶", "宜春", "吉安", "抚州", "景德镇", "萍乡", "新余", "鹰潭", "洪都"]),
|
||
("湖南", ["湖南", "长沙", "株洲", "湘潭", "衡阳", "邵阳", "岳阳", "常德", "张家界", "益阳", "郴州", "永州", "怀化", "娄底"]),
|
||
("湖北", ["湖北", "武汉", "黄石", "襄阳", "宜昌", "荆州", "十堰", "孝感", "荆门", "鄂州", "黄冈", "咸宁", "随州"]),
|
||
("河南", ["河南", "郑州", "开封", "洛阳", "平顶山", "安阳", "鹤壁", "新乡", "焦作", "濮阳", "许昌", "漯河", "三门峡", "南阳", "商丘", "信阳", "周口", "驻马店"]),
|
||
("广东", ["广东", "广州", "深圳", "东莞", "佛山", "珠海", "中山", "惠州", "汕头", "江门", "湛江", "肇庆", "清远", "潮州", "揭阳", "韶关", "梅州", "汕尾", "河源", "阳江", "茂名"]),
|
||
("北京", ["北京"]),
|
||
("天津", ["天津", "滨海"]),
|
||
("上海", ["上海", "浦东"]),
|
||
("浙江", ["浙江", "杭州", "宁波", "温州", "嘉兴", "湖州", "绍兴", "金华", "衢州", "舟山", "台州", "丽水"]),
|
||
("福建", ["福建", "福州", "厦门", "泉州", "漳州", "莆田", "三明", "南平", "龙岩", "宁德"]),
|
||
("安徽", ["安徽", "合肥", "芜湖", "蚌埠", "淮南", "马鞍山", "安庆", "黄山", "阜阳", "宿州", "六安", "亳州", "池州", "宣城", "铜陵", "滁州"]),
|
||
("贵州", ["贵州", "贵阳", "遵义", "六盘水", "安顺", "毕节", "铜仁"]),
|
||
("山西", ["山西", "太原", "大同", "阳泉", "长治", "晋城", "朔州", "晋中", "运城", "忻州", "临汾", "吕梁"]),
|
||
("河北", ["河北", "石家庄", "唐山", "秦皇岛", "邯郸", "邢台", "保定", "张家口", "承德", "沧州", "廊坊", "衡水", "雄安"]),
|
||
("新疆", ["新疆", "乌鲁木齐", "克拉玛依", "喀什", "伊犁"]),
|
||
("西藏", ["西藏", "拉萨"]),
|
||
]
|
||
|
||
|
||
def infer_region(name: str | None) -> str | None:
|
||
"""从项目名称反推所属区域(省/直辖市)。"""
|
||
s = name or ""
|
||
for region, keys in REGION_KEYWORDS:
|
||
for k in keys:
|
||
if k in s:
|
||
return region
|
||
return None
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 规范的省级行政区列表(用于下拉选择与筛选)
|
||
# 采用与历史数据一致的简称写法,避免不必要的迁移
|
||
# --------------------------------------------------------------------------
|
||
REGION_GROUPS: list[tuple[str, list[str]]] = [
|
||
("华北", ["北京", "天津", "河北", "山西", "内蒙古"]),
|
||
("东北", ["辽宁", "吉林", "黑龙江"]),
|
||
("华东", ["上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "台湾"]),
|
||
("华中", ["河南", "湖北", "湖南"]),
|
||
("华南", ["广东", "广西", "海南", "香港", "澳门"]),
|
||
("西南", ["重庆", "四川", "贵州", "云南", "西藏"]),
|
||
("西北", ["陕西", "甘肃", "青海", "宁夏", "新疆"]),
|
||
]
|
||
|
||
ALL_REGIONS: list[str] = [r for _, lst in REGION_GROUPS for r in lst]
|
||
|
||
# 非规范写法 → 规范值
|
||
REGION_ALIAS = {
|
||
"内蒙古自治区": "内蒙古",
|
||
"广西壮族自治区": "广西",
|
||
"宁夏回族自治区": "宁夏",
|
||
"新疆维吾尔自治区": "新疆",
|
||
"西藏自治区": "西藏",
|
||
"北京市": "北京",
|
||
"天津市": "天津",
|
||
"上海市": "上海",
|
||
"重庆市": "重庆",
|
||
"深圳": "广东",
|
||
"深圳市": "广东",
|
||
"未标注": None,
|
||
"": None,
|
||
}
|
||
|
||
|
||
def normalize_region(value: str | None) -> str | None:
|
||
"""把区域值规范到标准列表。"""
|
||
if value is None:
|
||
return None
|
||
v = str(value).strip()
|
||
if not v:
|
||
return None
|
||
if v in ALL_REGIONS:
|
||
return v
|
||
if v in REGION_ALIAS:
|
||
return REGION_ALIAS[v]
|
||
return v
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 周期 → 日期(用于推算「入库时间」)
|
||
# --------------------------------------------------------------------------
|
||
def month_week1(year: int, month: int):
|
||
"""该月第 1 周的周一。
|
||
|
||
口径:周一到周日算一周,一周归属『它的周四落在哪个月』;
|
||
第一个周四落在本月的周就是该月第 1 周(等价于「这一周至少 4 天在本月」)。
|
||
"""
|
||
import datetime
|
||
|
||
first = datetime.date(int(year), int(month), 1)
|
||
first_thu = first + datetime.timedelta(days=(3 - first.weekday()) % 7)
|
||
return first_thu - datetime.timedelta(days=3)
|
||
|
||
|
||
def period_first_day(year: int, month: int, week: int | None = None) -> str:
|
||
"""取该周期第一天的日期。
|
||
|
||
规则:第 1 周 = 第一个周四落在本月的那一周(见 month_week1),
|
||
第 N 周在其基础上顺延 7×(N-1) 天。week 为空或 0 时直接取该月 1 日。
|
||
"""
|
||
import datetime
|
||
|
||
if not week:
|
||
return datetime.date(int(year), int(month), 1).isoformat()
|
||
day = month_week1(year, month) + datetime.timedelta(days=(max(int(week), 1) - 1) * 7)
|
||
return day.isoformat()
|
||
|
||
|
||
def week_of_date(value) -> tuple[int, int, int]:
|
||
"""日期 → 该日所属的 (年, 月, 第几周),与 period_first_day 互为逆运算。"""
|
||
import datetime
|
||
|
||
d = datetime.date.fromisoformat(str(value)[:10]) if isinstance(value, str) else value
|
||
monday = d - datetime.timedelta(days=d.weekday())
|
||
thursday = monday + datetime.timedelta(days=3) # 周四在哪个月,这一周就算哪个月的
|
||
year, month = thursday.year, thursday.month
|
||
return year, month, (monday - month_week1(year, month)).days // 7 + 1
|
||
|
||
|
||
def period_last_day(year: int, month: int, week: int | None = None) -> str:
|
||
"""取该周期最后一天的日期。"""
|
||
import calendar
|
||
import datetime
|
||
|
||
if not week:
|
||
last = calendar.monthrange(int(year), int(month))[1]
|
||
return datetime.date(int(year), int(month), last).isoformat()
|
||
d = datetime.date.fromisoformat(period_first_day(year, month, week))
|
||
return (d + datetime.timedelta(days=6)).isoformat()
|
||
|
||
|
||
# 默认分类配色
|
||
DEFAULT_CATEGORY_COLORS = [
|
||
"#2563eb", "#7c3aed", "#0891b2", "#16a34a",
|
||
"#d97706", "#dc2626", "#db2777", "#0d9488",
|
||
]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 定制项目:全过程流水线(入库 → 签单 → 交付 → 验收 → 计收)
|
||
# key 环节代码(与 CUSTOM_STAGES 的阶段代码对齐,入库单独用 entry)
|
||
# name 环节名
|
||
# date_field 该环节的日期字段(CustomProject 上的属性名)
|
||
# stage 完成该环节后项目所处的阶段
|
||
# --------------------------------------------------------------------------
|
||
CUSTOM_PIPELINE = [
|
||
("entry", "入库", "entry_date", "lead", "#94a3b8"),
|
||
("signed", "签单", "sign_date", "signed", "#0891b2"),
|
||
("delivered", "交付", "deliver_date", "delivered", "#7c3aed"),
|
||
("accepted", "验收", "accept_date", "accepted", "#d97706"),
|
||
("revenue", "计收", "revenue_date", "revenue", "#16a34a"),
|
||
]
|
||
|
||
# 年度计收目标(万元)默认值,来源《年度任务.xlsx》;页面「年度任务」里可改,改后落库覆盖
|
||
CUSTOM_ANNUAL_TARGETS_DEFAULT = {1: 35.0, 2: 225.0, 3: 160.0, 4: 260.0}
|
||
|
||
# 「在途」阶段:还没走完 入库→签单→交付→验收 这条主线(已计收 / 暂停 / 已关闭不算在途)
|
||
CUSTOM_INFLIGHT_STAGES = {"lead", "signed", "developing", "delivered", "accepted"}
|
||
|
||
# 环节顺序索引,用于判断项目当前卡在哪一环
|
||
CUSTOM_STEP_ORDER = {key: i for i, (key, _, _, _, _) in enumerate(CUSTOM_PIPELINE)}
|
||
|
||
# 环节停滞天数超过该值视为「卡住」,需要在看板上提示
|
||
CUSTOM_STEP_AGING_DAYS = 45
|
||
|
||
|
||
def custom_current_step(dates: dict[str, str | None]) -> int:
|
||
"""按已完成的环节日期,返回项目当前所处的环节下标(-1 表示连入库都没有)。"""
|
||
step = -1
|
||
for i, (_key, _name, field, _stage, _color) in enumerate(CUSTOM_PIPELINE):
|
||
if (dates.get(field) or "").strip():
|
||
step = i
|
||
return step
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 人力资源库
|
||
# --------------------------------------------------------------------------
|
||
# 人员归属(用工主体)
|
||
STAFF_AFFILIATIONS = ["汇智", "云数", "外服"]
|
||
|
||
# 归属小组(标准口径)
|
||
STAFF_GROUPS = ["管理", "Nex产品组", "定制产品组", "AI产品组", "综合"]
|
||
|
||
# 历史数据里的小组别名 → 标准口径
|
||
STAFF_GROUP_ALIAS = {"AI项目组": "AI产品组"}
|
||
|
||
|
||
def normalize_staff_group(value: str | None) -> str | None:
|
||
v = (value or "").strip()
|
||
if not v:
|
||
return None
|
||
return STAFF_GROUP_ALIAS.get(v, v)
|
||
|
||
|
||
def normalize_staff_affiliation(value: str | None) -> str | None:
|
||
v = (value or "").strip()
|
||
if not v:
|
||
return None
|
||
for a in STAFF_AFFILIATIONS:
|
||
if a in v:
|
||
return a
|
||
return v
|