unis_manager/app/constants.py

408 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""状态 / 分类常量(前后端共用语义)。"""
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