"""商品中心模型:Category / Brand / Unit / Product。 预留点: - Product.ext_data:自定义属性 JSON(行业差异) - Product.source_channel:manual/api/web/miniprogram/ai_agent - Product.is_deleted:软删除 """ from decimal import Decimal from django.db import models from apps.core.base_models import TenantScopedModel class Category(TenantScopedModel): """商品分类(树形结构)。""" code = models.CharField(max_length=64, help_text="分类代码") name = models.CharField(max_length=128) parent = models.ForeignKey( "self", null=True, blank=True, on_delete=models.SET_NULL, related_name="children", ) sort_order = models.IntegerField(default=0) class Meta: db_table = "catalog_category" unique_together = [("tenant", "code")] verbose_name_plural = "商品分类" ordering = ["sort_order", "code"] def __str__(self): return f"{self.code} {self.name}" class Brand(TenantScopedModel): """品牌。""" code = models.CharField(max_length=64) name = models.CharField(max_length=128) logo_url = models.URLField(blank=True, default="") class Meta: db_table = "catalog_brand" unique_together = [("tenant", "code")] verbose_name_plural = "品牌" ordering = ["code"] def __str__(self): return self.name class Unit(TenantScopedModel): """计量单位(箱/瓶/件/kg)。""" code = models.CharField(max_length=16, help_text="单位代码,如 box/bottle/kg") name = models.CharField(max_length=32) is_base = models.BooleanField(default=True, help_text="是否基本单位") class Meta: db_table = "catalog_unit" unique_together = [("tenant", "code")] verbose_name_plural = "计量单位" def __str__(self): return self.name class Product(TenantScopedModel): """商品档案。""" STATUS_ACTIVE = "active" STATUS_INACTIVE = "inactive" STATUS_CHOICES = [ (STATUS_ACTIVE, "启用"), (STATUS_INACTIVE, "停用"), ] code = models.CharField(max_length=64, help_text="商品编码(租户内唯一)") name = models.CharField(max_length=255) spec = models.CharField(max_length=255, blank=True, default="", help_text="规格") barcode = models.CharField(max_length=64, blank=True, default="") category = models.ForeignKey( Category, null=True, blank=True, on_delete=models.SET_NULL, related_name="products", ) brand = models.ForeignKey( Brand, null=True, blank=True, on_delete=models.SET_NULL, related_name="products", ) base_unit = models.ForeignKey( Unit, null=True, blank=True, on_delete=models.SET_NULL, related_name="products", ) # 价格字段 cost_price = models.DecimalField( max_digits=18, decimal_places=4, default=0, help_text="参考成本价" ) sale_price = models.DecimalField( max_digits=18, decimal_places=4, default=0, help_text="默认销售价" ) min_sale_price = models.DecimalField( max_digits=18, decimal_places=4, default=0, help_text="最低售价(0=不限制;低于需审批放行)", ) # 批次/效期(食品、医药等批次管理行业) is_batch_managed = models.BooleanField( default=False, help_text="是否启用批次管理(出库按近效期优先 FEFO)" ) shelf_life_days = models.IntegerField( null=True, blank=True, help_text="保质期天数(用于入库时推算默认到期日)" ) # 税率(批次 D4):默认 13%(一般纳税人常见档),可按商品覆盖 tax_rate = models.DecimalField( max_digits=6, decimal_places=4, default=Decimal("0.13"), help_text="增值税率(0.13 = 13%);0 = 免税/不启用", ) status = models.CharField( max_length=16, choices=STATUS_CHOICES, default=STATUS_ACTIVE ) class Meta: db_table = "catalog_product" unique_together = [("tenant", "code")] verbose_name_plural = "商品档案" indexes = [ models.Index(fields=["tenant", "code"]), models.Index(fields=["tenant", "name"]), models.Index(fields=["tenant", "barcode"]), models.Index(fields=["tenant", "category", "-created_at"]), ] ordering = ["code"] def __str__(self): return f"{self.code} {self.name}" class UnitConversion(TenantScopedModel): """商品多单位换算:1 个该单位 = rate × 基本单位。 例:商品基本单位"瓶",换算"箱" rate=24 → 1 箱 = 24 瓶。 """ product = models.ForeignKey( Product, on_delete=models.CASCADE, related_name="unit_conversions", ) unit = models.ForeignKey( Unit, on_delete=models.PROTECT, related_name="conversions", ) rate = models.DecimalField( max_digits=18, decimal_places=6, help_text="1 个该单位 = rate × 基本单位" ) class Meta: db_table = "catalog_unit_conversion" unique_together = [("tenant", "product", "unit")] verbose_name_plural = "商品单位换算" ordering = ["product", "unit"] def __str__(self): return f"{self.product.code}:{self.unit.code}×{self.rate}"