Files
myaps_api/apps/data_opt/components/yonyou_tplus.py
T
2026-05-20 22:26:08 +08:00

1190 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
用友T+ 接口组件
"""
import json, time, inspect, asyncio
from typing import Dict, Any, Literal, Optional, NamedTuple, Type, Callable
from datetime import datetime, timedelta, date
import pandas as pd
from pydantic.v1.errors import cls_kwargs
from core.settings import MYAPS_MAIN_DB
from . import ApsPayloadSponsor, EventResultPoster
from ._base import (
PydanticModel, JSONManager,
logger,
DataProcessor, globalconst, PROJECT_JSON_FILE, pdv,
convert_timeunit, clean_value,
model_validator, Field,
AcceptMaterial, AcceptWorkcenter, AcceptMatVer, AcceptMatWc, AcceptMatWcBom,
AcceptMold, AcceptMatWcMold, AcceptSupply, AcceptConfirm,
db_query, TSupply, TDemand, ExternalBaseConnection, BaseSource, BaseVoucher, MoVoucher, RsVoucher, ExternalData, ExternalDataSet,
async_rate_limit, async_service_operation, batch_service_operation
)
CACHE_ERP = PROJECT_JSON_FILE.get("erp", {})
#################################################################################
# 数据规范模型
#################################################################################
"""
以下模型适用于 清洗转换 从T+获取的数据用于向HAP发送
需要客户在HAP中填写的字段统一设为 Optional[str/int/...] = Field(None)。
在 @model_validator 中需要将:
无法通过处理原生数据获取的联合索引字段设为 "🈳❗" 占位,以保证能构成完整的联合索引
"""
class MaterialPullModel(AcceptMaterial):
size: Optional[str] = Field(None) # 需要客户在HAP中填写的字段统一设为 None。
candelay: Optional[str] = Field(None) # 需要客户在HAP中填写的字段统一设为 None。
lotsize: Optional[str] = Field(None)
class Config:
extra = 'allow'
@model_validator(mode="before")
@classmethod
def model_valid(cls, values: Dict[str, Any]):
# values = super().model_valid(values)
cleaned_values = {}
cleaned_values['materialno'] = clean_value(values['编码'])
cleaned_values['description'] = clean_value(values['名称'])
cleaned_values['size'] = values['规格型号']
cleaned_values['plant'] = pdv.MAT_PLANT
cleaned_values['planner'] = pdv.MAT_PLANNER
cleaned_values['fifo'] = pdv.MAT_FIFO
cleaned_values['leadday'] = pdv.MAT_LEADDAY_E if values['是否需要检验'] else pdv.MAT_LEADDAY_F
cleaned_values['expday'] = convert_timeunit(values.get('保质期', 0), values['保质期单位'], 'day')
cleaned_values['grday'] = 1 if values['是否需要检验'] else 0
cleaned_values['abc'] = globalconst.AbcEnum.A if values['是否自制'] == 'True' else globalconst.AbcEnum.B
cleaned_values['unit'] = clean_value(values['主计量单位Name'])
cleaned_values['price'] = values['平均成本']
cleaned_values['groupno'] = str(values['存货分类Name'])
cleaned_values['type'] = globalconst.EfEnum.E if values['是否自制'] == 'True' else globalconst.EfEnum.F
cleaned_values['phantom'] = globalconst.YesNoEnum.YES if values['是否虚拟件'] else globalconst.YesNoEnum.NO
# cleaned_values['phantommin'] = values['']
# cleaned_values['firmday'] = values['']
# cleaned_values['daygap'] = values['']
# cleaned_values['candelay'] = globalconst.YesNoEnum.YES
# cleaned_values['lotsize'] = values['']
# cleaned_values['lotfix'] = values['']
# cleaned_values['lotmin'] = values['']
# cleaned_values['lotmax'] = values['']
# cleaned_values['lotround'] = values['']
# cleaned_values['lotss'] = values['']
# cleaned_values['lotpoint'] = values['']
# cleaned_values['lottop'] = values['']
# cleaned_values['planitem'] = values['']
# cleaned_values['preday'] = values['']
# cleaned_values['subday'] = values['']
# cleaned_values['memo'] = values['']
# cleaned_values['free1'] = values['']
# cleaned_values['free2'] = values['']
# cleaned_values['free3'] = values['']
values = cleaned_values
return values
class WorkcenterPullModel(AcceptWorkcenter):
class Config:
extra = 'allow'
@model_validator(mode="before")
@classmethod
def model_valid(cls, values: Dict[str, Any]):
cleaned_values = {}
cleaned_values['workcenter'] = values['编码']
cleaned_values['workcentername'] = values['名称']
# cleaned_values['pri_wc'] = values['']
# cleaned_values['bottleneck'] = values['']
# cleaned_values['sortno'] = values['']
# cleaned_values['plant'] = values['']
# cleaned_values['location'] = values['']
# cleaned_values['finite'] = values['']
# cleaned_values['type'] = values['']
# cleaned_values['capnum'] = values['']
# cleaned_values['capmax'] = values['']
# cleaned_values['worker'] = values['']
# cleaned_values['setupno'] = values['']
# cleaned_values['grpno'] = values['']
return cleaned_values
class RoutePullModel(AcceptMatWc):
matver: Optional[str] = Field(None)
itemno: Optional[str] = Field(None)
basesec: Optional[int] = Field(None)
workcenter: Optional[str] = Field(None)
class Config:
extra = 'allow'
@model_validator(mode="before")
@classmethod
def model_valid(cls, values: Dict[str, Any]):
cleaned_values = {}
cleaned_values['materialno'] = values['父件编码']
cleaned_values['matver'] = "🈳❗"
cleaned_values['workcenter'] = values['工作中心']
cleaned_values['itemno'] = values['工序编码']
cleaned_values['sortno'] = clean_value(values['加工顺序'])
# cleaned_values['basesec'] = clean_value(values[''])
# cleaned_values['fixqty'] = values['']
# cleaned_values['fixsec'] = values['']
# cleaned_values['sf'] = values['']
# cleaned_values['offsetsec'] = values['']
# cleaned_values['rate'] = values['']
return cleaned_values
class BomPullModel(AcceptMatWcBom):
matver: Optional[str] = Field(None)
itemno: Optional[str] = Field(None)
class Config:
extra = 'allow'
@model_validator(mode="before")
@classmethod
def model_valid(cls, values: Dict[str, Any]):
cleaned_values = {}
cleaned_values['productno'] = values['父件编码']
cleaned_values['matver'] = "🈳❗"
cleaned_values['itemno'] = "🈳❗"
cleaned_values['materialno'] = values['子件编码']
cleaned_values['qty'] = values['需用数量']
# cleaned_values['offsethour'] = values['']
# cleaned_values['mto'] = values['']
cleaned_values['scrap'] = values['损耗率']
# cleaned_values['alt'] = values['']
cleaned_values['denominator'] = values['生产数量']
cleaned_values['pu'] = values['计量单位'] or ''
cleaned_values['cu'] = values['子件计量单位'] or ''
# cleaned_values[''] = values['']
return cleaned_values
class StockPullModel(AcceptSupply):
type: str = Field('ST')
priority: int = Field(0)
status: str = Field('CRE')
class Config:
extra = 'allow'
@model_validator(mode="before")
@classmethod
def model_valid(cls, values: Dict[str, Any]):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cleaned_values = {}
cleaned_values['materialno'] = values['存货编码']
cleaned_values['supplyno'] = values['存货编码']
cleaned_values['itemno'] = "stock"
cleaned_values['avail_qty'] = values['现存量']
cleaned_values['create_date'] = now
cleaned_values['avail_date'] = now
cleaned_values['dt_req'] = now
cleaned_values['category'] = 'MTS'
cleaned_values['type'] = 'ST'
cleaned_values['priority'] = 0
cleaned_values['status'] = 'CRE'
return cleaned_values
class MoPushModel(PydanticModel):
"""
整理推送T+MO数据
"""
ExternalCode: Optional[str] = Field(None)
BusiType: dict = Field(None)
Department: dict = Field(None)
Customer: dict = Field(None)
StartDate: str = Field()
FinishDate: str = Field()
VoucherDate: str = Field()
Memo: Optional[str] = Field(None)
IsMaterialRequest: bool = Field(True)
ManufactureOrderDetails: list[dict] = Field()
class Config:
extra = 'allow'
@model_validator(mode="before")
@classmethod
def model_valid(cls, values: Dict[str, Any]):
cleaned_values = {}
demand_list = values.get('demand_list')
orderwc_list = values.get('orderwc')
mopd = []
if orderwc_list:
i = 1
for orderwc in orderwc_list:
mopd.append({
'Inventory': {'Code': orderwc['materialno']},
# 'Unit': {'Name': orderwc.get('unit', "")},
'Unit': {'Name': values.get('unit', "")},
'Quantity': orderwc['orderqty'],
'ProcessingType': {"Code": ""},
'Process': {"Code": orderwc['itemno']},
'SequenceNumber': i,
})
i += 1
momd = []
if demand_list:
for demand in demand_list:
momd.append({
'Inventory': {'Code': demand['materialno']},
'Unit': {'Name': demand.get('unit', "")},
'SonNeededQuantity': demand['req_qty'] * -1,
'SonScaleQuantity': demand['req_qty'] * -1,
'Quantity': demand['req_qty'] * -1,
'IsMaterialRequest': True, # 启用领料申请(明细行)
})
cleaned_values['ExternalCode'] = values['supplyno']
cleaned_values['StartDate'] = values['dt_ordstart']
cleaned_values['FinishDate'] = values['dt_ordend']
cleaned_values['BusiType'] = {'Code': CACHE_ERP.get("$MoBusiType", "")}
# cleaned_values['Department'] = {'Code': CACHE_ERP.get("$MoDepartment", "")}
cleaned_values['VoucherDate'] = values['dt_ordstart']
cleaned_values['IsMaterialRequest'] = True # 启用领料申请(MO单据头)
cleaned_values['Memo'] = values['vendorno']
mod = {
'Inventory': {'Code': values['materialno']},
'Unit': {'Name': values.get('unit', "")},
'Quantity': values['avail_qty'],
'PreStartDate': values['dt_ordstart'],
'PreFinishDate': values['dt_ordend'],
'ManufactureOrderMaterialDetails': momd,
'ManufactureOrderProcessDetails': mopd
}
so = values.get('so')
if so:
partnerno = so.get('partnerno')
# if partnerno:
# cleaned_values['Customer'] = {'Code': partnerno}
cleaned_values['Customer'] = {'Code': partnerno}
so_entryid = so.get('apiex_entryid')
if so_entryid:
# mod['SaleOrderCode'] = so.get('demandno', "")
mod['idsourceVoucherType'] = "43" # 销售订单
mod['SourceVoucherDetailId'] = so_entryid
cleaned_values['ManufactureOrderDetails'] = [mod]
return cleaned_values
class RsPushModel(PydanticModel):
"""
整理推送T+领料申请数据
"""
ExternalCode: str = Field()
VoucherType: dict = Field()
VoucherDate: str = Field()
BusiType: dict = Field()
Department: dict = Field()
MaterialRequestDetails: list[dict] = Field()
class Config:
extra = 'allow'
@model_validator(mode="before")
@classmethod
def model_valid(cls, values: Dict[str, Any]):
cleaned_values = {}
cleaned_values['ExternalCode'] = values['demandno']
cleaned_values['VoucherType'] = {"Code": "ST1039"}
cleaned_values['VoucherDate'] = values[globalconst.StaticString.MERGE_ENTRIY_KEY.value][0]['req_date']
cleaned_values['BusiType'] = {"Code": "MR01"}
cleaned_values['Department'] = {"Code": values.get('tplus_mo_data', {}).get('Department', {}).get('Code', "")}
aps_demand_qty = {_['materialno']: _ for _ in values[globalconst.StaticString.MERGE_ENTRIY_KEY.value]}
tplus_material_details = values["mo_material_details"]
mr_details = []
for md in tplus_material_details:
mr = {}
materialno = md['Inventory']['Code']
mr['IdSourceVoucherType'] = "69"
mr['SourceVoucherId'] = values['tplus_mo_id']
# mr['SourceVoucherDetailId'] = values['tplus_mo_entryid']
mr['SourceVoucherDetailId'] = md['ID']
mr['Inventory'] = {'Code': materialno}
mr['BaseQuantity'] = abs(aps_demand_qty.get(materialno, {}).get('req_qty', 0))
mr_details.append(mr)
cleaned_values['MaterialRequestDetails'] = mr_details
return cleaned_values
class PrPushModel(PydanticModel):
"""
整理推送T+请购单数据
"""
ExternalCode: Optional[str] = Field(None)
Code: Optional[str] = Field(None)
VoucherDate: Optional[str] = Field(None)
RequisitionPerson: dict = Field(...)
PurchaseRequisitionDetails: list[dict] = Field(...)
class Config:
extra = 'allow'
@model_validator(mode="before")
@classmethod
def model_valid(cls, values: Dict[str, Any]):
now_stamp = str(int(time.time() * 10000000))
vo_date = datetime.now().strftime("%Y-%m-%d")
cleaned_values = {
'VoucherDate': None, # 单据日期需要校验,不能晚于最早的物料需求日期
'ExternalCode': now_stamp,
'Code': now_stamp,
'RequisitionPerson': {"Code": CACHE_ERP.get("$RequisitionPerson", "")}
}
# 处理直接传递列表的情况
if isinstance(values, list):
data_list = values
else:
# 处理通过 data 关键字参数传递的情况
if 'data' in values:
data_list = values['data']
else:
data_list = values
prd = []
earliest_req_date = vo_date
for _ in data_list:
# 确保日期字段是字符串格式
# 校验并更新最早的物料需求日期
avail_date = _['avail_date']
if isinstance(avail_date, (date, datetime)):
avail_date = avail_date.strftime('%Y-%m-%d')
if avail_date < earliest_req_date:
earliest_req_date = avail_date
prd.append({
'Inventory': {'Code': _['materialno']},
'Unit': {},
'Quantity': _['avail_qty'],
'RequireDate': avail_date,
})
cleaned_values['PurchaseRequisitionDetails'] = prd
cleaned_values['VoucherDate'] = earliest_req_date
return cleaned_values
#################################################################################
# 用友T+连接及配置
#################################################################################
class TplusConfig:
"""
⬆️缓存文件用于存储畅捷通认证信息。文件包含如下结构用于T+的认证:
{
"erp": {
"app_key": "...",
"app_secret": "...",
"access_token": "...",
"refresh_token": "...",
"org_id": "",
"_auth_at_": "2023-12-01 00:00:00"
}
}
"""
def __init__(self, cache_file: str | JSONManager = PROJECT_JSON_FILE):
if isinstance(cache_file, str):
self.cache_file = JSONManager(cache_file)
else:
self.cache_file = cache_file
cache_erp = self.cache_file.get("erp", {})
self.base_url = cache_erp.get("base_url", "https://openapi.chanjet.com")
self.token_expire_seconds = cache_erp.get("token_expire_seconds", 12 * 3600) # 设token有效期为12hr
# 默认分页大小,上限1000
self.max_page_size = min(cache_erp.get("max_page_size", 1000), 1000)
class YonyouTplusConnection(ExternalBaseConnection):
"""
畅捷通 T+ 系统连接类 - 继承自 ExternalBaseConnection
使用父类提供的限流、连接池和超时保护机制,
实现畅捷通 T+ API 的认证和请求处理。
配置建议:
- async_qps: 根据 T+ 服务器性能调整,建议不超过50
- pool_maxsize: 建议与 async_qps 相等或更大
"""
def __init__(self, config: TplusConfig=TplusConfig()):
"""
初始化畅捷通连接
Args:
config: TplusConfig实例
使用示例:
config = TplusConfig(max_qps=50)
conn = YonyouTplusConnection(config)
"""
# 调用父类初始化,传递限流和连接池参数
super().__init__(
async_qps=getattr(config, 'max_qps', None),
async_burst=getattr(config, 'max_burst', None),
pool_maxsize=getattr(config, 'max_qps', None) # 连接池大小与QPS匹配
)
self.config = config
self.base_url = self.config.base_url
self.cache_file = self.config.cache_file
# 从缓存文件中读取认证信息,并将其设置为类实例属性
self.credential_keys = ("app_key", "app_secret", "access_token", "refresh_token", "org_id", globalconst.StaticString.AUTH_AT.value)
cache_erp = self.cache_file.get("erp", {})
for key in self.credential_keys:
setattr(self, key, cache_erp.get(key, ""))
self._auth_lock = asyncio.Lock()
self._BOM_CODES = None # 缓存已处理的BOM编码,用于取工艺路线(因为 T+ 的工艺路线是抽象的,具体到物料的工艺路线是在 BOM 中定义的,而只有通过具体BOM编号查询BOM时,才会展示工艺路线详情
async def auth(self, force: bool = False, max_retries: int = 5):
"""
异步认证连接,支持重试机制
"""
assert self.access_token and self.refresh_token, "畅捷通token缺失"
if self._auth_at_:
expire_time = datetime.strptime(self._auth_at_, "%Y-%m-%d %H:%M:%S") + timedelta(seconds=self.config.token_expire_seconds)
if datetime.now() < expire_time and not force:
logger.debug(f"畅捷通token有效,有效期至:{expire_time.strftime('%Y-%m-%d %H:%M:%S')}")
return self.access_token
async with self._auth_lock:
if self._auth_at_:
expire_time = datetime.strptime(self._auth_at_, "%Y-%m-%d %H:%M:%S") + timedelta(seconds=self.config.token_expire_seconds)
if datetime.now() < expire_time and not force:
logger.debug(f"畅捷通token有效(锁后复检),有效期至:{expire_time.strftime('%Y-%m-%d %H:%M:%S')}")
return self.access_token
retry_count = 0
last_error = None
async_session = None
while retry_count < max_retries:
try:
async_session = await self._get_async_session()
try:
auth_response = await async_session.get(
url=f"{self.base_url}/auth/v2/refreshToken",
params={
"grantType": "refresh_token",
"refreshToken": self.refresh_token,
},
headers={
"appKey": self.app_key,
"appSecret": self.app_secret,
"Content-Type": "application/json",
},
timeout=60.0
)
except Exception as request_error:
logger.fail(f"认证请求失败: {type(request_error).__name__}: {str(request_error)}")
raise
if hasattr(auth_response, 'json'):
if inspect.iscoroutinefunction(auth_response.json):
auth_response = await auth_response.json()
else:
auth_response = auth_response.json()
auth_result = auth_response.get("result")
if int(auth_response.get("code", 0)) == 200 and auth_result:
self._auth_at_ = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.access_token = auth_result["access_token"]
self.refresh_token = auth_result["refresh_token"]
self.cache_file.update("erp", {
globalconst.StaticString.AUTH_AT.value: self._auth_at_,
"access_token": self.access_token,
"refresh_token": self.refresh_token})
self.cache_file.save()
logger.debug(f"畅捷通token刷新为:{self.access_token}")
if async_session:
if hasattr(async_session, 'aclose'):
await async_session.aclose()
elif hasattr(async_session, 'close'):
async_session.close()
return self.access_token
else:
raise Exception(auth_response.get("message", ""))
except Exception as e:
last_error = e
retry_count += 1
logger.warning(f"畅捷通认证失败(第{retry_count}/{max_retries}次): {str(e)}")
if async_session:
if hasattr(async_session, 'aclose'):
await async_session.aclose()
elif hasattr(async_session, 'close'):
async_session.close()
if retry_count >= max_retries:
break
await asyncio.sleep(2 ** retry_count)
logger.fail("畅捷通认证", str(last_error))
raise last_error
async def _get(self, endpoint: str, params: dict=None):
await self.auth()
async_session = await self._get_async_session()
headers = {
"appKey": self.app_key,
"appSecret": self.app_secret,
"openToken": self.access_token,
"Content-Type": "application/json",
}
response = await async_session.get(
f"{self.base_url}{endpoint}",
headers=headers,
params=params,
timeout=self._read_timeout
)
# if hasattr(response, 'json'):
# if inspect.iscoroutinefunction(response.json):
# response = await response.json()
# else:
response_json = response.json()
return response_json
async def _post(self, endpoint: str, data: dict):
"""
异步发送POST请求到畅捷通API
使用父类的会话复用、自适应超时和增强重试机制。
Args:
endpoint: API端点路径
data: 请求体数据
Returns:
响应JSON数据
Raises:
Exception: 请求失败时抛出异常
"""
await self.auth()
async_session = await self._get_async_session()
async def make_request():
headers = {
"appKey": self.app_key,
"appSecret": self.app_secret,
"openToken": self.access_token,
"Content-Type": "application/json",
}
response = await async_session.post(
f"{self.base_url}{endpoint}",
headers=headers,
json=data,
timeout=self._read_timeout
)
return response
response = await self.execute_with_timeout_protection(
make_request, # 传递函数引用而不是协程对象,支持重试
f"POST {endpoint}"
)
response_json = response.json()
if hasattr(response, 'status_code'):
status_code = response.status_code
if status_code >= 500 and status_code < 600:
if isinstance(response_json, dict):
err_msg = response_json.get("message") or status_code
else:
err_msg = status_code
raise Exception(f"HTTP 服务器错误: {err_msg}")
return response_json
async def _pull_simple_data(self, endpoint: str, field_hints: dict[str, str], filter: dict=None):
# await self.auth()
params = {
"PageIndex": 1,
"PageSize": self.config.max_page_size,
"SelectFields": ",".join(field_hints.keys()),
**filter,
}
if filter:
params.update(filter)
data_list = []
while True:
resp_json = await self._post(endpoint=endpoint, data={"param": params})
try:
raw_data = resp_json['Data']
except:
raw_data = resp_json
if not raw_data:
break
params["PageIndex"] += 1
ts_value = raw_data[-1].get("Ts") or raw_data[-1].get("TS")
params["Ts"] = ts_value
data_list.extend([{v: row.get(k) for k, v in field_hints.items()} for row in raw_data])
return data_list
#################################################################################
# ERP对象管理器
#################################################################################
class TplusMaterial(BaseSource):
_QUERY_BATCH_ENDPOINT = "/tplus/api/v2/inventory/Query"
_PULL_PYDANTIC_MODEL = MaterialPullModel
_FIELD_HINTS = {
"ID": "ID", "Disabled": "是否停用", "Code": "编码", "Name": "名称", "Specification": "规格型号",
"InventoryClassCode": "存货分类Code", "InventoryClassName": "存货分类Name",
"UnitName": "单位Name", "BaseUnitName": "主计量单位Name", "UnitByManufactureName": "生产常用单位Name",
"IsMaterial": "是否物料", "IsPurchase": "是否采购", "IsMadeSelf": "是否自制", "IsMadeRequest": "是否委外",
"IsSuite": "是否套件", "IsPhantom": "是否虚拟件", "AvagCost": "平均成本", "Expired": "保质期", "ExpiredUnitName": "保质期单位",
"IsNeedQualityInspection": "是否需要检验", "Ts": "时间戳",
}
@classmethod
async def query_batch(cls, disabled: bool = False, is_material: bool = False, ts: str = None):
"""
查询批量物料数据
"""
assert cls._CONNECTION, globalconst.StaticString.ASSERT_CONNECTION.value
await cls._CONNECTION.auth()
endpoint = cls._QUERY_BATCH_ENDPOINT
filter = {
"Disabled": disabled,
"IsMaterial": is_material,
"Ts": ts,
}
data = await cls._CONNECTION._pull_simple_data(
endpoint=endpoint,
field_hints=cls._FIELD_HINTS,
filter=filter
)
return ExternalDataSet(raw_data=data, pydantic_model=cls._PULL_PYDANTIC_MODEL)
class TplusWorkcenter(BaseSource):
_QUERY_BATCH_ENDPOINT = "/tplus/api/v2/WorkCenter/QueryPage"
_PULL_PYDANTIC_MODEL = WorkcenterPullModel
_FIELD_HINTS = {"ID": "ID", "Code": "编码", "Name": "名称", "Disabled": "是否停用"}
@classmethod
async def query_batch(cls):
"""
查询批量工作中心数据
"""
assert cls._CONNECTION, globalconst.StaticString.ASSERT_CONNECTION.value
await cls._CONNECTION.auth()
endpoint = cls._QUERY_BATCH_ENDPOINT
data = await cls._CONNECTION._pull_simple_data(
endpoint=endpoint,
field_hints=cls._FIELD_HINTS,
)
return ExternalDataSet(raw_data=data, pydantic_model=cls._PULL_PYDANTIC_MODEL)
class TplusStock(BaseSource):
_QUERY_BATCH_ENDPOINT = "/tplus/api/v2/currentStock/Query"
_PULL_PYDANTIC_MODEL = StockPullModel
_FIELD_HINTS = {"InventoryCode": "存货编码", "ExistingQuantity": "现存量", "TS": "时间戳"}
@classmethod
async def query_batch(cls, is_include_zero: bool = False):
"""
查询批量库存数据
"""
assert cls._CONNECTION, globalconst.StaticString.ASSERT_CONNECTION.value
await cls._CONNECTION.auth()
endpoint = cls._QUERY_BATCH_ENDPOINT
data = await cls._CONNECTION._pull_simple_data(
endpoint=endpoint,
field_hints=cls._FIELD_HINTS,
filter={"IsIncludeZero": is_include_zero}
)
return ExternalDataSet(raw_data=data, pydantic_model=cls._PULL_PYDANTIC_MODEL)
# @classmethod
# async def pull(cls, is_include_zero: bool = False, pydantic_model: PydanticModel = None):
# """
# 查询批量库存数据
# """
# stock_data: ExternalDataSet = await cls.query_batch(is_include_zero)
# # if stock_data.is_empty:
# # return []
# # else:
# pydantic_model = pydantic_model or cls._PULL_PYDANTIC_MODEL
# stock_data = await stock_data.dumps(pydantic_model=pydantic_model)
# timestamp = datetime.now().strftime('%m%d-%H%M')
# df = pd.DataFrame(stock_data)
# # 按materialno分组,avail_qty求和,其他字段取first
# sum_cols = ['avail_qty']
# first_cols = [col for col in df.columns if col not in ['materialno'] + sum_cols]
# agg_dict = {col: 'first' for col in first_cols}
# agg_dict.update({col: 'sum' for col in sum_cols})
# aggregated_stock = df.groupby('materialno').agg(agg_dict).reset_index()
# # 替换缺失值为None
# aggregated_stock = aggregated_stock.replace({pd.NA: None, pd.NaT: None, float('nan'): None})
# # 生成supplyno字段为materialno@timestamp
# aggregated_stock['supplyno'] = aggregated_stock['materialno'] + '@' + timestamp
# return aggregated_stock.to_dict(orient='records')
class TplusRouting(BaseSource):
_QUERY_ENDPOINT = "/tplus/api/v2/bom/Query"
_PULL_PYDANTIC_MODEL = RoutePullModel
_FIELD_HINTS = {
"ID": "ID", "Inventory / Code": "父件编码", "Inventory / Name": "父件名称", "BOMProcessDTOs / SequenceNumber": "加工顺序",
"BOMProcessDTOs / Process / Code": "工序编码", "BOMProcessDTOs / Process / Name": "工序名称", "BOMProcessDTOs / Process / KeyProcess": "是否关键工序",
"BOMProcessDTOs / Process / Workshop": "生产车间", "BOMProcessDTOs / Process / WorkCenter": "工作中心",
"BOMProcessDTOs / Process / Equipment": "生产设备", "BOMProcessDTOs / Process / StandardWorkingHours": "标准工时",
}
@classmethod
async def query_batch(cls, only_today: bool = False, filter: dict = None):
"""
查询批量工艺路线数据
"""
def process_route_data(data: dict, field_map: dict):
"""
处理工艺路线数据,提取产品编码、产品名称、详情
"""
flat_item = DataProcessor.expand_parent_child_data(data, 'BOMProcessDTOs')
processed_data = []
for row in flat_item:
processed_data.append({v: row.get(k) for k, v in field_map.items()})
return processed_data
async def get_route_by_bomcode_async(bom_code: str):
try:
payload = {
"dto": {"code": bom_code}
}
response = await self._CONNECTION._post(endpoint=endpoint, data=payload)
bom_data = response[0] if isinstance(response, list) and response else {}
return process_route_data(bom_data, field_map=self._FIELD_HINTS)
except Exception as e:
logger.fail("BOM处理", bom_code, str(e))
return []
assert cls._CONNECTION, globalconst.StaticString.ASSERT_CONNECTION.value
assert cls._CONNECTION._BOM_CODES, "请先拉取BOM数据,获取BOM CODES"
await cls._CONNECTION.auth()
endpoint = cls._QUERY_ENDPOINT
params = {
"PageIndex": 1,
"PageSize": cls._CONNECTION.config.max_page_size,
"SelectFields": ",".join(cls._FIELD_HINTS.keys()),
}
if only_today:
today = datetime.now().strftime("%Y-%m-%d")
params.update({"UpdateDateBegin": f"{today} 00:00:00", "UpdateDateEnd": f"{today} 23:59:59"})
data_list = []
# 使用 asyncio.gather 并发处理
tasks = [get_route_by_bomcode_async(bom_code) for bom_code in cls._CONNECTION._BOM_CODES]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, list):
data_list.extend(result)
elif isinstance(result, Exception):
logger.fail("BOM处理", "未知BOM", str(result))
cls._CONNECTION._BOM_CODES = None
return ExternalDataSet(raw_data=data_list, pydantic_model=cls._PULL_PYDANTIC_MODEL)
@classmethod
async def pull(cls, only_today: bool = False, pydantic_model: PydanticModel=None, filter: dict = None):
"""
查询批量工艺路线数据
"""
pydantic_model = pydantic_model or cls._PULL_PYDANTIC_MODEL
routing_data: ExternalDataSet = await cls.query_batch(only_today=only_today, filter=filter)
if routing_data.is_empty:
return []
else:
return routing_data.dumps(pydantic_model=pydantic_model)
class TplusBom(BaseSource):
_QUERY_BATCH_ENDPOINT = "/tplus/api/v2/bom/QueryPage"
_FIELD_HINTS = {
"ID": "ID", "Disabled": "是否停用", "Code": "父件编码", "Name": "父件名称", "Version": "版本号", "IsPhantom": "是否虚拟",
"Unit / Name": "计量单位", "ProduceQuantity": "生产数量", "BOMChilds / Code": "子件编码", "BOMChilds / Name": "子件名称",
"BOMChilds / Unit / Name": "子件计量单位", "BOMChilds / RequiredQuantity": "需用数量", "BOMChilds / WasteRate": "损耗率",
}
_PULL_PYDANTIC_MODEL = BomPullModel
@classmethod
async def query_batch(cls, only_today: bool = False, filter: dict = None):
async def process_bomdata_async(bomdata_list: list, field_map: dict):
"""
处理BOM数据,提取产品编码、产品名称、组件编码、组件名称、组件数量
"""
cls._CONNECTION._BOM_CODES = set[str]()
processed_data = []
for item in bomdata_list:
cls._CONNECTION._BOM_CODES.add(item['Code'])
flat_item = DataProcessor.expand_parent_child_data(item, 'BOMChilds')
for row in flat_item:
processed_data.append({v: row.get(k) for k, v in field_map.items()})
return processed_data
assert cls._CONNECTION, globalconst.StaticString.ASSERT_CONNECTION.value
await cls._CONNECTION.auth()
params = {
"PageIndex": 1,
"PageSize": 100, # 数据量太大,单次不宜太多。官方默认值为20,最大支持500
}
if only_today:
today = datetime.now().strftime("%Y-%m-%d")
params.update({"UpdateDateBegin": f"{today} 00:00:00", "UpdateDateEnd": f"{today} 23:59:59"})
if filter:
params.update(filter)
data_list = []
while True:
response = await cls._CONNECTION._post(endpoint=cls._QUERY_BATCH_ENDPOINT, data={"param": params})
resp_json = await response.json()
try:
raw_data = resp_json['Data']
except:
raw_data = resp_json
if not raw_data:
break
params["PageIndex"] += 1
data_list.extend(await process_bomdata_async(raw_data, field_map=cls._FIELD_HINTS))
return ExternalDataSet(raw_data=data_list, pydantic_model=cls._PULL_PYDANTIC_MODEL)
class TplusMo(MoVoucher):
"""
生产加工单
"""
# from . import CacheItem
_QUERY_ENDPOINT = "/tplus/api/v2/ManufactureOrderOpenApi/GetVoucherDTO"
_CREATE_ENDPOINT = "/tplus/api/v2/ManufactureOrderOpenApi/Create"
_APPROVE_ENDPOINT = "/tplus/api/v2/ManufactureOrderOpenApi/Audit"
# _PULL_PYDANTIC_MODEL = MoPullModel
# _FIELD_HINTS = {"ID": "ID", "Code": "编码", "ExternalCode": "外部编码"}
_PUSH_PYDANTIC_MODEL = MoPushModel
_DOCUMENTATION_URL = None
@classmethod
@async_rate_limit()
# @async_service_operation(module="T+接口", operation="创建生产加工单")
async def create(
cls,
event_data: dict,
_aps: ApsPayloadSponsor,
_erp: EventResultPoster,
pydantic_model: Type[PydanticModel] = None,
remain_native_supplyno: bool = True,
**kwargs
):
# mo_create_response_json = {}
try:
endpoint = cls._CREATE_ENDPOINT
supplyno = event_data.get('supplyno')
task1 = _aps.get_demand_datalist(demandno=supplyno)
task2 = _aps.get_supplymo_detaildata(supplyno=supplyno, get_next_mo=True, get_origin_so=True)
demand_list, supplymo_detaildata = await asyncio.gather(task1, task2, return_exceptions=True)
if isinstance(demand_list, Exception):
raise demand_list
if isinstance(supplymo_detaildata, Exception):
raise supplymo_detaildata
supplymo_detaildata['demand_list'] = demand_list
pydantic_model = pydantic_model or cls._PUSH_PYDANTIC_MODEL
# dto = InternalData(data=supplymo_detaildata).dump(pydantic_model=pydantic_model)
dto = pydantic_model(**supplymo_detaildata).model_dump(exclude_none=True)
if remain_native_supplyno:
dto['Code'] = supplyno
payload = {"dto": dto}
mo_create_response_json = await cls._CONNECTION._post(endpoint=endpoint, data=payload)
if str(mo_create_response_json['code']) == '0': # 响应错误码为0MO 创建成功
response_data = mo_create_response_json['data']
tplus_mo_id = response_data['ID']
tplus_mo_code = supplyno if remain_native_supplyno else response_data['Code']
auto_approve = kwargs.get('auto_approve', True)
if auto_approve:
# 审批 MO ,要在领料申请前批准
_x_a = await cls.approve(tplus_moid=tplus_mo_id)
# 查询推送成功的 MO 在 T+ 中的详情
tplus_mo_data = await TplusMo.query(index_value=tplus_mo_id)
tplus_mo_data = tplus_mo_data.raw_data
# 从 T+ 中提取 MO 详情中的第一个详情记录的 ID 作为 _entryid
tplus_mo_entryid = tplus_mo_data['ManufactureOrderDetails'][0]['ID']
# 调用存储过程更改工单信息,❗一定放在最后一步,否则工单号变更太早,前面若有用原生供应号查询都会失败
_x_b = await _erp.mo_release_success(
native_plno=supplyno,
msg=mo_create_response_json['message'],
msg_from='T+',
mono=tplus_mo_code,
_id=tplus_mo_id,
_entryid=tplus_mo_entryid
)
else:
_x_c = await _erp.mo_release_failed(
native_plno=supplyno,
msg=mo_create_response_json['message'],
push_data=payload,
msg_from='T+'
)
except Exception as e:
# msg = mo_create_response_json.get('message', str(e))
logger.warning("创建生产加工单失败", str(e))
await _erp.mo_release_failed(native_plno=supplyno, msg=str(e))
@classmethod
async def approve(cls, tplus_moid: str):
assert cls._CONNECTION, globalconst.StaticString.ASSERT_CONNECTION.value
await cls._CONNECTION.auth()
endpoint = cls._APPROVE_ENDPOINT
payload = {"param": {"VoucherID": tplus_moid}}
resp_json = await cls._CONNECTION._post(endpoint=endpoint, data=payload)
if str(resp_json['code']) == '0': # 响应错误码为0MO 审批成功
logger.success("MO审批", tplus_moid)
return True
else:
logger.warning_msg(f"MO{tplus_moid}审批失败", resp_json['message'])
return False
@classmethod
async def query(
cls,
index_value: str | int,
filter_field: Literal['voucherID', 'voucherCode', 'externalCode']='voucherID'
):
assert cls._CONNECTION, globalconst.StaticString.ASSERT_CONNECTION.value
await cls._CONNECTION.auth()
payload = {"param": {filter_field: index_value}}
resp_json = await cls._CONNECTION._post(endpoint=cls._QUERY_ENDPOINT, data=payload)
return ExternalData(raw_data=resp_json['data'], pydantic_model=cls._PULL_PYDANTIC_MODEL)
class TplusRs(RsVoucher):
"""
领料申请单
"""
from . import CacheItem
_CREATE_ENDPOINT = "/tplus/api/v2/MaterialRequestOpenApi/Create"
_PUSH_PYDANTIC_MODEL = RsPushModel
_DOCUMENTATION_URL = None
def __init__(self, supplymo_data: dict):
"""
初始化领料申请对象
Args:
supplymo_data: APS 供应数据
"""
super().__init__(supplymo_data)
@classmethod
@async_rate_limit()
# @async_service_operation(module="T+接口", operation="创建领料申请")
async def create(
cls,
event_data: dict,
_aps: ApsPayloadSponsor,
_erp: EventResultPoster,
pydantic_model: Type[PydanticModel] = None,
**kwargs
):
# rs_create_response_json = {}
try:
endpoint = cls._CREATE_ENDPOINT
pydantic_model = pydantic_model or cls._PUSH_PYDANTIC_MODEL
rs_no = event_data.get('supplyno')
tplus_mo_id = event_data.get('apiex_id')
rs_data_list = await _aps.get_demand_datalist(demandno=rs_no)
tplus_mo_data = await TplusMo.query(index_value=tplus_mo_id)
tplus_mo_data = tplus_mo_data.raw_data
processed_rsdata = DataProcessor.merge_common_fields(
data=rs_data_list,
merge_with=["demandno", "type", "status", "create_date"],
entries_key=globalconst.StaticString.MERGE_ENTRIY_KEY.value
)
mo_id = tplus_mo_data['ID']
# mo_code = tplus_mo_data['Code']
# mo_depart_code = tplus_mo_data.get('Department', {}).get('Code', '')
tplus_mo_entryid = tplus_mo_data['ManufactureOrderDetails'][0]['ID']
mo_material_details = tplus_mo_data['ManufactureOrderDetails'][0]['ManufactureOrderMaterialDetails']
# mo_material_details_id = mo_material_details[0]['ID']
processed_rsdata['tplus_mo_id'] = mo_id
processed_rsdata['tplus_mo_entryid'] = tplus_mo_entryid
processed_rsdata['tplus_mo_data'] = tplus_mo_data
# processed_rsdata['mo_material_details_id'] = mo_material_details_id
processed_rsdata['mo_material_details'] = mo_material_details
dto = pydantic_model(**processed_rsdata).model_dump()
if dto["MaterialRequestDetails"]: # 有领料申请详情
payload = {"dto": dto}
logger.debug(f"向 T+ 推送领料申请,发送数据:{json.dumps(payload, ensure_ascii=False)}")
rs_create_response_json = await cls._CONNECTION._post(endpoint=endpoint, data=payload)
if str(rs_create_response_json['code']) == '0': # 创建成功
await _erp.rs_release_success(rsno=rs_no, msg=rs_create_response_json['message'], msg_from='T+', _code=rs_create_response_json['data'].get('Code'), _id=rs_create_response_json['data'].get('ID'))
else:
await _erp.rs_release_failed(rsno=rs_no, msg=rs_create_response_json['message'], push_data=processed_rsdata, msg_from='T+')
else:
await _erp.rs_release_success(rsno=rs_no, msg="无领料申请详情", msg_from='APS')
except Exception as e:
# msg = rs_create_response_json.get('message', str(e))
logger.warning_msg(f"创建领料申请单失败", str(e))
await _erp.rs_release_failed(rsno=rs_no, msg=str(e), push_data=processed_rsdata, msg_from='T+')
class TplusPr(BaseVoucher):
"""
请购单
"""
_CREATE_ENDPOINT = "/tplus/api/v2/PurchaseRequisitionOpenApi/Create"
_DELETE_ENDPOINT = "/tplus/api/v2/PurchaseRequisitionOpenApi/Delete"
_APPROVE_ENDPOINT = "/tplus/api/v2/PurchaseRequisitionOpenApi/Audit"
_PUSH_PYDANTIC_MODEL = PrPushModel
_DOCUMENTATION_URL = None
@classmethod
@async_rate_limit()
# @batch_service_operation(module="T+接口", operation="推送请购单")
async def create(
cls,
event_data_list: list[dict],
_erp: EventResultPoster,
pydantic_model: Type[PydanticModel] = None,
**kwargs
):
if not event_data_list:
return
assert cls._CONNECTION, globalconst.StaticString.ASSERT_CONNECTION.value
await cls._CONNECTION.auth()
try:
pydantic_model = pydantic_model or cls._PUSH_PYDANTIC_MODEL
logger.info(f"开始推送请购单,共 {len(event_data_list)} 条原始数据")
pr_batches = await ApsPayloadSponsor.aggregate_pr_data(pr_data_list=event_data_list)
for batch_idx, (batch_agg_data, batch_supplynos) in enumerate(pr_batches, 1):
logger.update("推送请购单", f"批次{batch_idx}/{len(pr_batches)}",
f"处理 {len(batch_agg_data)} 条聚合数据,对应 {len(batch_supplynos)} 条原始记录")
try:
tplus_pr_data = pydantic_model(data=batch_agg_data).model_dump(exclude_none=True)
payload = {"dto": tplus_pr_data}
endpoint = cls._CREATE_ENDPOINT
pr_create_response_json = await cls._CONNECTION._post(endpoint=endpoint, data=payload)
if str(pr_create_response_json['code']) == '0':
tplus_pr_id = pr_create_response_json['data'].get('ID')
tplus_pr_code = pr_create_response_json['data'].get('Code')
await _erp.pr_release_success(
prno=batch_supplynos,
msg=pr_create_response_json['message'],
msg_from='T+',
_code=tplus_pr_code,
_id=tplus_pr_id
)
auto_approve = kwargs.get('auto_approve', True)
if auto_approve:
await cls.approve(tplus_pr_code=tplus_pr_code)
logger.success("推送请购单", f"批次{batch_idx}", f"成功")
else:
await _erp.pr_release_failed(
prno=batch_supplynos,
msg=pr_create_response_json['message'],
msg_from='T+'
)
logger.warning("推送请购单", f"批次{batch_idx}", pr_create_response_json['message'])
except Exception as e:
logger.fail("推送请购单", f"批次{batch_idx}", str(e))
await _erp.pr_release_failed(
prno=batch_supplynos,
msg=str(e),
msg_from='T+'
)
except Exception as e:
all_prnos = [item['supplyno'] for item in event_data_list]
logger.fail("推送请购单", "整体失败", str(e))
await _erp.pr_release_failed(prno=all_prnos, msg=str(e), msg_from='T+')
@classmethod
async def approve(cls, tplus_pr_code: str):
assert cls._CONNECTION, globalconst.StaticString.ASSERT_CONNECTION.value
endpoint = cls._APPROVE_ENDPOINT
payload = {"param": {'voucherCode': tplus_pr_code}}
response_json = await cls._CONNECTION._post(endpoint=endpoint, data=payload)
if str(response_json['code']) == '0': # 审批成功
logger.success("请购单审批", tplus_pr_code)
else:
logger.warning_msg(f"请购单{tplus_pr_code}审批失败", response_json['message'])