Support employee company assignments and unassigned cashiers

This commit is contained in:
leefer
2026-09-23 23:21:15 +08:00
parent 95a7f5f6ff
commit 8ceb157e5e
12 changed files with 527 additions and 69 deletions
@@ -0,0 +1,13 @@
# 员工与公司分配
门户决定员工是否可以进入财务系统;财务本地角色区分管理端和公司端。公司端员工可以暂未分配公司,一名出纳可负责多家公司,一家公司可分配多名出纳。
管理端“公司与账号”通过姓名和工号选择出纳。候选人必须同时满足:本地公司端角色且启用、门户仍授权财务系统、双方不可变身份绑定一致。门户不可用时拒绝保存分配,不使用过期名单继续授权。
`company_memberships` 是公司操作范围的唯一依据。`users.company_id` 仅保留兼容信息;公司业务接口每次请求都从分配表解析当前公司。多公司员工必须提交明确的公司范围,未分配、越权和已撤销范围均拒绝。CSV 下载也进行同样校验。浏览器按标签页保存所选公司,切换时确认并重载以清空未提交内容。
分配调整记录原员工和新员工 ID,不转移或重写历史业务记录。部门或岗位名称不自动授予财务管理权限。
迁移 11 保留原用户 ID、密码、会话及业务记录,把既有单公司关联迁入分配表。迁移禁止新增外键错误;已有孤立审计引用保持原样,不借升级重写历史。存在多公司或未分配公司员工时,回滚到旧结构会明确拒绝,需先制定人员迁移方案或恢复部署前备份。
验证包含真实 HTTP 的未分配拒绝、多公司范围、同公司多人、旧会话撤权、导出越权、员工停用、门户身份不匹配及迁移历史保留;另有完整回归测试和隔离页面检查。
+61 -2
View File
@@ -20,6 +20,7 @@ from bank_importer import (
)
from bank_importer.db import connect, migrate, transaction, utc_now
from bank_importer import portal_sso
from bank_importer import company_access
ROOT = Path(__file__).resolve().parent
@@ -78,6 +79,9 @@ class AppHandler(portal_sso.PortalMixin, SimpleHTTPRequestHandler):
if path == "/api/admin/companies":
self._handle_admin_companies()
return
if path == "/api/admin/company-cashiers":
self._handle_company_cashiers()
return
if path == "/api/admin/users":
self._handle_admin_users()
return
@@ -495,6 +499,14 @@ class AppHandler(portal_sso.PortalMixin, SimpleHTTPRequestHandler):
{"status": "error", "message": "首次登录须修改密码后才能继续操作。"},
)
return None
if user['role'] == 'company' and not pending_password_ok:
try:
query = parse_qs(urlparse(self.path).query)
selected = self.headers.get('X-Company-ID') or (query.get('_company_scope') or [None])[0]
user = company_access.scoped_user(connection, user, selected)
except ValueError as exc:
self._send_json(403, {'status': 'error', 'code': 'company_scope_required', 'message': str(exc)})
return None
return user
def _require_admin(self, connection):
@@ -628,6 +640,13 @@ class AppHandler(portal_sso.PortalMixin, SimpleHTTPRequestHandler):
connection.close()
def _user_payload(self, connection, user) -> dict[str, object]:
companies = company_access.companies_for(connection, user['id']) if user['role'] == 'company' else []
if user['role'] == 'company':
selected = self.headers.get('X-Company-ID')
chosen = next((c for c in companies if str(c['id']) == selected), None)
if chosen is None and len(companies) == 1:
chosen = companies[0]
user = {**dict(user), 'company_id': chosen['id'] if chosen else None}
company_name = None
if user["company_id"] is not None:
company = connection.execute(
@@ -641,6 +660,8 @@ class AppHandler(portal_sso.PortalMixin, SimpleHTTPRequestHandler):
"must_change_password": bool(user["must_change_password"]) and not portal_sso.enabled(),
"company_id": user["company_id"],
"company_name": company_name,
"companies": companies,
"display_name": user['display_name'],
}
# ------------------------------------------------------------------
@@ -1140,13 +1161,18 @@ class AppHandler(portal_sso.PortalMixin, SimpleHTTPRequestHandler):
(SELECT COUNT(*) FROM bank_accounts a WHERE a.company_id = c.id)
AS account_count,
(SELECT GROUP_CONCAT(u.username, '、') FROM users u
WHERE u.company_id = c.id AND u.role = 'company')
JOIN company_memberships m ON m.user_id=u.id
WHERE m.company_id = c.id AND u.role = 'company' AND u.status='active')
AS usernames
FROM companies c
ORDER BY c.id
"""
).fetchall()
self._send_json(200, {"status": "ok", "companies": [dict(row) for row in rows]})
companies = [dict(row) for row in rows]
for company in companies:
company['cashier_user_ids'] = [r['user_id'] for r in connection.execute(
'SELECT user_id FROM company_memberships WHERE company_id=? ORDER BY user_id', (company['id'],))]
self._send_json(200, {"status": "ok", "companies": companies})
finally:
connection.close()
@@ -1164,6 +1190,9 @@ class AppHandler(portal_sso.PortalMixin, SimpleHTTPRequestHandler):
if portal_sso.enabled() and username:
self._send_json(403, {"message": "员工账号请在统一门户建立,再配置财务权限绑定。"})
return
if 'cashier_user_ids' in data:
self._save_company_members(connection, user, data)
return
try:
company_id = master_data.create_company(
connection,
@@ -1221,6 +1250,36 @@ class AppHandler(portal_sso.PortalMixin, SimpleHTTPRequestHandler):
finally:
connection.close()
def _handle_company_cashiers(self):
connection = connect(DB_PATH)
try:
if self._require_admin(connection) is None:
return
try:
employees = company_access.candidates(connection)
except Exception:
self._send_json(503, {'message': '暂时无法核对门户员工权限,请稍后重试。'})
return
self._send_json(200, {'employees': employees})
finally:
connection.close()
def _save_company_members(self, connection, user, data):
try:
available = company_access.candidates(connection)
except Exception:
self._send_json(503, {'message': '暂时无法核对门户员工权限,未保存更改。'})
return
try:
cid = data.get('id')
if cid is not None and type(cid) is not int:
raise ValueError('公司编号无效。')
cid = company_access.save_company(connection, cid, data, user, available)
except ValueError as exc:
self._send_json(400, {'message': str(exc)})
return
self._send_json(200, {'status': 'ok', 'company_id': cid})
# ------------------------------------------------------------------
# Bank account master data
# ------------------------------------------------------------------
+6 -2
View File
@@ -106,6 +106,7 @@ def create_user(
role: str,
company_id: int | None = None,
must_change_password: bool = True,
allow_unassigned: bool = False,
) -> int:
"""Create a user, enforcing the role/company binding rules. Returns the id."""
username = username.strip()
@@ -114,12 +115,12 @@ def create_user(
if role not in ("admin", "company"):
raise ValueError("角色必须是 admin 或 company。")
if role == "company":
if company_id is None:
if company_id is None and not allow_unassigned:
raise ValueError("公司账号必须绑定公司。")
company = connection.execute(
"SELECT id FROM companies WHERE id = ?", (company_id,)
).fetchone()
if company is None:
if company_id is not None and company is None:
raise ValueError("绑定的公司不存在。")
elif company_id is not None:
raise ValueError("管理员账号不能绑定公司。")
@@ -144,6 +145,9 @@ def create_user(
now,
),
)
if role == 'company' and company_id is not None:
connection.execute('INSERT INTO company_memberships(company_id,user_id,assigned_at) VALUES(?,?,?)',
(company_id, cursor.lastrowid, now))
except sqlite3.IntegrityError as exc:
raise ValueError("用户名已存在。") from exc
return int(cursor.lastrowid)
+92
View File
@@ -0,0 +1,92 @@
"""Explicit many-to-many company assignments; no company means no business access."""
import json
from . import auth, master_data, portal_sso
from .db import transaction, utc_now
def companies_for(connection, user_id):
return [dict(r) for r in connection.execute(
"SELECT c.id,c.name FROM company_memberships m JOIN companies c ON c.id=m.company_id "
"WHERE m.user_id=? AND c.status='active' ORDER BY c.id", (user_id,))]
def scoped_user(connection, user, selected=None):
if user['role'] != 'company':
return user
companies = companies_for(connection, user['id'])
ids = {str(c['id']) for c in companies}
if selected is not None and selected not in ids:
raise ValueError('该公司的操作权限已失效,请重新选择公司。')
if selected is None:
if not companies:
raise ValueError('尚未分配负责公司,请联系财务管理员。')
if len(companies) > 1:
raise ValueError('请先选择本次操作的公司。')
selected = str(companies[0]['id'])
return {**dict(user), 'company_id': int(selected)}
def candidates(connection):
rows = [dict(r) for r in connection.execute(
"SELECT id,username,display_name FROM users WHERE role='company' AND status='active' ORDER BY username")]
if not portal_sso.enabled():
return rows
# Read current portal grants rather than trusting an old synchronized flag.
identities = portal_sso.call('directory', {})['employees']
eligible = {}
for identity in identities:
pin = connection.execute('SELECT empid FROM portal_identity WHERE uid=?', (identity['local_uid'],)).fetchone()
if pin and pin['empid'] == identity['empid']:
eligible[identity['local_uid']] = identity
return [{**r, 'username': eligible[r['id']]['username'],
'display_name': eligible[r['id']]['display_name']} for r in rows if r['id'] in eligible]
def validate_members(value, available):
if not isinstance(value, list) or any(type(i) is not int for i in value):
raise ValueError('请选择有效的出纳员工。')
ids = set(value)
if len(ids) != len(value) or not ids.issubset({u['id'] for u in available}):
raise ValueError('所选员工已停用、未获财务公司端权限或身份绑定无效,请刷新后重选。')
return sorted(ids)
def replace_members(connection, company_id, ids, actor):
with transaction(connection):
before = [r['user_id'] for r in connection.execute(
'SELECT user_id FROM company_memberships WHERE company_id=? ORDER BY user_id', (company_id,))]
connection.execute('DELETE FROM company_memberships WHERE company_id=?', (company_id,))
connection.executemany('INSERT INTO company_memberships(company_id,user_id,assigned_at,assigned_by) VALUES(?,?,?,?)',
[(company_id, uid, utc_now(), actor['id']) for uid in ids])
# Legacy column is informational only; all company-side requests use memberships.
for uid in set(before) | set(ids):
connection.execute('UPDATE users SET company_id=(SELECT MIN(company_id) FROM company_memberships WHERE user_id=?) WHERE id=?', (uid, uid))
if before != ids:
auth.audit(connection, 'company_members_changed', actor=actor, target=f'company:{company_id}',
detail=json.dumps({'before': before, 'after': ids}))
def save_company(connection, company_id, data, actor, available):
ids = validate_members(data.get('cashier_user_ids', []), available)
name = str(data.get('name') or '').strip()
if not name:
raise ValueError('公司名称不能为空。')
credit = str(data.get('credit_code') or '').strip()
names = '、'.join(u['display_name'] or u['username'] for u in available if u['id'] in ids)
with transaction(connection):
if company_id is None:
company_id = master_data.create_company(connection, name, credit, names, actor)
else:
before = connection.execute('SELECT * FROM companies WHERE id=?', (company_id,)).fetchone()
if before is None:
raise ValueError('公司不存在。')
duplicate = connection.execute('SELECT id FROM companies WHERE name=? AND id<>?', (name, company_id)).fetchone()
if duplicate:
raise ValueError('公司名称已存在。')
connection.execute('UPDATE companies SET name=?,credit_code=?,cashier_name=?,updated_at=? WHERE id=?',
(name, credit, names, utc_now(), company_id))
after = connection.execute('SELECT * FROM companies WHERE id=?', (company_id,)).fetchone()
master_data.record_change(connection, 'company', company_id, 'update', dict(before), dict(after), '更新公司资料和负责出纳', actor)
replace_members(connection, company_id, ids, actor)
return company_id
+93
View File
@@ -1226,6 +1226,57 @@ MIGRATIONS: tuple[Migration, ...] = (
)
# Rebuild users without the old mandatory single-company constraint. IDs and
# all referenced history survive; membership rows become the authority.
MIGRATIONS += (Migration(
version=11, name="0011_employee_company_memberships",
up="""
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','company')),
company_id INTEGER REFERENCES companies(id),
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','disabled')),
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
CHECK(role != 'admin' OR company_id IS NULL)
);
INSERT INTO users_new(id,username,password_hash,role,company_id,status,must_change_password,created_at,updated_at)
SELECT id,username,password_hash,role,company_id,status,must_change_password,created_at,updated_at FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
CREATE TABLE company_memberships (
company_id INTEGER NOT NULL REFERENCES companies(id),
user_id INTEGER NOT NULL REFERENCES users(id),
assigned_at TEXT NOT NULL,
assigned_by INTEGER REFERENCES users(id),
PRIMARY KEY(company_id,user_id)
);
CREATE INDEX company_memberships_user ON company_memberships(user_id);
INSERT INTO company_memberships(company_id,user_id,assigned_at)
SELECT company_id,id,updated_at FROM users WHERE role='company' AND company_id IS NOT NULL;
""",
down="""
DROP TABLE company_memberships;
CREATE TABLE users_old (
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('admin','company')),
company_id INTEGER REFERENCES companies(id),
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','disabled')),
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
CHECK(role != 'company' OR company_id IS NOT NULL),
CHECK(role != 'admin' OR company_id IS NULL)
);
INSERT INTO users_old SELECT id,username,password_hash,role,company_id,status,must_change_password,created_at,updated_at FROM users;
DROP TABLE users;
ALTER TABLE users_old RENAME TO users;
""",
),)
def connect(path: str | Path) -> sqlite3.Connection:
db_path = Path(path)
if str(db_path) != ":memory:":
@@ -1270,6 +1321,26 @@ def migrate(connection: sqlite3.Connection) -> list[int]:
for migration in MIGRATIONS:
if migration.version in applied:
continue
if migration.version == 11:
# SQLite table rebuild: disable FKs outside the transaction and
# verify every preserved reference before committing the version.
connection.commit()
existing_fk_errors = {tuple(row) for row in connection.execute('PRAGMA foreign_key_check')}
connection.execute('PRAGMA foreign_keys=OFF')
try:
connection.executescript('BEGIN IMMEDIATE;\n' + migration.up)
if {tuple(row) for row in connection.execute('PRAGMA foreign_key_check')} - existing_fk_errors:
raise ValueError('Company membership migration found invalid references')
connection.execute('INSERT INTO schema_migrations(version,name,applied_at) VALUES(?,?,?)',
(migration.version, migration.name, utc_now()))
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.execute('PRAGMA foreign_keys=ON')
newly_applied.append(migration.version)
continue
with connection:
connection.executescript(migration.up)
connection.execute(
@@ -1287,6 +1358,28 @@ def rollback(connection: sqlite3.Connection, target_version: int = 0) -> list[in
for migration in sorted(MIGRATIONS, key=lambda item: item.version, reverse=True):
if migration.version <= target_version or migration.version not in applied:
continue
if migration.version == 11 and connection.execute(
"SELECT u.id FROM users u LEFT JOIN company_memberships m ON m.user_id=u.id "
"WHERE u.role='company' GROUP BY u.id HAVING COUNT(m.company_id)<>1"
).fetchone():
raise ValueError('Cannot roll back multi-company or unassigned employees without an explicit account migration')
if migration.version == 11:
connection.commit()
existing_fk_errors = {tuple(row) for row in connection.execute('PRAGMA foreign_key_check')}
connection.execute('PRAGMA foreign_keys=OFF')
try:
connection.executescript('BEGIN IMMEDIATE;\n' + migration.down)
if {tuple(row) for row in connection.execute('PRAGMA foreign_key_check')} - existing_fk_errors:
raise ValueError('Company membership rollback found invalid references')
connection.execute('DELETE FROM schema_migrations WHERE version=?', (migration.version,))
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.execute('PRAGMA foreign_keys=ON')
reversed_versions.append(migration.version)
continue
with connection:
connection.executescript(migration.down)
connection.execute(
+136
View File
@@ -0,0 +1,136 @@
"""Company assignments checked through real HTTP routes, including old sessions."""
import json
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
import server
from bank_importer import auth, company_access, master_data, portal_sso
from bank_importer.db import connect, migrate, MIGRATIONS, utc_now
from test_server_auth import Client, as_json
class CompanyMembershipTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name) / 'test.db'
self.db = connect(self.path)
self.addCleanup(self.db.close)
migrate(self.db)
self.admin = auth.create_user(self.db, 'manager', 'Password123', 'admin', must_change_password=False)
self.cashier = auth.create_user(self.db, '010013', 'Password123', 'company', allow_unassigned=True, must_change_password=False)
self.other = auth.create_user(self.db, '010015', 'Password123', 'company', allow_unassigned=True, must_change_password=False)
self.actor = self.db.execute('SELECT * FROM users WHERE id=?', (self.admin,)).fetchone()
self.a, self.b, self.c = [master_data.create_company(self.db, name, '', '', self.actor) for name in ['公司甲','公司乙','公司丙']]
self.env = patch.dict('os.environ', {'PORTAL_SSO_ENABLED':'0'})
self.env.start(); self.addCleanup(self.env.stop)
self.dbpatch = patch.object(server, 'DB_PATH', self.path)
self.dbpatch.start(); self.addCleanup(self.dbpatch.stop)
class Quiet(server.AppHandler):
def log_message(self, *args): pass
self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), Quiet)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.addCleanup(self.httpd.server_close); self.addCleanup(self.httpd.shutdown)
self.client = self.client_for(self.cashier)
self.manager = self.client_for(self.admin)
def client_for(self, uid):
client = Client('127.0.0.1', self.httpd.server_port)
client.cookies[server.SESSION_COOKIE] = auth.create_session(self.db, uid)
return client
def assign(self, cid, ids):
company_access.replace_members(self.db, cid, ids, self.actor)
def test_unassigned_can_see_identity_but_no_business_endpoints(self):
status, _, body = self.client.get('/api/me')
self.assertEqual(status, 200)
self.assertEqual(as_json(body)['companies'], [])
for route in ['/api/batches','/api/flows','/api/export.csv','/api/company/workspace', '/api/company/accounts', '/api/company/intercompany/export.csv']:
self.assertEqual(self.client.get(route)[0], 403, route)
self.assertEqual(self.client.post_json('/api/parse', {})[0], 403)
def test_multiple_companies_require_explicit_scope_and_reject_forged_scope(self):
self.assign(self.a,[self.cashier]); self.assign(self.b,[self.cashier])
me = as_json(self.client.get('/api/me')[2])
self.assertEqual([c['id'] for c in me['companies']], [self.a,self.b])
self.assertEqual(self.client.get('/api/batches')[0],403)
for cid in [self.a, self.b]:
self.assertEqual(self.client.request('GET','/api/batches',headers={'X-Company-ID':str(cid)})[0],200)
for route in ['/api/batches','/api/flows','/api/export.csv','/api/company/accounts']:
self.assertEqual(self.client.request('GET',route,headers={'X-Company-ID':str(self.c)})[0],403)
self.assertEqual(self.client.get(f'/api/export.csv?_company_scope={self.c}')[0],403)
self.assertEqual(self.client.get(f'/api/export.csv?_company_scope={self.b}')[0],200)
def test_two_cashiers_and_revocation_apply_to_existing_session(self):
self.assign(self.a,[self.cashier,self.other])
other = self.client_for(self.other)
self.assertEqual(self.client.get('/api/batches')[0],200)
self.assertEqual(other.get('/api/batches')[0],200)
self.assign(self.a,[self.other])
self.assertEqual(self.client.get('/api/batches')[0],403)
self.assertEqual(other.get('/api/batches')[0],200)
audit = self.db.execute("SELECT detail FROM audit_log WHERE action='company_members_changed' ORDER BY id DESC").fetchone()
self.assertEqual(json.loads(audit['detail'])['before'], [self.cashier,self.other])
def test_company_admin_assignment_is_atomic_and_role_limited(self):
payload = {'name':'新公司','credit_code':'','cashier_user_ids':[self.cashier,self.other]}
self.assertEqual(self.client.post_json('/api/admin/companies',payload)[0],403)
status,_,body = self.manager.post_json('/api/admin/companies',payload)
self.assertEqual(status,200)
cid=as_json(body)['company_id']
self.assertEqual(len(self.db.execute('SELECT * FROM company_memberships WHERE company_id=?',(cid,)).fetchall()),2)
payload.update(id=cid,name='新公司改名',cashier_user_ids=[self.admin])
self.assertEqual(self.manager.post_json('/api/admin/companies',payload)[0],400)
self.assertEqual(self.db.execute('SELECT name FROM companies WHERE id=?',(cid,)).fetchone()[0],'新公司')
payload['cashier_user_ids']=[]
self.assertEqual(self.manager.post_json('/api/admin/companies',payload)[0],200)
def test_disabled_employee_cannot_be_assigned_and_disabled_company_denies_access(self):
with self.db: self.db.execute("UPDATE users SET status='disabled' WHERE id=?",(self.other,))
status,_,_=self.manager.post_json('/api/admin/companies',{'id':self.a,'name':'公司甲','cashier_user_ids':[self.other]})
self.assertEqual(status,400)
self.assign(self.a,[self.cashier])
with self.db: self.db.execute("UPDATE companies SET status='disabled' WHERE id=?",(self.a,))
self.assertEqual(self.client.get('/api/batches')[0],403)
def test_portal_directory_requires_matching_pinned_identity(self):
portal_sso.migrate(self.db)
with self.db: self.db.execute('INSERT INTO portal_identity(uid,empid) VALUES(?,?)',(self.cashier,'correct'))
with patch.object(portal_sso,'enabled',return_value=True), patch.object(portal_sso,'call',return_value={'employees':[
{'local_uid':self.cashier,'empid':'wrong','username':'010013','display_name':'某出纳'}]}):
self.assertEqual(company_access.candidates(self.db),[])
class MembershipMigrationTests(unittest.TestCase):
def test_upgrade_preserves_preexisting_orphan_audit_instead_of_rewriting_history(self):
db=connect(':memory:'); self.addCleanup(db.close)
with patch('bank_importer.db.MIGRATIONS', MIGRATIONS[:-1]): migrate(db)
db.execute('PRAGMA foreign_keys=OFF')
with db: db.execute("INSERT INTO audit_log(actor_user_id,actor_username,action,created_at) VALUES(99,'former-employee','history',?)",(utc_now(),))
db.execute('PRAGMA foreign_keys=ON')
before=[tuple(r) for r in db.execute('PRAGMA foreign_key_check')]
self.assertEqual(len(before),1)
migrate(db)
self.assertEqual([tuple(r) for r in db.execute('PRAGMA foreign_key_check')],before)
self.assertEqual(db.execute('SELECT actor_username FROM audit_log').fetchone()[0],'former-employee')
def test_upgrade_keeps_ids_passwords_sessions_and_audit_history(self):
db=connect(':memory:')
self.addCleanup(db.close)
with patch('bank_importer.db.MIGRATIONS', MIGRATIONS[:-1]): migrate(db)
now=utc_now()
with db:
db.execute('INSERT INTO companies(id,name,created_at,updated_at) VALUES(7,?,?,?)',('历史公司',now,now))
db.execute("INSERT INTO users(id,username,password_hash,role,company_id,created_at,updated_at) VALUES(9,'old','unchanged','company',7,?,?)",(now,now))
db.execute("INSERT INTO audit_log(actor_user_id,actor_username,action,created_at) VALUES(9,'old','history',?)",(now,))
token=auth.create_session(db,9)
migrate(db)
self.assertEqual(auth.resolve_session(db,token)['id'],9)
self.assertEqual(db.execute('SELECT password_hash FROM users WHERE id=9').fetchone()[0],'unchanged')
self.assertEqual(company_access.companies_for(db,9),[{'id':7,'name':'历史公司'}])
self.assertEqual(db.execute('PRAGMA foreign_key_check').fetchall(),[])
self.assertEqual(migrate(db),[])
+5 -5
View File
@@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase):
class MigrationTests(PersistenceTestCase):
def test_migrate_creates_schema_and_is_idempotent(self) -> None:
first = applied_versions(self.connection)
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], first)
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], first)
self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection))
tables = {
@@ -98,14 +98,14 @@ class MigrationTests(PersistenceTestCase):
self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([10, 9, 8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([], applied_versions(self.connection))
remaining = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
).fetchone()
self.assertIsNone(remaining)
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], applied_versions(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], applied_versions(self.connection))
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample()
@@ -113,7 +113,7 @@ class MigrationTests(PersistenceTestCase):
"SELECT COUNT(*) AS n FROM source_rows"
).fetchone()["n"]
self.assertGreater(row_count, 0)
self.assertEqual([10, 9, 8, 7, 6, 5], rollback(self.connection, 4))
self.assertEqual([11, 10, 9, 8, 7, 6, 5], rollback(self.connection, 4))
# The pre-migration evidence and schema are untouched.
self.assertEqual(
row_count,
+1 -1
View File
@@ -477,7 +477,7 @@ class MigrationTests(unittest.TestCase):
versions = connection.execute(
"SELECT version FROM schema_migrations ORDER BY version"
).fetchall()
self.assertEqual(10, versions[-1]["version"])
self.assertEqual(11, versions[-1]["version"])
connection.execute("DELETE FROM schema_migrations WHERE version = 10")
connection.executescript(
"""
+8 -20
View File
@@ -475,7 +475,7 @@
<div class="page-head">
<div>
<h1>公司与账号</h1>
<p class="page-sub">集团成员公司的档案、银行账户与登录账号管理。公司新登记的银行账户须经审核中心确认后方可参与流水归集。</p>
<p class="page-sub">管理成员公司资料及负责出纳。一家公司可分配多名出纳,每名出纳可负责多家公司。新登记的银行账户仍须审核。</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" id="openCompanyDialog">新增公司</button>
@@ -493,7 +493,7 @@
<div class="card">
<div class="card-head">
<span class="card-title">成员公司<span class="sub">数据截至 2026-08-20</span></span>
<span class="card-title">成员公司</span>
</div>
<div class="table-wrap" style="border: 0;">
<table class="ds-table" id="companyTable">
@@ -501,7 +501,7 @@
<tr>
<th>公司名称</th>
<th class="num-col">银行账户数</th>
<th>登录账号</th>
<th>负责员工工号</th>
<th>出纳</th>
<th>启用状态</th>
<th>操作</th>
@@ -512,7 +512,7 @@
</div>
<div class="table-foot" style="border: 1px solid var(--border); border-radius: 0 0 var(--radius-lg) var(--radius-lg);">
<span id="companyFoot">共 0 家公司</span>
<span class="meta">创建账号时生成一次性随机密码,仅当时显示一次</span>
<span class="meta">员工账号统一在门户管理,公司操作范围在此分配</span>
</div>
</div>
</section>
@@ -876,7 +876,7 @@
<span class="modal-title">新增成员公司</span>
<button type="button" class="modal-close" data-close="companyDialog" aria-label="关闭">×</button>
</div>
<p class="modal-sub">登记后可同时生成公司端登录账号。系统会发一次性随机初始密码,仅创建成功时显示一次,请当场复制后交给出纳;首次登录必须改密。没有全公司通用默认密码。</p>
<p class="modal-sub">选择已获财务公司端权限的员工。保存后生效;移除员工会收回其对本公司的操作权限,历史记录保留。</p>
<form id="companyForm" novalidate>
<div class="field" style="margin-bottom: 12px;">
<label for="f-name">公司全称</label>
@@ -886,23 +886,11 @@
<label for="f-code">统一社会信用代码</label>
<input class="input num-input" id="f-code" name="creditCode" type="text" maxlength="18" placeholder="18 位,如 91410100MA9K2X417A" />
</div>
<label class="row" style="gap: 6px; font-size: 13px; margin-bottom: 12px; cursor: pointer;">
<input type="checkbox" name="createUser" checked style="width: auto;" />同时创建公司账号
</label>
<div class="grid grid-2">
<div class="field">
<label for="f-login">登录账号</label>
<input class="input" id="f-login" name="loginName" type="text" required />
</div>
<div class="field">
<label for="f-cashier">出纳人员</label>
<input class="input" id="f-cashier" name="cashier" type="text" required />
</div>
</div>
<p class="hint" style="margin-top: 10px;">创建成功后弹出一次性口令窗口,可复制账号和密码。关闭后无法再次查看明文,列表与日志也不会保存密码。</p>
<fieldset class="field"><legend>负责出纳(可多选)</legend><div id="companyCashierChoices"></div></fieldset>
<p class="hint" style="margin-top: 10px;">可先不分配出纳。未分配公司的员工进入财务系统后会看到待分配提示。</p>
<div class="modal-actions">
<button type="button" class="btn" data-close="companyDialog">取消</button>
<button type="submit" class="btn btn-primary">创建公司与账号</button>
<button type="submit" class="btn btn-primary">保存公司与分配</button>
</div>
</form>
</div>
+96 -6
View File
@@ -2,6 +2,45 @@ const $ = (selector, scope = document) => scope.querySelector(selector);
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
const portal = document.body.dataset.portal || "entry";
const nativeFetch = window.fetch.bind(window);
const fetch = async function(input, options = {}) {
const url = new URL(typeof input === "string" ? input : input.url, location.href);
const headers = new Headers(options.headers);
if (portal === "company" && state.companyId && url.origin === location.origin && url.pathname.startsWith('/api/')) {
headers.set('X-Company-ID', String(state.companyId));
}
const response = await nativeFetch(input, {...options, headers});
if (portal === 'company' && response.status === 403) {
const result = await response.clone().json().catch(() => ({}));
if (result.code === 'company_scope_required') {
showCompanyWaiting(result.message);
// Stop existing request handlers from treating this as a logout.
return new Response(JSON.stringify(result), {status: 409, headers: {'Content-Type': 'application/json'}});
}
}
return response;
};
function scopedDownload(url) {
const result = new URL(url, location.href);
if (portal === 'company' && state.companyId) result.searchParams.set('_company_scope', state.companyId);
return result.pathname + result.search;
}
function showCompanyWaiting(message) {
const main = $('#main-content');
const box = document.createElement('section');
box.className = 'card'; box.style.padding = '24px'; box.setAttribute('role', 'status');
const title = document.createElement('h1'); title.textContent = '公司业务端';
const text = document.createElement('p'); text.textContent = message;
const retry = document.createElement('button'); retry.className = 'btn'; retry.textContent = '刷新公司分配';
retry.onclick = () => location.reload();
const home = document.createElement('a'); home.className = 'btn'; home.textContent = '返回统一门户';
home.href = window.PORTAL_HOME || '/';
box.append(title, text, retry, home); main.replaceChildren(box);
$$('.side-nav [data-view]').forEach(el => { el.hidden = true; el.style.display = 'none'; });
$$('.modal-backdrop').forEach(el => el.classList.remove('is-open', 'open', 'show'));
}
const viewNames = portal === "admin"
? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", "period-audit": "审计记录", reminders: "提醒管理" }
: { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", transfers: "转账往来", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
@@ -442,10 +481,33 @@ async function initAuthGuard() {
return false;
}
state.me = me;
if (portal === 'company') {
const companies = me.companies || [];
if (!companies.length) {
showCompanyWaiting('尚未分配负责公司,请联系财务管理员。');
return false;
}
let saved;
try { saved = sessionStorage.getItem('finance-company:' + me.username); } catch {}
const chosen = companies.find(c => String(c.id) === saved) || companies[0];
state.companyId = chosen.id;
me.company_id = chosen.id; me.company_name = chosen.name;
const label = document.createElement('label'); label.textContent = '当前公司 ';
const select = document.createElement('select'); select.className = 'input'; select.id = 'activeCompany';
select.setAttribute('aria-label', '当前操作公司');
companies.forEach(c => select.add(new Option(c.name, String(c.id), false, c.id === chosen.id)));
select.onchange = () => {
if (!window.confirm('切换公司将清除本页尚未提交的内容,是否继续?')) { select.value = String(state.companyId); return; }
try { sessionStorage.setItem('finance-company:' + me.username, select.value); } catch {}
location.reload();
};
label.append(select); $('.topbar-right').prepend(label);
}
applyCompanyIdentity(me);
return true;
} catch {
return true;
if (portal === 'company') showCompanyWaiting('暂时无法核对身份和公司分配,请刷新重试。');
return false;
}
}
@@ -1103,7 +1165,7 @@ function renderAdminCompanyTable(companies) {
const badge = companyStatusBadge(company.status);
statusCell.innerHTML = `<span class="pill ${pillClass(badge.className)}">${badge.label}</span>`;
const actionCell = document.createElement("td");
actionCell.innerHTML = `<button type="button" class="btn btn-sm" data-company-view="${company.id}">查看</button>`;
actionCell.innerHTML = `<button type="button" class="btn btn-sm" data-company-view="${company.id}">查看</button> <button type="button" class="btn btn-sm" data-company-edit="${company.id}">编辑与分配出纳</button>`;
row.append(nameCell, accounts, usernames, cashier, statusCell, actionCell);
return row;
}));
@@ -2922,7 +2984,31 @@ function initAdmin() {
submitAuditResult(state.auditRow);
});
$("#openCompanyDialog")?.addEventListener("click", () => openModal("companyDialog"));
async function editCompany(company = null) {
const response = await fetch('/api/admin/company-cashiers').catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) { showToast('无法加载出纳名单', result?.message || '请稍后重试', 'danger'); return; }
const form = $('#companyForm'); form.reset();
form.dataset.companyId = company ? String(company.id) : '';
$('#f-name').value = company?.name || ''; $('#f-code').value = company?.credit_code || '';
const choices = $('#companyCashierChoices'); choices.replaceChildren();
for (const employee of result.employees) {
const label = document.createElement('label'); label.className = 'row';
const input = document.createElement('input'); input.type = 'checkbox'; input.name = 'cashierUser'; input.value = employee.id;
input.checked = (company?.cashier_user_ids || []).includes(employee.id);
label.append(input, document.createTextNode(`${employee.display_name || employee.username}(${employee.username})`));
choices.append(label);
}
if (!result.employees.length) choices.textContent = '暂无已授权的公司端员工,可先保存公司资料,稍后分配。';
const eligible = new Set(result.employees.map(e => e.id));
if ((company?.cashier_user_ids || []).some(id => !eligible.has(id))) {
const note = document.createElement('p'); note.textContent = '原分配中有员工已停用或失去权限,本次保存将移除这些无效分配。'; choices.append(note);
}
$('#companyDialog .modal-title').textContent = company ? '编辑公司与负责出纳' : '新增成员公司';
$('#companyForm button[type="submit"]').textContent = '保存公司与分配';
openModal('companyDialog');
}
$("#openCompanyDialog")?.addEventListener("click", () => editCompany());
$("#companyForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
@@ -2935,7 +3021,9 @@ function initAdmin() {
name: companyName,
credit_code: String(data.get("creditCode") || "").trim(),
cashier_name: String(data.get("cashier") || "").trim(),
cashier_user_ids: data.getAll('cashierUser').map(Number),
};
if (form.dataset.companyId) payload.id = Number(form.dataset.companyId);
if (createUser && loginName) payload.username = loginName;
const response = await fetch("/api/admin/companies", {
@@ -2965,7 +3053,7 @@ function initAdmin() {
});
showToast("公司与账号已创建", "请在一次性口令窗口复制初始密码,关闭后无法再看", "success");
} else {
showToast("公司已创建", "可稍后在账号管理中创建公司账号", "success");
showToast("公司资料和出纳分配已保存", "员工从统一门户进入后即可操作负责的公司", "success");
}
});
@@ -2990,6 +3078,8 @@ function initAdmin() {
}
$("#companyTable tbody")?.addEventListener("click", (event) => {
const edit = event.target.closest('[data-company-edit]');
if (edit) { editCompany(state.companies.find(c => String(c.id) === edit.dataset.companyEdit)); return; }
const btn = event.target.closest("[data-company-view]");
if (btn) openCompanyDetail(btn.dataset.companyView);
});
@@ -3348,7 +3438,7 @@ function exportFlows() {
const company = companyByName(companyName);
if (company) params.set("company_id", String(company.id));
}
window.location.href = `/api/export.csv${params.toString() ? `?${params}` : ""}`;
window.location.href = scopedDownload(`/api/export.csv${params.toString() ? `?${params}` : ""}`);
showToast("开始导出", "仅含出纳已确认工作表的银行原始流水", "success");
}
@@ -4717,7 +4807,7 @@ function buildTransfersExportUrl() {
if (summary.window.end) params.set("to", summary.window.end);
}
const qs = params.toString();
return `/api/company/intercompany/export.csv${qs ? `?${qs}` : ""}`;
return scopedDownload(`/api/company/intercompany/export.csv${qs ? `?${qs}` : ""}`);
}
function initTransfers() {
+13 -30
View File
@@ -72,7 +72,7 @@
<div class="page-head">
<div>
<h1>工作台</h1>
<p class="page-sub"><span class="company-identity">河南金牛煤业有限公司</span> · 2026 年 7 月账期。本月 18 项账期任务已完成 13 项,剩余 4 项待办,其中 3 项阻断结账。</p>
<p class="page-sub"><span class="company-identity">当前公司</span> · 下方仅显示当前所选公司的待办与业务信息。</p>
</div>
<div class="page-actions">
<button class="btn" data-open-upload>上传流水</button>
@@ -82,19 +82,18 @@
<div class="card">
<div class="card-head">
<span class="card-title">账期流程 · 2026-07<span class="sub" id="workspaceFlowSub">当前停在第 3 步「往来确认」,完成后即可等待集团结账</span></span>
<span class="pill pill-warn">结账日顺延至 08-29</span>
<span class="card-title">业务流程<span class="sub" id="workspaceFlowSub">正在读取待确认往来</span></span>
</div>
<div class="flow">
<a class="flow-step part" data-view-link="upload" href="#upload">
<a class="flow-step" data-view-link="upload" href="#upload">
<div class="fs-top"><span class="fs-idx">01</span><span class="fs-dot"></span><span class="fs-name">流水导入</span></div>
<div class="fs-state">部分完成</div>
<div class="fs-meta">2/3 账户已覆盖 · 交行 7710 待审核</div>
<div class="fs-state">查看流水</div>
<div class="fs-meta">选择本公司已审核银行账户上传</div>
</a>
<a class="flow-step done" data-view-link="manual" href="#manual">
<a class="flow-step" data-view-link="manual" href="#manual">
<div class="fs-top"><span class="fs-idx">02</span><span class="fs-dot"></span><span class="fs-name">手工补录</span></div>
<div class="fs-state">已完成</div>
<div class="fs-meta">7 月手工记录 5 笔已提交</div>
<div class="fs-state">按需登记</div>
<div class="fs-meta">手工记录需经管理端审核</div>
</a>
<a class="flow-step doing" data-view-link="reconcile" href="#reconcile">
<div class="fs-top"><span class="fs-idx">03</span><span class="fs-dot"></span><span class="fs-name">往来确认</span></div>
@@ -104,12 +103,12 @@
<div class="flow-step wait">
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">管理复核</span></div>
<div class="fs-state">等待集团</div>
<div class="fs-meta">2 笔复核在途 · 无需本公司操作</div>
<div class="fs-meta">由集团管理端处理审核事项</div>
</div>
<div class="flow-step wait">
<div class="fs-top"><span class="fs-idx">05</span><span class="fs-dot"></span><span class="fs-name">账期结账</span></div>
<div class="fs-state">08-29</div>
<div class="fs-meta">需先完成上方前 3 步</div>
<div class="fs-state">以集团通知为准</div>
<div class="fs-meta">由管理端执行结账检查</div>
</div>
</div>
</div>
@@ -148,23 +147,7 @@
<thead>
<tr><th>银行账户</th><th>流水覆盖区间</th><th>状态</th></tr>
</thead>
<tbody>
<tr>
<td class="cell-main">工商银行<span class="cell-sub">尾号 3305 · 基本户</span></td>
<td class="meta">2026-01-01 ~ 2026-08-20</td>
<td><span class="pill pill-success">已覆盖</span></td>
</tr>
<tr>
<td class="cell-main">中国银行<span class="cell-sub">尾号 9916 · 一般户</span></td>
<td class="meta">2026-01-01 ~ 2026-08-12</td>
<td><span class="pill pill-warn">部分覆盖</span></td>
</tr>
<tr>
<td class="cell-main">交通银行<span class="cell-sub">尾号 7710 · 一般户</span></td>
<td class="meta">未上传</td>
<td><span class="pill pill-info">待审核</span></td>
</tr>
</tbody>
<tbody><tr><td colspan="3">请通过“管理账户”查看本公司的真实银行账户;流水断档以系统校验结果为准。</td></tr></tbody>
</table>
</div>
</div>
@@ -229,7 +212,7 @@
<div class="page-head">
<div>
<h1>流水导入</h1>
<p class="page-sub">上传银行导出的 Excel 流水文件,系统按表头识别银行模板、解析期间与明细并做覆盖校验。当前账期 2026-07,仅已审核通过的账户可导入。</p>
<p class="page-sub">上传银行导出的 Excel 流水文件,系统按表头识别银行模板、解析期间与明细并做覆盖校验。仅当前所选公司已审核通过的账户可导入。</p>
</div>
</div>
+3 -3
View File
@@ -61,11 +61,11 @@
var reset = document.querySelector('#btn-reset-pwd');
if (reset) reset.classList.add("portal-native-account");
var sub = document.querySelector('#companyDialog .modal-sub');
if (sub) sub.textContent = "登记成员公司。员工账号由统一门户管理,财务角色与公司范围按员工单独绑定。";
if (sub) sub.textContent = "选择已获财务公司端权限的员工。保存后生效;移除员工会收回其对本公司的操作权限,历史记录保留。";
var hint = document.querySelector('#companyForm .hint');
if (hint) hint.textContent = "完成公司登记后,为指定员工配置财务权限和公司范围。";
if (hint) hint.textContent = "可选择多名出纳,也可暂不分配。员工可操作的公司由这里的分配决定。";
var submit = document.querySelector('#companyForm button[type="submit"]');
if (submit) submit.textContent = "创建公司";
if (submit) submit.textContent = "保存公司与分配";
});
document.addEventListener("click", function (event) {
var btn = event.target.closest("[data-theme-set]");