# -*- 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)