增加部分付款和部分交货状态、工作台筛选、权限校验、跟进记录及 v4 数据迁移;补充 Windows EXE 构建与验收脚本。附 16 页 PDF 使用升级手册及企业微信详细配置说明、生成脚本和测试截图。验证:76 项回归测试、EXE 浏览器验收与 PDF 逐页校验通过。
282 lines
11 KiB
Python
282 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""数据模型:用户、供应商、物料、订单、明细、审核记录。"""
|
|
from datetime import datetime
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer,
|
|
String, Text, LargeBinary, UniqueConstraint, create_engine, event)
|
|
from sqlalchemy.types import TypeDecorator
|
|
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
|
|
|
|
Base = declarative_base()
|
|
|
|
|
|
class FixedDecimal(TypeDecorator):
|
|
"""SQLite stores integers; Python always receives Decimal, including SUM."""
|
|
impl = Integer
|
|
cache_ok = True
|
|
|
|
def __init__(self, places=2):
|
|
self.places = places
|
|
self.factor = 10 ** places
|
|
super().__init__()
|
|
|
|
def process_bind_param(self, value, dialect):
|
|
if value is None:
|
|
return None
|
|
return int((Decimal(str(value)) * self.factor).quantize(Decimal('1'), rounding=ROUND_HALF_UP))
|
|
|
|
def process_result_value(self, value, dialect):
|
|
return Decimal(value) / self.factor if value is not None else None
|
|
|
|
COMPANY_NAME = u"苏州博特维智能科技有限公司"
|
|
DEFAULT_DELIVERY_PLACE = (u"苏州市工业园区星汉街5号腾飞新苏工业坊B幢5楼05-06单元 "
|
|
u"胡乃琛 13913172516")
|
|
# 需方(我方)默认信息:新建单据时自动预置到联系人 / 电话 / 税号 / 税点
|
|
DEFAULT_OUR_CONTACT = u"胡乃琛"
|
|
DEFAULT_OUR_PHONE = u"13913172516"
|
|
DEFAULT_OUR_TAX_NO = u"91320507MA1WW8D15D"
|
|
DEFAULT_TAX_RATE = u"13%"
|
|
|
|
DOC_TYPES = {
|
|
"purchase": u"采购单",
|
|
"outsourcing": u"委外生产订单",
|
|
}
|
|
|
|
STATUS_TEXT = {
|
|
"draft": u"草稿",
|
|
"pending_approval": u"待审批",
|
|
"approved": u"审批通过",
|
|
"rejected": u"已驳回",
|
|
"voided": u"已作废",
|
|
}
|
|
|
|
ROLE_TEXT = {
|
|
"admin": u"系统管理员",
|
|
"maker": u"制单员",
|
|
"approver": u"审批员",
|
|
}
|
|
|
|
INVOICE_STATUS = {'unknown': '未登记', 'pending': '未收票', 'received': '已收票'}
|
|
DELIVERY_STATUS = {'unknown': '未登记', 'pending': '未交货', 'partial': '部分交货', 'delivered': '已交货'}
|
|
PAYMENT_STATUS = {'unknown': '未登记', 'unpaid': '未付款', 'partial': '部分付款', 'paid': '已付款'}
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
id = Column(Integer, primary_key=True)
|
|
username = Column(String(50), unique=True, nullable=False)
|
|
password_hash = Column(String(200), nullable=False)
|
|
name = Column(String(50), nullable=False)
|
|
role = Column(String(20), nullable=False, default="maker")
|
|
active = Column(Boolean, default=True)
|
|
created_at = Column(DateTime, default=datetime.now)
|
|
session_version = Column(Integer, nullable=False, default=1)
|
|
must_change_password = Column(Boolean, nullable=False, default=True)
|
|
|
|
|
|
class Supplier(Base):
|
|
__tablename__ = "suppliers"
|
|
id = Column(Integer, primary_key=True)
|
|
code = Column(String(50))
|
|
name = Column(String(200), nullable=False)
|
|
contact = Column(String(50))
|
|
phone = Column(String(50))
|
|
tax_no = Column(String(80))
|
|
bank_name = Column(String(120))
|
|
bank_account = Column(String(80))
|
|
payment_terms = Column(String(120))
|
|
active = Column(Boolean, default=True)
|
|
|
|
|
|
class Material(Base):
|
|
__tablename__ = "materials"
|
|
id = Column(Integer, primary_key=True)
|
|
code = Column(String(80), nullable=False)
|
|
spec = Column(String(300))
|
|
unit = Column(String(20))
|
|
price = Column('price_units', FixedDecimal(4), default=0)
|
|
active = Column(Boolean, default=True)
|
|
|
|
|
|
class Order(Base):
|
|
__tablename__ = "orders"
|
|
id = Column(Integer, primary_key=True)
|
|
order_no = Column(String(50), nullable=False, unique=True)
|
|
doc_type = Column(String(20), nullable=False, default="purchase")
|
|
|
|
supplier_name = Column(String(200))
|
|
supplier_contact = Column(String(50))
|
|
supplier_phone = Column(String(50))
|
|
supplier_tax_no = Column(String(80))
|
|
supplier_bank_name = Column(String(120))
|
|
supplier_bank_account = Column(String(80))
|
|
payment_terms = Column(String(120))
|
|
|
|
our_company = Column(String(200), default=COMPANY_NAME)
|
|
our_contact = Column(String(50), default=DEFAULT_OUR_CONTACT)
|
|
our_phone = Column(String(50), default=DEFAULT_OUR_PHONE)
|
|
our_tax_no = Column(String(80), default=DEFAULT_OUR_TAX_NO)
|
|
tax_rate = Column(String(30), default=DEFAULT_TAX_RATE)
|
|
order_date = Column(String(20))
|
|
delivery_place = Column(String(300), default=DEFAULT_DELIVERY_PLACE)
|
|
note = Column(Text)
|
|
|
|
total_amount = Column('total_cents', FixedDecimal(), default=0)
|
|
|
|
status = Column(String(20), default="draft")
|
|
created_by = Column(Integer, ForeignKey("users.id"))
|
|
created_by_name = Column(String(50))
|
|
created_at = Column(DateTime, default=datetime.now)
|
|
submitted_at = Column(DateTime)
|
|
reviewed_by = Column(String(50))
|
|
reviewed_at = Column(DateTime)
|
|
approved_by = Column(String(50))
|
|
approved_at = Column(DateTime)
|
|
reject_reason = Column(Text)
|
|
reviewed_by_id = Column(Integer, ForeignKey('users.id'))
|
|
approved_by_id = Column(Integer, ForeignKey('users.id'))
|
|
revision = Column(Integer, nullable=False, default=0)
|
|
row_version = Column(Integer, nullable=False, default=1)
|
|
needs_prepayment = Column(Boolean, nullable=False, default=False)
|
|
invoice_status = Column(String(20), nullable=False, default='pending')
|
|
delivery_status = Column(String(20), nullable=False, default='pending')
|
|
payment_status = Column(String(20), nullable=False, default='unpaid')
|
|
payment_date = Column(String(10), nullable=False, default='')
|
|
fulfillment_note = Column(Text, nullable=False, default='')
|
|
payment_note = Column(Text, nullable=False, default='')
|
|
fulfillment_revision = Column(Integer, nullable=False, default=-1)
|
|
payment_revision = Column(Integer, nullable=False, default=-1)
|
|
__mapper_args__ = {'version_id_col': row_version}
|
|
|
|
items = relationship("OrderItem", backref="order", cascade="all, delete-orphan",
|
|
order_by="OrderItem.seq")
|
|
logs = relationship("ApprovalLog", backref="order", cascade="all, delete-orphan",
|
|
order_by="ApprovalLog.id")
|
|
versions = relationship('OrderVersion', backref='order', order_by='OrderVersion.revision')
|
|
|
|
@property
|
|
def status_text(self):
|
|
return STATUS_TEXT.get(self.status, self.status)
|
|
|
|
@property
|
|
def doc_type_text(self):
|
|
return DOC_TYPES.get(self.doc_type, self.doc_type)
|
|
|
|
@property
|
|
def invoice_status_text(self):
|
|
return INVOICE_STATUS.get(self.invoice_status, '未登记')
|
|
|
|
@property
|
|
def delivery_status_text(self):
|
|
return DELIVERY_STATUS.get(self.delivery_status, '未登记')
|
|
|
|
@property
|
|
def payment_status_text(self):
|
|
return PAYMENT_STATUS.get(self.payment_status, '未登记')
|
|
|
|
@property
|
|
def fulfillment_stale(self):
|
|
return self.fulfillment_revision >= 0 and self.fulfillment_revision != self.revision
|
|
|
|
@property
|
|
def payment_stale(self):
|
|
return self.payment_revision >= 0 and self.payment_revision != self.revision
|
|
|
|
|
|
class OrderItem(Base):
|
|
__tablename__ = "order_items"
|
|
id = Column(Integer, primary_key=True)
|
|
order_id = Column(Integer, ForeignKey("orders.id"))
|
|
seq = Column(Integer, default=1)
|
|
material_code = Column(String(80))
|
|
material_spec = Column(String(300))
|
|
unit = Column(String(20))
|
|
qty = Column('qty_units', FixedDecimal(4), default=0)
|
|
price = Column('price_units', FixedDecimal(4), default=0)
|
|
amount = Column('amount_cents', FixedDecimal(), default=0)
|
|
delivery_date = Column(String(30))
|
|
remark = Column(String(200))
|
|
|
|
|
|
class ApprovalLog(Base):
|
|
__tablename__ = "approval_logs"
|
|
id = Column(Integer, primary_key=True)
|
|
order_id = Column(Integer, ForeignKey("orders.id"))
|
|
action = Column(String(50))
|
|
user_name = Column(String(50))
|
|
comment = Column(Text)
|
|
created_at = Column(DateTime, default=datetime.now)
|
|
user_id = Column(Integer, ForeignKey('users.id'))
|
|
revision = Column(Integer, nullable=False, default=0)
|
|
from_status = Column(String(20))
|
|
to_status = Column(String(20))
|
|
|
|
|
|
class OrderVersion(Base):
|
|
__tablename__ = 'order_versions'
|
|
__table_args__ = (UniqueConstraint('order_id', 'revision'),)
|
|
id = Column(Integer, primary_key=True)
|
|
order_id = Column(Integer, ForeignKey('orders.id'), nullable=False)
|
|
revision = Column(Integer, nullable=False)
|
|
content_json = Column(Text, nullable=False)
|
|
created_at = Column(DateTime, default=datetime.now)
|
|
pdf_data = Column(LargeBinary)
|
|
pdf_sha256 = Column(String(64))
|
|
template_version = Column(String(30))
|
|
|
|
|
|
class CreationRequest(Base):
|
|
__tablename__ = 'creation_requests'
|
|
key = Column(String(64), primary_key=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
operation = Column(String(80), nullable=False)
|
|
order_id = Column(Integer, ForeignKey('orders.id'), nullable=False)
|
|
|
|
|
|
class NotificationSettings(Base):
|
|
__tablename__ = 'notification_settings'
|
|
id = Column(Integer, primary_key=True)
|
|
enabled = Column(Boolean, nullable=False, default=False)
|
|
base_url = Column(String(300), nullable=False, default='')
|
|
review_webhook = Column(Text, nullable=False, default='')
|
|
approval_webhook = Column(Text, nullable=False, default='')
|
|
result_webhook = Column(Text, nullable=False, default='')
|
|
next_dispatch_at = Column(DateTime)
|
|
|
|
|
|
class Notification(Base):
|
|
__tablename__ = 'notifications'
|
|
id = Column(Integer, primary_key=True)
|
|
log_id = Column(Integer, ForeignKey('approval_logs.id'), unique=True)
|
|
order_id = Column(Integer, ForeignKey('orders.id'))
|
|
revision = Column(Integer, nullable=False, default=0)
|
|
channel = Column(String(20), nullable=False)
|
|
event = Column(String(50), nullable=False)
|
|
expected_status = Column(String(20))
|
|
status = Column(String(20), nullable=False, default='pending', index=True)
|
|
attempts = Column(Integer, nullable=False, default=0)
|
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
|
next_attempt_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
|
sent_at = Column(DateTime)
|
|
lease_until = Column(DateTime)
|
|
lease_token = Column(String(32))
|
|
last_error = Column(String(200), nullable=False, default='')
|
|
|
|
|
|
def init_db(db_path):
|
|
from migrations import upgrade_database
|
|
existed = Path(db_path).exists()
|
|
upgrade_database(db_path)
|
|
engine = create_engine("sqlite:///" + db_path, echo=False, hide_parameters=True, connect_args={'timeout': 30})
|
|
@event.listens_for(engine, 'connect')
|
|
def sqlite_settings(connection, _):
|
|
connection.execute('PRAGMA foreign_keys=ON')
|
|
connection.execute('PRAGMA busy_timeout=30000')
|
|
Base.metadata.create_all(engine)
|
|
if not existed:
|
|
with engine.connect() as con:
|
|
con.exec_driver_sql('PRAGMA user_version=4')
|
|
return engine, sessionmaker(bind=engine)
|