From aa965da69d1b83635d876cfd98f656e93007cdb9 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Mon, 22 Jun 2026 15:55:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E6=8A=A5=E9=94=80=E5=8D=95?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E5=B7=A5=E5=8F=B7/=E9=82=AE=E7=AE=B1?= =?UTF-8?q?=E5=B9=B6=E6=89=A9=E5=B1=95=E7=94=B3=E8=AF=B7=E4=BA=BA=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E5=89=8D=E7=BC=80=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExpenseClaimRead 新增 employee_no/employee_email 字段,ExpenseClaim 模型补对应只读属性 - expense_claim_access_policy 在姓名匹配未果时,按 candidate@% 邮箱前缀匹配 Employee.email,命中唯一记录即返回 - test_backend_pagination/test_expense_claim_service 补充工号/邮箱字段断言与邮箱前缀匹配用例 - 更新公司通信费报销规则表 --- .../finance-rules/公司通信费报销规则.xlsx | Bin 5933 -> 5933 bytes server/src/app/models/financial_record.py | 8 ++++ server/src/app/schemas/reimbursement.py | 2 + .../services/expense_claim_access_policy.py | 14 ++++++ server/tests/test_backend_pagination.py | 2 + server/tests/test_expense_claim_service.py | 44 ++++++++++++++++++ 6 files changed, 70 insertions(+) diff --git a/server/rules/finance-rules/公司通信费报销规则.xlsx b/server/rules/finance-rules/公司通信费报销规则.xlsx index 8bb6e968a6fc6bb2b1c7d14e7d1a3c4f8525d7e6..403644dbb976da8913127205a95614d452f9af03 100644 GIT binary patch delta 408 zcmZ3hw^olQz?+#xgn@y9gCWFdBF{mN5Tk1`mzj39O}wg5?|0aMr!7A9)!x!s3%Oil z^_=H8I3)>nL_evBcDd}<8Mu4}c~QN?DG`AUh-GM?|TU)qkR+c+5f$m3%P>fo~3xUr4b=a7xgu{UeB?9e#uo|D$- zx}eQw>zoBTUOP7VT&!PiFa0buL(7Be$uj!4TSj+h&WdLrft08N&rmjD0& delta 408 zcmZ3hw^olQz?+#xgn@y9gJGKJM4p2j(?qYubRXT2Kk=$Ueb!+Ep0@aU;F=Za!>XbuHO#+3%`rp2#V=l_jZxf zeo^(b^$be$PQ09Ib9Z3O6gKgt|2f|3 z7;s#fw75@sN+qZHG|l=QKhh7+(Rm{A)`|D<1pe1Ey``sq`1Rw1jsEFetgY8tBxE7*J5VI%uyC#V_=xG`7@&l8`Ct=&6*s~SwOT9 zuMsnlp6n*@9K_WZY-R*;w+KB3ac2tqfVs~_Y~Z0dSzFW`q$EYu9z-n_bpcVYMJ+*; VniyCvUd$TAn str | None: return str(self.employee.position).strip() if self.employee is not None and self.employee.position else None + @property + def employee_no(self) -> str | None: + return str(self.employee.employee_no).strip() if self.employee is not None and self.employee.employee_no else None + + @property + def employee_email(self) -> str | None: + return str(self.employee.email).strip() if self.employee is not None and self.employee.email else None + @property def employee_grade(self) -> str | None: return str(self.employee.grade).strip() if self.employee is not None and self.employee.grade else None diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index fe60739..d33c9f6 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -144,6 +144,8 @@ class ExpenseClaimRead(BaseModel): claim_no: str employee_id: str | None employee_name: str + employee_no: str | None = None + employee_email: str | None = None department_id: str | None department_name: str employee_position: str | None = None diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index ce56e2f..5590666 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -509,6 +509,20 @@ class ExpenseClaimAccessPolicy: if employee is not None: return employee + for candidate in normalized_candidates: + if self.is_email_like(candidate): + continue + matches = list( + self.db.scalars( + select(Employee) + .options(*load_options) + .where(func.lower(Employee.email).like(f"{candidate.lower()}@%")) + .limit(2) + ).all() + ) + if len(matches) == 1: + return matches[0] + for candidate in normalized_candidates: matches = list( self.db.scalars( diff --git a/server/tests/test_backend_pagination.py b/server/tests/test_backend_pagination.py index c01ec15..57280db 100644 --- a/server/tests/test_backend_pagination.py +++ b/server/tests/test_backend_pagination.py @@ -105,6 +105,8 @@ def test_expense_claims_support_page_envelope_and_keep_legacy_list() -> None: assert payload["total_pages"] == 2 assert payload["has_next"] is True assert payload["has_previous"] is False + assert payload["items"][0]["employee_no"] == "E-PAGE" + assert payload["items"][0]["employee_email"] == "page-user@example.com" def test_employee_directory_supports_backend_pagination() -> None: diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 80d2a51..e911bcf 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -4301,6 +4301,50 @@ def test_list_claims_scopes_to_current_user_id_even_when_names_duplicate() -> No assert claims[0].claim_no == "EXP-DUP-001" +def test_list_claims_resolves_short_username_to_unique_employee_email_prefix() -> None: + current_user = CurrentUserContext( + username="caoxiaozhu", + name="caoxiaozhu", + role_codes=["employee"], + is_admin=False, + ) + + with build_session() as db: + employee = Employee( + employee_no="E90919", + name="曹笑竹", + email="caoxiaozhu@xf.com", + ) + db.add(employee) + db.flush() + db.add( + ExpenseClaim( + claim_no="AP-SHORT-USERNAME-001", + employee_id=employee.id, + employee_name=employee.name, + department_name="研发部", + project_code="PRJ-APP", + expense_type="travel_application", + reason="客户现场实施", + location="北京", + amount=Decimal("1600.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 6, 23, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 6, 23, 10, 0, tzinfo=UTC), + status="approved", + approval_stage=APPROVAL_DONE_STAGE, + risk_flags_json=[], + ) + ) + db.commit() + + claims = ExpenseClaimService(db).list_claims(current_user) + + assert len(claims) == 1 + assert claims[0].claim_no == "AP-SHORT-USERNAME-001" + + def test_list_claims_limits_finance_to_personal_records() -> None: current_user = CurrentUserContext( username="finance@example.com",