feat(oa): add bank receipt attachment service for instances
Store multiple bank receipts on biz_attachment keyed by instance id. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
00a315b61c
commit
5e38dc1eca
2 files changed
+406
No files matched your search
@@ -0,0 +1,171 @@
|
||||
"""OA 实例银行回执单(biz_attachment 关联)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import BizAttachment, OaInstance
|
||||
|
||||
BUSINESS_TYPE = "oa_bank_receipt"
|
||||
|
||||
|
||||
class OaBankReceiptError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _require_instance(db: Session, instance_id: str, tenant_no: str) -> OaInstance:
|
||||
iid = (instance_id or "").strip()
|
||||
if not iid:
|
||||
raise OaBankReceiptError("实例ID无效")
|
||||
row = (
|
||||
db.query(OaInstance)
|
||||
.filter(
|
||||
OaInstance.id == iid,
|
||||
OaInstance.tenant_no == tenant_no,
|
||||
OaInstance.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
raise OaBankReceiptError("审批实例不存在")
|
||||
return row
|
||||
|
||||
|
||||
def _row_to_item(row: BizAttachment) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"fileName": row.original_name or "",
|
||||
"fileUrl": row.file_url or "",
|
||||
"fileType": row.file_type or "",
|
||||
"fileSize": row.file_size,
|
||||
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_bank_receipts(db: Session, *, instance_id: str, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
_require_instance(db, instance_id, tenant_no)
|
||||
rows = (
|
||||
db.query(BizAttachment)
|
||||
.filter(
|
||||
BizAttachment.tenant_no == tenant_no,
|
||||
BizAttachment.business_type == BUSINESS_TYPE,
|
||||
BizAttachment.business_id == instance_id,
|
||||
BizAttachment.del_flag == 0,
|
||||
)
|
||||
.order_by(BizAttachment.create_time.asc(), BizAttachment.id.asc())
|
||||
.all()
|
||||
)
|
||||
return [_row_to_item(r) for r in rows]
|
||||
|
||||
|
||||
def replace_bank_receipts(
|
||||
db: Session,
|
||||
*,
|
||||
instance_id: str,
|
||||
tenant_no: str,
|
||||
items: List[Dict[str, Any]],
|
||||
user_id: str,
|
||||
dept_id: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
_require_instance(db, instance_id, tenant_no)
|
||||
now = datetime.now()
|
||||
desired: List[Dict[str, Any]] = []
|
||||
seen_urls = set()
|
||||
for raw in items or []:
|
||||
url = str((raw or {}).get("fileUrl") or (raw or {}).get("file_url") or "").strip()
|
||||
if not url or url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(url)
|
||||
desired.append(
|
||||
{
|
||||
"fileUrl": url,
|
||||
"fileName": str((raw or {}).get("fileName") or (raw or {}).get("file_name") or "").strip()
|
||||
or url.rsplit("/", 1)[-1],
|
||||
"fileType": str((raw or {}).get("fileType") or (raw or {}).get("file_type") or "").strip(),
|
||||
"fileSize": (raw or {}).get("fileSize")
|
||||
if (raw or {}).get("fileSize") is not None
|
||||
else (raw or {}).get("file_size"),
|
||||
}
|
||||
)
|
||||
|
||||
existing = (
|
||||
db.query(BizAttachment)
|
||||
.filter(
|
||||
BizAttachment.tenant_no == tenant_no,
|
||||
BizAttachment.business_type == BUSINESS_TYPE,
|
||||
BizAttachment.business_id == instance_id,
|
||||
BizAttachment.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
existing_by_url = {r.file_url: r for r in existing if r.file_url}
|
||||
desired_urls = {d["fileUrl"] for d in desired}
|
||||
|
||||
for row in existing:
|
||||
if row.file_url not in desired_urls:
|
||||
row.del_flag = 1
|
||||
row.update_by = user_id or None
|
||||
row.update_time = now
|
||||
|
||||
for d in desired:
|
||||
url = d["fileUrl"]
|
||||
row: Optional[BizAttachment] = existing_by_url.get(url)
|
||||
if row is None:
|
||||
row = (
|
||||
db.query(BizAttachment)
|
||||
.filter(
|
||||
BizAttachment.tenant_no == tenant_no,
|
||||
BizAttachment.file_url == url,
|
||||
BizAttachment.del_flag == 0,
|
||||
)
|
||||
.order_by(BizAttachment.create_time.desc())
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
ext = ""
|
||||
if "." in d["fileName"]:
|
||||
ext = d["fileName"].rsplit(".", 1)[-1][:20]
|
||||
size = d["fileSize"]
|
||||
try:
|
||||
size_int = int(size) if size is not None else None
|
||||
except (TypeError, ValueError):
|
||||
size_int = None
|
||||
row = BizAttachment(
|
||||
id=uuid.uuid4().hex,
|
||||
business_type=BUSINESS_TYPE,
|
||||
business_id=instance_id,
|
||||
original_name=d["fileName"][:200],
|
||||
file_ext=ext or None,
|
||||
file_url=url,
|
||||
file_size=size_int,
|
||||
file_type=(d["fileType"] or None),
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id or None,
|
||||
create_dept_id=dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id or None,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
else:
|
||||
row.business_type = BUSINESS_TYPE
|
||||
row.business_id = instance_id
|
||||
row.del_flag = 0
|
||||
if d["fileName"]:
|
||||
row.original_name = d["fileName"][:200]
|
||||
if d["fileType"]:
|
||||
row.file_type = d["fileType"]
|
||||
if d["fileSize"] is not None:
|
||||
try:
|
||||
row.file_size = int(d["fileSize"])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
row.update_by = user_id or None
|
||||
row.update_time = now
|
||||
|
||||
db.flush()
|
||||
return list_bank_receipts(db, instance_id=instance_id, tenant_no=tenant_no)
|
||||
@@ -0,0 +1,235 @@
|
||||
"""OA 银行回执单:biz_attachment 挂载 instance。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.models.base import Base
|
||||
from app.models.models import BizAttachment, OaApp, OaAppVersion, OaInstance
|
||||
from app.services import oa_bank_receipt as svc
|
||||
|
||||
|
||||
class TestOaBankReceipt(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
OaApp.__table__,
|
||||
OaAppVersion.__table__,
|
||||
OaInstance.__table__,
|
||||
BizAttachment.__table__,
|
||||
],
|
||||
)
|
||||
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
|
||||
self.db = Session()
|
||||
t0 = datetime(2026, 10, 16, 10, 0, 0)
|
||||
self.db.add_all(
|
||||
[
|
||||
OaApp(
|
||||
id="app1",
|
||||
app_name="付款申请",
|
||||
initiator_scope="all",
|
||||
admin_scope="all_oa_admin",
|
||||
status="1",
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=t0,
|
||||
update_time=t0,
|
||||
del_flag=0,
|
||||
),
|
||||
OaAppVersion(
|
||||
id="v1",
|
||||
app_id="app1",
|
||||
version_no=1,
|
||||
status="published",
|
||||
is_latest_published="1",
|
||||
form_schema_json="{}",
|
||||
flow_tree_json="{}",
|
||||
advanced_settings_json="{}",
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=t0,
|
||||
update_time=t0,
|
||||
del_flag=0,
|
||||
),
|
||||
OaInstance(
|
||||
id="inst-1",
|
||||
app_id="app1",
|
||||
app_version_id="v1",
|
||||
title="付款-1",
|
||||
initiator_user_id="u1",
|
||||
status="completed",
|
||||
current_round=1,
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=t0,
|
||||
update_time=t0,
|
||||
del_flag=0,
|
||||
),
|
||||
OaInstance(
|
||||
id="inst-other-tenant",
|
||||
app_id="app1",
|
||||
app_version_id="v1",
|
||||
title="付款-x",
|
||||
initiator_user_id="u1",
|
||||
status="completed",
|
||||
current_round=1,
|
||||
tenant_no="other",
|
||||
create_dept_id="",
|
||||
create_time=t0,
|
||||
update_time=t0,
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_list_empty(self):
|
||||
items = svc.list_bank_receipts(self.db, instance_id="inst-1", tenant_no="zkxw")
|
||||
self.assertEqual(items, [])
|
||||
|
||||
def test_replace_then_list(self):
|
||||
out = svc.replace_bank_receipts(
|
||||
self.db,
|
||||
instance_id="inst-1",
|
||||
tenant_no="zkxw",
|
||||
items=[
|
||||
{
|
||||
"fileUrl": "oa-bank-receipt/a.pdf",
|
||||
"fileName": "回单A.pdf",
|
||||
"fileType": "application/pdf",
|
||||
"fileSize": 100,
|
||||
},
|
||||
{
|
||||
"fileUrl": "oa-bank-receipt/b.jpg",
|
||||
"fileName": "回单B.jpg",
|
||||
"fileType": "image/jpeg",
|
||||
"fileSize": 200,
|
||||
},
|
||||
],
|
||||
user_id="u1",
|
||||
dept_id="d1",
|
||||
)
|
||||
self.db.commit()
|
||||
self.assertEqual(len(out), 2)
|
||||
listed = svc.list_bank_receipts(self.db, instance_id="inst-1", tenant_no="zkxw")
|
||||
self.assertEqual([x["fileUrl"] for x in listed], [
|
||||
"oa-bank-receipt/a.pdf",
|
||||
"oa-bank-receipt/b.jpg",
|
||||
])
|
||||
self.assertEqual(listed[0]["fileName"], "回单A.pdf")
|
||||
|
||||
def test_replace_removes_missing(self):
|
||||
svc.replace_bank_receipts(
|
||||
self.db,
|
||||
instance_id="inst-1",
|
||||
tenant_no="zkxw",
|
||||
items=[
|
||||
{"fileUrl": "oa-bank-receipt/a.pdf", "fileName": "A.pdf"},
|
||||
{"fileUrl": "oa-bank-receipt/b.pdf", "fileName": "B.pdf"},
|
||||
],
|
||||
user_id="u1",
|
||||
dept_id="",
|
||||
)
|
||||
self.db.commit()
|
||||
svc.replace_bank_receipts(
|
||||
self.db,
|
||||
instance_id="inst-1",
|
||||
tenant_no="zkxw",
|
||||
items=[{"fileUrl": "oa-bank-receipt/b.pdf", "fileName": "B.pdf"}],
|
||||
user_id="u1",
|
||||
dept_id="",
|
||||
)
|
||||
self.db.commit()
|
||||
listed = svc.list_bank_receipts(self.db, instance_id="inst-1", tenant_no="zkxw")
|
||||
self.assertEqual([x["fileUrl"] for x in listed], ["oa-bank-receipt/b.pdf"])
|
||||
gone = (
|
||||
self.db.query(BizAttachment)
|
||||
.filter(BizAttachment.file_url == "oa-bank-receipt/a.pdf")
|
||||
.first()
|
||||
)
|
||||
self.assertEqual(gone.del_flag, 1)
|
||||
|
||||
def test_replace_empty_clears_all(self):
|
||||
svc.replace_bank_receipts(
|
||||
self.db,
|
||||
instance_id="inst-1",
|
||||
tenant_no="zkxw",
|
||||
items=[{"fileUrl": "oa-bank-receipt/a.pdf", "fileName": "A.pdf"}],
|
||||
user_id="u1",
|
||||
dept_id="",
|
||||
)
|
||||
self.db.commit()
|
||||
svc.replace_bank_receipts(
|
||||
self.db,
|
||||
instance_id="inst-1",
|
||||
tenant_no="zkxw",
|
||||
items=[],
|
||||
user_id="u1",
|
||||
dept_id="",
|
||||
)
|
||||
self.db.commit()
|
||||
self.assertEqual(
|
||||
svc.list_bank_receipts(self.db, instance_id="inst-1", tenant_no="zkxw"),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_bind_orphan_upload_row(self):
|
||||
"""通用上传已写入无 business_type 的行时,PUT 应绑定而非再插一行。"""
|
||||
t0 = datetime(2026, 10, 16, 11, 0, 0)
|
||||
orphan = BizAttachment(
|
||||
id="att-orphan",
|
||||
business_type=None,
|
||||
business_id=None,
|
||||
original_name="orphan.pdf",
|
||||
file_ext="pdf",
|
||||
file_url="oa-bank-receipt/orphan.pdf",
|
||||
file_size=10,
|
||||
file_type="application/pdf",
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=t0,
|
||||
update_time=t0,
|
||||
del_flag=0,
|
||||
)
|
||||
self.db.add(orphan)
|
||||
self.db.commit()
|
||||
svc.replace_bank_receipts(
|
||||
self.db,
|
||||
instance_id="inst-1",
|
||||
tenant_no="zkxw",
|
||||
items=[{"fileUrl": "oa-bank-receipt/orphan.pdf", "fileName": "orphan.pdf"}],
|
||||
user_id="u1",
|
||||
dept_id="",
|
||||
)
|
||||
self.db.commit()
|
||||
rows = (
|
||||
self.db.query(BizAttachment)
|
||||
.filter(BizAttachment.file_url == "oa-bank-receipt/orphan.pdf", BizAttachment.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0].business_type, svc.BUSINESS_TYPE)
|
||||
self.assertEqual(rows[0].business_id, "inst-1")
|
||||
|
||||
def test_missing_instance_raises(self):
|
||||
with self.assertRaises(svc.OaBankReceiptError):
|
||||
svc.list_bank_receipts(self.db, instance_id="nope", tenant_no="zkxw")
|
||||
|
||||
def test_cross_tenant_raises(self):
|
||||
with self.assertRaises(svc.OaBankReceiptError):
|
||||
svc.list_bank_receipts(
|
||||
self.db, instance_id="inst-other-tenant", tenant_no="zkxw"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user