diff --git a/.gitignore b/.gitignore index 984cccd..b6379ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # AI辅助目录(整个目录忽略) .ai/ +.agents/ +/AGENTS.md # 依赖 node_modules/ @@ -53,6 +55,7 @@ frontend/e2e/**/*.png *.tmp *.bak *.cache +.tmp-processes*.txt # Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx) ~$* @@ -67,6 +70,12 @@ backend/uploads/ db/backups/ # 客户发来的原始账套备份压缩包。 /db/*.zip +# 本地恢复与连通性排查脚本/结果,不作为业务交付物。 +/db/restore_*.ps1 +/db/restore_*.sql +/db/restore_result.txt +/db/probe*.ps1 +/backend/probe_*_status.py # 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库 docs/**/*.rar @@ -82,3 +91,4 @@ backend/.venv-workbench/ # 本地浏览器验收自动生成的快照 /.playwright-mcp/ +/phase2-evidence-*/ diff --git a/backend/.env.example b/backend/.env.example index a2fff45..ea7ecd6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -43,6 +43,8 @@ T_PLUS_USER= T_PLUS_PASSWORD= # 仅用于已确认的开发测试库,默认禁止使用 sa。 T_PLUS_ALLOW_DEV_SA=false +# 仅在客户确认只能使用管理员账号时显式开启。 +T_PLUS_ALLOW_ADMIN_ACCOUNT=false # 可显式指定项目外的只读连接 JSON;不从页面接收该路径。 T_PLUS_CONNECTION_FILE= # 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。 @@ -70,5 +72,7 @@ T_PLUS_SCHEDULE_WEEKDAY=0 T_PLUS_SCHEDULE_TIME=02:00 # 托管启动器自动设置;发现标记后停止排队和领取新任务 T_PLUS_STOP_FILE= +# 仅允许本机 HTTP 健康检查地址;API 更换监听端口时同步修改。 +T_PLUS_SUPERVISOR_HEALTH_URL=http://127.0.0.1:8000/api/health # 可选上传文件根目录;留空使用 backend/uploads,隔离测试由运行器独立指定。 UPLOAD_DIR= diff --git a/backend/app/schemas/calc_template.py b/backend/app/schemas/calc_template.py index 8cfd3ba..7aa3f11 100644 --- a/backend/app/schemas/calc_template.py +++ b/backend/app/schemas/calc_template.py @@ -43,12 +43,18 @@ class CalcTemplateOptionOut(BaseModel): class FormulaOptionOut(BaseModel): - """公式骨架下拉项(新增模板时选择,受控集合)。""" + """公式骨架下拉项(新增模板时选择,受控集合)。 + + role_labels / role_hints 把英文角色键翻译成业务可读的中文标签与提示, + 模板表单据此渲染每个参数槽位的标签与 placeholder。 + """ code: str name: str description: str roles: list[str] + role_labels: dict[str, str] = Field(default_factory=dict) + role_hints: dict[str, str] = Field(default_factory=dict) class CalcTemplateCreateIn(BaseModel): diff --git a/backend/app/services/calc_service.py b/backend/app/services/calc_service.py index af94533..60ed7ec 100644 --- a/backend/app/services/calc_service.py +++ b/backend/app/services/calc_service.py @@ -45,7 +45,6 @@ from app.schemas.calc import CalcReceiptOut from app.services import audit_service, param_service, personnel_service from app.services.audit_service import AuditRecord, RequestMeta from app.services.formula import ( - apply_barter, clamp_weight_r, converted_profit, payment_days, @@ -366,12 +365,10 @@ def _compose_amounts( performance = ( Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate) ) - barter_applied = receipt.is_barter == 1 + barter_applied = False + # 以物抵顶口径(2026-11-03 决策):抵顶回款线下核算绩效,系统不做自动折算。 + # 原回款的 is_barter 标记保留;barter_applied=False 表示没有执行折算。 base_amount = performance - if barter_applied: - discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person))) - base_amount = apply_barter(performance, discount) - received_total = business.total_received_amount or Decimal("0") start_ratio = parse_fraction( snapshot.get("payment.start.ratio", role_code=role_code_of(person)) diff --git a/backend/app/services/calc_template_service.py b/backend/app/services/calc_template_service.py index 74b4de7..77acae9 100644 --- a/backend/app/services/calc_template_service.py +++ b/backend/app/services/calc_template_service.py @@ -75,7 +75,14 @@ def list_templates(session) -> PageResult[CalcTemplateOut]: def list_formula_options() -> list[dict]: """骨架下拉(受控集合)。""" return [ - {"code": spec.code, "name": spec.name, "description": spec.description, "roles": list(spec.roles)} + { + "code": spec.code, + "name": spec.name, + "description": spec.description, + "roles": list(spec.roles), + "role_labels": dict(spec.role_labels), + "role_hints": dict(spec.role_hints), + } for spec in FORMULAS.values() ] diff --git a/backend/app/services/calc_templates.py b/backend/app/services/calc_templates.py index 0798c0f..938db5c 100644 --- a/backend/app/services/calc_templates.py +++ b/backend/app/services/calc_templates.py @@ -12,11 +12,17 @@ from sqlalchemy.orm import Session @dataclass(frozen=True, slots=True) class FormulaSpec: - """公式骨架元数据:参数角色 → 模板参数映射键。""" + """公式骨架元数据:参数角色 → 模板参数映射键。 + + role_labels 把英文角色键翻译成业务可读的中文标签(模板表单展示), + role_hints 进一步解释该槽位承载的口径(placeholder 提示)。 + """ code: str name: str roles: tuple[str, ...] + role_labels: dict[str, str] + role_hints: dict[str, str] description: str @@ -26,30 +32,40 @@ FORMULAS: dict[str, FormulaSpec] = { code="linear_decay", name="天数线性递减", roles=("start", "slope", "floor", "boundary"), + role_labels={"start": "起始比例", "slope": "日递减斜率", "floor": "下限比例", "boundary": "递减截止天数"}, + role_hints={"start": "回款第 1 天适用的计提比例", "slope": "每延后一天递减的幅度", "floor": "超过截止天数后固定使用的比例", "boundary": "按日递减的天数上限"}, description="按回款天数线性递减,超过截止天数取下限比例", ), "fixed_profit": FormulaSpec( code="fixed_profit", name="折算毛利固定比例", roles=("rate",), + role_labels={"rate": "计提比例"}, + role_hints={"rate": "折算毛利润额乘以的固定比例"}, description="折算毛利润额 × 固定比例", ), "fixed_sales": FormulaSpec( code="fixed_sales", name="销售额固定比例", roles=("rate",), + role_labels={"rate": "计提比例"}, + role_hints={"rate": "本人销售额乘以的固定比例"}, description="本人销售额 × 固定比例", ), "manager_share": FormulaSpec( code="manager_share", name="经理团队分成", roles=("team", "company"), + role_labels={"team": "团队分成比例", "company": "全公司分成比例"}, + role_hints={"team": "本团队业务员绩效乘以的比例", "company": "全公司业务员绩效乘以的比例"}, description="本团队绩效 × 团队比例 + 全公司绩效 × 公司比例", ), "prepay_fixed": FormulaSpec( code="prepay_fixed", name="预付款固定比例", roles=("rate",), + role_labels={"rate": "计提比例"}, + role_hints={"rate": "预付回款额乘以的固定比例"}, description="预付回款额 × 固定比例", ), } diff --git a/backend/app/services/receipt_service.py b/backend/app/services/receipt_service.py index 327682a..6cd5340 100644 --- a/backend/app/services/receipt_service.py +++ b/backend/app/services/receipt_service.py @@ -175,7 +175,7 @@ def confirm_receipt(session, receipt_id, payload, *, operator, meta=None) -> Rec if not receipt_confirmation.is_confirmed(receipt): receipt.confirmation_status = 1 receipt.confirmed_by = operator.username - receipt.confirmed_at = datetime.now() + receipt.confirmed_at = datetime.now().replace(microsecond=0) receipt.confirmed_revision = payload.expected_revision out = get_detail(session, receipt_id) session.commit() diff --git a/backend/tests/README.md b/backend/tests/README.md index 7fcd738..00e185f 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -393,3 +393,15 @@ uv run pytest tests/integration -q # 仅集成 - 模板定义迁移至 `calc_template_definitions`(V28 种子 5 个内置模板);骨架 `FORMULAS` 受控,核算/预览按骨架分派,岗位模板校验改为数据库定义优先、旧库注册表兜底。 - 更新 `test_param_registry` 模板断言:内置模板参数绑定覆盖骨架角色且参数编码已注册。单元 24 项通过;`test_calc_helpers`、`test_param_snapshot`、`test_formal_payroll` 合计 63 项通过;Ruff 与前端类型/构建通过。 - 岗位相关 MySQL 集成需独立测试库环境(`PAYROLL_TEST_DATABASE`),本轮未执行;新增模板 CRUD 集成用例待补。 + + +## 更正单与备份恢复(2026-11-03) + +105 项定点单元、37 项隔离 MySQL 集成通过。新用例覆盖正负成对核对、回款转原业务、来源防重/原子拒绝/同号实体隔离、来源变化拦截、普通抵顶不打折、备份篡改/断链/服务器更换/无更新不归档。独立 Docker 全量+增量重放逐值核对通过;说明及证据见 `docs/更正单与备份恢复交付_2026-11-03.md`。 + + +## 真实样本闭环与历史岗位(2026-11-03) + +新增 `tests/unit/test_historical_roles.py`,覆盖按月任职区间、重叠拒绝、跨月转岗、经理本人业务排除、正式台账与月结冻结岗位、客户负责人暂缺、带实际成交参照的产品导入。完整单元 961 项、隔离 MySQL 专项 19 项通过;辽宁兴晟达真实样本完成正式导入至月结、四张报表及导出,30 个不同检查点通过。证据与边界见 `docs/真实样本业务闭环验收_2026-11-03.md`。 + +定点复验:`python -m pytest tests/unit/test_historical_roles.py -q`。真实样本只证明本组经理本人排除场景,不能据 0 元结果认定人员整月工资为零。 diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index 407eb57..04ce526 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -390,9 +390,11 @@ def tplus_tasks(monkeypatch): @pytest.fixture def legacy_calculation(monkeypatch): - """明确启用旧引擎兼容回归;生产和正式工资测试默认关闭。""" + """旧引擎回归使用关闭确认开关的口径;确认门槛由独立用例覆盖。""" from app.core.config import get_settings + from app.services import receipt_confirmation monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", True) + monkeypatch.setattr(receipt_confirmation, "enabled", lambda _session: False) @pytest.fixture diff --git a/backend/tests/integration/test_calc.py b/backend/tests/integration/test_calc.py index a060c67..8aaba79 100644 --- a/backend/tests/integration/test_calc.py +++ b/backend/tests/integration/test_calc.py @@ -307,11 +307,10 @@ class TestGuards: resp = _calc(client, admin["headers"], receipt["id"]) assert resp.status_code == 200, resp.text ledger = resp.json()["data"]["ledger"] - # 成本缺失 → 毛利按 0 → 折算 = 销售额×(0−30%) = **−3000**(保守少算 - # 方向:缺成本的行宁少算不多发),missing_cost=1 + # 旧引擎缺成本标记保留,零毛利按现行公式特例折算为零。 assert ledger["missing_cost"] == 1 - assert float(ledger["converted_profit"]) == -3000.0 - assert float(ledger["performance_amount"]) == pytest.approx(-29.33, abs=0.01) + assert float(ledger["converted_profit"]) == 0.0 + assert float(ledger["performance_amount"]) == 0.0 def test_负绩效照算不归零(self, client: TestClient, admin: dict) -> None: items = [ diff --git a/backend/tests/integration/test_formal_payroll.py b/backend/tests/integration/test_formal_payroll.py index 451bd85..9f53475 100644 --- a/backend/tests/integration/test_formal_payroll.py +++ b/backend/tests/integration/test_formal_payroll.py @@ -30,8 +30,9 @@ def post(client, path, headers, data): @pytest.fixture -def formal_chain(client, admin): +def unconfirmed_formal_chain(client, admin, request): headers = admin["headers"] + unit_cost = getattr(request, "param", {}).get("unit_cost", "25") with get_session_factory()() as s: roles = {r.role_code: r.id for r in s.scalars(select(BusinessRole))} persons = [] @@ -56,7 +57,7 @@ def formal_chain(client, admin): "product_category": "municipal_water", "quantity": "2", "sale_price": "50", - "guided_price": "25", + "guided_price": unit_cost, } business = post( client, @@ -103,7 +104,7 @@ def formal_chain(client, admin): "shipment_item_id": detail["items"][0]["id"], "personnel_id": owner["id"], "manager_id": manager["id"], - "unit_cost": "25", + "unit_cost": unit_cost, } ], } @@ -111,6 +112,15 @@ def formal_chain(client, admin): return business, receipt, setup, owner, manager +@pytest.fixture +def formal_chain(client, admin, unconfirmed_formal_chain): + business, receipt, setup, owner, manager = unconfirmed_formal_chain + confirmed = post(client, f"/api/v1/business/receipts/{receipt['id']}/confirm", admin["headers"], { + "expected_revision": receipt["confirmation_revision"], + }) + return business, confirmed, setup, owner, manager + + def test_正式核算生成工资锁定及报表一致(client, admin, formal_chain): business, _, setup, owner, manager = formal_chain headers = admin["headers"] @@ -321,3 +331,30 @@ def test_无正式资料时默认拒绝旧核算(client, admin, formal_chain): f"/api/v1/calc/receipts/{empty_receipt['id']}/calc", headers=admin["headers"] ) assert response.status_code == 400 and "正式核算" in response.json()["message"] + + +def test_抵顶标记不阻断正式平账且不自动打八折(client, admin, formal_chain): + from app.models import Receipt + + business, cash, setup, _, _ = formal_chain + with get_session_factory()() as session: + receipt = session.get(Receipt, cash["id"]) + receipt.is_barter = 1 + session.commit() + post(client, f"{BASE}/businesses/{business['id']}/input", admin["headers"], + {**setup, "expected_version": 1}) + rejected = client.post(f"{BASE}/businesses/{business['id']}/calculate", headers=admin["headers"], + json={"month": "2026-03"}) + assert rejected.status_code == 400 and "人工确认" in rejected.json()["message"] + current = client.get(f"/api/v1/business/receipts/{cash['id']}", headers=admin["headers"]) + assert current.status_code == 200, current.text + post(client, f"/api/v1/business/receipts/{cash['id']}/confirm", admin["headers"], { + "expected_revision": current.json()["data"]["confirmation_revision"], + }) + result = post(client, f"{BASE}/businesses/{business['id']}/calculate", admin["headers"], + {"month": "2026-03"}) + assert result["performance"] == "0.20" and result["payable"] == "0.20" + with get_session_factory()() as session: + assert session.get(Receipt, cash["id"]).is_barter == 1 + assert all(session.get(PerformanceLedger, ledger_id).barter_applied == 0 + for ledger_id in result["ledger_ids"]) diff --git a/backend/tests/integration/test_param_management.py b/backend/tests/integration/test_param_management.py index 473576d..d6f4b64 100644 --- a/backend/tests/integration/test_param_management.py +++ b/backend/tests/integration/test_param_management.py @@ -87,10 +87,11 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of: class TestCatalog: """参数目录:种子完整性与时点化回溯。""" - def test_目录含27项及产品系数组(self, client: TestClient, admin: dict) -> None: - """V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。""" + def test_目录含28项及产品系数组(self, client: TestClient, admin: dict) -> None: + """目录包含回款人工确认开关和既有参数。""" items = client.get(_PARAMS, headers=admin["headers"]).json()["data"] - assert len(items) == 27 + assert len(items) == 28 + assert any(item["param_code"] == "receipt.confirmation.enabled" for item in items) groups = {item["param_group"] for item in items} assert groups == { "业务员个人户", @@ -99,6 +100,7 @@ class TestCatalog: "企业户", "信息员", "支付环节", + "回款确认", "产品阈值", "月工资口径", "审批开关", @@ -355,7 +357,7 @@ class TestPreview: "salesperson.rate.floor", "salesperson.days.boundary", "product.threshold", - "barter.discount", + # 2026-11-03 口径:抵顶线下核算,预览不再读取 barter.discount(参数保留作回退位)。 "payment.start.ratio", "probation.annual.threshold", ] @@ -495,7 +497,7 @@ class TestPermission: session.commit() resp = client.get(_PARAMS, headers=headers) assert resp.status_code == 200 - assert len(resp.json()["data"]) == 27 + assert len(resp.json()["data"]) == 28 def test_未登录访问目录_403(self, client: TestClient) -> None: # LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401 diff --git a/backend/tests/integration/test_receipt_confirmation.py b/backend/tests/integration/test_receipt_confirmation.py index 8029331..f487031 100644 --- a/backend/tests/integration/test_receipt_confirmation.py +++ b/backend/tests/integration/test_receipt_confirmation.py @@ -10,6 +10,8 @@ from app.core.db import get_session_factory from app.models import OperationLog, PayrollInput, PerformanceLedger, Receipt from tests.integration.test_formal_payroll import ( post, +) +from tests.integration.test_formal_payroll import ( unconfirmed_formal_chain as unconfirmed_formal_chain, ) @@ -128,7 +130,7 @@ def test_正式核算零毛利特例但低阈值仍为负(client, admin, unconfi @pytest.mark.parametrize("role,profit,converted,performance", [ ("salesperson", "0", "0", "0"), ("salesperson", "10", "-20", "-0.20"), - ("info_officer", "0", "0", "0.10"), + ("info_officer", "0", "100", "0.10"), ]) def test_试算零毛利与信息员基数保持独立(client, admin, role, profit, converted, performance): data = post(client, "/api/v1/system/params/preview", admin["headers"], { diff --git a/backend/tests/integration/test_requirement_completion.py b/backend/tests/integration/test_requirement_completion.py index 944b50d..de37ac6 100644 --- a/backend/tests/integration/test_requirement_completion.py +++ b/backend/tests/integration/test_requirement_completion.py @@ -12,6 +12,9 @@ from app.core.config import get_settings from app.core.db import get_session_factory from app.models import ParamChangeBatch, ProbationPayment from tests.integration.test_formal_payroll import formal_chain as formal_chain +from tests.integration.test_formal_payroll import ( + unconfirmed_formal_chain as unconfirmed_formal_chain, +) pytestmark = pytest.mark.integration diff --git a/backend/tests/integration/test_schema_sync.py b/backend/tests/integration/test_schema_sync.py index 7378a47..fce2bbe 100644 --- a/backend/tests/integration/test_schema_sync.py +++ b/backend/tests/integration/test_schema_sync.py @@ -14,6 +14,7 @@ 注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。 """ +import ast import re from pathlib import Path @@ -24,7 +25,7 @@ MIGRATION_DIR = PROJECT_ROOT / "db" / "migration" INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql" ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions" -_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL) +_CREATE_TABLE = re.compile(r"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL | re.IGNORECASE) _COLUMN_LINE = re.compile(r"^[ \t]+`?(\w+)`?[ \t]", re.MULTILINE) # 这些开头的是约束定义,不是列定义 _NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"} @@ -32,6 +33,7 @@ _NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "IN # MODIFY 只改类型不改列名,对列集合无影响,无需处理 _ALTER_ADD = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+ADD(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE) _ALTER_DROP = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+DROP(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE) +_RENAME_TABLE = re.compile(r"RENAME TABLE\s+`?(\w+)`?\s+TO\s+`?(\w+)`?", re.IGNORECASE) def _parse_tables(sql: str) -> dict[str, set[str]]: @@ -67,6 +69,8 @@ def _migration_final_state(paths: list[Path]) -> dict[str, set[str]]: merged.setdefault(table, set()).add(column) for table, column in _ALTER_DROP.findall(sql): merged.setdefault(table, set()).discard(column) + for old, new in _RENAME_TABLE.findall(sql): + merged[new] = merged.pop(old) return merged @@ -83,8 +87,15 @@ def _alembic_head() -> str: text = path.read_text(encoding="utf-8") if found := re.search(r"^revision(?:\s*:\s*str)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE): revisions.add(found.group(1)) - if found := re.search(r"^down_revision(?:\s*:.*?)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE): - referenced.add(found.group(1)) + for node in ast.parse(text).body: + if isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if any(isinstance(target, ast.Name) and target.id == "down_revision" for target in targets): + value = ast.literal_eval(node.value) + if isinstance(value, str): + referenced.add(value) + elif isinstance(value, tuple): + referenced.update(value) heads = revisions - referenced assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}" @@ -94,14 +105,14 @@ def _alembic_head() -> str: @pytest.fixture(scope="module") def migration_tables() -> dict[str, set[str]]: """按文件序重放 db/migration/ 下全部 V*.sql,取迁移链终态。""" - files = sorted(MIGRATION_DIR.glob("V*.sql")) + files = sorted(MIGRATION_DIR.glob("V*.sql"), key=lambda path: int(re.match(r"V(\d+)", path.name).group(1))) assert files, f"未找到迁移脚本:{MIGRATION_DIR}" return _migration_final_state(files) @pytest.fixture(scope="module") def init_dev_tables() -> dict[str, set[str]]: - return _parse_tables(INIT_DEV_FILE.read_text(encoding="utf-8")) + return _migration_final_state([INIT_DEV_FILE]) class TestSchemaSync: @@ -111,6 +122,16 @@ class TestSchemaSync: assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}" assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}" + def test_迁移解析支持建表后增加列与重命名(self, tmp_path: Path) -> None: + migration = tmp_path / "V1__example.sql" + migration.write_text( + "CREATE TABLE old_name (\n id BIGINT\n);\n" + "ALTER TABLE old_name ADD extra VARCHAR(8);\n" + "RENAME TABLE old_name TO new_name;\n", + encoding="utf-8", + ) + assert _migration_final_state([migration]) == {"new_name": {"id", "extra"}} + def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None: assert set(init_dev_tables) == set(migration_tables), ( "两份 DDL 的表集合不一致 —— 表结构变更时需同时维护 " diff --git a/backend/tests/unit/test_formal_payroll.py b/backend/tests/unit/test_formal_payroll.py index 059259f..ef09a95 100644 --- a/backend/tests/unit/test_formal_payroll.py +++ b/backend/tests/unit/test_formal_payroll.py @@ -32,6 +32,7 @@ from app.services.payroll_access import can_write_payroll from app.services.tplus.business_import import execute_business from app.services.tplus.workbench import digest from tests.unit.test_tplus_workbench import apply, fact, prepare_calc, preview +from tests.unit.test_tplus_workbench import source as publish_source from tests.unit.test_tplus_workbench import work_session as work_session @@ -192,6 +193,96 @@ def test_正式导入防重与来源可追溯(payroll_case): assert record.data_json["source_facts"] +@pytest.fixture +def correction_case(payroll_case, monkeypatch): + from contextlib import nullcontext + + from app.models import TPlusSyncRecord + from app.schemas.tplus_workbench import CorrectionPairIn + from app.services.tplus import corrections + from app.services.tplus.snapshots import source_records + + s, user, _ = payroll_case + original = source_records(s, "test_source", "shipments")[0].payload_json + original_line = source_records(s, "test_source", "shipment_items")[0].payload_json + # 收款ID与负更正发货ID相同,必须由实体前缀区分。 + publish_source(s, "shipments", [original, + {**original, "id": 30, "code": "A-NEG"}, + {**original, "id": 40, "code": "B-POS", "idcustomer": 88}]) + publish_source(s, "shipment_items", [original_line, + {**original_line, "id": 31, "source_parent_id": 30, "taxAmount": "-100", "quantity": "-2"}, + {**original_line, "id": 41, "source_parent_id": 40}]) + publish_source(s, "receipts", [{"id": 30, "code": "B-CASH", "idpartner": 88, + "voucherDate": "2026-03-10", "isReceiveFlag": 1, "voucherstate": 189, "amount": "100", + "accounting_voucher_count": 1, "valid_accounting_voucher_count": 1, + "accounting_voucher_id": 123, "accounting_voucher_code": "TEST-GL"}]) + monkeypatch.setattr(corrections, "sync_lock", lambda db: nullcontext()) + payload = CorrectionPairIn(negative_shipment_id="30", positive_shipment_id="40", + receipt_ids=["30"], target_group_key="g", evidence="隔离转客户例单核对") + before = s.scalar(select(func.count()).select_from(TPlusSyncRecord)) + return s, user, corrections, payload, before + + +def test_更正对贯通正式导入与FIFO_保留日期金额且重复不增加(payroll_case, correction_case): + from app.models import TPlusFact, TPlusSyncRecord + + s, user, service, payload, before = correction_case + first = service.save_pair(s, payload, user.id) + again = service.save_pair(s, payload, user.id) + assert first["pair_key"] == again["pair_key"] + assert s.scalar(select(func.count()).select_from(TPlusFact).where(TPlusFact.kind == "CORRECTION")) == 3 + business = imported(payroll_case) + assert business.total_sale_amount == Decimal("100") + cash = s.scalar(select(Receipt)) + assert cash.source_no == "B-CASH" and cash.receipt_date == date(2026, 3, 10) + assert cash.amount == Decimal("100") and cash.business_id == business.id + assert s.scalar(select(func.count()).select_from(Shipment)) == 1 + assert s.scalar(select(func.count()).select_from(TPlusSyncRecord)) == before + result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user) + assert result["performance"] == "0.20" and result["payable"] == "0.20" + repeated = business_work(s, user, "BUSINESS_PREVIEW") + assert repeated.summary_json["counts"] == {"SKIP": 1} + with pytest.raises(BadRequestError): + payroll_calculation.calculate(s, business.id, "2026-03", operator=user) + + +@pytest.mark.parametrize("change", ["wrong_sign", "wrong_group", "wrong_receipt", "missing_source"]) +def test_无效更正对原子拒绝不留下半边事实(correction_case, change): + from app.models import TPlusFact + + s, user, service, payload, _ = correction_case + updates = {"wrong_sign": {"negative_shipment_id": "40", "positive_shipment_id": "30"}, + "wrong_group": {"target_group_key": "unknown"}, + "wrong_receipt": {"receipt_ids": ["999"]}, + "missing_source": {"positive_shipment_id": "999"}} + with pytest.raises(BadRequestError): + service.save_pair(s, payload.model_copy(update=updates[change]), user.id) + assert s.scalar(select(func.count()).select_from(TPlusFact).where(TPlusFact.kind == "CORRECTION")) == 0 + + +def test_更正对后来源变化阻止静默排除(correction_case): + from app.services.tplus.snapshots import source_records + from app.services.tplus.workbench import latest_facts + + s, user, service, payload, _ = correction_case + service.save_pair(s, payload, user.id) + rows = [r.payload_json for r in source_records(s, "test_source", "shipments")] + publish_source(s, "shipments", [{**r, "code": "CHANGED"} if r["id"] == 30 else r for r in rows]) + record = source_records(s, "test_source", "shipments", ids=["30"])[0] + with pytest.raises(ValueError, match="CORRECTION_RECONFIRM_REQUIRED"): + service.verified_correction(s, "test_source", latest_facts(s, "test_source"), "SHIPMENT", record) + + +def test_抵顶预览显示未自动折算且不读取折扣参数(): + from app.services.param_service import _preview_barter + + def forbidden(_): + raise AssertionError("线下抵顶不得读取自动折扣参数") + result = _preview_barter(SimpleNamespace(payload=SimpleNamespace(barter=True), take=forbidden), Decimal("100")) + assert result.base_amount == Decimal("100") + assert result.applied is False and result.after_barter is None + + def test_正式FIFO台账金额与逐段过程守恒(payroll_case): s, user, manager = payroll_case business = imported(payroll_case) diff --git a/backend/tests/unit/test_formula.py b/backend/tests/unit/test_formula.py index 467d004..6e7e987 100644 --- a/backend/tests/unit/test_formula.py +++ b/backend/tests/unit/test_formula.py @@ -113,6 +113,12 @@ class TestConvertedProfit: def test_毛利润额为零_折算为零_不除零(self) -> None: assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0") + def test_有销售但零毛利润_折算及普通绩效均为零(self) -> None: + rate = profit_rate(Decimal("100") - Decimal("100"), Decimal("100")) + base = converted_profit(Decimal("100"), rate, Fraction(30)) + assert base == Decimal("0") + assert performance_amount(base, Fraction(1, 100)) == Decimal("0") + def test_毛利润率低于阈值_折算为负_不归零(self) -> None: # 100 × (1/10 − 3/10) = -20(业务确认负值正常计算) assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20") diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index fa578ef..da6966c 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -17,6 +17,7 @@ EXPECTED_TABLES = { "authorization_settings", "user_permission_overrides", "tplus_automation_states", "tplus_automation_configs", + "tplus_runtime_controls", "payroll_months", "payroll_inputs", "payroll_allocations", "tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines", "users", diff --git a/backend/tests/unit/test_report_helpers.py b/backend/tests/unit/test_report_helpers.py index 0b40ad2..8ca89ae 100644 --- a/backend/tests/unit/test_report_helpers.py +++ b/backend/tests/unit/test_report_helpers.py @@ -207,6 +207,12 @@ class TestExcel模板: text = _sheet_text(_build("business-stats", rows)) assert "已完成" in text + def test_业务销售额未知时导出明细和合计不显示零(self): + rows = [{"business_no": "YW001", "status": 1, "total_sale_amount": None}] + workbook = load_workbook(io.BytesIO(_build("business-stats", rows))) + values = [cell.value for row in workbook.active.iter_rows() for cell in row] + assert values.count("待核对") >= 2 + def _member( personnel_id: int, diff --git a/backend/tests/unit/test_source_price_authorization.py b/backend/tests/unit/test_source_price_authorization.py index 10514b4..747e07c 100644 --- a/backend/tests/unit/test_source_price_authorization.py +++ b/backend/tests/unit/test_source_price_authorization.py @@ -4,9 +4,9 @@ from unittest.mock import Mock import pytest -from app.api.v1.business.businesses import confirm_source_price +from app.api.v1.business.businesses import confirm_guidance, confirm_source_price from app.core.exceptions import ForbiddenError -from app.schemas.business_source import SourcePriceConfirmIn +from app.schemas.business_source import GuidanceConfirmIn, SourcePriceConfirmIn from app.services import authorization_service, business_source from app.services.authorization_policy import permission_for @@ -29,3 +29,46 @@ def test_无核算准备权限不能确认来源单价(monkeypatch): with pytest.raises(ForbiddenError): confirm_source_price(session, actor, 1, 1, payload, meta) confirm.assert_not_called() + + +def test_指导成本登记必须同时具备核算准备和事实维护权限(monkeypatch): + session, actor, meta = Mock(), Mock(), Mock() + save = Mock(return_value={"status": "CREATED"}) + permissions = [] + + def allow(current_session, current_actor, permission): + assert (current_session, current_actor) == (session, actor) + permissions.append(permission) + + monkeypatch.setattr(authorization_service, "require_permission", allow) + monkeypatch.setattr(business_source, "confirm_guidance", save) + payload = GuidanceConfirmIn(shipment_item_id=1, amount="2.1234", evidence="核对月度指导成本") + + result = confirm_guidance(session, actor, 1, payload, meta) + + assert result.data == {"status": "CREATED"} + assert permissions == ["payroll:prepare", "tplus:facts"] + save.assert_called_once_with(session, 1, payload, actor, meta) + + +def test_缺事实维护权限不能登记指导成本(monkeypatch): + session, actor, meta = Mock(), Mock(), Mock() + save = Mock() + + def deny(current_session, current_actor, permission): + if permission == "tplus:facts": + raise ForbiddenError("无权维护事实") + + monkeypatch.setattr(authorization_service, "require_permission", deny) + monkeypatch.setattr(business_source, "confirm_guidance", save) + payload = GuidanceConfirmIn(shipment_item_id=1, amount="2", evidence="核对") + + with pytest.raises(ForbiddenError): + confirm_guidance(session, actor, 1, payload, meta) + save.assert_not_called() + + +@pytest.mark.parametrize("amount", ["abc", "-1", "1.12345", "NaN"]) +def test_指导成本入参拒绝非法精度和值(amount): + with pytest.raises(ValueError): + GuidanceConfirmIn(shipment_item_id=1, amount=amount, evidence="核对") diff --git a/backend/tests/unit/test_tplus_business_sync.py b/backend/tests/unit/test_tplus_business_sync.py index d394d6e..2668eea 100644 --- a/backend/tests/unit/test_tplus_business_sync.py +++ b/backend/tests/unit/test_tplus_business_sync.py @@ -1,10 +1,7 @@ """业务接入以源单据为边界;核算资料缺失不得阻断建档。""" -from contextlib import nullcontext from datetime import date from decimal import Decimal -from fractions import Fraction -from types import SimpleNamespace from uuid import NAMESPACE_URL, uuid4, uuid5 import pytest @@ -17,7 +14,6 @@ from app.models import ( BusinessItem, BusinessRole, Customer, - OwnershipChangeLog, PayrollInput, Personnel, Product, @@ -26,41 +22,32 @@ from app.models import ( ShipmentItem, TPlusBusinessDocument, TPlusBusinessSyncConfig, - TPlusSourceHead, TPlusSyncBatch, TPlusWorkRun, User, ) from app.repositories import customer_repository, product_repository from app.schemas.business_source import ( - SalesRelationConfirmIn, - SalesRelationReleaseIn, SourceMaterialsIn, - SourcePriceConfirmIn, ) from app.schemas.category_batch import ( CustomerCategoryBatch, - CustomerOwnershipBatch, ProductCategoryBatch, ) from app.schemas.customer import CustomerQuery from app.schemas.payroll import PayrollSetupIn from app.schemas.product import ProductQuery -from app.schemas.report import BusinessStatsQuery from app.services import ( business_service, business_source, category_batch, - customer_service, payroll_months, receipt_confirmation, - report_service, ) -from app.services.business_source import ensure_source_editable -from app.services.tplus import business_sync, business_sync_control, sales_relations +from app.services.tplus import business_sync, business_sync_control from app.services.tplus.snapshots import source_manifest from app.services.tplus.workbench import WorkbenchError, digest, execute_next -from tests.unit.test_tplus_workbench import fact, source +from tests.unit.test_tplus_workbench import source from tests.unit.test_tplus_workbench import work_session as work_session diff --git a/docs/2026.9.8修改方案.docx b/docs/2026.9.8修改方案.docx index 6b96bda..cdc1f3f 100644 Binary files a/docs/2026.9.8修改方案.docx and b/docs/2026.9.8修改方案.docx differ diff --git a/docs/T+本地恢复与同步开发手册.md b/docs/T+本地恢复与同步开发手册.md index 597870d..8d7debd 100644 --- a/docs/T+本地恢复与同步开发手册.md +++ b/docs/T+本地恢复与同步开发手册.md @@ -54,7 +54,7 @@ Windows 本地开发任务目录为 `\PayrollManagementSystem-Dev\`,新增三 | `T_PLUS_CONNECT_TIMEOUT/QUERY_TIMEOUT` | 默认 10/120 秒;查询超时范围 1–600 秒 | | `T_PLUS_QUERY_DIR` | 留空使用仓库 `db/queries/tplus/`;部署时须交付查询文件 | -连接文件只接受 `host`、`port`(整数)、`database`、`user`、`password`、`driver` 六个键,最大 16 KB;拒绝 `sa` 和额外配置项。文件覆盖源库连接字段,TLS 和超时仍取应用配置。连接文件、真实密码与完整连接串不入 Git、不贴到确认单或报告。 +连接文件只接受 `host`、`port`(整数)、`database`、`user`、`password`、`driver` 六个键,最大 16 KB,拒绝额外配置项。默认拒绝 `sa`;客户确认只能使用管理员账号时,须在应用环境中显式设置 `T_PLUS_ALLOW_ADMIN_ACCOUNT=true`,不能在连接文件内设置。文件覆盖源库连接字段,TLS 和超时仍取应用配置。连接文件、真实密码与完整连接串不入 Git、不贴到确认单或报告。 默认加密可能使旧驱动或未配置 TLS 的实例连接失败。先核对驱动与服务端能力;确需临时回退时,仅在隔离开发环境显式设置 `T_PLUS_ENCRYPT=false`,记录原因、范围和复测结果。此次变更只更新代码默认值与模板,既有私有配置不会自动被覆盖。真实 10.50.1600 的 TLS 兼容仍待实测。 diff --git a/docs/实际样本核对_辽宁兴晟达_2026-07_2026-11-02.md b/docs/实际样本核对_辽宁兴晟达_2026-07_2026-11-02.md index d01d8ea..eadddf1 100644 --- a/docs/实际样本核对_辽宁兴晟达_2026-07_2026-11-02.md +++ b/docs/实际样本核对_辽宁兴晟达_2026-07_2026-11-02.md @@ -1,5 +1,7 @@ # 辽宁兴晟达物资有限公司 2026 年 7 月实际样本核对 +> 2026-11-03 后续:已修复历史岗位衔接及真实产品导入阻塞,并使用本组真实数据在隔离 MySQL 完成正式导入、核算、月结、报表及导出。结果见[真实样本业务闭环验收](真实样本业务闭环验收_2026-11-03.md)。下文保留 11 月 2 日只读核对时的阶段记录;本次未向原开发库生成工资。 + 记录日期:2026-11-02。来源:本次会话中用户转述的客户回复及本地已发布 T+ 源快照。当前阶段:单据、FIFO、参数及经理本人排除分支的只读核对完成;未执行正式导入或工资生成,也未将本组结果代作整月验收。 ## 客户已提供的资料 diff --git a/frontend/README.md b/frontend/README.md index 5ab29ee..9b6fdf5 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,5 +1,43 @@ # 前端 +## 工作台、同步页与进度动效(2026-11-03) + +工作台与 T+ 数据同步页沿用应用内容区全宽,桌面保持 22px 常规留白,与用户管理等页面对齐。工作台改为连续指标区,收紧待办与风险空态,采集状态集中展示,图表缺失月份保留空缺。同步页整理计划、记录、资料导入与关联核验,核验明细与单据分布分开呈现。 + +进度区分排队、读取或核验、处理结束三个阶段。已知总量按实际百分比以 `scaleX` 过渡;未知总量的轨道保持静止,仅执行指示点淡入淡出。核验没有数量时显示“核验中”。完成、异常或状态更新中断后停止执行动画,减少动态效果模式下停用动画和过渡;任务详情继续只显示所选任务的依据,不重复大进度条。 + +从项目根执行 `node frontend/e2e/operations_pages.mjs`。本轮 8 组合成 API 回归通过,覆盖两页 320 至 2560px 十二档宽度、68 组页面与弹层布局、七种任务状态、动态独立分页、关联核验、四类工作区、创建范围、计划开关、只读限制及失联恢复。实测比例由 40% 更新至 70%,执行点透明度变化且在减少动态效果时停用;页面错误和布局溢出均为 0,类型检查及生产构建通过。 + +证据在 `.ai/operations-pages-20261103/`,设计约定见 `docs/design/operations/DESIGN.md`。接口全部拦截为合成响应,未触发真实采集、自动计划设置、资料导入或工资生成。可用 `OPS_UI_URL`、`OPS_UI_OUTPUT` 和 `PLAYWRIGHT_MODULE` 调整验收环境。 + +## 计提参数整页打磨(2026-11-03) + +页面按产品系数、计提规则、待提交变更组织,版本管理、导出记录和计算预览入口放到页头。系数表按连续的产品类别与户型合并重复单元格,精确分数仍直接展示;工具栏与表格保持 16px 间距。 + +规则支持按名称、编码、说明或分组搜索,以及全部展开/收起。窄屏纵向展示规则详情,待提交变更直接呈现原值和新值;版本、历史与试算抽屉提供独立关闭区,试算操作固定在底部。规则与系数加载失败时分别提示并支持重试,读取和修改权限沿用现有约定。 + +从项目根执行 `node frontend/e2e/params_page.mjs`。合成 API 回归通过,覆盖 320 至 2560px 九档宽度、21 组页面与弹层布局检查,以及搜索、暂存不写入、版本对比、试算、中文导入预览、故障恢复和只读限制。页面错误 0,类型检查及生产构建通过。截图与结果位于 `.ai/params-page-20261103/`;未写真实参数或工资。 + +## 全站界面打磨(2026-11-03) + +沿用灰白底与靛蓝重点色,统一页面标题、说明、筛选区、表单分组、状态对比度和键盘焦点。产品、人员、客户、业务和用户档案按内容分组;弹窗明确区分标题、可滚动正文和固定操作区,常用编辑弹窗提交中禁止关闭。 + +表格补齐长字段提示、金额与数量右对齐,修正操作列宽度。窄屏筛选控件铺满行,分页的前后按钮与页码保持同行,表格操作列随表格横向滚动;日期范围双月改为纵向排列并在视口内滚动。14 个列表页接入持续错误提示和重新加载。工作台手机指标采用两列,月结主流程位于年度结清之前,参数目录保留可读列宽,四种打印预览支持独立横向查看。 + +登录页支持验证码加载失败后重试;无权限页面重新检查成功后跳转可访问页面。产品与客户的唯一组合条件保留为字段下方说明,金额仍按接口字符串展示。 + +回归命令从项目根执行: + +```bash +node frontend/e2e/ui_polish.mjs +node frontend/e2e/ledger-dialog-regression.mjs +node frontend/e2e/authorization_roles.mjs +``` + +`ui_polish.mjs` 使用合成 API,覆盖 22 个主页面的 1440/768/390/320px 布局、档案与专项弹层、产品提取、四类打印预览、列表失败重试、授权刷新和验证码故障恢复。可通过 `UI_POLISH_URL`、`UI_POLISH_OUTPUT`、`PLAYWRIGHT_MODULE` 指定前端地址、证据目录与浏览器依赖。本轮三组浏览器回归通过,页面脚本错误和检测到的横向溢出均为 0;类型检查、生产构建与 Impeccable 静态扫描通过。证据在 `.ai/ui-polish-20261103/`。 + +本轮浏览器验收拦截全部业务 API,不修改真实账号、业务记录或工资,不替代真实金额联调。当前本机 Rolldown 可选依赖缺少包入口文件,验证时临时设置 `NAPI_RS_NATIVE_LIBRARY_PATH` 指向已有原生二进制,未改依赖版本或锁文件;构建仍有部分分包超过 500kB 的体积提示。 + ## 产品、人员及首页数据体验(2026-11-02) 产品提取入口移到操作栏,来源空值改为明确说明;产品和人员长标签、日期控件及手机布局已修复。人员经理参照只列在职且绑定启用经理计提模板的人员,旧参考关系保留;实际绩效仍按业务分管。岗位编辑说明内置模板与参数维护的区别,提供参数入口。首页以趋势金额是否为 `null` 区分尚无台账/回款与真实零值,并说明正式导入和核算步骤。 @@ -332,3 +370,26 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py 业务列表增加正式核算,台账可看逐段依据和创建当月分配调整;T+页面增加正式业务导入预览/执行。会话返回can_write_payroll控制按钮,后端仍逐请求校验指定制作人。审核能力默认关闭,不展示提交审核步骤或审批单列;开启后原节点可用。 本轮vue-tsc与Vite构建通过;真实浏览器使用合成接口验证资料表单、封存确认请求及768宽弹窗、默认无审核入口,控制台错误0。界面检查不替代后端真实金额与客户样本验收;详情见 `docs/正式工资闭环开发记录_2026-10-30.md`。 + + +## 更正单成对处理(2026-11-03) + +T+ 数据应用工作区的补充资料增加“更正单成对处理”。运行 `node frontend/e2e/correction-pairs.mjs`(项目根)隔离验证真实组件,合成接口不访问业务库;4 组检查覆盖必填、完整成对提交、只读和 1440/768/390 布局,脚本错误 0,类型及构建通过。后台真实链路验证另见交付记录。 + + +## 产品提取按钮禁用原因(2026-11-03) + +“确认执行提取”须在勾选明细并成功预览后启用,管理员同样遵循该流程。弹窗已增加操作步骤及按钮旁的具体原因,区分未选、未预览、处理中、已完成与真实权限不足。前端构建通过;隔离 Edge 的 5 组检查通过,覆盖管理员预览后可执行、执行后防重、权限说明及 1440/768/390 三宽度,脚本错误 0。证据 `.ai/product-extract-disabled-20261103/result.json`;仅合成接口,未修改真实产品。 + +## 基础导入明细展示(2026-11-03) + +人员、产品、客户基础任务显示源编号、名称、处理结果及原因,产品额外显示规格和单位;金额、发货单、回款天数仅在核算类明细显示。跳过明确区分名单外记录与已导入无变化,旧任务缺少名称时由后端从原始快照补齐展示。过期导入提供“重新生成预览”,沿用原范围,不自动执行导入。 + +运行 `node frontend/e2e/workbench-details.mjs` 验证真实组件;8 组隔离接口检查覆盖人员/产品/客户、核算列、筛选、原范围重建、过期恢复、明细失败恢复及 1440/768/390 三宽度,结果位于 `.ai/workbench-details-browser/`。已另在实际 T+ 页面只读确认 2026-10-29 历史人员任务的姓名、工号和跳过原因。 + + +## 历史岗位资料(2026-11-03) + +T+ 数据同步 → 资料与导入 → 补充资料 → 人员名单,支持保留当前岗位并按开始、结束月份追加已确认历史岗位;起止月份均包含在内,区间重叠由后端拒绝。月份未填完整时前端阻止提交,保存沿用资料版本和权限。客户公司负责人姓名在源导入资料中可暂缺,人工新建客户的必填规则不变。 + +浏览器隔离组件验证通过:月份回显、完整性校验、实际提交内容、只读保护和 1440/390 宽度,页面错误 0;类型与生产构建通过。真实样本 MySQL 全链路与界面合成接口验证分别记录于 `docs/真实样本业务闭环验收_2026-11-03.md`,证据 `.ai/liaoning-full-chain-20261103/`。 diff --git a/frontend/src/api/businessRoles.ts b/frontend/src/api/businessRoles.ts index 1741fa7..7eb5753 100644 --- a/frontend/src/api/businessRoles.ts +++ b/frontend/src/api/businessRoles.ts @@ -123,6 +123,10 @@ export interface FormulaOption { name: string description: string roles: string[] + /** 角色 → 中文标签(模板表单展示) */ + role_labels: Record + /** 角色 → 业务口径提示(placeholder) */ + role_hints: Record } export interface CalcTemplateCreateParams { diff --git a/frontend/src/api/businesses.ts b/frontend/src/api/businesses.ts index f64451c..42a0ee8 100644 --- a/frontend/src/api/businesses.ts +++ b/frontend/src/api/businesses.ts @@ -143,6 +143,10 @@ export function confirmMissingSourcePrice(id: number, documentId: number, data: return request({ url: `/v1/business/businesses/${id}/source-documents/${documentId}/missing-price`, method: 'put', data }) } +export function confirmGuidance(id: number, data: { shipment_item_id: number; amount: string; evidence: string }): Promise<{ status: 'CREATED' | 'REUSED'; fact_id: number; product_id: string; month: string; unit: string; amount: string }> { + return request({ url: `/v1/business/businesses/${id}/guidance`, method: 'put', data }) +} + export interface SourceQuantity { product_id: number | null product_name: string diff --git a/frontend/src/assets/styles/index.css b/frontend/src/assets/styles/index.css index a901b12..c27c482 100644 --- a/frontend/src/assets/styles/index.css +++ b/frontend/src/assets/styles/index.css @@ -211,15 +211,16 @@ body { .page-title { margin: 0; - font-size: 18px; + font-size: 20px; font-weight: 600; letter-spacing: -0.01em; color: var(--ys-ink); } .page-desc { - margin: 6px 0 0; - font-size: 12px; + margin: 8px 0 0; + font-size: 13px; + line-height: 1.7; letter-spacing: 0.01em; color: var(--ys-ink-3); } @@ -249,6 +250,7 @@ body { /* 卡片:纸白微渐变 + 半透明描边 + 双层柔影 */ .card { + min-width: 0; background: linear-gradient(180deg, var(--ys-paper), #fcfcfe); border: 1px solid var(--ys-line); border-radius: var(--ys-radius); @@ -257,6 +259,7 @@ body { .card-head { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; @@ -276,6 +279,8 @@ body { .card-foot { display: flex; + flex-wrap: wrap; + gap: 12px; align-items: center; justify-content: flex-end; padding: 10px 16px; @@ -299,6 +304,7 @@ body { } .filter-bar .el-form-item { + min-width: 0; margin-right: 0; margin-bottom: 0; } @@ -319,10 +325,20 @@ body { } .toolbar-note { + line-height: 1.7; font-size: 12px; color: var(--ys-ink-3); } +.summary-bar { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px 20px; + line-height: 1.7; + font-variant-numeric: tabular-nums; +} + /* 编号/代码类单元格:等宽字体,弱一档颜色 */ .cell-code { font-family: var(--ys-font-mono); @@ -388,13 +404,13 @@ span.cell-num { } .ys-pill--ok { - color: var(--ys-ok); + color: #18764f; background: linear-gradient(180deg, var(--ys-ok-wash), var(--ys-ok-wash-2)); border-color: rgba(30, 158, 106, 0.2); } .ys-pill--wait { - color: var(--ys-warn); + color: #895d12; background: linear-gradient(180deg, var(--ys-warn-wash), var(--ys-warn-wash-2)); border-color: rgba(201, 138, 27, 0.22); } @@ -406,7 +422,7 @@ span.cell-num { } .ys-pill--reject { - color: var(--ys-danger); + color: #ac3d3d; background: linear-gradient(180deg, var(--ys-danger-wash), var(--ys-danger-wash-2)); border-color: rgba(201, 79, 79, 0.2); } @@ -569,7 +585,7 @@ span.cell-num { padding: 10px 0; font-size: 12px; font-weight: 500; - letter-spacing: 0.04em; + letter-spacing: 0.01em; background-color: transparent; background-image: linear-gradient(180deg, #fafbfc, #f5f6f9); } @@ -589,6 +605,39 @@ span.cell-num { line-height: 1.5; } +.el-table th.el-table__cell .cell { + word-break: normal; + overflow-wrap: break-word; +} + +.el-table td.is-right .cell, +.el-descriptions__content, +.el-statistic__content { + font-variant-numeric: tabular-nums; +} + +.el-table .el-input__inner { + min-width: 0; +} + +.el-table .el-table__empty-block { + min-height: 144px; +} + +.el-table .el-table__empty-text { + width: min(100%, 440px); + padding: 24px; + line-height: 1.8; +} + +.el-table .el-scrollbar__bar.is-horizontal { opacity: 1; height: 8px; } + +.el-alert--info.is-light { color: var(--ys-ink-2); } +.el-alert--warning.is-light { color: #895d12; } +.el-alert--success.is-light { color: #18764f; } +.el-alert--error.is-light { color: #ac3d3d; } +.el-alert .el-alert__description { color: inherit; line-height: 1.7; } + .el-table__body tr:hover > td.el-table__cell { transition: background-color 120ms var(--ys-ease); } @@ -634,7 +683,19 @@ span.cell-num { } .el-drawer__body { + min-height: 0; padding: 18px 20px; + overscroll-behavior: contain; +} + +.el-drawer__footer { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; + padding: 16px 20px; + border-top: 1px solid var(--ys-line-soft); + background: var(--ys-mist); } /* 弹窗共享同一组留白;长表单在正文内滚动,操作按钮始终可见。 */ @@ -654,7 +715,8 @@ span.cell-num { .el-dialog__header { flex-shrink: 0; margin-right: 0; - padding: 20px 52px 16px 24px; + padding: 20px 56px 18px 24px; + border-bottom: 1px solid var(--ys-line-soft); } .el-dialog__title { @@ -662,6 +724,7 @@ span.cell-num { font-size: 16px; font-weight: 600; line-height: 1.5; + overflow-wrap: anywhere; } .el-dialog__headerbtn { @@ -673,10 +736,11 @@ span.cell-num { .el-dialog__body { min-height: 0; - padding: 4px 24px 20px; + padding: 20px 24px; overflow-y: auto; overscroll-behavior: contain; color: var(--ys-ink-2); + line-height: 1.7; } .el-dialog__footer { @@ -703,6 +767,13 @@ span.cell-num { .el-message-box { max-width: min(var(--el-messagebox-width, 420px), calc(100vw - 32px)); + padding: 22px; + border-radius: var(--ys-radius); +} + +.el-message-box__message { + overflow-wrap: anywhere; + line-height: 1.8; } .el-message-box__btns { @@ -726,6 +797,72 @@ span.cell-num { line-height: 1.7; } +/* 表单分组用于长档案;说明保持在控件外,输入后仍可查阅。 */ +.form-section-title { + margin: 24px 0 16px; + padding-top: 20px; + border-top: 1px solid var(--ys-line-soft); + color: var(--ys-ink); + font-size: 14px; + font-weight: 600; +} + +.form-section-title:first-child { + margin-top: 0; + padding-top: 0; + border-top: 0; +} + +.el-dialog__body > .el-form .el-form-item__label { + height: auto; + min-height: 32px; + align-items: center; + line-height: 1.5; +} + +.el-dialog__body .el-form-item__content { + min-width: 0; +} + +.el-dialog__body .form-hint { flex-basis: 100%; } + +.el-dialog__body .el-form-item .el-select, +.el-dialog__body .el-form-item .el-date-editor, +.el-drawer__body .el-form-item .el-select { + max-width: 100%; +} + +.el-dialog__body .el-checkbox, +.el-drawer__body .el-checkbox { + max-width: 100%; +} + +.el-dialog__body .el-checkbox__label, +.el-drawer__body .el-checkbox__label { + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.6; +} + +::selection { + color: var(--ys-ink); + background: var(--el-color-primary-light-8); +} + +input, textarea { caret-color: var(--ys-indigo); } + +:where(button, a, [tabindex]):focus-visible { + outline: 2px solid var(--ys-indigo); + outline-offset: 3px; +} + +.el-button.is-link.el-button--warning { --el-button-text-color: #895d12; } +.el-button.is-link.el-button--success { --el-button-text-color: #18764f; } +.el-button.is-link.el-button--danger { --el-button-text-color: #ac3d3d; } +.el-tag--success { --el-tag-text-color: #18764f; } +.el-tag--warning { --el-tag-text-color: #895d12; } +.el-tag--danger { --el-tag-text-color: #ac3d3d; } + /* 长字段标签由本行撑开高度,窄屏切到控件上方,避免换行侵入下一行。 */ .record-form .el-form-item__label { height: auto; @@ -754,7 +891,7 @@ span.cell-num { } .el-dialog__body { - padding: 4px 16px 16px; + padding: 16px; } .el-dialog__footer { @@ -764,6 +901,74 @@ span.cell-num { .el-dialog__footer > .el-button { min-height: 40px; } + + .filter-bar { + align-items: stretch; + gap: 12px; + padding: 16px; + } + + .filter-bar .el-form-item { + display: block; + flex: 1 1 100%; + max-width: 100%; + } + + .filter-bar .el-form-item__label, + .el-dialog__body .el-form:not(.el-form--inline) .el-form-item__label { + display: block; + width: auto !important; + min-height: 0; + height: auto; + padding: 0 0 6px; + line-height: 1.5; + text-align: left; + } + + .filter-bar .el-form-item__content, + .el-dialog__body .el-form:not(.el-form--inline) .el-form-item__content { + margin-left: 0 !important; + min-width: 0; + } + + .filter-bar .el-form-item__content > .el-input, + .filter-bar .el-form-item__content > .el-select, + .filter-bar .el-form-item__content > .el-date-editor { + width: 100% !important; + min-width: 0; + } + + .filter-bar__actions { flex-wrap: wrap; width: 100%; margin-left: 0; } + .filter-bar__actions > .el-button { flex: 1 1 auto; min-height: 38px; } + .toolbar { gap: 8px; } + .toolbar-note { flex-basis: 100%; } + .card-foot { padding: 12px; } + .el-dialog__body .el-form:not(.el-form--inline) .el-form-item { display: block; } + .el-dialog__body .el-col-12 { max-width: 100%; flex: 0 0 100%; } + .el-dialog__body .el-checkbox { height: auto; min-height: 34px; } + .el-drawer__header { padding: 16px; } + .el-drawer__body { padding: 16px; } + .el-drawer__footer { padding: 12px 16px; } + + .el-picker__popper.el-popper { max-width: calc(100vw - 24px); } + .el-picker__popper.el-popper:has(.el-date-range-picker) { + position: fixed !important; + inset: auto 12px 12px !important; + transform: none !important; + } + .el-picker__popper:has(.el-date-range-picker) .el-popper__arrow { display: none; } + .el-date-range-picker { width: min(646px, calc(100vw - 24px)); max-height: 70dvh; overflow-y: auto; } + .el-date-range-picker .el-picker-panel__body { min-width: 0; display: block; } + .el-date-range-picker__content { display: block; width: 100%; padding: 12px; } + .el-date-range-picker__content.is-left { border-right: 0; border-bottom: 1px solid var(--ys-line-soft); } + .el-date-range-picker__time-header { display: flex; flex-wrap: wrap; gap: 8px; } + .el-date-range-picker__time-header > .el-date-range-picker__editors-wrap { width: 100%; } + + /* 手机依旧横向查看完整数据,操作列随表滚动,避免遮住整张表。 */ + .el-table .el-table__cell.el-table-fixed-column--right, + .el-table .el-table__cell.el-table-fixed-column--left { position: static !important; } + .el-table .el-table-fixed-column--right::before, + .el-table .el-table-fixed-column--left::before { display: none; } } /* 空状态:弱墨、留白,不喊叫 */ diff --git a/frontend/src/components/coefficients/CoefficientPanel.vue b/frontend/src/components/coefficients/CoefficientPanel.vue index b62d84a..ce3f131 100644 --- a/frontend/src/components/coefficients/CoefficientPanel.vue +++ b/frontend/src/components/coefficients/CoefficientPanel.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/components/layout/AppMenu.vue b/frontend/src/components/layout/AppMenu.vue index a4d341c..bdc5166 100644 --- a/frontend/src/components/layout/AppMenu.vue +++ b/frontend/src/components/layout/AppMenu.vue @@ -9,7 +9,7 @@ const { topLevelMenus, groupedMenus } = useAppMenu()