From b3de35c2eceb7a2f16a36c29573c99b5159ddc4d Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Fri, 6 Nov 2026 16:27:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=A0=B8=E7=AE=97):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E6=8A=B5=E9=A1=B6=E5=8F=A3=E5=BE=84=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回款确认时间收敛到秒级;抵顶回款不再自动折算并保留标记。补齐公式模板角色标签与提示、指导成本登记入口及基础布局、分页、设置等响应式细节。同步忽略本地排查产物。 --- .gitignore | 10 + backend/.env.example | 4 + backend/app/schemas/calc_template.py | 8 +- backend/app/services/calc_service.py | 9 +- backend/app/services/calc_template_service.py | 9 +- backend/app/services/calc_templates.py | 18 +- backend/app/services/receipt_service.py | 2 +- backend/tests/README.md | 12 + backend/tests/integration/conftest.py | 4 +- backend/tests/integration/test_calc.py | 7 +- .../tests/integration/test_formal_payroll.py | 43 ++- .../integration/test_param_management.py | 12 +- .../integration/test_receipt_confirmation.py | 4 +- .../test_requirement_completion.py | 3 + backend/tests/integration/test_schema_sync.py | 31 +- backend/tests/unit/test_formal_payroll.py | 91 +++++ backend/tests/unit/test_formula.py | 6 + backend/tests/unit/test_models.py | 1 + backend/tests/unit/test_report_helpers.py | 6 + .../unit/test_source_price_authorization.py | 47 ++- .../tests/unit/test_tplus_business_sync.py | 17 +- docs/2026.9.8修改方案.docx | Bin 15815 -> 18391 bytes docs/T+本地恢复与同步开发手册.md | 2 +- ...¸对_辽宁兴晟达_2026-07_2026-11-02.md | 2 + frontend/README.md | 61 ++++ frontend/src/api/businessRoles.ts | 4 + frontend/src/api/businesses.ts | 4 + frontend/src/assets/styles/index.css | 225 +++++++++++- .../coefficients/CoefficientPanel.vue | 140 ++++++-- frontend/src/components/layout/AppMenu.vue | 2 +- .../src/components/layout/BasicLayout.vue | 13 +- .../src/components/payroll/ProbationPanel.vue | 15 +- .../products/ProductExtractDialog.vue | 74 +++- .../src/components/table/AppPagination.vue | 28 +- .../base/customers/RatingCalculatorDialog.vue | 3 + frontend/src/views/base/personnel/index.vue | 30 +- .../business/businesses/PayrollDialog.vue | 33 +- frontend/src/views/calc/ledger/index.vue | 10 +- frontend/src/views/dashboard/index.vue | 236 +++++++------ frontend/src/views/login/index.vue | 18 +- frontend/src/views/no-access/index.vue | 28 +- .../reports/department-summary/index.vue | 12 +- .../src/views/reports/payment-stats/index.vue | 6 +- .../views/reports/personal-detail/index.vue | 8 +- frontend/src/views/reports/print/index.vue | 11 + frontend/src/views/settlements/index.vue | 11 +- frontend/src/views/system/approvals/index.vue | 8 +- .../src/views/system/business-roles/index.vue | 54 ++- frontend/src/views/system/logs/index.vue | 3 + frontend/src/views/system/params/index.vue | 324 +++++++++++++----- frontend/src/views/system/roles/index.vue | 13 +- frontend/src/views/system/settings/index.vue | 8 + frontend/src/views/system/users/index.vue | 13 +- 53 files changed, 1381 insertions(+), 362 deletions(-) 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 6b96bda11ea804ea33e102bf5010924152c968d3..cdc1f3fa8de2384a7adc84c24435d21b7163e00e 100644 GIT binary patch literal 18391 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 G^#1`pd}!7H 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()