348 lines
15 KiB
Python
348 lines
15 KiB
Python
import json
|
||
|
||
from django.db import connection, models
|
||
from apps.utils.models import BaseModel, CommonBModel, CommonBDModel, CommonADModel
|
||
from apps.pum.models import Supplier, PuOrder
|
||
from apps.sam.models import Customer, Order
|
||
from apps.mtm.models import Material, Mgroup
|
||
from apps.system.models import User
|
||
from datetime import datetime
|
||
from django.db.models import Max, Sum
|
||
# Create your models here.
|
||
|
||
|
||
class WareHouse(CommonBModel):
|
||
"""
|
||
TN:仓库信息
|
||
"""
|
||
number = models.CharField('仓库编号', max_length=20)
|
||
name = models.CharField('仓库名称', max_length=20)
|
||
place = models.CharField('具体地点', max_length=50)
|
||
|
||
|
||
class MaterialBatch(BaseModel):
|
||
"""
|
||
TN:物料批次
|
||
"""
|
||
batch = models.TextField('批次号', db_index=True)
|
||
state = models.PositiveSmallIntegerField('状态', default=10, choices=((10, '合格'), (20, '不合格'), (30, '返修'), (40, '检验'), (50, '报废')))
|
||
material = models.ForeignKey(
|
||
Material, on_delete=models.CASCADE, verbose_name='物料', related_name="mb_m")
|
||
warehouse = models.ForeignKey(
|
||
WareHouse, on_delete=models.CASCADE, verbose_name='所在仓库')
|
||
count = models.DecimalField('存量', max_digits=12, decimal_places=3, default=0)
|
||
production_dept = models.ForeignKey('system.dept', verbose_name='生产车间', on_delete=models.CASCADE, null=True, blank=True)
|
||
expiration_date = models.DateField('有效期', null=True, blank=True)
|
||
supplier = models.ForeignKey(
|
||
Supplier, verbose_name='供应商', on_delete=models.SET_NULL, null=True, blank=True)
|
||
|
||
batch_ofrom = models.TextField('原料批次号', null=True, blank=True)
|
||
material_ofrom = models.ForeignKey(Material, verbose_name='原料物料', on_delete=models.SET_NULL, null=True, blank=True, related_name='mb_mofrom')
|
||
defect = models.ForeignKey('qm.defect', verbose_name='缺陷', on_delete=models.PROTECT, null=True, blank=True)
|
||
|
||
INVENTORY_KEY_FIELDS = (
|
||
'material',
|
||
'batch',
|
||
'warehouse',
|
||
'state',
|
||
'defect',
|
||
)
|
||
|
||
@classmethod
|
||
def _normalize_inventory_lookup(cls, **kwargs):
|
||
"""生成唯一、完整的仓库库存业务键。"""
|
||
unknown_fields = set(kwargs) - set(cls.INVENTORY_KEY_FIELDS)
|
||
if unknown_fields:
|
||
fields = ', '.join(sorted(unknown_fields))
|
||
raise TypeError(f'不支持的仓库库存定位字段: {fields}')
|
||
|
||
required_fields = ('material', 'batch', 'warehouse')
|
||
missing_fields = [
|
||
field for field in required_fields
|
||
if kwargs.get(field) is None
|
||
]
|
||
if missing_fields:
|
||
fields = ', '.join(missing_fields)
|
||
raise ValueError(f'仓库库存业务键必须包含: {fields}')
|
||
|
||
lookup = {
|
||
field: kwargs.get(field)
|
||
for field in cls.INVENTORY_KEY_FIELDS
|
||
}
|
||
if lookup['state'] is None:
|
||
lookup['state'] = cls._meta.get_field('state').get_default()
|
||
return lookup
|
||
|
||
@classmethod
|
||
def _inventory_advisory_lock_payload(cls, lookup):
|
||
lock_values = {}
|
||
for name in cls.INVENTORY_KEY_FIELDS:
|
||
field = cls._meta.get_field(name)
|
||
value = lookup[name]
|
||
if field.is_relation and value is not None:
|
||
value = getattr(value, 'pk', value)
|
||
lock_values[field.attname] = value
|
||
return json.dumps(
|
||
{'model': cls._meta.label_lower, 'lookup': lock_values},
|
||
sort_keys=True,
|
||
ensure_ascii=False,
|
||
default=str,
|
||
separators=(',', ':'),
|
||
)
|
||
|
||
@classmethod
|
||
def locked_get_or_create_inventory(cls, defaults=None, **kwargs):
|
||
"""
|
||
在事务中按完整业务键获取或创建仓库库存。
|
||
|
||
已存在记录使用行锁;首次创建使用 PostgreSQL 事务级 advisory lock,
|
||
并在取得锁后重新查询,避免并发创建重复库存。
|
||
"""
|
||
if not connection.in_atomic_block:
|
||
raise RuntimeError(
|
||
'locked_get_or_create_inventory 必须在事务中调用'
|
||
)
|
||
if connection.vendor != 'postgresql':
|
||
raise RuntimeError(
|
||
'locked_get_or_create_inventory 仅支持 PostgreSQL'
|
||
)
|
||
|
||
defaults = defaults or {}
|
||
lookup = cls._normalize_inventory_lookup(**kwargs)
|
||
create_defaults = {
|
||
key: value
|
||
for key, value in defaults.items()
|
||
if key not in cls.INVENTORY_KEY_FIELDS
|
||
}
|
||
|
||
rows = list(
|
||
cls.objects.select_for_update().filter(**lookup)[:2]
|
||
)
|
||
if len(rows) > 1:
|
||
raise RuntimeError(
|
||
f'{cls.__name__} 数据异常:库存业务键 {lookup} 命中多条'
|
||
)
|
||
if rows:
|
||
return rows[0], False
|
||
|
||
lock_payload = cls._inventory_advisory_lock_payload(lookup)
|
||
with connection.cursor() as cursor:
|
||
cursor.execute(
|
||
'SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))',
|
||
[lock_payload],
|
||
)
|
||
|
||
rows = list(
|
||
cls.objects.select_for_update().filter(**lookup)[:2]
|
||
)
|
||
if len(rows) > 1:
|
||
raise RuntimeError(
|
||
f'{cls.__name__} 数据异常:库存业务键 {lookup} 命中多条'
|
||
)
|
||
if rows:
|
||
return rows[0], False
|
||
|
||
return cls.objects.create(
|
||
**lookup,
|
||
**create_defaults,
|
||
), True
|
||
|
||
|
||
@property
|
||
def count_mioing(self):
|
||
return MIOItem.objects.filter(mb=self, mio__submit_time__isnull=True).aggregate(count=Sum('count'))['count'] or 0
|
||
|
||
class MaterialBatchA(BaseModel):
|
||
"""
|
||
TN:组合件物料批次
|
||
"""
|
||
batch = models.TextField('批次号', db_index=True)
|
||
material = models.ForeignKey(
|
||
Material, on_delete=models.CASCADE, verbose_name='物料')
|
||
rate = models.PositiveIntegerField('比例', default=1)
|
||
mb = models.ForeignKey(
|
||
MaterialBatch, verbose_name='关联物料批次', on_delete=models.CASCADE, related_name='a_mb')
|
||
|
||
|
||
MIO_TYPE_PREFIX = {
|
||
'do_in': 'SCRK', # 生产入库
|
||
'do_out': 'SCLL', # 生产领料
|
||
'sale_out': 'XSFH', # 销售发货
|
||
'pur_in': 'CGRK', # 采购入库
|
||
'pur_out': 'CGTH', # 采购退货
|
||
'borrow_out': 'LYCK', # 领用出库
|
||
'return_in': 'THRK', # 退还入库
|
||
'other_in': 'QTRK', # 其他入库
|
||
'other_out': 'QTCK' # 其他出库
|
||
}
|
||
|
||
class MIO(CommonBDModel):
|
||
"""
|
||
TN:出入库记录
|
||
"""
|
||
MIO_TYPE_DO_OUT = 'do_out'
|
||
MIO_TYPE_SALE_OUT = 'sale_out'
|
||
MIO_TYPE_PUR_IN = 'pur_in'
|
||
MIO_TYPE_PUR_OUT = 'pur_out'
|
||
MIO_TYPE_DO_IN = 'do_in'
|
||
MIO_TYPE_OTHER_IN = 'other_in'
|
||
MIO_TYPE_OTHER_OUT = 'other_out'
|
||
MIO_TYPE_BORROW_OUT = 'borrow_out'
|
||
MIO_TYPE_RETURN_IN = 'return_in'
|
||
|
||
MIO_TYPES = (
|
||
(MIO_TYPE_DO_OUT, '生产领料'),
|
||
(MIO_TYPE_SALE_OUT, '销售发货'),
|
||
(MIO_TYPE_PUR_IN, '采购入库'),
|
||
(MIO_TYPE_PUR_OUT, '采购退货'),
|
||
(MIO_TYPE_DO_IN, '生产入库'),
|
||
(MIO_TYPE_BORROW_OUT, '领用出库'),
|
||
(MIO_TYPE_RETURN_IN, '退还入库'),
|
||
(MIO_TYPE_OTHER_IN, '其他入库'),
|
||
(MIO_TYPE_OTHER_OUT, '其他出库')
|
||
)
|
||
MIO_CREATE = 10
|
||
MIO_SUBMITED = 20
|
||
MIO_STATES = (
|
||
(MIO_CREATE, '创建中'),
|
||
(MIO_SUBMITED, '已提交')
|
||
)
|
||
number = models.CharField('编号', max_length=20, unique=True)
|
||
state = models.PositiveSmallIntegerField(
|
||
'状态', choices=MIO_STATES, default=10, help_text=str(MIO_CREATE))
|
||
type = models.CharField('出入库类型', max_length=10, default=MIO_TYPE_DO_OUT,
|
||
choices=MIO_TYPES, help_text=str(MIO_TYPES))
|
||
mgroup = models.ForeignKey(Mgroup, verbose_name='出入库工段', on_delete=models.CASCADE, null=True, blank=True)
|
||
inout_date = models.DateField('出入库日期', null=True, blank=True)
|
||
supplier = models.ForeignKey(
|
||
Supplier, verbose_name='供应商', on_delete=models.CASCADE, null=True, blank=True)
|
||
customer = models.ForeignKey(
|
||
Customer, verbose_name='客户', on_delete=models.CASCADE, null=True, blank=True)
|
||
pu_order = models.ForeignKey(
|
||
PuOrder, verbose_name='关联采购订单', on_delete=models.CASCADE, null=True, blank=True)
|
||
order = models.ForeignKey(
|
||
Order, verbose_name='关联订单', on_delete=models.CASCADE, null=True, blank=True)
|
||
note = models.CharField('备注', max_length=1000, default='')
|
||
expiration_date = models.DateField('有效期', null=True, blank=True)
|
||
submit_time = models.DateTimeField('提交时间', null=True, blank=True)
|
||
submit_user = models.ForeignKey(
|
||
User, verbose_name='提交人', related_name='submit_user_mio', on_delete=models.CASCADE, null=True, blank=True)
|
||
mio_user = models.ForeignKey(
|
||
User, verbose_name='仓库执行人', related_name='mio_user_mio', on_delete=models.CASCADE, null=True, blank=True)
|
||
do_user = models.ForeignKey(
|
||
User, verbose_name='车间执行人', related_name='do_user_mio', on_delete=models.CASCADE, null=True, blank=True)
|
||
materials = models.ManyToManyField(
|
||
Material, verbose_name='物料明细', through='inm.mioitem', blank=True)
|
||
|
||
@classmethod
|
||
def get_a_number(cls, mio_type:str):
|
||
today_str = datetime.now().strftime('%Y%m%d')
|
||
prefix = MIO_TYPE_PREFIX[mio_type]
|
||
last_record = MIO.objects.filter(number__startswith=f"{prefix}-{today_str}") \
|
||
.aggregate(Max('number'))['number__max']
|
||
if last_record:
|
||
last_number = int(last_record.split('-')[-1]) + 1
|
||
else:
|
||
last_number = 1
|
||
return f"{prefix}-{today_str}-{last_number:04d}"
|
||
|
||
class Pack(BaseModel):
|
||
"""
|
||
TN:装箱单
|
||
"""
|
||
index = models.PositiveSmallIntegerField('序号', default=1)
|
||
mio = models.ForeignKey(MIO, verbose_name='关联出入库记录', on_delete=models.CASCADE, related_name='pack_mio')
|
||
|
||
class MIOItem(BaseModel):
|
||
"""
|
||
TN:出入库明细
|
||
"""
|
||
mio = models.ForeignKey(MIO, verbose_name='关联出入库',
|
||
on_delete=models.CASCADE, related_name='item_mio')
|
||
mb = models.ForeignKey(MaterialBatch, verbose_name='关联仓库库存',
|
||
on_delete=models.SET_NULL, related_name='mb_mioitem', null=True, blank=True)
|
||
wm = models.ForeignKey("wpm.wmaterial", verbose_name='关联车间库存',
|
||
on_delete=models.SET_NULL, related_name='wm_mioitem', null=True, blank=True)
|
||
warehouse = models.ForeignKey(
|
||
WareHouse, on_delete=models.CASCADE, verbose_name='仓库')
|
||
material = models.ForeignKey(
|
||
Material, verbose_name='物料', on_delete=models.CASCADE)
|
||
batch = models.TextField('批次号', db_index=True)
|
||
unit_price = models.DecimalField('单价', max_digits=14, decimal_places=2, null=True, blank=True)
|
||
count = models.DecimalField('出入数量', max_digits=12, decimal_places=3)
|
||
count_send = models.DecimalField('发出数量', max_digits=12, decimal_places=3, null=True, blank=True)
|
||
count_tested = models.PositiveIntegerField('已检数', null=True, blank=True)
|
||
test_date = models.DateField('检验日期', null=True, blank=True)
|
||
test_user = models.ForeignKey(
|
||
User, verbose_name='检验人', on_delete=models.CASCADE, null=True, blank=True)
|
||
test_note = models.TextField('检验备注', default='', blank=True)
|
||
count_bag = models.PositiveIntegerField('总袋数', default=0)
|
||
count_sampling = models.PositiveIntegerField('抽样数', default=0)
|
||
weight_kgs = models.JSONField('称重记录', default=list)
|
||
|
||
count_notok = models.PositiveIntegerField('不合格数', default=0)
|
||
count_n_zw = models.PositiveIntegerField('炸纹', default=0)
|
||
count_n_tw = models.PositiveIntegerField('条纹', default=0)
|
||
count_n_qp = models.PositiveIntegerField('气泡', default=0)
|
||
count_n_wq = models.PositiveIntegerField('弯曲', default=0)
|
||
count_n_dl = models.PositiveIntegerField('断裂', default=0)
|
||
count_n_pb = models.PositiveIntegerField('偏壁', default=0)
|
||
count_n_dxt = models.PositiveIntegerField('大小头', default=0)
|
||
count_n_js = models.PositiveIntegerField('结石', default=0)
|
||
count_n_qx = models.PositiveIntegerField('气线', default=0)
|
||
count_n_zz = models.PositiveIntegerField('杂质', default=0)
|
||
count_n_ysq = models.PositiveIntegerField('颜色青', default=0)
|
||
count_n_hs = models.PositiveIntegerField('划伤', default=0)
|
||
count_n_b = models.PositiveIntegerField('扁', default=0)
|
||
count_n_jsqx = models.PositiveIntegerField('结石气线', default=0) # 废弃字段
|
||
count_n_qt = models.PositiveIntegerField('其他', default=0)
|
||
|
||
is_testok = models.BooleanField('检验是否合格', null=True, blank=True)
|
||
note = models.TextField('备注', null=True, blank=True)
|
||
pack_index = models.PositiveSmallIntegerField('装箱序号', null=True, blank=True)
|
||
|
||
# 以下字段暂时不用
|
||
pack = models.ForeignKey(Pack, verbose_name='关联装箱单', on_delete=models.SET_NULL, related_name='mioitem_pack', null=True, blank=True)
|
||
|
||
@classmethod
|
||
def count_fields(cls):
|
||
mioitem_count_fields = []
|
||
for f in MIOItem._meta.fields:
|
||
if f.name.startswith("count"):
|
||
mioitem_count_fields.append(f.name)
|
||
return mioitem_count_fields
|
||
|
||
@property
|
||
def mioitemw(self):
|
||
return MIOItemw.objects.filter(mioitem=self)
|
||
|
||
class MIOItemA(BaseModel):
|
||
"""
|
||
TN:组合件出入库明细
|
||
"""
|
||
material = models.ForeignKey(
|
||
Material, verbose_name='物料', on_delete=models.CASCADE)
|
||
batch = models.TextField('批次号', db_index=True)
|
||
rate = models.PositiveIntegerField('比例', default=1)
|
||
mioitem = models.ForeignKey(
|
||
MIOItem, verbose_name='关联出入库明细', on_delete=models.CASCADE, related_name='a_mioitem')
|
||
|
||
zhuidu = models.FloatField('锥度', null=True, blank=True)
|
||
b_zuidawaijing = models.FloatField('最大外径', null=True, blank=True)
|
||
g_zuidaneijing = models.FloatField('最大内径', null=True, blank=True)
|
||
|
||
|
||
class MIOItemw(BaseModel):
|
||
"""
|
||
TN:单件记录
|
||
"""
|
||
number = models.TextField('编号')
|
||
number_out = models.TextField('对外编号', null=True, blank=True)
|
||
wpr = models.ForeignKey("wpmw.wpr", verbose_name='关联产品', on_delete=models.SET_NULL, related_name='wpr_mioitemw'
|
||
, null=True, blank=True)
|
||
mioitem = models.ForeignKey(MIOItem, verbose_name='关联出入库明细', on_delete=models.CASCADE, related_name='w_mioitem')
|
||
ftest = models.ForeignKey("qm.ftest", verbose_name='关联检验记录', on_delete=models.PROTECT,
|
||
related_name='mioitemw_ftest', null=True, blank=True)
|
||
note = models.TextField('备注', null=True, blank=True)
|
||
|