Prevent duplicate warehouse inventory creation
This commit is contained in:
parent
6068f2315d
commit
89cc999c42
|
|
@ -1,4 +1,6 @@
|
||||||
from django.db import models
|
import json
|
||||||
|
|
||||||
|
from django.db import connection, models
|
||||||
from apps.utils.models import BaseModel, CommonBModel, CommonBDModel, CommonADModel
|
from apps.utils.models import BaseModel, CommonBModel, CommonBDModel, CommonADModel
|
||||||
from apps.pum.models import Supplier, PuOrder
|
from apps.pum.models import Supplier, PuOrder
|
||||||
from apps.sam.models import Customer, Order
|
from apps.sam.models import Customer, Order
|
||||||
|
|
@ -38,6 +40,113 @@ class MaterialBatch(BaseModel):
|
||||||
material_ofrom = models.ForeignKey(Material, verbose_name='原料物料', on_delete=models.SET_NULL, null=True, blank=True, related_name='mb_mofrom')
|
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)
|
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
|
@property
|
||||||
def count_mioing(self):
|
def count_mioing(self):
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ def do_in(item: MIOItem):
|
||||||
|
|
||||||
# 增加mb
|
# 增加mb
|
||||||
if not is_zhj:
|
if not is_zhj:
|
||||||
mb, _ = MaterialBatch.objects.get_or_create(
|
mb, _ = MaterialBatch.locked_get_or_create_inventory(
|
||||||
material=xmaterial,
|
material=xmaterial,
|
||||||
warehouse=item.warehouse,
|
warehouse=item.warehouse,
|
||||||
batch=xbatch,
|
batch=xbatch,
|
||||||
|
|
@ -258,7 +258,7 @@ def do_in(item: MIOItem):
|
||||||
|
|
||||||
|
|
||||||
if is_zhj: # 组合件单独处理并且不做追踪单个处理
|
if is_zhj: # 组合件单独处理并且不做追踪单个处理
|
||||||
mb, is_created = MaterialBatch.objects.get_or_create(
|
mb, is_created = MaterialBatch.locked_get_or_create_inventory(
|
||||||
material=item.material,
|
material=item.material,
|
||||||
warehouse=item.warehouse,
|
warehouse=item.warehouse,
|
||||||
batch=item.batch,
|
batch=item.batch,
|
||||||
|
|
@ -415,7 +415,7 @@ class InmService:
|
||||||
state = WMaterial.WM_OK
|
state = WMaterial.WM_OK
|
||||||
if defect and defect.okcate in [Defect.DEFECT_NOTOK]:
|
if defect and defect.okcate in [Defect.DEFECT_NOTOK]:
|
||||||
state = WMaterial.WM_NOTOK
|
state = WMaterial.WM_NOTOK
|
||||||
mb, _ = MaterialBatch.objects.get_or_create(
|
mb, _ = MaterialBatch.locked_get_or_create_inventory(
|
||||||
material=material,
|
material=material,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
batch=batch,
|
batch=batch,
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ def daoru_mb(path: str):
|
||||||
process=process,
|
process=process,
|
||||||
defaults={"type": type, "name": name, "specification": specification, "model": model, "process": process, "number": ranstr(6), "id": idWorker.get_id()},
|
defaults={"type": type, "name": name, "specification": specification, "model": model, "process": process, "number": ranstr(6), "id": idWorker.get_id()},
|
||||||
)
|
)
|
||||||
MaterialBatch.objects.get_or_create(
|
MaterialBatch.locked_get_or_create_inventory(
|
||||||
material=material, batch=batch, warehouse=warehouse, defaults={"material": material, "batch": batch, "warehouse": warehouse, "count": count, "id": idWorker.get_id()}
|
material=material, batch=batch, warehouse=warehouse, defaults={"material": material, "batch": batch, "warehouse": warehouse, "count": count, "id": idWorker.get_id()}
|
||||||
)
|
)
|
||||||
i = i + 1
|
i = i + 1
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,115 @@
|
||||||
from django.test import TestCase
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from decimal import Decimal
|
||||||
|
from threading import Barrier
|
||||||
|
from unittest import skipUnless
|
||||||
|
|
||||||
# Create your tests here.
|
from django.db import connection, connections, transaction
|
||||||
|
from django.test import SimpleTestCase, TransactionTestCase
|
||||||
|
|
||||||
|
from apps.inm.models import MaterialBatch, WareHouse
|
||||||
|
from apps.mtm.models import Material
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialBatchInventoryKeyTests(SimpleTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.material = Material(id='100', name='测试物料')
|
||||||
|
self.warehouse = WareHouse(
|
||||||
|
id='200',
|
||||||
|
number='TEST',
|
||||||
|
name='测试仓库',
|
||||||
|
place='测试地点',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_inventory_key_normalizes_omitted_optional_fields(self):
|
||||||
|
omitted = MaterialBatch._normalize_inventory_lookup(
|
||||||
|
material=self.material,
|
||||||
|
batch='BATCH-001',
|
||||||
|
warehouse=self.warehouse,
|
||||||
|
)
|
||||||
|
explicit = MaterialBatch._normalize_inventory_lookup(
|
||||||
|
material=self.material,
|
||||||
|
batch='BATCH-001',
|
||||||
|
warehouse=self.warehouse,
|
||||||
|
state=10,
|
||||||
|
defect=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(omitted, explicit)
|
||||||
|
self.assertEqual(
|
||||||
|
MaterialBatch._inventory_advisory_lock_payload(omitted),
|
||||||
|
MaterialBatch._inventory_advisory_lock_payload(explicit),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_inventory_key_requires_material_batch_and_warehouse(self):
|
||||||
|
required_values = {
|
||||||
|
'material': self.material,
|
||||||
|
'batch': 'BATCH-001',
|
||||||
|
'warehouse': self.warehouse,
|
||||||
|
}
|
||||||
|
|
||||||
|
for field in required_values:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
lookup = required_values.copy()
|
||||||
|
lookup[field] = None
|
||||||
|
with self.assertRaisesRegex(ValueError, field):
|
||||||
|
MaterialBatch._normalize_inventory_lookup(**lookup)
|
||||||
|
|
||||||
|
def test_inventory_key_rejects_unknown_fields(self):
|
||||||
|
with self.assertRaisesRegex(TypeError, 'supplier'):
|
||||||
|
MaterialBatch._normalize_inventory_lookup(
|
||||||
|
material=self.material,
|
||||||
|
batch='BATCH-001',
|
||||||
|
warehouse=self.warehouse,
|
||||||
|
supplier=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@skipUnless(
|
||||||
|
connection.vendor == 'postgresql',
|
||||||
|
'advisory lock concurrency test requires PostgreSQL',
|
||||||
|
)
|
||||||
|
class MaterialBatchConcurrencyTests(TransactionTestCase):
|
||||||
|
def test_concurrent_first_create_uses_one_inventory_record(self):
|
||||||
|
material = Material.objects.create(name='仓库并发测试物料')
|
||||||
|
warehouse = WareHouse.objects.create(
|
||||||
|
number='CONCURRENT',
|
||||||
|
name='并发测试仓库',
|
||||||
|
place='测试地点',
|
||||||
|
)
|
||||||
|
barrier = Barrier(2)
|
||||||
|
|
||||||
|
def create_inventory():
|
||||||
|
connections.close_all()
|
||||||
|
try:
|
||||||
|
barrier.wait()
|
||||||
|
with transaction.atomic():
|
||||||
|
mb, created = (
|
||||||
|
MaterialBatch.locked_get_or_create_inventory(
|
||||||
|
material=material,
|
||||||
|
batch='CONCURRENT-001',
|
||||||
|
warehouse=warehouse,
|
||||||
|
defaults={'count': Decimal('0')},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mb.count += Decimal('1')
|
||||||
|
mb.save(update_fields=['count'])
|
||||||
|
return created
|
||||||
|
finally:
|
||||||
|
connections.close_all()
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
created_results = list(executor.map(
|
||||||
|
lambda _: create_inventory(),
|
||||||
|
range(2),
|
||||||
|
))
|
||||||
|
|
||||||
|
queryset = MaterialBatch.objects.filter(
|
||||||
|
material=material,
|
||||||
|
batch='CONCURRENT-001',
|
||||||
|
warehouse=warehouse,
|
||||||
|
state=10,
|
||||||
|
defect=None,
|
||||||
|
)
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertEqual(queryset.get().count, Decimal('2'))
|
||||||
|
self.assertCountEqual(created_results, [True, False])
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ def ftestwork_submit(ins:FtestWork, user: User):
|
||||||
mbstate = WMaterial.WM_OK
|
mbstate = WMaterial.WM_OK
|
||||||
if item.defect.okcate == Defect.DEFECT_NOTOK:
|
if item.defect.okcate == Defect.DEFECT_NOTOK:
|
||||||
mbstate = WMaterial.WM_NOTOK
|
mbstate = WMaterial.WM_NOTOK
|
||||||
mbx, new_create = MaterialBatch.objects.get_or_create(
|
mbx, new_create = MaterialBatch.locked_get_or_create_inventory(
|
||||||
material=mb.material,
|
material=mb.material,
|
||||||
warehouse=mb.warehouse,
|
warehouse=mb.warehouse,
|
||||||
batch=mb.batch,
|
batch=mb.batch,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue