< Summary

Information
Class: Services.ApiServices._1CApi.OData
Assembly: Services
File(s): File 1: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataCatalogModels.cs
File 2: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataDocumentModels.cs
File 3: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataDocumentResponse.cs
File 4: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataExpenseDocument.cs
File 5: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataExpenseRequest.cs
File 6: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataReceiptRequest.cs
File 7: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataTransferRequest.cs
File 8: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCDocumentPostingException.cs
File 9: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCDocumentPostingMessage.cs
File 10: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCDocumentViewService.cs
File 11: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureCreationLock.cs
File 12: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureMapStore.cs
File 13: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureMapSyncJob.cs
File 14: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureService.cs
File 15: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureService.Resolver.cs
File 16: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataConfig.cs
File 17: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataConstants.cs
File 18: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataError.cs
File 19: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataErrorFormatter.cs
File 20: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataHttpClient.cs
File 21: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataReferenceService.cs
File 22: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCReceiptDuplicateFinder.cs
File 23: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCRegisterEndpoint.cs
File 24: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCResponseErrors.cs
File 25: /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCTransferShortage.cs
Line coverage
54%
Covered lines: 1011
Uncovered lines: 848
Coverable lines: 1859
Total lines: 3739
Line coverage: 54.3%
Branch coverage
58%
Covered branches: 461
Total branches: 787
Branch coverage: 58.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: get_RefKey()100%11100%
File 1: get_Description()100%11100%
File 1: get_Inn()100%11100%
File 1: get_DeletionMark()100%11100%
File 1: get_RefKey()100%11100%
File 1: get_Description()100%11100%
File 1: get_Inn()100%11100%
File 1: get_RegistrationNumber()100%11100%
File 1: get_Identifier()100%22100%
File 1: get_RefKey()100%11100%
File 1: get_Description()100%11100%
File 1: get_RefKey()100%11100%
File 1: get_Description()100%11100%
File 1: get_Article()100%11100%
File 1: get_IsFolder()100%11100%
File 1: get_StockAccountKey()100%11100%
File 1: get_RefKey()100%210%
File 1: get_PropertyKey()100%210%
File 1: get_Value()100%210%
File 1: get_RefKey()100%11100%
File 1: get_Description()100%11100%
File 1: get_OwnerKey()100%11100%
File 1: get_DeletionMark()100%11100%
File 1: get_RefKey()100%210%
File 1: get_Description()100%210%
File 1: get_RefKey()100%11100%
File 1: get_Description()100%11100%
File 1: get_Owner()100%11100%
File 1: get_OrganizationKey()100%11100%
File 1: get_RefKey()100%11100%
File 1: get_Description()100%11100%
File 1: get_RefKey()100%11100%
File 1: get_Description()100%11100%
File 1: get_RegistrationNumber()100%11100%
File 1: get_Value()100%11100%
File 1: get_Error()100%11100%
File 1: get_Code()100%11100%
File 1: get_Message()100%11100%
File 1: get_Lang()100%11100%
File 1: get_Value()100%11100%
File 2: get_RefKey()100%210%
File 2: get_Number()100%210%
File 2: get_Date()100%210%
File 2: get_Posted()100%210%
File 2: get_DeletionMark()100%210%
File 2: get_CounterpartyKey()100%210%
File 2: get_IncomingDocNumber()100%210%
File 2: get_IncomingDocDate()100%210%
File 2: get_OrganizationKey()100%210%
File 2: get_WarehouseKey()100%210%
File 2: get_RefKey()100%210%
File 2: get_Number()100%210%
File 2: get_Date()100%210%
File 2: get_Posted()100%210%
File 2: get_DeletionMark()100%210%
File 2: get_WarehouseSenderKey()100%210%
File 2: get_WarehouseRecipientKey()100%210%
File 2: get_Comment()100%210%
File 2: get_AuthorKey()100%210%
File 2: get_OrganizationKey()100%210%
File 2: get_RefKey()100%210%
File 2: get_Number()100%210%
File 2: get_Date()100%210%
File 2: get_Posted()100%210%
File 2: get_DeletionMark()100%210%
File 2: get_CounterpartyKey()100%210%
File 2: get_Amount()100%210%
File 2: get_Purpose()100%210%
File 2: get_OperationType()100%210%
File 2: get_RefKey()100%210%
File 2: get_Description()100%210%
File 2: get_Inn()100%210%
File 2: get_Kpp()100%210%
File 2: get_DeletionMark()100%210%
File 2: get_IsFolder()100%210%
File 2: get_RefKey()100%210%
File 2: get_Description()100%210%
File 2: get_Article()100%210%
File 2: get_IsFolder()100%210%
File 2: get_DeletionMark()100%210%
File 2: get_RefKey()100%210%
File 2: get_Description()100%210%
File 2: get_DeletionMark()100%210%
File 2: get_RefKey()100%210%
File 2: get_Description()100%210%
File 2: get_Inn()100%210%
File 2: get_Kpp()100%210%
File 2: get_DeletionMark()100%210%
File 2: get_RefKey()100%210%
File 2: get_Description()100%210%
File 2: get_Owner()100%210%
File 2: get_DeletionMark()100%210%
File 2: get_Recorder()100%11100%
File 2: get_RecorderType()100%11100%
File 2: get_RecordSet()100%11100%
File 2: get_Period()100%11100%
File 2: get_RecordType()100%11100%
File 2: get_NomenclatureKey()100%11100%
File 2: get_WarehouseKey()100%11100%
File 2: get_Quantity()100%11100%
File 2: get_OrganizationKey()100%11100%
File 2: get_NomenclatureKey()100%11100%
File 2: get_WarehouseKey()100%11100%
File 2: get_QuantityBalance()100%11100%
File 2: get_CostBalance()100%11100%
File 2: get_NomenclatureKey()100%11100%
File 2: get_WarehouseKey()100%11100%
File 2: get_QuantityReceipt()100%11100%
File 2: get_QuantityExpense()100%11100%
File 2: get_NomenclatureKey()100%11100%
File 2: get_RefKey()100%11100%
File 2: get_Quantity()100%11100%
File 2: get_Price()100%11100%
File 2: get_RefKey()100%210%
File 2: get_LineNumber()100%210%
File 2: get_NomenclatureKey()100%210%
File 2: get_Quantity()100%210%
File 2: get_SourceReservePlaceKey()100%210%
File 2: get_SourceReservePlaceType()100%210%
File 2: get_TargetReservePlaceKey()100%210%
File 2: get_TargetReservePlaceType()100%210%
File 2: get_RefKey()100%11100%
File 2: get_Number()100%11100%
File 2: get_Date()100%11100%
File 2: get_DeletionMark()100%11100%
File 2: get_Posted()100%11100%
File 2: get_WarehouseKey()100%11100%
File 2: get_CounterpartyKey()100%11100%
File 2: get_Lines()100%11100%
File 2: get_OperationKey()100%210%
File 2: get_Date()100%210%
File 2: get_OrganizationKey()100%210%
File 2: get_WarehouseKey()100%210%
File 2: get_OperationKey()100%210%
File 2: get_Comment()100%210%
File 2: get_Lines()100%210%
File 2: get_LineNumber()100%210%
File 2: get_NomenclatureKey()100%210%
File 2: get_CharacteristicKey()100%210%
File 2: get_BatchKey()100%210%
File 2: get_UnitKey()100%210%
File 2: get_UnitType()100%210%
File 2: get_ActualQuantity()100%210%
File 2: get_BookQuantity()100%210%
File 2: get_Difference()100%210%
File 2: get_Surplus()100%210%
File 2: get_Shortage()100%210%
File 2: get_Price()100%210%
File 2: get_Amount()100%210%
File 2: get_BookAmount()100%210%
File 2: get_CellKey()100%210%
File 2: get_WarehouseRecipientKey()100%11100%
File 2: get_Comment()100%210%
File 2: get_Lines()100%11100%
File 2: get_CounterpartyKey()100%11100%
File 3: get_RefKey()100%210%
File 3: get_Number()100%210%
File 3: get_Date()100%210%
File 3: get_Posted()100%210%
File 3: get_DeletionMark()100%210%
File 3: get_RefKey()100%210%
File 3: get_Number()100%210%
File 3: get_Posted()100%210%
File 3: get_Comment()100%210%
File 4: get_RefKey()100%210%
File 4: get_Number()100%210%
File 4: get_Date()100%210%
File 4: get_Posted()100%210%
File 4: get_DeletionMark()100%210%
File 4: get_CounterpartyKey()100%210%
File 4: get_ContractKey()100%210%
File 4: get_OrganizationKey()100%210%
File 4: get_WarehouseKey()100%210%
File 4: get_OperationKind()100%210%
File 4: get_TotalAmount()100%210%
File 4: get_VatAmount()100%210%
File 4: get_Comment()100%210%
File 4: get_CustomerOrderKey()100%210%
File 4: get_RefKey()100%210%
File 4: get_Number()100%210%
File 4: get_Date()100%210%
File 4: get_Posted()100%210%
File 4: get_DeletionMark()100%210%
File 4: get_OrganizationKey()100%210%
File 4: get_WarehouseSenderKey()100%210%
File 4: get_WarehouseRecipientKey()100%210%
File 4: get_OperationKind()100%210%
File 4: get_TotalAmount()100%210%
File 4: get_Comment()100%210%
File 4: get_LineNumber()100%210%
File 4: get_NomenclatureKey()100%210%
File 4: get_CharacteristicKey()100%210%
File 4: get_BatchKey()100%210%
File 4: get_WarehouseKey()100%210%
File 4: get_Quantity()100%210%
File 4: get_Price()100%210%
File 4: get_Amount()100%210%
File 4: get_LineNumber()100%210%
File 4: get_NomenclatureKey()100%210%
File 4: get_CharacteristicKey()100%210%
File 4: get_BatchKey()100%210%
File 4: get_WarehouseKey()100%210%
File 4: get_Quantity()100%210%
File 4: get_PackageQuantity()100%210%
File 4: get_Price()100%210%
File 4: get_Amount()100%210%
File 4: get_VatAmount()100%210%
File 4: get_VatRateKey()100%210%
File 4: get_PackageCoefficient()100%210%
File 4: get_UnitKey()100%210%
File 5: get_Date()100%210%
File 5: get_OrganizationKey()100%210%
File 5: get_CounterpartyKey()100%210%
File 5: get_ContractKey()100%210%
File 5: get_WarehouseKey()100%210%
File 5: get_DivisionKey()100%210%
File 5: get_OperationType()100%210%
File 5: get_VatIncludedInCost()100%210%
File 5: get_AmountIncludesVat()100%210%
File 5: get_IsUniversalDocument()100%210%
File 5: get_TotalAmount()100%210%
File 5: get_Comment()100%210%
File 5: get_Lines()100%210%
File 5: get_LineNumber()100%210%
File 5: get_NomenclatureKey()100%210%
File 5: get_CharacteristicKey()100%210%
File 5: get_UnitKey()100%210%
File 5: get_UnitType()100%210%
File 5: get_Quantity()100%210%
File 5: get_Price()100%210%
File 5: get_Amount()100%210%
File 5: get_VatAmount()100%210%
File 5: get_TotalAmount()100%210%
File 5: get_DiscountPercent()100%210%
File 5: get_DiscountAmount()100%210%
File 5: get_VatRateKey()100%210%
File 5: get_WarehouseKey()100%210%
File 5: get_OrderKey()100%210%
File 5: get_OrderType()100%210%
File 5: get_ReceiptDocKey()100%210%
File 5: get_BatchKey()100%210%
File 5: get_ProjectKey()100%210%
File 5: get_BuyerOrderKey()100%210%
File 6: get_Date()100%11100%
File 6: get_IncomingDocNumber()100%11100%
File 6: get_IncomingDocDate()100%11100%
File 6: get_OrganizationKey()100%11100%
File 6: get_CounterpartyKey()100%11100%
File 6: get_ContractKey()100%11100%
File 6: get_WarehouseKey()100%11100%
File 6: get_OperationType()100%11100%
File 6: get_AmountIncludesVat()100%11100%
File 6: get_Comment()100%11100%
File 6: get_Lines()100%11100%
File 6: get_LineNumber()100%11100%
File 6: get_NomenclatureKey()100%11100%
File 6: get_UnitKey()100%11100%
File 6: get_UnitType()100%11100%
File 6: get_CharacteristicKey()100%11100%
File 6: get_CountryOfOriginKey()100%11100%
File 6: get_GtdNumberKey()100%11100%
File 6: get_Quantity()100%11100%
File 6: get_Price()100%11100%
File 6: get_DiscountPercent()100%11100%
File 6: get_DiscountAmount()100%11100%
File 6: get_Amount()100%11100%
File 6: get_VatRateKey()100%11100%
File 6: get_VatAmount()100%11100%
File 6: get_TotalAmount()100%11100%
File 6: get_WarehouseKey()100%11100%
File 6: get_OrderKey()100%11100%
File 6: get_OrderType()100%11100%
File 7: get_Date()100%210%
File 7: get_OrganizationKey()100%210%
File 7: get_WarehouseSenderKey()100%210%
File 7: get_WarehouseRecipientKey()100%210%
File 7: get_Comment()100%210%
File 7: get_Lines()100%210%
File 7: get_LineNumber()100%210%
File 7: get_NomenclatureKey()100%11100%
File 7: get_CharacteristicKey()100%11100%
File 7: get_UnitKey()100%11100%
File 7: get_UnitType()100%11100%
File 7: get_Quantity()100%11100%
File 7: get_WarehouseSenderKey()100%11100%
File 8: .ctor(...)100%11100%
File 8: get_Endpoint()100%11100%
File 8: get_Detail()100%11100%
File 8: get_FullDetail()100%44100%
File 9: .cctor()100%11100%
File 9: IsUninformative(...)100%22100%
File 9: DocumentTitle(...)100%44100%
File 9: Build(...)100%66100%
File 10: .cctor()100%210%
File 10: .ctor(...)100%210%
File 10: GetDocumentAsync()0%2040%
File 10: FindByNumberAsync()0%7280%
File 10: TryFetchDocumentAsync()0%2162460%
File 10: ResolveNameAsync()0%4260%
File 10: ParseDate(...)0%2040%
File 10: get_RefKey()100%210%
File 10: get_Number()100%210%
File 10: get_Date()100%210%
File 10: get_Posted()100%210%
File 10: get_DeletionMark()100%210%
File 10: get_OrganizationKey()100%210%
File 10: get_WarehouseKey()100%210%
File 10: get_OperationKind()100%210%
File 10: get_TotalAmount()100%210%
File 10: get_Comment()100%210%
File 10: get_RefKey()100%210%
File 10: get_CounterpartyKey()100%210%
File 10: get_ContractKey()100%210%
File 10: get_VatAmount()100%210%
File 10: get_Description()100%210%
File 10: get_RefKey()100%210%
File 10: get_DocTypeLabel()100%210%
File 10: get_Number()100%210%
File 10: get_Date()100%210%
File 10: get_Posted()100%210%
File 10: get_DeletionMark()100%210%
File 10: get_CounterpartyName()100%210%
File 10: get_ContractName()100%210%
File 10: get_OrganizationName()100%210%
File 10: get_WarehouseName()100%210%
File 10: get_WarehouseRecipientName()100%210%
File 10: get_OperationKind()100%210%
File 10: get_Comment()100%210%
File 10: get_TotalAmount()100%210%
File 10: get_VatAmount()100%210%
File 10: get_Lines()100%210%
File 10: get_LineNumber()100%210%
File 10: get_NomenclatureName()100%210%
File 10: get_NomenclatureArticle()100%210%
File 10: get_CharacteristicName()100%210%
File 10: get_BatchName()100%210%
File 10: get_WarehouseName()100%210%
File 10: get_Quantity()100%210%
File 10: get_Price()100%210%
File 10: get_Amount()100%210%
File 10: get_VatAmount()100%210%
File 11: .ctor(...)100%210%
File 11: AcquireAsync()0%2040%
File 11: BuildLockName(...)100%210%
File 11: AddParameter(...)100%210%
File 11: .ctor(...)100%210%
File 11: DisposeAsync()0%620%
File 12: get_RefKey()100%11100%
File 12: get_RefKey()100%11100%
File 12: .ctor(...)100%210%
File 12: LoadAllAsync()100%210%
File 12: FindAsync()0%2040%
File 12: FindByArticleAsync()0%620%
File 12: FindByRefKeysAsync()0%4260%
File 12: UpsertAsync()100%210%
File 12: BulkUpsertAsync()0%156120%
File 12: GetAllKeysAsync()0%620%
File 12: FindProducerAliasAsync()0%2040%
File 12: UpsertProducerAliasAsync()0%4260%
File 12: BuildPlaceholders(...)0%4260%
File 12: ToEntry(...)100%210%
File 12: Truncate(...)0%2040%
File 13: .ctor(...)100%210%
File 13: ExecuteAsync()0%930300%
File 13: PickWinner(...)100%210%
File 13: IsPostable(...)0%620%
File 13: get_RefKey()100%210%
File 14: get_TransactionType()100%11100%
File 14: ResolveManyAsync()100%22100%
File 14: get_RefKey()100%11100%
File 14: .cctor()100%11100%
File 14: .ctor(...)100%44100%
File 14: GetOrCreateAsync()100%44100%
File 14: SignalAsync()100%22100%
File 14: AuditAsync()50%22100%
File 14: ShadowMatchAsync()100%44100%
File 14: LogShadow(...)100%44100%
File 14: Services-ApiServices-_1CApi-Nomenclature-INomenclatureLiveLookup-FindByArticleAsync()37.5%23838.46%
File 14: CreateNewCardAsync()100%9873.68%
File 14: GetOrCreateCoreAsync()91.66%242497.14%
File 14: GetOrCreateManyAsync()100%11100%
File 14: <GetOrCreateManyAsync()100%11100%
File 14: FindAsync()100%11100%
File 14: FindCoreAsync()95.45%2222100%
File 14: FindManyAsync()100%11100%
File 14: <FindManyAsync()100%11100%
File 14: NormalizeArticle(...)100%22100%
File 14: ResolveArticle(...)85.71%1414100%
File 14: NormalizeProducerName(...)100%22100%
File 14: get_RefKey()100%11100%
File 14: ResolveProducerAliasAsync()100%9878.57%
File 14: RememberAsync()100%2255.55%
File 14: FindExistingAsync()70%332068%
File 14: FetchCandidatesAsync()87.5%88100%
File 14: ReadProducersAsync()100%22100%
File 14: ReadProducerAsync()75%121287.5%
File 14: TryFindProducerAsync()100%22100%
File 14: GetOrCreateProducerAsync()91.66%121295.83%
File 14: InferProducerByContainment(...)100%88100%
File 14: SetProducerAsync()62.5%8895.23%
File 14: EnsureRequiredFieldsAsync()52.94%353491.66%
File 14: BuildOriginComment(...)87.5%88100%
File 14: BuildNomenclatureBody(...)100%11100%
File 14: EscapeOData(...)100%11100%
File 15: ResolveManyAsync()83.33%6693.75%
File 15: <ResolveManyAsync()100%11100%
File 15: ResolveOneAsync()94.44%221877.41%
File 15: RejectionNotificationKind(...)83.33%6688.88%
File 15: NotificationScope(...)71.42%7780%
File 15: NotifyAsync()25%11423.07%
File 15: LogResolver(...)100%88100%
File 15: AfterReuseAsync()36.36%2262225%
File 15: CreateResolvedAsync()66.66%121290.9%
File 15: ResolveProducerForCreationAsync()62.5%242491.66%
File 15: CreationArticleFor(...)90%1010100%
File 16: get_BaseUrl()100%11100%
File 16: get_User()100%11100%
File 16: get_Password()100%11100%
File 16: get_BackfillProducerOnReuse()100%11100%
File 16: get_ResolverMode()100%11100%
File 17: IsDefaultGtd(...)100%22100%
File 18: .ctor(...)100%11100%
File 18: get_Method()100%11100%
File 18: get_Endpoint()100%11100%
File 18: get_StatusCode()100%11100%
File 18: get_ReasonPhrase()100%210%
File 18: get_Code()100%11100%
File 18: get_Message()100%11100%
File 18: get_RawBody()100%11100%
File 18: ToDetailText()50%22100%
File 18: From(...)100%66100%
File 18: .ctor(...)100%11100%
File 18: get_Error()100%11100%
File 19: Format(...)64.28%211466.66%
File 19: TryExtractODataCode(...)90%111080%
File 19: TryExtractODataMessage(...)62.5%13857.14%
File 20: .cctor()100%11100%
File 20: .ctor(...)100%11100%
File 20: CreateClient()100%11100%
File 20: GetListAsync()70%101089.47%
File 20: GetByGuidAsync()100%22100%
File 20: GetSinglePageAsync()83.33%1212100%
File 20: GetPageAsync()83.33%1212100%
File 20: GetCountAsync()33.33%6680%
File 20: GetRawJsonAsync()100%22100%
File 20: PostAsync()50%22100%
File 20: PostDocumentAsync()100%66100%
File 20: PatchAsync()66.66%6692.85%
File 20: SetPostedAsync(...)100%210%
File 20: PutAsync()100%11100%
File 20: BuildQuery(...)100%1414100%
File 20: HasRefKey(...)100%66100%
File 20: EnsureSuccess(...)100%44100%
File 20: LogSwallowed(...)50%3240%
File 20: ExecuteWithRetryAsync()75%9880%
File 20: ShouldRetry(...)100%44100%
File 20: TruncateError(...)75%44100%
File 21: .cctor()100%11100%
File 21: .ctor(...)100%11100%
File 21: GetOrganizationGuidByInnAsync()0%2040%
File 21: GetCounterpartyGuidByInnAsync()100%11100%
File 21: GetWarehouseGuidByNameAsync()0%2040%
File 21: GetNomenclatureGuidByArticleAsync()0%620%
File 21: <GetNomenclatureGuidByArticleAsync()0%2040%
File 21: GetCountryGuidByNameAsync()0%2040%
File 21: GetGtdGuidByNumberAsync()0%2040%
File 21: EscapeOData(...)100%11100%
File 21: GetVatRateGuidByRateAsync()0%2040%
File 21: GetNoVatRateGuidAsync()0%620%
File 21: GetWarehousesAsync()100%210%
File 21: GetCountriesAsync()100%210%
File 21: GetGtdNumbersAsync()100%210%
File 21: GetAllContractsAsync()100%210%
File 21: GetContractsByCounterpartyAsync()100%210%
File 21: GetCounterpartiesAsync()100%210%
File 21: FindCounterpartyGuidByInnAsync()75%131280.76%
File 21: GetCounterpartyByGuidAsync()0%4260%
File 21: GetOrganizationsAsync()100%210%
File 21: GetAllCounterpartiesAsync()100%11100%
File 21: LoadCounterpartiesAsync()100%1147.05%
File 21: GetVatRatesAsync()100%210%
File 21: GetAllContractsInternalAsync()100%210%
File 21: LoadContractsAsync()100%210%
File 21: GetCachedListAsync()50%5461.53%
File 21: CleanArticle(...)100%210%
File 22: get_RefKey()100%11100%
File 22: .ctor(...)100%11100%
File 22: FindAsync()87.5%88100%
File 22: IsDuplicate(...)94.44%1818100%
File 22: get_RefKey()100%11100%
File 22: get_Number()100%11100%
File 22: get_Posted()100%11100%
File 22: get_DeletionMark()100%11100%
File 22: get_IncomingDocNumber()100%11100%
File 22: get_CounterpartyKey()100%11100%
File 22: get_WarehouseKey()100%11100%
File 22: get_Lines()100%11100%
File 22: get_NomenclatureKey()100%11100%
File 22: get_Quantity()100%11100%
File 23: WithCondition(...)100%44100%
File 24: Describe(...)100%88100%
File 24: AppendOneCDetails(...)100%66100%
File 24: Reason(...)50%44100%
File 25: .ctor(...)100%11100%
File 25: get_Article()100%11100%
File 25: get_Required()100%11100%
File 25: get_OnSender()100%11100%
File 25: get_Elsewhere()100%11100%
File 25: Build(...)75%44100%
File 25: Where(...)75%44100%
File 25: Fmt(...)100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataCatalogModels.cs

#LineLine coverage
 1using Newtonsoft.Json;
 2
 3namespace Services.ApiServices._1CApi.OData.Models;
 4
 5public class ODataOrganization
 6{
 247    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 428    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 539    [JsonProperty("ИНН")] public string? Inn { get; set; } = string.Empty;
 910    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 11}
 12
 13public class ODataCounterparty
 14{
 1915    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 2216    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 1817    [JsonProperty("ИНН")] public string? Inn { get; set; } = string.Empty;
 1418    [JsonProperty("РегистрационныйНомер")] public string? RegistrationNumber { get; set; } = string.Empty;
 19
 520    public string? Identifier => !string.IsNullOrWhiteSpace(Inn) ? Inn : RegistrationNumber;
 21}
 22
 23public class ODataWarehouse
 24{
 20625    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 35726    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 27}
 28
 29public class ODataNomenclature
 30{
 17731    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 17132    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 20033    [JsonProperty("Артикул")] public string? Article { get; set; } = string.Empty;
 1334    [JsonProperty("IsFolder")] public bool IsFolder { get; set; }
 35
 36    /// <summary>Заполняется только в выборках сверки — признак того, что карточка пригодна для проведения.</summary>
 237    [JsonProperty("СчетУчетаЗапасов_Key")] public Guid? StockAccountKey { get; set; }
 38}
 39
 40/// <summary>
 41/// Строка табличной части «ДополнительныеРеквизиты» справочника номенклатуры.
 42/// Отдельный OData-эндпоинт позволяет выгрузить производителей всех карточек одним запросом,
 43/// вместо чтения каждой карточки по Ref_Key.
 44/// </summary>
 45public class ODataNomenclatureProperty
 46{
 047    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 048    [JsonProperty("Свойство_Key")] public Guid PropertyKey { get; set; }
 049    [JsonProperty("Значение")] public string? Value { get; set; } = string.Empty;
 50}
 51
 52public class ODataPropertyValue
 53{
 16054    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 22955    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 756    [JsonProperty("Owner_Key")] public Guid OwnerKey { get; set; }
 757    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 58}
 59
 60public class ODataVatRate
 61{
 062    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 063    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 64}
 65
 66public class ODataContract
 67{
 3468    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 5469    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 3870    [JsonProperty("Owner")] public Guid Owner { get; set; }
 71    /// <summary>Организация договора; null/пустой GUID — договор не привязан к организации (годится для любой).</summar
 3872    [JsonProperty("Организация_Key")] public Guid? OrganizationKey { get; set; }
 73}
 74
 75public class ODataCountry
 76{
 2077    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 6078    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 79}
 80
 81public class ODataGtdCode
 82{
 683    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 1284    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 1885    [JsonProperty("РегистрационныйНомер")] public string? RegistrationNumber { get; set; } = string.Empty;
 86}
 87
 88public class ODataListResponse<T>
 89{
 133890    [JsonProperty("value")] public List<T>? Value { get; set; } = new();
 91}
 92
 93public class ODataErrorResponse
 94{
 5795    [JsonProperty("odata.error")] public ODataErrorDetail? Error { get; set; }
 96}
 97
 98public class ODataErrorDetail
 99{
 21100    [JsonProperty("code")] public string? Code { get; set; }
 43101    [JsonProperty("message")] public ODataErrorMessage? Message { get; set; }
 102}
 103
 104public class ODataErrorMessage
 105{
 16106    [JsonProperty("lang")] public string? Lang { get; set; }
 27107    [JsonProperty("value")] public string? Value { get; set; }
 108}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataDocumentModels.cs

#LineLine coverage
 1using Newtonsoft.Json;
 2
 3namespace Services.ApiServices._1CApi.OData.Models;
 4
 5public class ODataReceiptListItem
 6{
 07    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 08    [JsonProperty("Number")] public string? Number { get; set; } = string.Empty;
 09    [JsonProperty("Date")] public string? Date { get; set; } = string.Empty;
 010    [JsonProperty("Posted")] public bool Posted { get; set; }
 011    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 012    [JsonProperty("Контрагент_Key")] public Guid CounterpartyKey { get; set; }
 013    [JsonProperty("НомерВходящегоДокумента")] public string? IncomingDocNumber { get; set; } = string.Empty;
 014    [JsonProperty("ДатаВходящегоДокумента")] public string? IncomingDocDate { get; set; } = string.Empty;
 015    [JsonProperty("Организация_Key")] public Guid OrganizationKey { get; set; }
 016    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 17}
 18
 19public class ODataTransferListItem
 20{
 021    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 022    [JsonProperty("Number")] public string? Number { get; set; } = string.Empty;
 023    [JsonProperty("Date")] public string? Date { get; set; } = string.Empty;
 024    [JsonProperty("Posted")] public bool Posted { get; set; }
 025    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 026    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseSenderKey { get; set; }
 027    [JsonProperty("СтруктурнаяЕдиницаПолучатель_Key")] public Guid WarehouseRecipientKey { get; set; }
 028    [JsonProperty("Комментарий")] public string? Comment { get; set; }
 029    [JsonProperty("Автор_Key")] public Guid AuthorKey { get; set; }
 030    [JsonProperty("Организация_Key")] public Guid OrganizationKey { get; set; }
 31}
 32
 33public class ODataPaymentListItem
 34{
 035    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 036    [JsonProperty("Number")] public string? Number { get; set; } = string.Empty;
 037    [JsonProperty("Date")] public string? Date { get; set; } = string.Empty;
 038    [JsonProperty("Posted")] public bool Posted { get; set; }
 039    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 040    [JsonProperty("Контрагент_Key")] public Guid CounterpartyKey { get; set; }
 041    [JsonProperty("СуммаДокумента")] public decimal Amount { get; set; }
 042    [JsonProperty("НазначениеПлатежа")] public string? Purpose { get; set; }
 043    [JsonProperty("ВидОперации")] public string? OperationType { get; set; }
 44}
 45
 46public class ODataCounterpartyFull
 47{
 048    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 049    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 050    [JsonProperty("ИНН")] public string? Inn { get; set; } = string.Empty;
 051    [JsonProperty("КПП")] public string? Kpp { get; set; } = string.Empty;
 052    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 053    [JsonProperty("IsFolder")] public bool IsFolder { get; set; }
 54}
 55
 56public class ODataNomenclatureFull
 57{
 058    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 059    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 060    [JsonProperty("Артикул")] public string? Article { get; set; } = string.Empty;
 061    [JsonProperty("IsFolder")] public bool IsFolder { get; set; }
 062    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 63}
 64
 65public class ODataWarehouseFull
 66{
 067    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 068    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 069    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 70}
 71
 72public class ODataOrganizationFull
 73{
 074    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 075    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 076    [JsonProperty("ИНН")] public string? Inn { get; set; } = string.Empty;
 077    [JsonProperty("КПП")] public string? Kpp { get; set; } = string.Empty;
 078    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 79}
 80
 81public class ODataContractFull
 82{
 083    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 084    [JsonProperty("Description")] public string? Description { get; set; } = string.Empty;
 085    [JsonProperty("Owner")] public Guid Owner { get; set; }
 086    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 87}
 88
 89public class ODataStockRecord
 90{
 3491    [JsonProperty("Recorder")] public Guid Recorder { get; set; }
 4692    [JsonProperty("Recorder_Type")] public string? RecorderType { get; set; } = string.Empty;
 6993    [JsonProperty("RecordSet")] public List<ODataStockMovement>? RecordSet { get; set; } = new();
 94}
 95
 96public class ODataStockMovement
 97{
 6698    [JsonProperty("Period")] public string? Period { get; set; } = string.Empty;
 9999    [JsonProperty("RecordType")] public string? RecordType { get; set; } = string.Empty;
 66100    [JsonProperty("Номенклатура_Key")] public Guid NomenclatureKey { get; set; }
 66101    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 66102    [JsonProperty("Количество")] public decimal Quantity { get; set; }
 33103    [JsonProperty("Организация_Key")] public Guid OrganizationKey { get; set; }
 104}
 105
 106public class ODataStockBalance
 107{
 343108    [JsonProperty("Номенклатура_Key")] public Guid NomenclatureKey { get; set; }
 106109    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 189110    [JsonProperty("КоличествоBalance")] public decimal QuantityBalance { get; set; }
 111
 112    /// <summary>Ресурс «Стоимость» — есть не во всех конфигурациях, поэтому nullable.</summary>
 2113    [JsonProperty("СтоимостьBalance")] public decimal? CostBalance { get; set; }
 114}
 115
 116/// <summary>
 117/// Строка виртуальной таблицы «Обороты» регистра запасов за период. Приход за окно — единственный
 118/// способ узнать «двигался ли товар», т.к. параметр Periodicity в 1С Fresh не поддерживается
 119/// и разложить обороты по датам средствами OData нельзя.
 120/// </summary>
 121public class ODataStockTurnover
 122{
 2123    [JsonProperty("Номенклатура_Key")] public Guid NomenclatureKey { get; set; }
 1124    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 2125    [JsonProperty("КоличествоReceipt")] public decimal QuantityReceipt { get; set; }
 1126    [JsonProperty("КоличествоExpense")] public decimal QuantityExpense { get; set; }
 127}
 128
 129/// <summary>
 130/// Строка табличной части «Запасы» вместе с номенклатурой. Отличается от <see cref="ODataMovementTabRow"/>
 131/// тем, что позволяет разложить один ответ на несколько номенклатур: при выборке сразу по 20 позициям
 132/// без этого поля непонятно, какая строка какой относится.
 133/// </summary>
 134/// <summary>
 135/// Строка табличной части «Запасы», прочитанная вложенным полем заголовка
 136/// (<c>$select=…,Запасы/Номенклатура_Key</c>). Отдельный эндпоинт <c>Document_*_Запасы</c>
 137/// 1С Fresh по <c>$filter</c> больше не отдаёт (HTTP 500), поэтому строки приходят вместе с документом.
 138/// </summary>
 139public class ODataDocNomenclatureLine
 140{
 2338141    [JsonProperty("Номенклатура_Key")] public Guid NomenclatureKey { get; set; }
 142}
 143
 144public class ODataMovementTabRow
 145{
 12146    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 12147    [JsonProperty("Количество")] public decimal Quantity { get; set; }
 12148    [JsonProperty("Цена")] public decimal Price { get; set; }
 149}
 150
 151/// <summary>
 152/// Строка табличной части документа «Резервирование запасов». В 1С Fresh
 153/// фильтрация этой табличной части по номенклатуре недоступна, поэтому карта
 154/// движений отбирает нужную строку после загрузки небольшого справочника резервов.
 155/// </summary>
 156public class ODataReserveTabRow
 157{
 0158    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 0159    [JsonProperty("LineNumber")] public int LineNumber { get; set; }
 0160    [JsonProperty("Номенклатура_Key")] public Guid NomenclatureKey { get; set; }
 0161    [JsonProperty("Количество")] public decimal Quantity { get; set; }
 0162    [JsonProperty("ИсходноеМестоРезерва")] public Guid? SourceReservePlaceKey { get; set; }
 0163    [JsonProperty("ИсходноеМестоРезерва_Type")] public string? SourceReservePlaceType { get; set; }
 0164    [JsonProperty("НовоеМестоРезерва")] public Guid? TargetReservePlaceKey { get; set; }
 0165    [JsonProperty("НовоеМестоРезерва_Type")] public string? TargetReservePlaceType { get; set; }
 166}
 167
 168public class ODataDocHeader
 169{
 2310170    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 3465171    [JsonProperty("Number")] public string? Number { get; set; } = string.Empty;
 4617172    [JsonProperty("Date")] public string? Date { get; set; } = string.Empty;
 2310173    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 2310174    [JsonProperty("Posted")] public bool Posted { get; set; }
 2274175    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 176}
 177
 178/// <summary>Заголовок приходной накладной с поставщиком для фильтра остатков склада.</summary>
 179public class ODataReceiptDocHeader : ODataDocHeader
 180{
 2238181    [JsonProperty("Контрагент_Key")] public Guid CounterpartyKey { get; set; }
 3339182    [JsonProperty("Запасы")] public List<ODataDocNomenclatureLine> Lines { get; set; } = [];
 183}
 184
 185public class ODataInventoryDocumentTemplate
 186{
 0187    [JsonProperty("ХозяйственнаяОперация_Key")] public Guid OperationKey { get; set; }
 188}
 189
 190public class ODataInventoryAdjustmentRequest
 191{
 0192    [JsonProperty("Date")] public string Date { get; set; } = string.Empty;
 0193    [JsonProperty("Организация_Key")] public Guid OrganizationKey { get; set; }
 0194    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 0195    [JsonProperty("ХозяйственнаяОперация_Key")] public Guid OperationKey { get; set; }
 0196    [JsonProperty("Комментарий")] public string Comment { get; set; } = string.Empty;
 0197    [JsonProperty("Запасы")] public List<ODataInventoryAdjustmentLine> Lines { get; set; } = [];
 198}
 199
 200public class ODataInventoryAdjustmentLine
 201{
 0202    [JsonProperty("LineNumber")] public int LineNumber { get; set; } = 1;
 0203    [JsonProperty("Номенклатура_Key")] public Guid NomenclatureKey { get; set; }
 0204    [JsonProperty("Характеристика_Key")] public string CharacteristicKey { get; set; } = OneCODataConstants.ZeroGuid;
 0205    [JsonProperty("Партия_Key")] public string BatchKey { get; set; } = OneCODataConstants.ZeroGuid;
 0206    [JsonProperty("ЕдиницаИзмерения")] public string UnitKey { get; set; } = OneCODataConstants.UnitShtukaGuid;
 0207    [JsonProperty("ЕдиницаИзмерения_Type")] public string UnitType { get; set; } = OneCODataConstants.UnitType;
 0208    [JsonProperty("Количество")] public decimal ActualQuantity { get; set; }
 0209    [JsonProperty("КоличествоУчет")] public decimal BookQuantity { get; set; }
 0210    [JsonProperty("Отклонение")] public decimal Difference { get; set; }
 0211    [JsonProperty("Излишки")] public decimal Surplus { get; set; }
 0212    [JsonProperty("Недостача")] public decimal Shortage { get; set; }
 0213    [JsonProperty("Цена")] public decimal Price { get; set; }
 0214    [JsonProperty("Сумма")] public decimal Amount { get; set; }
 0215    [JsonProperty("СуммаУчет")] public decimal BookAmount { get; set; }
 0216    [JsonProperty("Ячейка_Key")] public string CellKey { get; set; } = OneCODataConstants.ZeroGuid;
 217}
 218
 219public class ODataTransferDocHeader : ODataDocHeader
 220{
 72221    [JsonProperty("СтруктурнаяЕдиницаПолучатель_Key")] public Guid WarehouseRecipientKey { get; set; }
 0222    [JsonProperty("Комментарий")] public string? Comment { get; set; }
 108223    [JsonProperty("Запасы")] public List<ODataDocNomenclatureLine> Lines { get; set; } = [];
 224}
 225
 226public class ODataExpenseDocHeader : ODataDocHeader
 227{
 2228    [JsonProperty("Контрагент_Key")] public Guid CounterpartyKey { get; set; }
 229}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataDocumentResponse.cs

#LineLine coverage
 1using Newtonsoft.Json;
 2
 3namespace Services.ApiServices._1CApi.OData.Models;
 4
 5public class ODataDocumentResponse
 6{
 07    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 08    [JsonProperty("Number")] public string? Number { get; set; } = string.Empty;
 09    [JsonProperty("Date")] public string? Date { get; set; } = string.Empty;
 010    [JsonProperty("Posted")] public bool Posted { get; set; }
 011    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 12}
 13
 14public class ODataDocumentWithComment
 15{
 016    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 017    [JsonProperty("Number")] public string? Number { get; set; } = string.Empty;
 018    [JsonProperty("Posted")] public bool Posted { get; set; }
 019    [JsonProperty("Комментарий")] public string? Comment { get; set; }
 20}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataExpenseDocument.cs

#LineLine coverage
 1using Newtonsoft.Json;
 2
 3namespace Services.ApiServices._1CApi.OData.Models;
 4
 5public class ODataExpenseDocument
 6{
 07    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 08    [JsonProperty("Number")] public string? Number { get; set; } = string.Empty;
 09    [JsonProperty("Date")] public string? Date { get; set; } = string.Empty;
 010    [JsonProperty("Posted")] public bool Posted { get; set; }
 011    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 012    [JsonProperty("Контрагент_Key")] public Guid CounterpartyKey { get; set; }
 013    [JsonProperty("Договор_Key")] public Guid ContractKey { get; set; }
 014    [JsonProperty("Организация_Key")] public Guid OrganizationKey { get; set; }
 015    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 016    [JsonProperty("ВидОперации")] public string? OperationKind { get; set; } = string.Empty;
 017    [JsonProperty("СуммаДокумента")] public decimal TotalAmount { get; set; }
 018    [JsonProperty("СуммаНДС")] public decimal VatAmount { get; set; }
 019    [JsonProperty("Комментарий")] public string? Comment { get; set; }
 020    [JsonProperty("ЗаказКлиента_Key")] public Guid? CustomerOrderKey { get; set; }
 21}
 22
 23public class ODataTransferDocument
 24{
 025    [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 026    [JsonProperty("Number")] public string? Number { get; set; } = string.Empty;
 027    [JsonProperty("Date")] public string? Date { get; set; } = string.Empty;
 028    [JsonProperty("Posted")] public bool Posted { get; set; }
 029    [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 030    [JsonProperty("Организация_Key")] public Guid OrganizationKey { get; set; }
 031    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseSenderKey { get; set; }
 032    [JsonProperty("СтруктурнаяЕдиницаПолучатель_Key")] public Guid WarehouseRecipientKey { get; set; }
 033    [JsonProperty("ВидОперации")] public string? OperationKind { get; set; } = string.Empty;
 034    [JsonProperty("СуммаДокумента")] public decimal TotalAmount { get; set; }
 035    [JsonProperty("Комментарий")] public string? Comment { get; set; }
 36}
 37
 38public class ODataTransferDocumentLine
 39{
 040    [JsonProperty("LineNumber")] public int LineNumber { get; set; }
 041    [JsonProperty("Номенклатура_Key")] public Guid NomenclatureKey { get; set; }
 042    [JsonProperty("Характеристика_Key")] public Guid? CharacteristicKey { get; set; }
 043    [JsonProperty("Партия_Key")] public Guid? BatchKey { get; set; }
 044    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 045    [JsonProperty("Количество")] public decimal Quantity { get; set; }
 046    [JsonProperty("Цена")] public decimal Price { get; set; }
 047    [JsonProperty("Сумма")] public decimal Amount { get; set; }
 48}
 49
 50public class ODataExpenseDocumentLine
 51{
 052    [JsonProperty("LineNumber")] public int LineNumber { get; set; }
 053    [JsonProperty("Номенклатура_Key")] public Guid NomenclatureKey { get; set; }
 054    [JsonProperty("Характеристика_Key")] public Guid? CharacteristicKey { get; set; }
 055    [JsonProperty("Партия_Key")] public Guid? BatchKey { get; set; }
 056    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 057    [JsonProperty("Количество")] public decimal Quantity { get; set; }
 058    [JsonProperty("КоличествоУпаковок")] public decimal? PackageQuantity { get; set; }
 059    [JsonProperty("Цена")] public decimal Price { get; set; }
 060    [JsonProperty("Сумма")] public decimal Amount { get; set; }
 061    [JsonProperty("СуммаНДС")] public decimal VatAmount { get; set; }
 062    [JsonProperty("СтавкаНДС_Key")] public Guid? VatRateKey { get; set; }
 063    [JsonProperty("КоэффициентУпаковки")] public decimal? PackageCoefficient { get; set; }
 064    [JsonProperty("Единица_Key")] public Guid? UnitKey { get; set; }
 65}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataExpenseRequest.cs

#LineLine coverage
 1using Newtonsoft.Json;
 2
 3namespace Services.ApiServices._1CApi.OData.Models;
 4
 5public class ODataExpenseRequest
 6{
 07    [JsonProperty("Date")] public string? Date { get; set; } = string.Empty;
 08    [JsonProperty("Организация_Key")] public Guid OrganizationKey { get; set; }
 09    [JsonProperty("Контрагент_Key")] public Guid CounterpartyKey { get; set; }
 010    [JsonProperty("Договор_Key")] public Guid? ContractKey { get; set; }
 011    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 012    [JsonProperty("Подразделение_Key")] public Guid DivisionKey { get; set; }
 013    [JsonProperty("ВидОперации")] public string? OperationType { get; set; } = OneCODataConstants.OperationTypeExpense;
 014    [JsonProperty("НДСВключатьВСтоимость")] public bool VatIncludedInCost { get; set; } = true;
 015    [JsonProperty("СуммаВключаетНДС")] public bool AmountIncludesVat { get; set; } = true;
 016    [JsonProperty("ЭтоУниверсальныйДокумент")] public bool IsUniversalDocument { get; set; } = true;
 017    [JsonProperty("СуммаДокумента")] public decimal TotalAmount { get; set; }
 018    [JsonProperty("Комментарий")] public string? Comment { get; set; }
 019    [JsonProperty("Запасы")] public List<ODataExpenseLine>? Lines { get; set; } = new();
 20}
 21
 22public class ODataExpenseLine
 23{
 024    [JsonProperty("LineNumber")] public int LineNumber { get; set; }
 025    [JsonProperty("Номенклатура_Key")] public Guid NomenclatureKey { get; set; }
 026    [JsonProperty("Характеристика_Key")] public string? CharacteristicKey { get; set; } = OneCODataConstants.ZeroGuid;
 027    [JsonProperty("ЕдиницаИзмерения")] public string? UnitKey { get; set; } = OneCODataConstants.UnitShtukaGuid;
 028    [JsonProperty("ЕдиницаИзмерения_Type")] public string? UnitType { get; set; } = OneCODataConstants.UnitType;
 029    [JsonProperty("Количество")] public decimal Quantity { get; set; }
 030    [JsonProperty("Цена")] public decimal Price { get; set; }
 031    [JsonProperty("Сумма")] public decimal Amount { get; set; }
 032    [JsonProperty("СуммаНДС")] public decimal VatAmount { get; set; }
 033    [JsonProperty("Всего")] public decimal TotalAmount { get; set; }
 034    [JsonProperty("ПроцентСкидкиНаценки")] public decimal DiscountPercent { get; set; } = 0;
 035    [JsonProperty("СуммаСкидкиНаценки")] public decimal DiscountAmount { get; set; } = 0;
 036    [JsonProperty("СтавкаНДС_Key")] public Guid VatRateKey { get; set; }
 037    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 038    [JsonProperty("Заказ")] public string? OrderKey { get; set; } = OneCODataConstants.ZeroGuid;
 039    [JsonProperty("Заказ_Type")] public string? OrderType { get; set; } = OneCODataConstants.OrderTypeExpenseBuyer;
 040    [JsonProperty("ДокументПоступления_Key")] public string? ReceiptDocKey { get; set; } = OneCODataConstants.ZeroGuid;
 041    [JsonProperty("Партия_Key")] public string? BatchKey { get; set; } = OneCODataConstants.ZeroGuid;
 042    [JsonProperty("Проект_Key")] public string? ProjectKey { get; set; } = OneCODataConstants.ZeroGuid;
 043    [JsonProperty("ЗаказПокупателя_Key")] public string? BuyerOrderKey { get; set; } = OneCODataConstants.ZeroGuid;
 44}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataReceiptRequest.cs

#LineLine coverage
 1using Newtonsoft.Json;
 2
 3namespace Services.ApiServices._1CApi.OData.Models;
 4
 5public class ODataReceiptRequest
 6{
 27    [JsonProperty("Date")] public string? Date { get; set; } = string.Empty;
 28    [JsonProperty("НомерВходящегоДокумента")] public string? IncomingDocNumber { get; set; } = string.Empty;
 29    [JsonProperty("ДатаВходящегоДокумента")] public string? IncomingDocDate { get; set; } = string.Empty;
 110    [JsonProperty("Организация_Key")] public Guid OrganizationKey { get; set; }
 111    [JsonProperty("Контрагент_Key")] public Guid CounterpartyKey { get; set; }
 112    [JsonProperty("Договор_Key")] public Guid? ContractKey { get; set; }
 113    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 214    [JsonProperty("ВидОперации")] public string? OperationType { get; set; } = OneCODataConstants.OperationTypeReceipt;
 115    [JsonProperty("СуммаВключаетНДС")] public bool AmountIncludesVat { get; set; } = false;
 116    [JsonProperty("Комментарий")] public string? Comment { get; set; }
 317    [JsonProperty("Запасы")] public List<ODataReceiptLine>? Lines { get; set; } = new();
 18}
 19
 20public class ODataReceiptLine
 21{
 122    [JsonProperty("LineNumber")] public int LineNumber { get; set; }
 2523    [JsonProperty("Номенклатура_Key")] public Guid NomenclatureKey { get; set; }
 1724    [JsonProperty("ЕдиницаИзмерения")] public string? UnitKey { get; set; } = OneCODataConstants.UnitShtukaGuid;
 1725    [JsonProperty("ЕдиницаИзмерения_Type")] public string? UnitType { get; set; } = OneCODataConstants.UnitType;
 1726    [JsonProperty("Характеристика_Key")] public string? CharacteristicKey { get; set; } = OneCODataConstants.ZeroGuid;
 227    [JsonProperty("СтранаПроисхождения_Key")] public Guid? CountryOfOriginKey { get; set; }
 228    [JsonProperty("НомерГТД_Key")] public Guid? GtdNumberKey { get; set; }
 2329    [JsonProperty("Количество")] public decimal Quantity { get; set; }
 130    [JsonProperty("Цена")] public decimal Price { get; set; }
 131    [JsonProperty("ПроцентСкидкиНаценки")] public decimal DiscountPercent { get; set; } = 0;
 132    [JsonProperty("СуммаСкидкиНаценки")] public decimal DiscountAmount { get; set; } = 0;
 133    [JsonProperty("Сумма")] public decimal Amount { get; set; }
 134    [JsonProperty("СтавкаНДС_Key")] public Guid VatRateKey { get; set; }
 135    [JsonProperty("СуммаНДС")] public decimal VatAmount { get; set; }
 136    [JsonProperty("Всего")] public decimal TotalAmount { get; set; }
 137    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 1738    [JsonProperty("Заказ")] public string? OrderKey { get; set; } = OneCODataConstants.ZeroGuid;
 1739    [JsonProperty("Заказ_Type")] public string? OrderType { get; set; } = OneCODataConstants.OrderType;
 40}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/Models/ODataTransferRequest.cs

#LineLine coverage
 1using Newtonsoft.Json;
 2
 3namespace Services.ApiServices._1CApi.OData.Models;
 4
 5public class ODataTransferRequest
 6{
 07    [JsonProperty("Date")] public string? Date { get; set; } = string.Empty;
 08    [JsonProperty("Организация_Key")] public Guid OrganizationKey { get; set; }
 09    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseSenderKey { get; set; }
 010    [JsonProperty("СтруктурнаяЕдиницаПолучатель_Key")] public Guid WarehouseRecipientKey { get; set; }
 011    [JsonProperty("Комментарий")] public string? Comment { get; set; }
 012    [JsonProperty("Запасы")] public List<ODataTransferLine>? Lines { get; set; } = new();
 13}
 14
 15public class ODataTransferLine
 16{
 017    [JsonProperty("LineNumber")] public int LineNumber { get; set; }
 918    [JsonProperty("Номенклатура_Key")] public Guid NomenclatureKey { get; set; }
 519    [JsonProperty("Характеристика_Key")] public string? CharacteristicKey { get; set; } = OneCODataConstants.ZeroGuid;
 520    [JsonProperty("ЕдиницаИзмерения")] public string? UnitKey { get; set; } = OneCODataConstants.UnitShtukaGuid;
 521    [JsonProperty("ЕдиницаИзмерения_Type")] public string? UnitType { get; set; } = OneCODataConstants.UnitType;
 922    [JsonProperty("Количество")] public decimal Quantity { get; set; }
 523    [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseSenderKey { get; set; }
 24}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCDocumentPostingException.cs

#LineLine coverage
 1namespace Services.ApiServices._1CApi.OData;
 2
 3/// <summary>
 4/// 1С отказалась провести документ (ответ на /Post — не 2xx).
 5///
 6/// Это ожидаемый бизнес-исход, а не сбой интеграции: чаще всего не хватает остатка на
 7/// складе-отправителе. Документ остаётся созданным, но непроведённым, а причина уходит
 8/// пользователю в <c>PostingError</c> и показывается в алерте транзакции.
 9/// Поэтому вызывающая сторона пишет такой отказ в лог как Information: Warning/Error попадают
 10/// в файл errors-*.log и заводят автоматический issue на штатную ситуацию.
 11/// Error остаётся для настоящих сбоев обмена (сеть, таймаут, авторизация).
 12///
 13/// <see cref="Message"/> уже человекочитаем (<see cref="OneCDocumentPostingMessage"/>) и годится
 14/// для показа пользователю; технические подробности — в <see cref="Endpoint"/> и <see cref="Detail"/>.
 15/// <see cref="Detail"/> укорочен до 500 символов для показа; текст ответа 1С целиком — в
 16/// <see cref="OneCODataException.Error"/> (<see cref="FullDetail"/>).
 17///
 18/// Наследуется от <see cref="InvalidOperationException"/> (через <see cref="OneCODataException"/>), чтобы
 19/// не ломать код, который уже ловит проведение документа по базовому типу.
 20/// </summary>
 21public sealed class OneCDocumentPostingException(string message, string endpoint, string detail, OneCODataError? error =
 1122    : OneCODataException(message, error)
 23{
 24    /// <summary>OData-эндпоинт документа, например <c>Document_ПеремещениеЗапасов</c>.</summary>
 1225    public string Endpoint { get; } = endpoint;
 26
 27    /// <summary>Причина отказа в том виде, в каком её вернула 1С (для показа, может быть укорочена).</summary>
 1628    public string Detail { get; } = detail;
 29
 30    /// <summary>Полный текст отказа 1С: статус, код и причина без усечения.</summary>
 531    public string FullDetail => Error?.ToDetailText() ?? Detail;
 32}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCDocumentPostingMessage.cs

#LineLine coverage
 1using System.Text.RegularExpressions;
 2
 3namespace Services.ApiServices._1CApi.OData;
 4
 5/// <summary>
 6/// Собирает человекочитаемое сообщение об отказе 1С провести документ.
 7///
 8/// Отказ проведения — ожидаемый бизнес-исход (чаще всего не хватает остатка), и его текст
 9/// целиком уходит пользователю в алерт транзакции через <c>OneCResponse.PostingError</c>.
 10/// Поэтому сообщение не должно содержать техники: имени OData-эндпоинта, HTTP-тела ответа
 11/// и голой фразы 1С «Не удалось провести "…"!», по которой непонятно, что делать дальше.
 12/// </summary>
 13public static class OneCDocumentPostingMessage
 14{
 15    /// <summary>Названия документов в винительном падеже: «1С не провела <i>перемещение запасов</i>».</summary>
 116    private static readonly Dictionary<string, string> DocumentTitles = new(StringComparer.OrdinalIgnoreCase)
 117    {
 118        ["Document_ПеремещениеЗапасов"] = "перемещение запасов",
 119        ["Document_РасходнаяНакладная"] = "расходную накладную",
 120        ["Document_ПриходнаяНакладная"] = "приходную накладную",
 121        ["Document_ИнвентаризацияЗапасов"] = "инвентаризацию запасов",
 122        ["Document_ЗаказКлиента"] = "заказ клиента"
 123    };
 24
 25    private const string DefaultTitle = "документ";
 26
 27    /// <summary>
 28    /// Типовой отказ 1С без объяснения причины: «Не удалось провести "Перемещение запасов 4043 от 07.09.2026"!».
 29    /// Имя документа в кавычках вытаскиваем и показываем отдельно.
 30    /// </summary>
 131    private static readonly Regex GenericRefusalRegex = new(
 132        @"^Не\s+удалось\s+провести\s+[«""'](?<name>.+?)[»""']\s*!?\s*$",
 133        RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
 34
 35    /// <summary>1С не назвала причину: пусто или только «Не удалось провести "…"!».</summary>
 36    public static bool IsUninformative(string? detail)
 437        => string.IsNullOrWhiteSpace(detail) || GenericRefusalRegex.IsMatch(detail.Trim());
 38
 39    /// <summary>Название документа 1С для текста сообщения.</summary>
 40    public static string DocumentTitle(string? endpoint)
 2241        => !string.IsNullOrWhiteSpace(endpoint) && DocumentTitles.TryGetValue(endpoint.Trim(), out var title)
 2242            ? title
 2243            : DefaultTitle;
 44
 45    /// <summary>
 46    /// Формирует сообщение для пользователя по эндпоинту документа и разобранной причине от 1С.
 47    /// </summary>
 48    public static string Build(string? endpoint, string? detail)
 49    {
 1650        var title = DocumentTitle(endpoint);
 1651        var trimmed = detail?.Trim();
 52
 1653        if (string.IsNullOrEmpty(trimmed))
 354            return $"1С не провела {title}: причину 1С не сообщила. " +
 355                   "Документ сохранён непроведённым — откройте его в 1С и проведите вручную.";
 56
 1357        var generic = GenericRefusalRegex.Match(trimmed);
 1358        if (generic.Success)
 459            return $"1С не провела {title} «{generic.Groups["name"].Value.Trim()}»: подробностей 1С не вернула, " +
 460                   "чаще всего не хватает остатка на складе. " +
 461                   "Документ сохранён непроведённым — проверьте остатки в 1С и проведите его вручную.";
 62
 963        return $"1С не провела {title}: {trimmed}";
 64    }
 65}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCDocumentViewService.cs

#LineLine coverage
 1using Services.ApiServices._1CApi.OData.Models;
 2
 3namespace Services.ApiServices._1CApi.OData;
 4
 5public class OneCDocumentViewService
 6{
 7    private readonly IOneCODataClient _client;
 8    private readonly IOneCODataReferenceService _refs;
 9
 10    // Порядок попытки: сначала расходная, потом перемещение
 011    private static readonly (string header, string lines, string label)[] KnownDocTypes =
 012    [
 013        ("Document_РасходнаяНакладная",   "Document_РасходнаяНакладная_Запасы",   "Расходная накладная"),
 014        ("Document_ПеремещениеЗапасов",    "Document_ПеремещениеЗапасов_Запасы",    "Перемещение запасов"),
 015        ("Document_ПриходнаяНакладная",   "Document_ПриходнаяНакладная_Запасы",   "Приходная накладная"),
 016    ];
 17
 018    public OneCDocumentViewService(IOneCODataClient client, IOneCODataReferenceService refs)
 19    {
 020        _client = client;
 021        _refs = refs;
 022    }
 23
 24    public async Task<OneCDocumentDetails?> GetDocumentAsync(Guid refKey)
 25    {
 026        foreach (var (headerEndpoint, linesEndpoint, docLabel) in KnownDocTypes)
 27        {
 028            var result = await TryFetchDocumentAsync(refKey, headerEndpoint, linesEndpoint, docLabel);
 029            if (result != null) return result;
 30        }
 031        return null;
 032    }
 33
 34    public async Task<(Guid RefKey, OneCDocumentDetails Doc)?> FindByNumberAsync(string number)
 35    {
 036        var escaped = number.Replace("'", "''");
 037        foreach (var (headerEndpoint, _, _) in KnownDocTypes)
 38        {
 39            try
 40            {
 041                var rows = await _client.GetSinglePageAsync<ODataDocHeader>(
 042                    headerEndpoint, top: 1,
 043                    queryParams: new Dictionary<string, string>
 044                    {
 045                        ["$filter"] = $"Number eq '{escaped}'",
 046                        ["$select"] = "Ref_Key,Number"
 047                    });
 048                if (rows.Count == 0 || rows[0].RefKey == Guid.Empty) continue;
 049                var doc = await GetDocumentAsync(rows[0].RefKey);
 050                if (doc != null) return (rows[0].RefKey, doc);
 051            }
 052            catch { /* следующий тип */ }
 53        }
 054        return null;
 055    }
 56
 57    private async Task<OneCDocumentDetails?> TryFetchDocumentAsync(
 58        Guid refKey, string headerEndpoint, string linesEndpoint, string docLabel)
 59    {
 60        // Универсальная шапка — поля есть во всех типах документов
 061        var header = await _client.GetByGuidAsync<ODataGenericDocument>(
 062            headerEndpoint, refKey,
 063            "Ref_Key,Number,Date,Posted,DeletionMark,Организация_Key,СтруктурнаяЕдиница_Key,ВидОперации,СуммаДокумента,К
 64
 065        if (header == null || header.RefKey == Guid.Empty) return null;
 66
 67        // Дополнительные поля специфичные для расходной накладной
 068        ODataExpenseDocumentExtra? extra = null;
 069        if (headerEndpoint is "Document_РасходнаяНакладная" or "Document_ПриходнаяНакладная")
 70        {
 071            extra = await _client.GetByGuidAsync<ODataExpenseDocumentExtra>(
 072                headerEndpoint, refKey,
 073                "Ref_Key,Контрагент_Key,Договор_Key,СуммаНДС");
 74        }
 75
 76        // Для перемещения — получатель
 077        Guid warehouseRecipientKey = Guid.Empty;
 078        if (headerEndpoint == "Document_ПеремещениеЗапасов")
 79        {
 080            var transfer = await _client.GetByGuidAsync<ODataTransferDocument>(
 081                headerEndpoint, refKey,
 082                "Ref_Key,СтруктурнаяЕдиницаПолучатель_Key");
 083            warehouseRecipientKey = transfer?.WarehouseRecipientKey ?? Guid.Empty;
 84        }
 85
 086        var orgName = await ResolveNameAsync("Catalog_Организации", header.OrganizationKey);
 087        var warehouseName = await ResolveNameAsync("Catalog_СтруктурныеЕдиницы", header.WarehouseKey);
 088        var warehouseRecipientName = warehouseRecipientKey != Guid.Empty
 089            ? await ResolveNameAsync("Catalog_СтруктурныеЕдиницы", warehouseRecipientKey)
 090            : string.Empty;
 091        var counterpartyName = extra != null ? await ResolveNameAsync("Catalog_Контрагенты", extra.CounterpartyKey) : st
 092        var contractName = extra != null ? await ResolveNameAsync("Catalog_ДоговорыКонтрагентов", extra.ContractKey) : s
 93
 094        var lines = await _client.GetSinglePageAsync<ODataExpenseDocumentLine>(
 095            linesEndpoint,
 096            top: 200,
 097            queryParams: new Dictionary<string, string>
 098            {
 099                ["$filter"] = $"Ref_Key eq guid'{refKey}'",
 0100                ["$orderby"] = "LineNumber"
 0101            });
 102
 0103        var resolvedLines = new List<OneCDocumentLineDetails>();
 0104        foreach (var line in lines)
 105        {
 0106            var nomName = string.Empty;
 0107            var nomArticle = string.Empty;
 0108            if (line.NomenclatureKey != Guid.Empty)
 109            {
 0110                var nom = await _client.GetByGuidAsync<ODataNomenclatureFull>(
 0111                    "Catalog_Номенклатура", line.NomenclatureKey, "Ref_Key,Description,Артикул");
 0112                nomName = nom?.Description ?? string.Empty;
 0113                nomArticle = nom?.Article ?? string.Empty;
 114            }
 115
 0116            var charName = string.Empty;
 0117            if (line.CharacteristicKey.HasValue && line.CharacteristicKey.Value != Guid.Empty)
 0118                charName = await ResolveNameAsync("Catalog_ХарактеристикиНоменклатуры", line.CharacteristicKey.Value);
 119
 0120            var batchName = string.Empty;
 0121            if (line.BatchKey.HasValue && line.BatchKey.Value != Guid.Empty)
 0122                batchName = await ResolveNameAsync("Catalog_ПартииНоменклатуры", line.BatchKey.Value);
 123
 0124            var lineWhName = string.Empty;
 0125            if (line.WarehouseKey != header.WarehouseKey && line.WarehouseKey != Guid.Empty)
 0126                lineWhName = await ResolveNameAsync("Catalog_СтруктурныеЕдиницы", line.WarehouseKey);
 127
 0128            resolvedLines.Add(new OneCDocumentLineDetails
 0129            {
 0130                LineNumber = line.LineNumber,
 0131                NomenclatureName = nomName,
 0132                NomenclatureArticle = nomArticle,
 0133                CharacteristicName = charName,
 0134                BatchName = batchName,
 0135                WarehouseName = lineWhName,
 0136                Quantity = line.Quantity,
 0137                Price = line.Price,
 0138                Amount = line.Amount,
 0139                VatAmount = line.VatAmount,
 0140            });
 0141        }
 142
 0143        return new OneCDocumentDetails
 0144        {
 0145            RefKey = refKey,
 0146            DocTypeLabel = docLabel,
 0147            Number = header.Number,
 0148            Date = ParseDate(header.Date),
 0149            Posted = header.Posted,
 0150            DeletionMark = header.DeletionMark,
 0151            CounterpartyName = counterpartyName,
 0152            ContractName = contractName,
 0153            OrganizationName = orgName,
 0154            WarehouseName = warehouseName,
 0155            WarehouseRecipientName = warehouseRecipientName,
 0156            OperationKind = header.OperationKind,
 0157            Comment = header.Comment,
 0158            TotalAmount = header.TotalAmount,
 0159            VatAmount = extra?.VatAmount ?? 0,
 0160            Lines = resolvedLines,
 0161        };
 0162    }
 163
 164    private async Task<string> ResolveNameAsync(string catalog, Guid guid)
 165    {
 0166        if (guid == Guid.Empty) return string.Empty;
 167        try
 168        {
 0169            var item = await _client.GetByGuidAsync<ODataNamedItem>(catalog, guid, "Ref_Key,Description");
 0170            return item?.Description ?? string.Empty;
 171        }
 0172        catch
 173        {
 0174            return string.Empty;
 175        }
 0176    }
 177
 178    private static DateTime? ParseDate(string raw)
 179    {
 0180        if (string.IsNullOrWhiteSpace(raw)) return null;
 0181        if (DateTime.TryParse(raw, out var dt)) return dt;
 0182        return null;
 183    }
 184}
 185
 186// Общие поля есть в любом типе документа
 187file class ODataGenericDocument
 188{
 0189    [Newtonsoft.Json.JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 0190    [Newtonsoft.Json.JsonProperty("Number")] public string Number { get; set; } = string.Empty;
 0191    [Newtonsoft.Json.JsonProperty("Date")] public string Date { get; set; } = string.Empty;
 0192    [Newtonsoft.Json.JsonProperty("Posted")] public bool Posted { get; set; }
 0193    [Newtonsoft.Json.JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 0194    [Newtonsoft.Json.JsonProperty("Организация_Key")] public Guid OrganizationKey { get; set; }
 0195    [Newtonsoft.Json.JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 0196    [Newtonsoft.Json.JsonProperty("ВидОперации")] public string OperationKind { get; set; } = string.Empty;
 0197    [Newtonsoft.Json.JsonProperty("СуммаДокумента")] public decimal TotalAmount { get; set; }
 0198    [Newtonsoft.Json.JsonProperty("Комментарий")] public string? Comment { get; set; }
 199}
 200
 201// Дополнительные поля только расходной накладной
 202file class ODataExpenseDocumentExtra
 203{
 0204    [Newtonsoft.Json.JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 0205    [Newtonsoft.Json.JsonProperty("Контрагент_Key")] public Guid CounterpartyKey { get; set; }
 0206    [Newtonsoft.Json.JsonProperty("Договор_Key")] public Guid ContractKey { get; set; }
 0207    [Newtonsoft.Json.JsonProperty("СуммаНДС")] public decimal VatAmount { get; set; }
 208}
 209
 210file class ODataNamedItem
 211{
 212    [Newtonsoft.Json.JsonProperty("Description")]
 0213    public string Description { get; set; } = string.Empty;
 214}
 215
 216public class OneCDocumentDetails
 217{
 0218    public Guid RefKey { get; set; }
 0219    public string DocTypeLabel { get; set; } = string.Empty;
 0220    public string Number { get; set; } = string.Empty;
 0221    public DateTime? Date { get; set; }
 0222    public bool Posted { get; set; }
 0223    public bool DeletionMark { get; set; }
 0224    public string CounterpartyName { get; set; } = string.Empty;
 0225    public string ContractName { get; set; } = string.Empty;
 0226    public string OrganizationName { get; set; } = string.Empty;
 0227    public string WarehouseName { get; set; } = string.Empty;
 0228    public string WarehouseRecipientName { get; set; } = string.Empty;
 0229    public string OperationKind { get; set; } = string.Empty;
 0230    public string? Comment { get; set; }
 0231    public decimal TotalAmount { get; set; }
 0232    public decimal VatAmount { get; set; }
 0233    public List<OneCDocumentLineDetails> Lines { get; set; } = new();
 234}
 235
 236public class OneCDocumentLineDetails
 237{
 0238    public int LineNumber { get; set; }
 0239    public string NomenclatureName { get; set; } = string.Empty;
 0240    public string NomenclatureArticle { get; set; } = string.Empty;
 0241    public string CharacteristicName { get; set; } = string.Empty;
 0242    public string BatchName { get; set; } = string.Empty;
 0243    public string WarehouseName { get; set; } = string.Empty;
 0244    public decimal Quantity { get; set; }
 0245    public decimal Price { get; set; }
 0246    public decimal Amount { get; set; }
 0247    public decimal VatAmount { get; set; }
 248}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureCreationLock.cs

#LineLine coverage
 1using System.Data;
 2using System.Security.Cryptography;
 3using System.Text;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.DependencyInjection;
 6using OzonRepositories.Context;
 7
 8namespace Services.ApiServices._1CApi.OData;
 9
 10/// <summary>Сериализует создание одной номенклатуры между экземплярами приложения.</summary>
 11public interface IOneCNomenclatureCreationLock
 12{
 13    Task<IAsyncDisposable> AcquireAsync(string article, string producerName, CancellationToken cancellationToken = defau
 14}
 15
 16public sealed class OneCNomenclatureCreationLock : IOneCNomenclatureCreationLock
 17{
 18    private const int TimeoutSeconds = 30;
 19    private readonly IServiceScopeFactory _scopeFactory;
 20
 021    public OneCNomenclatureCreationLock(IServiceScopeFactory scopeFactory)
 22    {
 023        _scopeFactory = scopeFactory;
 024    }
 25
 26    public async Task<IAsyncDisposable> AcquireAsync(string article, string producerName, CancellationToken cancellation
 27    {
 028        var scope = _scopeFactory.CreateAsyncScope();
 29        try
 30        {
 031            var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 032            var connection = db.Database.GetDbConnection();
 033            await connection.OpenAsync(cancellationToken);
 34
 035            var lockName = BuildLockName(article, producerName);
 036            await using var command = connection.CreateCommand();
 037            command.CommandText = "SELECT GET_LOCK(@lockName, @timeoutSeconds)";
 038            AddParameter(command, "@lockName", lockName);
 039            AddParameter(command, "@timeoutSeconds", TimeoutSeconds);
 040            var acquired = Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1;
 041            if (!acquired)
 042                throw new TimeoutException($"Не удалось получить межпроцессную блокировку создания номенклатуры '{articl
 43
 044            return new Releaser(scope, connection, lockName);
 045        }
 046        catch
 47        {
 048            await scope.DisposeAsync();
 049            throw;
 50        }
 051    }
 52
 53    private static string BuildLockName(string article, string producerName)
 54    {
 055        var source = $"{article}|{producerName}";
 056        var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source)));
 057        return $"onec-nomenclature:{hash}";
 58    }
 59
 60    private static void AddParameter(IDbCommand command, string name, object value)
 61    {
 062        var parameter = command.CreateParameter();
 063        parameter.ParameterName = name;
 064        parameter.Value = value;
 065        command.Parameters.Add(parameter);
 066    }
 67
 68    private sealed class Releaser : IAsyncDisposable
 69    {
 70        private readonly AsyncServiceScope _scope;
 71        private readonly System.Data.Common.DbConnection _connection;
 72        private readonly string _lockName;
 73
 074        public Releaser(AsyncServiceScope scope, System.Data.Common.DbConnection connection, string lockName)
 75        {
 076            _scope = scope;
 077            _connection = connection;
 078            _lockName = lockName;
 079        }
 80
 81        public async ValueTask DisposeAsync()
 82        {
 83            try
 84            {
 085                await using var command = _connection.CreateCommand();
 086                command.CommandText = "SELECT RELEASE_LOCK(@lockName)";
 087                AddParameter(command, "@lockName", _lockName);
 088                await command.ExecuteScalarAsync();
 089            }
 90            finally
 91            {
 092                await _connection.CloseAsync();
 093                await _scope.DisposeAsync();
 94            }
 095        }
 96    }
 97}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureMapStore.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.DependencyInjection;
 3using OzonDomains.Models;
 4using OzonRepositories.Context;
 5
 6namespace Services.ApiServices._1CApi.OData;
 7
 8/// <summary>Найденная карточка 1С по нормализованному ключу.</summary>
 3479public sealed record OneCMapEntry(Guid RefKey, string ArticleNorm, string ProducerNorm, string? ProducerName);
 10
 11/// <summary>Каноническое значение производителя, к которому сведён альтернативный вариант написания.</summary>
 2812public sealed record OneCProducerAliasEntry(Guid RefKey, string? CanonicalName);
 13
 14/// <summary>
 15/// Хранилище соответствий «нормализованный артикул + производитель» → карточка 1С.
 16/// Живёт в общей БД, поэтому карта одна на оба прод-инстанса.
 17/// </summary>
 18public interface IOneCNomenclatureMapStore
 19{
 20    Task<OneCMapEntry?> FindAsync(string articleNorm, string producerNorm, CancellationToken cancellationToken = default
 21
 22    Task<IReadOnlyList<OneCMapEntry>> FindByArticleAsync(string articleNorm, CancellationToken cancellationToken = defau
 23
 24    /// <summary>
 25    /// Обратный поиск: карточка 1С → её артикул и производитель. Нужен там, где отправной точкой
 26    /// служит GUID из регистра 1С, а показать надо человекочитаемые реквизиты.
 27    /// Если на один RefKey заведено несколько строк, берётся первая — реквизиты у них совпадают.
 28    /// </summary>
 29    Task<IReadOnlyDictionary<Guid, OneCMapEntry>> FindByRefKeysAsync(
 30        IReadOnlyCollection<Guid> refKeys,
 31        CancellationToken cancellationToken = default);
 32
 33    Task UpsertAsync(OneCMapEntry entry, string? rawArticle, string source, CancellationToken cancellationToken = defaul
 34
 35    Task<int> BulkUpsertAsync(IReadOnlyList<(OneCMapEntry Entry, string? RawArticle)> items, string source, Cancellation
 36
 37    /// <summary>Все известные соответствия — для фоновой сверки с 1С.</summary>
 38    Task<IReadOnlyDictionary<(string ArticleNorm, string ProducerNorm), Guid>> GetAllKeysAsync(CancellationToken cancell
 39
 40    Task<OneCProducerAliasEntry?> FindProducerAliasAsync(string aliasNorm, CancellationToken cancellationToken = default
 41
 42    Task UpsertProducerAliasAsync(string aliasNorm, Guid canonicalRefKey, string? canonicalName, string source, Cancella
 43}
 44
 45public sealed class OneCNomenclatureMapStore : IOneCNomenclatureMapStore, Services.ApiServices._1CApi.Nomenclature.INome
 46{
 47    private readonly IServiceScopeFactory _scopeFactory;
 48
 049    public OneCNomenclatureMapStore(IServiceScopeFactory scopeFactory)
 50    {
 051        _scopeFactory = scopeFactory;
 052    }
 53
 54    public async Task<IReadOnlyList<Services.ApiServices._1CApi.Nomenclature.MapRow>> LoadAllAsync(CancellationToken can
 55    {
 056        await using var scope = _scopeFactory.CreateAsyncScope();
 057        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 58
 059        return await db.OneCNomenclatureMaps
 060            .AsNoTracking()
 061            .Select(m => new Services.ApiServices._1CApi.Nomenclature.MapRow(
 062                m.ArticleNorm, m.ProducerNorm, m.OneCProducerName, m.OneCRefKey, m.OneCArticleRaw, m.Source))
 063            .ToListAsync(cancellationToken);
 064    }
 65
 66    public async Task<OneCMapEntry?> FindAsync(string articleNorm, string producerNorm, CancellationToken cancellationTo
 67    {
 068        if (string.IsNullOrEmpty(articleNorm))
 069            return null;
 70
 071        await using var scope = _scopeFactory.CreateAsyncScope();
 072        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 73
 074        var row = await db.OneCNomenclatureMaps
 075            .AsNoTracking()
 076            .FirstOrDefaultAsync(m => m.ArticleNorm == articleNorm && m.ProducerNorm == producerNorm, cancellationToken)
 77
 078        return row == null ? null : ToEntry(row);
 079    }
 80
 81    public async Task<IReadOnlyList<OneCMapEntry>> FindByArticleAsync(string articleNorm, CancellationToken cancellation
 82    {
 083        if (string.IsNullOrEmpty(articleNorm))
 084            return [];
 85
 086        await using var scope = _scopeFactory.CreateAsyncScope();
 087        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 88
 089        var rows = await db.OneCNomenclatureMaps
 090            .AsNoTracking()
 091            .Where(m => m.ArticleNorm == articleNorm)
 092            .ToListAsync(cancellationToken);
 93
 094        return rows.Select(ToEntry).ToList();
 095    }
 96
 97    public async Task<IReadOnlyDictionary<Guid, OneCMapEntry>> FindByRefKeysAsync(
 98        IReadOnlyCollection<Guid> refKeys,
 99        CancellationToken cancellationToken = default)
 100    {
 0101        if (refKeys.Count == 0)
 0102            return new Dictionary<Guid, OneCMapEntry>();
 103
 0104        await using var scope = _scopeFactory.CreateAsyncScope();
 0105        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 106
 0107        var distinct = refKeys.Distinct().ToList();
 0108        var result = new Dictionary<Guid, OneCMapEntry>(distinct.Count);
 109
 110        // Порциями, чтобы не упереться в лимит параметров запроса при большой странице.
 111        const int chunkSize = 200;
 0112        for (var offset = 0; offset < distinct.Count; offset += chunkSize)
 113        {
 0114            var chunk = distinct.Skip(offset).Take(chunkSize).ToList();
 0115            var rows = await db.OneCNomenclatureMaps
 0116                .AsNoTracking()
 0117                .Where(m => chunk.Contains(m.OneCRefKey))
 0118                .ToListAsync(cancellationToken);
 119
 0120            foreach (var row in rows)
 0121                result.TryAdd(row.OneCRefKey, ToEntry(row));
 122        }
 123
 0124        return result;
 0125    }
 126
 127    public async Task UpsertAsync(OneCMapEntry entry, string? rawArticle, string source, CancellationToken cancellationT
 0128        => await BulkUpsertAsync([(entry, rawArticle)], source, cancellationToken);
 129
 130    /// <summary>
 131    /// Пишет пачку соответствий одним INSERT ... ON DUPLICATE KEY UPDATE — атомарно относительно
 132    /// второго прод-инстанса, который может писать тот же ключ параллельно.
 133    /// </summary>
 134    public async Task<int> BulkUpsertAsync(
 135        IReadOnlyList<(OneCMapEntry Entry, string? RawArticle)> items,
 136        string source,
 137        CancellationToken cancellationToken = default)
 138    {
 0139        if (items.Count == 0)
 0140            return 0;
 141
 0142        await using var scope = _scopeFactory.CreateAsyncScope();
 0143        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 144
 0145        var affected = 0;
 146        const int chunkSize = 500;
 147
 0148        for (var offset = 0; offset < items.Count; offset += chunkSize)
 149        {
 0150            var chunk = items.Skip(offset).Take(chunkSize).ToList();
 0151            var parameters = new List<object>();
 0152            var rows = new List<string>(chunk.Count);
 153
 0154            foreach (var (entry, rawArticle) in chunk)
 155            {
 0156                var values = new[]
 0157                {
 0158                    Truncate(entry.ArticleNorm, 128) ?? string.Empty,
 0159                    Truncate(entry.ProducerNorm, 128) ?? string.Empty,
 0160                    entry.RefKey.ToString(),
 0161                    Truncate(rawArticle, 191),
 0162                    Truncate(entry.ProducerName, 191),
 0163                    Truncate(source, 32) ?? string.Empty,
 0164                };
 165
 0166                var (placeholders, _) = BuildPlaceholders(values, parameters);
 0167                rows.Add($"({string.Join(", ", placeholders)}, UTC_TIMESTAMP(), UTC_TIMESTAMP())");
 168            }
 169
 0170            var sql = $"""
 0171                INSERT INTO `OneCNomenclatureMaps`
 0172                    (`ArticleNorm`, `ProducerNorm`, `OneCRefKey`, `OneCArticleRaw`, `OneCProducerName`, `Source`, `Creat
 0173                VALUES {string.Join(", ", rows)}
 0174                ON DUPLICATE KEY UPDATE
 0175                    `OneCRefKey` = VALUES(`OneCRefKey`),
 0176                    `OneCArticleRaw` = VALUES(`OneCArticleRaw`),
 0177                    `OneCProducerName` = VALUES(`OneCProducerName`),
 0178                    `Source` = VALUES(`Source`),
 0179                    `UpdatedAt` = UTC_TIMESTAMP()
 0180                """;
 181
 0182            affected += await db.Database.ExecuteSqlRawAsync(sql, parameters, cancellationToken);
 183        }
 184
 0185        return affected;
 0186    }
 187
 188    public async Task<IReadOnlyDictionary<(string ArticleNorm, string ProducerNorm), Guid>> GetAllKeysAsync(
 189        CancellationToken cancellationToken = default)
 190    {
 0191        await using var scope = _scopeFactory.CreateAsyncScope();
 0192        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 193
 0194        var rows = await db.OneCNomenclatureMaps
 0195            .AsNoTracking()
 0196            .Select(m => new { m.ArticleNorm, m.ProducerNorm, m.OneCRefKey })
 0197            .ToListAsync(cancellationToken);
 198
 0199        var result = new Dictionary<(string, string), Guid>(rows.Count);
 0200        foreach (var row in rows)
 0201            result[(row.ArticleNorm, row.ProducerNorm)] = row.OneCRefKey;
 0202        return result;
 0203    }
 204
 205    public async Task<OneCProducerAliasEntry?> FindProducerAliasAsync(string aliasNorm, CancellationToken cancellationTo
 206    {
 0207        if (string.IsNullOrEmpty(aliasNorm))
 0208            return null;
 209
 0210        await using var scope = _scopeFactory.CreateAsyncScope();
 0211        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 212
 0213        var row = await db.OneCProducerAliases
 0214            .AsNoTracking()
 0215            .FirstOrDefaultAsync(a => a.AliasNorm == aliasNorm, cancellationToken);
 216
 0217        return row == null ? null : new OneCProducerAliasEntry(row.CanonicalRefKey, row.CanonicalName);
 0218    }
 219
 220    public async Task UpsertProducerAliasAsync(
 221        string aliasNorm,
 222        Guid canonicalRefKey,
 223        string? canonicalName,
 224        string source,
 225        CancellationToken cancellationToken = default)
 226    {
 0227        if (string.IsNullOrEmpty(aliasNorm))
 0228            return;
 229
 0230        await using var scope = _scopeFactory.CreateAsyncScope();
 0231        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 232
 233        // Как и в BulkUpsertAsync: NULL — литералом, DBNull через ExecuteSqlRawAsync не проходит.
 0234        var values = new[]
 0235        {
 0236            Truncate(aliasNorm, 128) ?? string.Empty,
 0237            canonicalRefKey.ToString(),
 0238            Truncate(canonicalName, 191),
 0239            Truncate(source, 32) ?? string.Empty,
 0240        };
 0241        var (placeholders, parameters) = BuildPlaceholders(values);
 242
 0243        var sql = $"INSERT INTO `OneCProducerAliases` "
 0244                  + "(`AliasNorm`, `CanonicalRefKey`, `CanonicalName`, `Source`, `CreatedAt`) "
 0245                  + $"VALUES ({string.Join(", ", placeholders)}, UTC_TIMESTAMP()) "
 0246                  + "ON DUPLICATE KEY UPDATE "
 0247                  + "`CanonicalRefKey` = VALUES(`CanonicalRefKey`), "
 0248                  + "`CanonicalName` = VALUES(`CanonicalName`)";
 249
 0250        await db.Database.ExecuteSqlRawAsync(sql, parameters, cancellationToken);
 0251    }
 252
 253    /// <summary>
 254    /// Раскладывает значения на позиционные плейсхолдеры EF Core, подставляя литерал NULL вместо
 255    /// параметра для пустых значений: ExecuteSqlRawAsync не принимает DBNull («provider doesn't have
 256    /// a store type mapping for properties of type DBNull»), а вывести тип из null-объекта EF Core
 257    /// не может. Карточка без производителя — обычное дело, поэтому путь с NULL основной.
 258    /// </summary>
 259    private static (List<string> Placeholders, List<object> Parameters) BuildPlaceholders(
 260        IReadOnlyList<string?> values,
 261        List<object>? parameters = null)
 262    {
 0263        parameters ??= [];
 0264        var placeholders = new List<string>(values.Count);
 0265        foreach (var value in values)
 266        {
 0267            if (value == null)
 268            {
 0269                placeholders.Add("NULL");
 0270                continue;
 271            }
 0272            placeholders.Add($"{{{parameters.Count}}}");
 0273            parameters.Add(value);
 274        }
 0275        return (placeholders, parameters);
 276    }
 277
 278    private static OneCMapEntry ToEntry(OneCNomenclatureMap row)
 0279        => new(row.OneCRefKey, row.ArticleNorm, row.ProducerNorm, row.OneCProducerName);
 280
 281    private static string? Truncate(string? value, int maxLength)
 0282        => value != null && value.Length > maxLength ? value[..maxLength] : value;
 283}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureMapSyncJob.cs

#LineLine coverage
 1using Hangfire;
 2using Microsoft.Extensions.Logging;
 3using Services.ApiServices._1CApi.OData.Models;
 4using Services.HangfireService;
 5
 6namespace Services.ApiServices._1CApi.OData;
 7
 8/// <summary>
 9/// Пересобирает карту «нормализованный артикул + производитель» → карточка 1С и отчитывается
 10/// о дублях. Первый запуск наполняет пустую карту (полный бэкфилл), последующие чинят дрейф:
 11/// карточки, заведённые в 1С руками между запусками, и переименования.
 12/// </summary>
 13public sealed class OneCNomenclatureMapSyncJob
 14{
 15    public const string JobId = "OneCNomenclatureMapSyncJob";
 16    /// <summary>Каждый день в 03:30 — выгрузка каталога тяжёлая, гоняем вне рабочих часов.</summary>
 17    public const string DefaultCron = "30 3 * * *";
 18    public const string QueueName = HangfireQueueNames.Main;
 19
 20    private readonly IOneCODataClient _client;
 21    private readonly IOneCNomenclatureMapStore _map;
 22    private readonly ILogger<OneCNomenclatureMapSyncJob> _logger;
 23
 024    public OneCNomenclatureMapSyncJob(
 025        IOneCODataClient client,
 026        IOneCNomenclatureMapStore map,
 027        ILogger<OneCNomenclatureMapSyncJob> logger)
 28    {
 029        _client = client;
 030        _map = map;
 031        _logger = logger;
 032    }
 33
 34    [Queue(QueueName)]
 35    [DisableConcurrentExecution(timeoutInSeconds: 0)]
 36    public async Task<string> ExecuteAsync()
 37    {
 038        var cards = await _client.GetListAsync<ODataNomenclature>(
 039            OneCODataConstants.NomenclatureEndpoint,
 040            new Dictionary<string, string>
 041            {
 042                ["$select"] = "Ref_Key,Артикул,IsFolder,СчетУчетаЗапасов_Key",
 043                ["$filter"] = "IsFolder eq false and DeletionMark eq false"
 044            });
 45
 46        // Производители всех карточек одним запросом к табличной части — на порядки дешевле,
 47        // чем читать каждую карточку по Ref_Key.
 048        var producerLinks = await _client.GetListAsync<ODataNomenclatureProperty>(
 049            OneCODataConstants.NomenclaturePropertiesEndpoint,
 050            new Dictionary<string, string>
 051            {
 052                ["$select"] = "Ref_Key,Свойство_Key,Значение",
 053                ["$filter"] = $"Свойство_Key eq guid'{OneCODataConstants.ProducerPropertyKey}'",
 054                // У табличной части составной ключ (Ref_Key, LineNumber). Без явного второго поля
 055                // порядок строк внутри одного Ref_Key не определён, и при выборке в несколько страниц
 056                // часть строк может выпасть между $skip-запросами.
 057                ["$orderby"] = "Ref_Key,LineNumber"
 058            });
 59
 060        var producerValues = await _client.GetListAsync<ODataPropertyValue>(
 061            OneCODataConstants.PropertyValuesEndpoint,
 062            new Dictionary<string, string>
 063            {
 064                ["$select"] = "Ref_Key,Description,Owner_Key,DeletionMark",
 065                ["$filter"] = $"Owner_Key eq guid'{OneCODataConstants.ProducerPropertyKey}'"
 066            });
 67
 068        var producerNameByGuid = producerValues
 069            .GroupBy(x => x.RefKey)
 070            .ToDictionary(g => g.Key, g => g.First().Description);
 71
 072        var producerGuidByCard = new Dictionary<Guid, Guid>();
 073        foreach (var link in producerLinks)
 74        {
 075            if (Guid.TryParse(link.Value, out var value) && value != Guid.Empty)
 076                producerGuidByCard[link.RefKey] = value;
 77        }
 78
 79        // Группируем живые карточки по нормализованному ключу.
 080        var grouped = new Dictionary<(string Article, string Producer), List<CardInfo>>();
 081        foreach (var card in cards)
 82        {
 083            var articleNorm = OneCNomenclatureService.NormalizeArticle(card.Article);
 084            if (string.IsNullOrEmpty(articleNorm))
 85                continue;
 86
 087            producerGuidByCard.TryGetValue(card.RefKey, out var producerGuid);
 088            var producerName = producerGuid != Guid.Empty && producerNameByGuid.TryGetValue(producerGuid, out var n) ? n
 089            var producerNorm = OneCNomenclatureService.NormalizeProducerName(producerName);
 90
 091            var key = (articleNorm, producerNorm);
 092            if (!grouped.TryGetValue(key, out var list))
 093                grouped[key] = list = [];
 094            list.Add(new CardInfo(card.RefKey, card.Article, producerName, IsPostable(card)));
 95        }
 96
 097        var existing = await _map.GetAllKeysAsync();
 098        var liveRefKeys = cards.Select(c => c.RefKey).ToHashSet();
 99
 0100        var payload = new List<(OneCMapEntry Entry, string? RawArticle)>();
 0101        var conflicts = 0;
 0102        var conflictExamples = new List<string>();
 103
 0104        foreach (var ((articleNorm, producerNorm), candidates) in grouped)
 105        {
 0106            if (candidates.Count > 1)
 107            {
 0108                conflicts++;
 0109                if (conflictExamples.Count < 20)
 0110                    conflictExamples.Add($"{articleNorm}|{(producerNorm.Length == 0 ? "—" : producerNorm)}: {string.Join
 111            }
 112
 113            // Стабильность важнее «лучшего» выбора: если ключ уже сопоставлен и та карточка жива,
 114            // не переводим будущие проведения на другую — иначе история разъедется по двум карточкам.
 0115            if (existing.TryGetValue((articleNorm, producerNorm), out var mapped) && liveRefKeys.Contains(mapped))
 116                continue;
 117
 0118            var winner = PickWinner(candidates);
 0119            payload.Add((new OneCMapEntry(winner.RefKey, articleNorm, producerNorm, winner.ProducerName), winner.RawArti
 120        }
 121
 0122        var written = await _map.BulkUpsertAsync(payload, "sync");
 123
 0124        if (conflicts > 0)
 125        {
 0126            _logger.LogInformation(
 0127                "[OData] Сверка номенклатуры: {Conflicts} ключей с несколькими карточками в 1С. Примеры: {Examples}",
 0128                conflicts, string.Join(" | ", conflictExamples));
 129        }
 130
 0131        var summary =
 0132            $"Карточек 1С: {cards.Count}; ключей: {grouped.Count}; записано/обновлено: {written}; дублей по ключу: {conf
 0133        _logger.LogInformation("[OData] Сверка карты номенклатуры завершена. {Summary}", summary);
 0134        return summary;
 0135    }
 136
 137    /// <summary>
 138    /// Карточка без счёта учёта запасов не проводится в документах, поэтому при дубле
 139    /// предпочитаем «рабочую». При равенстве — детерминированно наименьший Ref_Key,
 140    /// чтобы оба прод-инстанса выбрали одно и то же.
 141    /// </summary>
 142    private static CardInfo PickWinner(List<CardInfo> candidates)
 0143        => candidates
 0144            .OrderByDescending(c => c.Postable)
 0145            .ThenBy(c => c.RefKey)
 0146            .First();
 147
 148    private static bool IsPostable(ODataNomenclature card)
 0149        => card.StockAccountKey.HasValue && card.StockAccountKey.Value != Guid.Empty;
 150
 0151    private sealed record CardInfo(Guid RefKey, string? RawArticle, string? ProducerName, bool Postable);
 152}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureService.cs

#LineLine coverage
 1using Services.SystemMonitoringServices;
 2using System.Collections.Concurrent;
 3using System.Text;
 4using Microsoft.Extensions.Logging;
 5using Newtonsoft.Json.Linq;
 6using OzonDomains;
 7using OzonDomains.Models;
 8using Services.ApiServices._1CApi.Nomenclature;
 9using Services.ApiServices._1CApi.OData.Models;
 10using Services.BackgroundJobsServices;
 11
 12namespace Services.ApiServices._1CApi.OData;
 13
 14/// <summary>Откуда пришло создание карточки — попадает в «Комментарий» номенклатуры в 1С.</summary>
 815public sealed record OneCNomenclatureOrigin(TransactionType? TransactionType);
 16
 17public interface IOneCNomenclatureService
 18{
 19    Task<OneCNomenclatureResult> GetOrCreateAsync(Order order, OneCNomenclatureOrigin? origin = null);
 20
 21    /// <summary>
 22    /// Разрешает номенклатуру для нескольких заказов параллельно (с ограничением конкурентности) —
 23    /// на порядки быстрее, чем последовательный вызов GetOrCreateAsync в цикле, когда несколько
 24    /// позиций документа отсутствуют в 1С. Результат в том же порядке, что и входные orders.
 25    /// </summary>
 26    Task<IReadOnlyList<OneCNomenclatureResult>> GetOrCreateManyAsync(
 27        IReadOnlyList<Order> orders,
 28        int maxConcurrency = 3,
 29        OneCNomenclatureOrigin? origin = null);
 30
 31    /// <summary>
 32    /// Ищет карточку номенклатуры теми же правилами, что GetOrCreateAsync (карта соответствий,
 33    /// алиасы производителей, проба по префиксу артикула), но НИКОГДА ничего не создаёт в 1С.
 34    /// null — карточки нет. Нужен для проверок «до записи», где создание карточки недопустимо.
 35    /// </summary>
 36    Task<Guid?> FindAsync(string? article, string? producerName);
 37
 38    /// <summary>Пакетный FindAsync. Результат в том же порядке, что и входные items.</summary>
 39    Task<IReadOnlyList<Guid?>> FindManyAsync(
 40        IReadOnlyList<(string? Article, string? ProducerName)> items,
 41        int maxConcurrency = 3);
 42
 43    /// <summary>
 44    /// Разрешает номенклатуру позиций с возможностью «карточки нет»: <see cref="NomenclatureResolution.RefKey"/> = null
 45    /// и причина в <see cref="NomenclatureResolution.Reason"/>. Менеджеры документов кладут такие позиции в черновик.
 46    /// Реализация по умолчанию — прежнее поведение: «только найти» через <see cref="FindManyAsync"/>,
 47    /// «найти или создать» через <see cref="GetOrCreateManyAsync"/> (там карточка есть всегда или бросается исключение)
 48    /// </summary>
 49    async Task<IReadOnlyList<NomenclatureResolution>> ResolveManyAsync(
 50        IReadOnlyList<Order> orders,
 51        NomenclatureMode mode,
 52        int maxConcurrency = 3,
 53        OneCNomenclatureOrigin? origin = null)
 54    {
 255        if (mode == NomenclatureMode.FindOnly)
 56        {
 157            var found = await FindManyAsync(
 258                orders.Select(o => (Article: (string?)NomenclatureResolution.LegacySearchArticle(o), ProducerName: o.EtP
 159                maxConcurrency);
 360            return found.Select(g => new NomenclatureResolution(g, false, string.Empty, null, null, false)).ToList();
 61        }
 62
 163        var created = await GetOrCreateManyAsync(orders, maxConcurrency, origin);
 264        return created.Select(c => new NomenclatureResolution(c.RefKey, c.Created, c.DisplayName, null, null, false)).To
 265    }
 66}
 67
 11468public sealed record OneCNomenclatureResult(Guid RefKey, bool Created, string DisplayName);
 69
 70/// <summary>Гарантирует, что товар из заказа существует и пригоден для проведения документов 1С.</summary>
 71public sealed partial class OneCNomenclatureService : IOneCNomenclatureService, INomenclatureLiveLookup
 72{
 73    private const string NomenclatureEndpoint = "Catalog_Номенклатура";
 74    internal const string PropertyValuesEndpoint = "Catalog_ЗначенияСвойствОбъектов";
 75    internal const string ProducerPropertyKey = "abba39bc-6cc0-11e8-418f-005056890939";
 76    private const string StockAccountKey = "fa5a6448-4871-11e8-4680-fa163e49a0ee";
 77    private const string ExpenseAccountKey = "fa5a70f0-4871-11e8-4680-fa163e49a0ee";
 78    private const string ActivityDirectionKey = "faf5480a-4871-11e8-4680-fa163e49a0ee";
 79    // «Без категории» в Catalog_КатегорииНоменклатуры.
 80    private const string CategoryKey = "fdd99328-4871-11e8-4680-fa163e49a0ee";
 81    private const string VatSalesAccountKey = "3206a2f4-7ed1-49da-92a8-5f2bf0384c0e";
 82    private const string VatPurchaseAccountKey = "9e4ace75-ec94-49b2-842c-3642f4a35a32";
 83
 84    /// <summary>Длина префикса артикула для substringof-пробы. См. FetchCandidatesAsync.</summary>
 85    private const int ProbePrefixLength = 5;
 86
 87    /// <summary>Короче этого имя производителя не участвует в эвристике вхождения — слишком много ложных срабатываний.<
 88    private const int MinProducerContainsLength = 4;
 89
 190    private static readonly ConcurrentDictionary<string, SemaphoreSlim> Locks = new(StringComparer.Ordinal);
 91    // Отдельный лок на имя производителя: при параллельном создании (GetOrCreateManyAsync) два разных
 92    // артикула с одним новым производителем не заблокируют друг друга через Locks (ключ там включает
 93    // артикул) и могут создать в 1С два дублирующих Catalog_ЗначенияСвойствОбъектов на один Description.
 94    // Ключ — нормализованное имя (см. NormalizeProducerName), иначе "PARTS-MALL" и "PARTS MALL" лочатся
 95    // раздельно и всё равно создают два разных значения реквизита.
 196    private static readonly ConcurrentDictionary<string, SemaphoreSlim> ProducerLocks = new(StringComparer.Ordinal);
 97
 98    private readonly IOneCODataClient _client;
 99    private readonly ILogger<OneCNomenclatureService> _logger;
 100    private readonly IOneCNomenclatureCreationLock? _creationLock;
 101    private readonly IOneCNomenclatureMapStore? _map;
 102    private readonly bool _backfillProducerOnReuse;
 103    private readonly ISystemMonitoringSignalWriter? _signals;
 104    private readonly NomenclatureMatcher? _matcher;
 105    private readonly NomenclatureResolverMode _resolverMode;
 106    private readonly ProducerValueResolver? _producerValues;
 107    private readonly INomenclatureIndexProvider? _indexProvider;
 108    private readonly INomenclatureNotificationSink? _notificationSink;
 109
 52110    public OneCNomenclatureService(
 52111        IOneCODataClient client,
 52112        ILogger<OneCNomenclatureService> logger,
 52113        IOneCNomenclatureCreationLock? creationLock = null,
 52114        IOneCNomenclatureMapStore? map = null,
 52115        OneCODataConfig? config = null,
 52116        ISystemMonitoringSignalWriter? signals = null,
 52117        NomenclatureMatcher? matcher = null,
 52118        ProducerValueResolver? producerValues = null,
 52119        INomenclatureIndexProvider? indexProvider = null,
 52120        INomenclatureNotificationSink? notificationSink = null)
 121    {
 52122        _producerValues = producerValues;
 52123        _indexProvider = indexProvider;
 52124        _notificationSink = notificationSink;
 52125        _signals = signals;
 52126        _matcher = matcher;
 52127        _resolverMode = config?.ResolverMode ?? NomenclatureResolverMode.Off;
 52128        _client = client;
 52129        _logger = logger;
 52130        _creationLock = creationLock;
 52131        _map = map;
 52132        _backfillProducerOnReuse = config?.BackfillProducerOnReuse ?? true;
 52133    }
 134
 135    public async Task<OneCNomenclatureResult> GetOrCreateAsync(Order order, OneCNomenclatureOrigin? origin = null)
 136    {
 20137        var shadow = await ShadowMatchAsync(new ProductQuery(order.Article, order.ProductKey, order.ProductName, order.E
 138        try
 139        {
 20140            var result = await GetOrCreateCoreAsync(order, origin);
 18141            LogShadow(shadow, result.RefKey, result.Created, NomenclatureMode.FindOrCreate, order.Article, order.EtProdu
 18142            return result;
 143        }
 2144        catch (Exception ex)
 145        {
 2146            await SignalAsync("create-failed", $"{order.Article}: {ex.Message}");
 2147            throw;
 148        }
 18149    }
 150
 151    /// <summary>
 152    /// Проблемы с номенклатурой/производителями 1С попадают в «Системные алерты» (Studio2/JobLogs?tab=alerts)
 153    /// по общим правилам пассивных сигналов. Запись сигнала не должна влиять на основную операцию.
 154    /// </summary>
 155    private async Task SignalAsync(string scope, string message)
 156    {
 9157        if (_signals == null) return;
 5158        await _signals.RecordAsync(NomenclatureSignalSource, "failure", scope, message);
 7159    }
 160
 161    private async Task AuditAsync(string scope, string message)
 162    {
 9163        if (_signals == null) return;
 9164        await _signals.RecordAsync(NomenclatureSignalSource, "success", scope, message);
 9165    }
 166
 167    public const string NomenclatureSignalSource = "onec-nomenclature";
 168
 169    /// <summary>Новый поиск в режиме Shadow: только читает, любые сбои гасятся — прежний поиск от него не зависит.</sum
 170    private async Task<NomenclatureMatch?> ShadowMatchAsync(ProductQuery query)
 171    {
 40172        if (_resolverMode != NomenclatureResolverMode.Shadow || _matcher == null)
 37173            return null;
 174
 175        try
 176        {
 3177            return await _matcher.MatchAsync(query, this);
 178        }
 1179        catch (Exception ex)
 180        {
 1181            _logger.LogWarning(ex, "[NomenclatureShadow] Новый поиск не отработал для {Article}", query.Article);
 1182            return null;
 183        }
 40184    }
 185
 186    private void LogShadow(NomenclatureMatch? shadow, Guid? oldRefKey, bool oldCreated, NomenclatureMode mode, string? a
 187    {
 38188        if (shadow == null)
 36189            return;
 190
 2191        var outcome = NomenclatureShadowComparer.Compare(oldRefKey, oldCreated, shadow, mode);
 2192        if (outcome.Kind == ShadowDiffKind.Agree)
 193        {
 1194            _logger.LogDebug("[NomenclatureShadow] совпало {Article}/{Producer}: {Text}", article, producer, outcome.Des
 1195            return;
 196        }
 197
 1198        _logger.LogInformation("[NomenclatureShadow] {Kind} {Article}/{Producer}: {Text}", outcome.Kind, article, produc
 1199    }
 200
 201    /// <summary>Живой поиск для нового матчера: те же OData-запросы, что у прежнего поиска, плюс имя производителя карт
 202    async Task<IReadOnlyList<LiveCard>> INomenclatureLiveLookup.FindByArticleAsync(
 203        string articleClean, string articleKey, CancellationToken cancellationToken)
 204    {
 15205        var candidates = await FetchCandidatesAsync(NormalizeArticle(articleClean), articleClean);
 15206        var cards = new List<LiveCard>(candidates.Count);
 30207        foreach (var candidate in candidates)
 208        {
 0209            var producerGuid = await ReadProducerAsync(candidate.RefKey);
 0210            string? producerName = null;
 0211            if (producerGuid.HasValue)
 212            {
 0213                var value = await _client.GetByGuidAsync<ODataPropertyValue>(
 0214                    PropertyValuesEndpoint, producerGuid.Value, "Ref_Key,Description");
 0215                producerName = value?.Description;
 216            }
 217
 0218            cards.Add(new LiveCard(candidate.RefKey, candidate.Article, producerName));
 0219        }
 220
 15221        return cards;
 15222    }
 223
 224    /// <summary>
 225    /// Создаёт карточку в 1С: тело, единица, производитель, обязательные поля, запись в карту. Вызывать только под локо
 226    /// ключа (артикул + производитель), чтобы параллельные вызовы не завели две карточки.
 227    /// </summary>
 228    private async Task<OneCNomenclatureResult> CreateNewCardAsync(
 229        Order order,
 230        string article,
 231        string creationArticle,
 232        Guid? producerGuid,
 233        string? producerName,
 234        string producerNorm,
 235        OneCNomenclatureOrigin? origin)
 236    {
 19237        var name = string.IsNullOrWhiteSpace(order.ProductName) ? creationArticle : order.ProductName.Trim();
 19238        var body = BuildNomenclatureBody(creationArticle, name);
 239        ODataNomenclature created;
 240        try
 241        {
 19242            created = await _client.PostAsync<object, ODataNomenclature>(NomenclatureEndpoint, body);
 19243        }
 0244        catch (InvalidOperationException ex) when (ex is OneCODataException { Error.StatusCode: 500 }
 0245                                               || ex.Message.Contains("HTTP 500", StringComparison.Ordinal))
 246        {
 0247            _logger.LogWarning("[OData] Создание номенклатуры {Article} с единицей вернуло 500; повторяем без единицы.",
 0248            body.Remove("ЕдиницаИзмерения_Key");
 0249            created = await _client.PostAsync<object, ODataNomenclature>(NomenclatureEndpoint, body);
 250        }
 251
 19252        if (created.RefKey == Guid.Empty)
 1253            throw new InvalidOperationException($"1С создала номенклатуру '{article}' без Ref_Key.");
 254
 18255        if (producerGuid.HasValue)
 16256            await SetProducerAsync(created.RefKey, producerGuid.Value);
 257
 258        // EnsureRequiredFieldsAsync должен быть последней записью в карточку: SetProducerAsync делает
 259        // full-PUT (PutAsync), который может откатить единицу/категорию, если вызвать проверку раньше него.
 260        // Отметка о происхождении идёт этим же PATCH — в теле POST её слать нельзя: неизвестное поле
 261        // может уронить создание карточки, а PATCH по «Комментарий» проверен на проде.
 18262        await EnsureRequiredFieldsAsync(created.RefKey, BuildOriginComment(origin));
 263
 18264        await RememberAsync(article, producerNorm, created.RefKey, creationArticle, producerName, "created");
 265
 18266        _logger.LogInformation("[OData] Создана номенклатура {Article} (производитель {Producer}): {RefKey}",
 18267            creationArticle, producerName ?? "—", created.RefKey);
 18268        return new OneCNomenclatureResult(created.RefKey, true, $"{name} [{article}]");
 18269    }
 270
 271    private async Task<OneCNomenclatureResult> GetOrCreateCoreAsync(Order order, OneCNomenclatureOrigin? origin)
 272    {
 20273        var (article, searchArticle, creationArticle) = ResolveArticle(order);
 20274        if (string.IsNullOrWhiteSpace(article))
 1275            throw new InvalidOperationException($"Заказ {order.ShipmentNumber}: артикул пуст после нормализации, создать
 276
 19277        var producerName = order.EtProducer?.Name?.Trim();
 19278        var producerNorm = NormalizeProducerName(producerName);
 279
 280        // Алиас разрешается ДО ключа карты и локов. Иначе «BLUE» ищется по собственному ключу,
 281        // находит старую дублирующую карточку и до канонического «BLUE PRINT» дело не доходит.
 19282        var (aliasProducerGuid, aliasedNorm, aliasedName) = await ResolveProducerAliasAsync(producerNorm, producerName);
 19283        producerNorm = aliasedNorm;
 19284        producerName = aliasedName;
 285
 19286        var lockKey = $"{article}|{producerNorm}";
 287        var gate = Locks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1));
 19288        await gate.WaitAsync();
 289        try
 290        {
 19291            await using var distributedLock = _creationLock == null
 19292                ? null
 19293                : await _creationLock.AcquireAsync(article, producerNorm);
 294
 295            // Быстрый путь: карта соответствий знает карточку по нормализованному ключу независимо
 296            // от того, как артикул отформатирован в самой 1С. Проверяем уже под локом, чтобы второй
 297            // инстанс увидел запись, сделанную первым.
 19298            if (_map != null)
 299            {
 13300                var mapped = await _map.FindAsync(article, producerNorm);
 13301                if (mapped != null)
 2302                    return new OneCNomenclatureResult(mapped.RefKey, false, string.Empty);
 303            }
 304
 17305            var producerGuid = aliasProducerGuid
 17306                               ?? (string.IsNullOrWhiteSpace(producerName)
 17307                                   ? (Guid?)null
 17308                                   : await GetOrCreateProducerAsync(producerName));
 309
 17310            var existing = await FindExistingAsync(article, searchArticle, producerGuid, producerNorm);
 17311            if (existing != null)
 312            {
 7313                if (existing.NeedsProducerBackfill && producerGuid.HasValue && _backfillProducerOnReuse)
 314                {
 2315                    _logger.LogInformation(
 2316                        "[OData] Артикул {Article}: переиспользуем карточку {RefKey} без производителя и дописываем «{Pr
 2317                        article, existing.RefKey, producerName);
 2318                    await SetProducerAsync(existing.RefKey, producerGuid.Value);
 319
 320                    // SetProducerAsync — полная перезапись карточки (PUT), а 1С Fresh при ней не
 321                    // сохраняет навигационные реквизиты: единица, счета учёта и категория обнуляются.
 322                    // В ветке создания это лечит EnsureRequiredFieldsAsync ниже, здесь нужен такой же
 323                    // вызов, иначе строка документа уходит в 1С с пустой «Ед.». Комментарий
 324                    // происхождения не пишем: карточку завёл не Studio2.
 2325                    await EnsureRequiredFieldsAsync(existing.RefKey);
 326                }
 327
 7328                await RememberAsync(article, producerNorm, existing.RefKey, existing.RawArticle, producerName, "probe");
 7329                return new OneCNomenclatureResult(existing.RefKey, false, string.Empty);
 330            }
 331
 10332            return await CreateNewCardAsync(order, article, creationArticle, producerGuid, producerName, producerNorm, o
 0333        }
 334        finally
 335        {
 19336            gate.Release();
 337        }
 18338    }
 339
 340    public async Task<IReadOnlyList<OneCNomenclatureResult>> GetOrCreateManyAsync(
 341        IReadOnlyList<Order> orders,
 342        int maxConcurrency = 3,
 343        OneCNomenclatureOrigin? origin = null)
 344    {
 1345        using var throttle = new SemaphoreSlim(Math.Max(1, maxConcurrency));
 1346        var tasks = orders.Select(async order =>
 1347        {
 2348            await throttle.WaitAsync();
 1349            try
 1350            {
 2351                return await GetOrCreateAsync(order, origin);
 1352            }
 1353            finally
 1354            {
 2355                throttle.Release();
 1356            }
 3357        });
 1358        return await Task.WhenAll(tasks);
 1359    }
 360
 361    public async Task<Guid?> FindAsync(string? article, string? producerName)
 362    {
 20363        var shadow = await ShadowMatchAsync(new ProductQuery(article, null, null, producerName));
 20364        var found = await FindCoreAsync(article, producerName);
 20365        LogShadow(shadow, found, false, NomenclatureMode.FindOnly, article, producerName);
 20366        return found;
 20367    }
 368
 369    private async Task<Guid?> FindCoreAsync(string? article, string? producerName)
 370    {
 20371        var normalizedArticle = NormalizeArticle(article);
 20372        if (normalizedArticle.Length == 0)
 2373            return null;
 374
 18375        var trimmedProducer = producerName?.Trim();
 18376        var producerNorm = NormalizeProducerName(trimmedProducer);
 18377        var originalProducerNorm = producerNorm;
 378
 379        // Порядок тот же, что в GetOrCreateAsync: алиас → карта соответствий → поиск по 1С.
 380        // Локи не берём — поиск ничего не мутирует, а лок сериализовал бы его с параллельным
 381        // созданием карточек того же артикула.
 18382        var (aliasProducerGuid, aliasedNorm, aliasedName) = await ResolveProducerAliasAsync(producerNorm, trimmedProduce
 18383        producerNorm = aliasedNorm;
 18384        trimmedProducer = aliasedName;
 385
 18386        if (_map != null)
 387        {
 17388            var mapped = await _map.FindAsync(normalizedArticle, producerNorm);
 389
 390            // GetOrCreateAsync намеренно ищет только по каноническому производителю, чтобы не взять старую
 391            // карточку-дубль. Поиск без создания (сверка остатков, проверки) должен находить любую карточку,
 392            // а карта хранит их под тем написанием, под которым их завели: «FEBI», а не «FEBI BILSTEIN».
 393            // 14.10.2026 алиас FEBI → FEBI BILSTEIN сделал такие карточки невидимыми, защита «1С онлайн»
 394            // перестала их проверять и на Ozon встал товар, которого на M1 нет.
 17395            if (mapped == null && !string.Equals(originalProducerNorm, producerNorm, StringComparison.Ordinal))
 3396                mapped = await _map.FindAsync(normalizedArticle, originalProducerNorm);
 397
 17398            if (mapped != null)
 10399                return mapped.RefKey;
 400        }
 401
 8402        var producerGuid = aliasProducerGuid;
 8403        if (producerGuid == null && !string.IsNullOrWhiteSpace(trimmedProducer))
 404        {
 405            // Значения реквизита нет в 1С — карточки с точным производителем тоже быть не может,
 406            // поэтому ищем дальше как без производителя (та же ветка, куда попал бы GetOrCreateAsync).
 4407            var (match, _) = await TryFindProducerAsync(NormalizeProducerName(trimmedProducer));
 4408            producerGuid = match?.RefKey;
 409        }
 410
 8411        var existing = await FindExistingAsync(normalizedArticle, article?.Trim(), producerGuid, producerNorm);
 8412        return existing?.RefKey;
 20413    }
 414
 415    public async Task<IReadOnlyList<Guid?>> FindManyAsync(
 416        IReadOnlyList<(string? Article, string? ProducerName)> items,
 417        int maxConcurrency = 3)
 418    {
 3419        using var throttle = new SemaphoreSlim(Math.Max(1, maxConcurrency));
 3420        var tasks = items.Select(async item =>
 3421        {
 5422            await throttle.WaitAsync();
 3423            try
 3424            {
 5425                return await FindAsync(item.Article, item.ProducerName);
 3426            }
 3427            finally
 3428            {
 5429                throttle.Release();
 3430            }
 8431        });
 3432        return await Task.WhenAll(tasks);
 3433    }
 434
 435    internal static string NormalizeArticle(string? value)
 436    {
 79437        var normalized = (value ?? string.Empty).Normalize(NormalizationForm.FormKC).ToUpperInvariant();
 79438        return new string(normalized.Where(char.IsLetterOrDigit).ToArray());
 439    }
 440
 441    /// <summary>
 442    /// Возвращает нормализованный ключ для карты, исходное написание для поиска и артикул для создания в 1С.
 443    /// В заказе <see cref="Order.Article"/> может быть очищен импортом, но ProductKey сохраняет
 444    /// артикул до знака «=» вместе с дефисами, точками и другими разделителями.
 445    /// </summary>
 446    private static (string Normalized, string SearchArticle, string CreationArticle) ResolveArticle(Order order)
 447    {
 20448        var article = order.Article?.Trim();
 20449        var normalized = NormalizeArticle(article);
 20450        if (normalized.Length == 0)
 1451            return (string.Empty, string.Empty, string.Empty);
 452
 19453        var productKeyArticle = order.ProductKey?.Split('=', 2)[0].Trim();
 19454        if (!string.IsNullOrWhiteSpace(productKeyArticle)
 19455            && NormalizeArticle(productKeyArticle) == normalized
 22456            && productKeyArticle.Any(c => !char.IsLetterOrDigit(c)))
 457        {
 1458            return (normalized, productKeyArticle, productKeyArticle);
 459        }
 460
 18461        return (normalized, article ?? normalized, normalized);
 462    }
 463
 464    /// <summary>
 465    /// Убирает дефисы/пробелы/регистр из имени производителя перед поиском и сравнением, иначе
 466    /// "PARTS-MALL" и "PARTS MALL" считаются разными производителями — оба создают своё значение
 467    /// реквизита, и карточка номенклатуры с тем же артикулом не находится по составному ключу
 468    /// (артикул + производитель) в FindExistingAsync, что порождает дубль карточки.
 469    /// </summary>
 470    public static string NormalizeProducerName(string? value)
 471    {
 309472        var normalized = (value ?? string.Empty).Normalize(NormalizationForm.FormKC).ToUpperInvariant();
 309473        return new string(normalized.Where(char.IsLetterOrDigit).ToArray());
 474    }
 475
 43476    private sealed record ExistingMatch(Guid RefKey, string? RawArticle, bool NeedsProducerBackfill);
 477
 478    /// <summary>
 479    /// Сводит написание производителя к каноническому: «BLUE» → «BLUE PRINT», «Kayaba» → «KYB».
 480    /// Возвращает GUID канонического значения и канонические имя/ключ для карты соответствий.
 481    /// </summary>
 482    private async Task<(Guid? Guid, string Norm, string? Name)> ResolveProducerAliasAsync(string producerNorm, string? p
 483    {
 39484        if (_map == null || producerNorm.Length == 0)
 17485            return (null, producerNorm, producerName);
 486
 487        OneCProducerAliasEntry? alias;
 488        try
 489        {
 22490            alias = await _map.FindProducerAliasAsync(producerNorm);
 22491        }
 0492        catch (Exception ex)
 493        {
 0494            _logger.LogWarning(ex, "[OData] Не удалось прочитать алиасы производителя для «{Producer}».", producerName);
 0495            return (null, producerNorm, producerName);
 496        }
 497
 22498        if (alias == null)
 15499            return (null, producerNorm, producerName);
 500
 7501        var canonicalNorm = NormalizeProducerName(alias.CanonicalName);
 7502        return canonicalNorm.Length == 0
 7503            ? (alias.RefKey, producerNorm, producerName)
 7504            : (alias.RefKey, canonicalNorm, alias.CanonicalName);
 39505    }
 506
 507    private async Task RememberAsync(string article, string producerNorm, Guid refKey, string? rawArticle, string? produ
 508    {
 25509        if (_map == null)
 6510            return;
 511
 512        try
 513        {
 19514            await _map.UpsertAsync(new OneCMapEntry(refKey, article, producerNorm, producerName), rawArticle, source);
 19515        }
 0516        catch (Exception ex)
 517        {
 518            // Карта — ускоритель и защита от дублей, но не источник истины: её недоступность
 519            // не должна ронять проведение документа.
 0520            _logger.LogWarning(ex, "[OData] Не удалось записать соответствие {Article}|{Producer} → {RefKey} в карту ном
 0521                article, producerNorm, refKey);
 0522        }
 25523    }
 524
 525    private async Task<ExistingMatch?> FindExistingAsync(string article, string? rawArticle, Guid? producerGuid, string?
 526    {
 25527        var candidates = await FetchCandidatesAsync(article, rawArticle);
 25528        if (candidates.Count == 0)
 16529            return null;
 530
 9531        if (!producerGuid.HasValue)
 532        {
 533            // Производитель у входящей позиции не указан — сопоставляем только по артикулу.
 534            // Единственный кандидат берётся без лишнего чтения карточки.
 6535            if (candidates.Count == 1)
 6536                return new ExistingMatch(candidates[0].RefKey, candidates[0].Article, false);
 537
 0538            var withProducers = await ReadProducersAsync(candidates);
 539            var producerless = withProducers.FirstOrDefault(x => !x.Producer.HasValue);
 540            var chosen = producerless.Card ?? candidates.OrderBy(x => x.RefKey).First();
 0541            _logger.LogWarning(
 0542                "[OData] Артикул {Article}: {Count} карточек в 1С, производитель во входящей позиции не задан — выбрана 
 0543                article, candidates.Count, chosen.RefKey);
 0544            await SignalAsync("duplicate", $"Артикул {article}: {candidates.Count} карточек в 1С, производитель не задан
 0545            return new ExistingMatch(chosen.RefKey, chosen.Article, false);
 546        }
 547
 3548        var resolved = await ReadProducersAsync(candidates);
 549
 6550        var exact = resolved.FirstOrDefault(x => x.Producer == producerGuid);
 3551        if (exact.Card != null)
 0552            return new ExistingMatch(exact.Card.RefKey, exact.Card.Article, false);
 553
 554        // В 1С одно и то же имя бренда бывает записано разными значениями (в т.ч. у другого владельца
 555        // реквизита), и GUID у карточки не совпадает с найденным по Owner_Key. Сверяем по имени.
 3556        if (!string.IsNullOrEmpty(producerNorm))
 557        {
 558            foreach (var (card, producer) in resolved.Where(x => x.Producer.HasValue))
 559            {
 1560                var value = await _client.GetByGuidAsync<ODataPropertyValue>(
 1561                    PropertyValuesEndpoint, producer!.Value, "Ref_Key,Description");
 1562                if (NormalizeProducerName(value?.Description) == producerNorm)
 0563                    return new ExistingMatch(card!.RefKey, card.Article, false);
 1564            }
 565        }
 566
 567        // Карточка с тем же артикулом, но вообще без производителя — самый частый источник дублей:
 568        // раньше на неё не попадали и заводили вторую. Переиспользуем, если она единственная.
 569        var producerlessCards = resolved.Where(x => !x.Producer.HasValue).ToList();
 3570        if (producerlessCards.Count == 1)
 2571            return new ExistingMatch(producerlessCards[0].Card!.RefKey, producerlessCards[0].Card!.Article, true);
 572
 573        // Остальное — тот же артикул у другого бренда (один OEM-номер у разных производителей).
 1574        return null;
 25575    }
 576
 577    /// <summary>
 578    /// Кандидаты по артикулу. В OData 1С нет replace, а <c>Артикул</c> сравнивается точно, поэтому
 579    /// «PR-66622» не находится по фильтру <c>Артикул eq 'PR66622'</c>. Сначала пробуем исходное
 580    /// написание из ProductKey (оно не теряет разделители при импорте), затем нормализованное и
 581    /// только после этого выполняем substringof-пробу. Полное покрытие даёт карта соответствий
 582    /// (IOneCNomenclatureMapStore).
 583    /// </summary>
 584    private async Task<List<ODataNomenclature>> FetchCandidatesAsync(string article, string? rawArticle)
 585    {
 40586        var result = new List<ODataNomenclature>();
 587
 40588        var exactArticles = new[] { rawArticle?.Trim(), article }
 589            .Where(x => !string.IsNullOrWhiteSpace(x))
 40590            .Distinct(StringComparer.OrdinalIgnoreCase);
 186591        foreach (var exactArticle in exactArticles)
 592        {
 53593            var exact = await _client.GetSinglePageAsync<ODataNomenclature>(NomenclatureEndpoint, 50,
 53594                new Dictionary<string, string>
 53595                {
 53596                    ["$select"] = "Ref_Key,Артикул,Description,IsFolder",
 53597                    ["$filter"] = $"Артикул eq '{EscapeOData(exactArticle!)}' and IsFolder eq false and DeletionMark eq 
 53598                });
 67599            result.AddRange(exact.Where(x => result.All(known => known.RefKey != x.RefKey)));
 600        }
 601
 40602        if (article.Length >= ProbePrefixLength)
 603        {
 38604            var probe = article[..ProbePrefixLength];
 38605            var contains = await _client.GetSinglePageAsync<ODataNomenclature>(NomenclatureEndpoint, 50,
 38606                new Dictionary<string, string>
 38607                {
 38608                    ["$select"] = "Ref_Key,Артикул,Description,IsFolder",
 38609                    ["$filter"] = $"substringof('{EscapeOData(probe)}', Артикул) and IsFolder eq false and DeletionMark 
 38610                });
 43611            result.AddRange(contains.Where(x => result.All(known => known.RefKey != x.RefKey)));
 612        }
 613
 49614        return result.Where(x => NormalizeArticle(x.Article) == article).ToList();
 40615    }
 616
 617    private async Task<List<(ODataNomenclature? Card, Guid? Producer)>> ReadProducersAsync(List<ODataNomenclature> candi
 618    {
 3619        var result = new List<(ODataNomenclature?, Guid?)>(candidates.Count);
 12620        foreach (var candidate in candidates)
 3621            result.Add((candidate, await ReadProducerAsync(candidate.RefKey)));
 3622        return result;
 3623    }
 624
 625    private async Task<Guid?> ReadProducerAsync(Guid refKey)
 626    {
 3627        var raw = await _client.GetRawJsonAsync(NomenclatureEndpoint, refKey);
 3628        if (raw == "{}")
 0629            return null;
 630
 3631        var properties = JObject.Parse(raw)["ДополнительныеРеквизиты"] as JArray;
 3632        var node = properties?.FirstOrDefault(p =>
 633            string.Equals(p?["Свойство_Key"]?.Value<string>(), ProducerPropertyKey, StringComparison.OrdinalIgnoreCase))
 3634        var value = node?["Значение"]?.Value<string>();
 3635        return Guid.TryParse(value, out var guid) && guid != Guid.Empty ? guid : null;
 3636    }
 637
 638    /// <summary>
 639    /// Ищет значение реквизита «Производитель» без создания. ByContainment=true — совпадение найдено
 640    /// эвристикой вхождения, а не точным сравнением имён.
 641    /// </summary>
 642    private async Task<(ODataPropertyValue? Match, bool ByContainment)> TryFindProducerAsync(string normalizedTarget)
 643    {
 644        // Тянем все значения этого реквизита (не фильтруем по Description на сервере — иначе
 645        // разные написания одного бренда, напр. "PARTS-MALL" и "PARTS MALL", не совпадут по
 646        // точному OData-сравнению строк) и матчим локально по нормализованному имени.
 14647        var values = await _client.GetListAsync<ODataPropertyValue>(PropertyValuesEndpoint,
 14648            new Dictionary<string, string>
 14649            {
 14650                ["$select"] = "Ref_Key,Description,Owner_Key,DeletionMark",
 14651                ["$filter"] = $"Owner_Key eq guid'{ProducerPropertyKey}' and DeletionMark eq false"
 14652            });
 653
 29654        var exact = values.FirstOrDefault(x => NormalizeProducerName(x.Description) == normalizedTarget);
 14655        return exact != null
 14656            ? (exact, false)
 14657            : (InferProducerByContainment(values, normalizedTarget), true);
 14658    }
 659
 660    private async Task<Guid> GetOrCreateProducerAsync(string name)
 661    {
 10662        var normalizedTarget = NormalizeProducerName(name);
 663        var gate = ProducerLocks.GetOrAdd(normalizedTarget, _ => new SemaphoreSlim(1, 1));
 10664        await gate.WaitAsync();
 665        try
 666        {
 667            // Явные алиасы уже разрешены в ResolveProducerAliasAsync — сюда попадают только имена,
 668            // для которых соответствия не задано.
 10669            var (match, byContainment) = await TryFindProducerAsync(normalizedTarget);
 10670            if (match != null && !byContainment)
 6671                return match.RefKey;
 672
 4673            if (match != null)
 674            {
 1675                _logger.LogInformation(
 1676                    "[OData] Производитель «{Name}» сопоставлен с существующим «{Existing}» по вхождению — новое значени
 1677                    name, match.Description);
 1678                if (_map != null)
 1679                    await _map.UpsertProducerAliasAsync(normalizedTarget, match.RefKey, match.Description, "auto-contain
 1680                return match.RefKey;
 681            }
 682
 3683            var created = await _client.PostAsync<object, ODataPropertyValue>(PropertyValuesEndpoint, new
 3684            {
 3685                Description = name,
 3686                DeletionMark = false,
 3687                Owner_Key = ProducerPropertyKey
 3688            });
 3689            if (created.RefKey == Guid.Empty)
 0690                throw new InvalidOperationException($"1С создала производителя '{name}' без Ref_Key.");
 691
 3692            _logger.LogInformation("[OData] Создано значение реквизита «Производитель»: {Name} → {RefKey}", name, create
 3693            return created.RefKey;
 694        }
 695        finally
 696        {
 10697            gate.Release();
 698        }
 10699    }
 700
 701    /// <summary>
 702    /// Сводит усечённые написания бренда к существующему значению: «BLUE» → «BLUE PRINT».
 703    /// Срабатывает только при единственном кандидате — иначе можно склеить разные бренды.
 704    /// </summary>
 705    internal static ODataPropertyValue? InferProducerByContainment(
 706        IReadOnlyCollection<ODataPropertyValue> values,
 707        string normalizedTarget)
 708    {
 10709        if (normalizedTarget.Length < MinProducerContainsLength)
 2710            return null;
 711
 8712        var matches = values
 8713            .Where(x =>
 8714            {
 8715                var candidate = NormalizeProducerName(x.Description);
 8716                if (candidate.Length < MinProducerContainsLength)
 1717                    return false;
 7718                return candidate.StartsWith(normalizedTarget, StringComparison.Ordinal)
 7719                       || normalizedTarget.StartsWith(candidate, StringComparison.Ordinal);
 8720            })
 8721            .ToList();
 722
 8723        return matches.Count == 1 ? matches[0] : null;
 724    }
 725
 726    private async Task SetProducerAsync(Guid nomenclatureGuid, Guid producerGuid)
 727    {
 728        // 412 означает, что 1С изменила карточку между GET и full-PUT. Повторяем ровно один раз
 729        // с заново прочитанной карточкой, чтобы не перезаписать параллельные изменения устаревшим телом.
 38730        for (var attempt = 1; attempt <= 2; attempt++)
 731        {
 19732            var raw = await _client.GetRawJsonAsync(NomenclatureEndpoint, nomenclatureGuid);
 19733            if (raw == "{}")
 0734                throw new InvalidOperationException($"Не удалось прочитать созданную номенклатуру {nomenclatureGuid} для
 19735            var item = JObject.Parse(raw);
 19736            item.Remove("odata.metadata");
 19737            item.Remove("odata.type");
 19738            item.Remove("odata.etag");
 739
 19740            var properties = item["ДополнительныеРеквизиты"] as JArray ?? new JArray();
 741            var producer = properties.FirstOrDefault(p => string.Equals(p?["Свойство_Key"]?.Value<string>(), ProducerPro
 19742            if (producer == null)
 743            {
 18744                producer = new JObject { ["LineNumber"] = properties.Count + 1, ["Свойство_Key"] = ProducerPropertyKey }
 18745                properties.Add(producer);
 746            }
 19747            producer["Значение"] = producerGuid.ToString();
 19748            producer["Значение_Type"] = "StandardODATA.Catalog_ЗначенияСвойствОбъектов";
 19749            item["ДополнительныеРеквизиты"] = properties;
 750
 751            try
 752            {
 19753                await _client.PutAsync(NomenclatureEndpoint, nomenclatureGuid, item);
 18754                return;
 755            }
 1756            catch (OneCODataException ex) when (ex.Error?.StatusCode == 412 && attempt == 1)
 757            {
 1758                _logger.LogInformation("[OData] Карточка номенклатуры {RefKey} изменилась перед установкой производителя
 1759            }
 760        }
 18761    }
 762
 763    /// <summary>
 764    /// 1С Fresh может вернуть успешный POST, но не записать навигационные реквизиты карточки.
 765    /// Не продолжаем проведение, пока повторное чтение не подтвердит единицу «шт» и «Без категории».
 766    /// </summary>
 767    private async Task EnsureRequiredFieldsAsync(Guid nomenclatureGuid, string? originComment = null)
 768    {
 20769        var patch = new Dictionary<string, object>
 20770        {
 20771            ["ЕдиницаИзмерения_Key"] = OneCODataConstants.UnitShtukaGuid,
 20772            ["КатегорияНоменклатуры_Key"] = CategoryKey,
 20773            ["СчетУчетаЗапасов_Key"] = StockAccountKey,
 20774            ["СчетУчетаЗатрат_Key"] = ExpenseAccountKey,
 20775            ["НаправлениеДеятельности_Key"] = ActivityDirectionKey,
 20776            ["СчетУчетаНДСПоРеализации_Key"] = VatSalesAccountKey,
 20777            ["СчетУчетаНДСПоПриобретеннымЦенностям_Key"] = VatPurchaseAccountKey
 20778        };
 779
 780        // «Комментарий» — свободное поле карточки. Наименование (Description) не трогаем:
 781        // оно видно в списках номенклатуры и в печатных формах.
 20782        if (!string.IsNullOrWhiteSpace(originComment))
 18783            patch["Комментарий"] = originComment;
 784
 40785        for (var attempt = 1; attempt <= 2; attempt++)
 786        {
 20787            await _client.PatchAsync<object>(NomenclatureEndpoint, nomenclatureGuid, patch);
 788
 20789            var raw = await _client.GetRawJsonAsync(NomenclatureEndpoint, nomenclatureGuid);
 20790            if (raw != "{}")
 791            {
 20792                var item = JObject.Parse(raw);
 20793                var unit = item["ЕдиницаИзмерения_Key"]?.Value<string>();
 20794                var category = item["КатегорияНоменклатуры_Key"]?.Value<string>();
 20795                var stockAccount = item["СчетУчетаЗапасов_Key"]?.Value<string>();
 20796                var expenseAccount = item["СчетУчетаЗатрат_Key"]?.Value<string>();
 20797                var activityDirection = item["НаправлениеДеятельности_Key"]?.Value<string>();
 20798                var vatSalesAccount = item["СчетУчетаНДСПоРеализации_Key"]?.Value<string>();
 20799                var vatPurchaseAccount = item["СчетУчетаНДСПоПриобретеннымЦенностям_Key"]?.Value<string>();
 20800                if (string.Equals(unit, OneCODataConstants.UnitShtukaGuid, StringComparison.OrdinalIgnoreCase)
 20801                    && string.Equals(category, CategoryKey, StringComparison.OrdinalIgnoreCase)
 20802                    && string.Equals(stockAccount, StockAccountKey, StringComparison.OrdinalIgnoreCase)
 20803                    && string.Equals(expenseAccount, ExpenseAccountKey, StringComparison.OrdinalIgnoreCase)
 20804                    && string.Equals(activityDirection, ActivityDirectionKey, StringComparison.OrdinalIgnoreCase)
 20805                    && string.Equals(vatSalesAccount, VatSalesAccountKey, StringComparison.OrdinalIgnoreCase)
 20806                    && string.Equals(vatPurchaseAccount, VatPurchaseAccountKey, StringComparison.OrdinalIgnoreCase))
 20807                    return;
 808            }
 809
 0810            _logger.LogWarning("[OData] Номенклатура {RefKey}: попытка {Attempt}/2 не подтвердила обязательные реквизиты
 811        }
 812
 0813        throw new InvalidOperationException(
 0814            $"Карточка номенклатуры {nomenclatureGuid} создана, но 1С не сохранила обязательные реквизиты учёта. Докумен
 20815    }
 816
 817    /// <summary>
 818    /// Отметка в «Комментарий» карточки: кто завёл позицию, когда и в рамках какой транзакции.
 819    /// Нужна, чтобы в 1С было видно, что карточка не заведена вручную.
 820    /// </summary>
 821    internal static string BuildOriginComment(OneCNomenclatureOrigin? origin, DateTime? nowUtc = null)
 822    {
 21823        var moscow = TimeZoneInfo.ConvertTimeFromUtc(nowUtc ?? DateTime.UtcNow, RussianTimeZoneHelper.Zone);
 21824        var stamp = moscow.ToString("dd.MM.yyyy HH:mm:ss");
 825
 21826        var transaction = origin?.TransactionType?.GetDisplayName();
 21827        return string.IsNullOrWhiteSpace(transaction)
 21828            ? $"Создано автоматически: Studio2, {stamp} (МСК)."
 21829            : $"Создано автоматически: Studio2, {stamp} (МСК). Транзакция: {transaction}.";
 830    }
 831
 19832    private static Dictionary<string, object> BuildNomenclatureBody(string article, string name) => new()
 19833    {
 19834        ["Description"] = name,
 19835        ["НаименованиеПолное"] = name,
 19836        ["Артикул"] = article,
 19837        ["IsFolder"] = false,
 19838        ["DeletionMark"] = false,
 19839        ["ЕдиницаИзмерения_Key"] = OneCODataConstants.UnitShtukaGuid,
 19840        ["СчетУчетаЗапасов_Key"] = StockAccountKey,
 19841        ["СчетУчетаЗатрат_Key"] = ExpenseAccountKey,
 19842        ["ВидСтавкиНДС"] = "Общая",
 19843        ["НаправлениеДеятельности_Key"] = ActivityDirectionKey,
 19844        ["КатегорияНоменклатуры_Key"] = CategoryKey,
 19845        ["СчетУчетаНДСПоРеализации_Key"] = VatSalesAccountKey,
 19846        ["СчетУчетаНДСПоПриобретеннымЦенностям_Key"] = VatPurchaseAccountKey
 19847    };
 848
 91849    private static string EscapeOData(string value) => value.Replace("'", "''");
 850}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureService.Resolver.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using OzonDomains.Models;
 3using OzonDomains.Nomenclature;
 4using Services.ApiServices._1CApi.Nomenclature;
 5
 6namespace Services.ApiServices._1CApi.OData;
 7
 8/// <summary>
 9/// Разрешение номенклатуры новым поиском (<see cref="NomenclatureResolverMode.On"/>): матчер решает, берём ли карточку,
 10/// создаём или отклоняем позицию. В режимах Off и Shadow метод работает как прежний <c>FindMany</c>/<c>GetOrCreateMany<
 11/// </summary>
 12public sealed partial class OneCNomenclatureService
 13{
 14    public async Task<IReadOnlyList<NomenclatureResolution>> ResolveManyAsync(
 15        IReadOnlyList<Order> orders,
 16        NomenclatureMode mode,
 17        int maxConcurrency = 3,
 18        OneCNomenclatureOrigin? origin = null)
 19    {
 2020        if (_resolverMode != NomenclatureResolverMode.On || _matcher == null)
 21        {
 222            if (mode == NomenclatureMode.FindOnly)
 23            {
 224                var found = await FindManyAsync(
 25                    orders.Select(o => (Article: (string?)NomenclatureResolution.LegacySearchArticle(o), ProducerName: o
 226                    maxConcurrency);
 27                return found.Select(g => new NomenclatureResolution(g, false, string.Empty, null, null, false)).ToList()
 28            }
 29
 030            var created = await GetOrCreateManyAsync(orders, maxConcurrency, origin);
 31            return created.Select(c => new NomenclatureResolution(c.RefKey, c.Created, c.DisplayName, null, null, false)
 32        }
 33
 1834        using var throttle = new SemaphoreSlim(Math.Max(1, maxConcurrency));
 1835        var tasks = orders.Select(async order =>
 1836        {
 1837            await throttle.WaitAsync();
 1838            try
 1839            {
 1840                return await ResolveOneAsync(order, mode, origin);
 1841            }
 1842            finally
 1843            {
 1844                throttle.Release();
 1845            }
 3646        });
 1847        return await Task.WhenAll(tasks);
 2048    }
 49
 50    private async Task<NomenclatureResolution> ResolveOneAsync(Order order, NomenclatureMode mode, OneCNomenclatureOrigi
 51    {
 1852        var query = new ProductQuery(order.Article, order.ProductKey, order.ProductName, order.EtProducer?.Name);
 1853        var match = await _matcher!.MatchAsync(query, this);
 1854        var decision = NomenclatureResolvePolicy.Decide(match, mode);
 1855        LogResolver(order, match, decision);
 56
 1857        switch (decision.Action)
 58        {
 59            case NomenclatureAction.UseCard:
 260                if (match.Duplicate)
 61                {
 062                    await SignalAsync("duplicate", $"Артикул {match.Parsed.ArticleClean}: {match.Reason}");
 063                    await NotifyAsync(order, match, NomenclatureNotificationKind.Duplicate, match.RefKey, match.Reason);
 64                }
 265                await AfterReuseAsync(order, match);
 266                return new NomenclatureResolution(decision.RefKey, false, string.Empty,
 267                    decision.FoundByVariant ? decision.Reason : null, match.Status, decision.FoundByVariant);
 68
 69            case NomenclatureAction.Create:
 70                try
 71                {
 1172                    var resolution = await CreateResolvedAsync(order, match, origin);
 1173                    if (resolution.Created)
 74                    {
 975                        var kind = match.Status == NomenclatureMatchStatus.BrandMismatch
 976                            ? NomenclatureNotificationKind.BrandMismatchCreated
 977                            : NomenclatureNotificationKind.Created;
 978                        await AuditAsync(kind == NomenclatureNotificationKind.BrandMismatchCreated ? "brand-mismatch-cre
 979                            $"Заказ {order.Id}: {match.Parsed.ArticleClean}; {match.Reason}");
 980                        await NotifyAsync(order, match, kind, resolution.RefKey, match.Reason);
 81                    }
 1182                    return resolution;
 83                }
 084                catch (Exception ex)
 85                {
 086                    await SignalAsync("create-failed", $"{order.Article}: {ex.Message}");
 087                    await NotifyAsync(order, match, NomenclatureNotificationKind.CreateFailed, null, ex.Message);
 088                    throw;
 089                }
 90
 91            default:
 592                var notificationKind = RejectionNotificationKind(match.Status);
 593                if (notificationKind.HasValue)
 94                {
 595                    await SignalAsync(NotificationScope(notificationKind.Value), $"Заказ {order.Id}; артикул {match.Pars
 596                    await NotifyAsync(order, match, notificationKind.Value, null, match.Reason);
 97                }
 598                return new NomenclatureResolution(null, false, string.Empty, decision.Reason, match.Status, false);
 99        }
 18100    }
 101
 5102    private static NomenclatureNotificationKind? RejectionNotificationKind(NomenclatureMatchStatus status) => status swi
 5103    {
 1104        NomenclatureMatchStatus.NotFound => NomenclatureNotificationKind.NotFound,
 1105        NomenclatureMatchStatus.InputConflict => NomenclatureNotificationKind.InputConflict,
 1106        NomenclatureMatchStatus.ProducerUnknown => NomenclatureNotificationKind.ProducerUnknown,
 1107        NomenclatureMatchStatus.Ambiguous => NomenclatureNotificationKind.Ambiguous,
 1108        NomenclatureMatchStatus.BrandMismatch => NomenclatureNotificationKind.BrandMismatch,
 0109        _ => null
 5110    };
 111
 5112    private static string NotificationScope(NomenclatureNotificationKind kind) => kind switch
 5113    {
 1114        NomenclatureNotificationKind.NotFound => "missing",
 0115        NomenclatureNotificationKind.Duplicate => "duplicate",
 1116        NomenclatureNotificationKind.InputConflict => "input-conflict",
 1117        NomenclatureNotificationKind.ProducerUnknown => "producer-unknown",
 1118        NomenclatureNotificationKind.Ambiguous => "ambiguous",
 1119        NomenclatureNotificationKind.BrandMismatch => "brand-mismatch",
 0120        _ => "nomenclature"
 5121    };
 122
 123    private async Task NotifyAsync(Order order, NomenclatureMatch match, NomenclatureNotificationKind kind, Guid? refKey
 124    {
 14125        if (_notificationSink == null)
 14126            return;
 127
 0128        var existingBrands = match.Candidates
 129            .Select(c => string.IsNullOrWhiteSpace(c.ProducerName) ? c.ProducerNorm : c.ProducerName!.Trim())
 130            .Where(x => !string.IsNullOrWhiteSpace(x))
 0131            .Distinct(StringComparer.OrdinalIgnoreCase)
 0132            .ToList();
 0133        var notification = new NomenclatureNotification(kind, order.Id, order.ShipmentNumber, order.ProductName,
 0134            order.Article, order.ProductKey, order.EtProducer?.Name, match.Parsed.ArticleKey, match.Status, reason, refK
 135        try
 136        {
 0137            await _notificationSink.NotifyAsync(notification);
 0138        }
 0139        catch (Exception ex)
 140        {
 0141            _logger.LogWarning(ex, "Не удалось отправить уведомление резолвера номенклатуры для заказа {OrderId}", order
 0142        }
 14143    }
 144
 145    private void LogResolver(Order order, NomenclatureMatch match, NomenclatureDecision decision)
 146    {
 18147        var quiet = match.Status == NomenclatureMatchStatus.Found && match.Confidence == MatchConfidence.High && !match.
 18148        _logger.Log(quiet ? LogLevel.Debug : LogLevel.Information,
 18149            "[NomenclatureResolver] {Status}/{Action} {Article}/{Producer}: {Reason}",
 18150            match.Status, decision.Action, order.Article, order.EtProducer?.Name, decision.Reason);
 18151    }
 152
 153    /// <summary>
 154    /// После выбора существующей карточки: карточка без производителя получает бренд заказа (как раньше), а карточка,
 155    /// найденная живым запросом, попадает в карту, чтобы следующий поиск шёл из индекса.
 156    /// </summary>
 157    private async Task AfterReuseAsync(Order order, NomenclatureMatch match)
 158    {
 2159        if (!match.RefKey.HasValue)
 0160            return;
 161
 2162        if (match.ProducerlessCard && _backfillProducerOnReuse && match.Producer.Kind == ProducerResolutionKind.Known &&
 163        {
 0164            var index = _indexProvider != null ? await _indexProvider.GetAsync() : null;
 0165            var value = await _producerValues.FindForGroupsAsync(match.Producer.Groups, index?.ProducerUsage, order.EtPr
 0166            if (value != null)
 167            {
 0168                _logger.LogInformation(
 0169                    "[OData] Артикул {Article}: переиспользуем карточку {RefKey} без производителя и дописываем «{Produc
 0170                    match.Parsed.ArticleClean, match.RefKey, value.Name);
 0171                await SetProducerAsync(match.RefKey.Value, value.RefKey);
 0172                await EnsureRequiredFieldsAsync(match.RefKey.Value);
 173            }
 174        }
 175
 2176        if (match.FromLiveLookup)
 177        {
 0178            var card = match.Candidates.FirstOrDefault(c => c.RefKey == match.RefKey);
 0179            if (card != null)
 0180                await RememberAsync(NormalizeArticle(match.Parsed.ArticleClean), NormalizeProducerName(card.ProducerName
 0181                    card.RefKey, card.RawArticle, card.ProducerName, "probe");
 182        }
 2183    }
 184
 185    /// <summary>
 186    /// Создание карточки по решению матчера. Производитель берётся из группы бренда (существующее значение 1С), новое
 187    /// значение заводится только когда у группы его нет. Под локом ключа (артикул + производитель) карта проверяется
 188    /// ещё раз: параллельный вызов или второй инстанс мог создать ту же карточку секундой раньше.
 189    /// </summary>
 190    private async Task<NomenclatureResolution> CreateResolvedAsync(Order order, NomenclatureMatch match, OneCNomenclatur
 191    {
 11192        var parsed = match.Parsed;
 11193        var article = NormalizeArticle(parsed.ArticleClean);
 11194        if (article.Length == 0)
 0195            throw new InvalidOperationException($"Заказ {order.ShipmentNumber}: артикул пуст после нормализации, создать
 196
 11197        var creationArticle = CreationArticleFor(order, parsed, article);
 11198        var (producerGuid, producerName) = await ResolveProducerForCreationAsync(order, match);
 11199        var producerNorm = NormalizeProducerName(producerName);
 200
 201        var gate = Locks.GetOrAdd($"{article}|{producerNorm}", _ => new SemaphoreSlim(1, 1));
 11202        await gate.WaitAsync();
 203        try
 204        {
 11205            await using var distributedLock = _creationLock == null
 11206                ? null
 11207                : await _creationLock.AcquireAsync(article, producerNorm);
 208
 11209            if (_map != null)
 210            {
 11211                var mapped = await _map.FindAsync(article, producerNorm);
 11212                if (mapped != null)
 2213                    return new NomenclatureResolution(mapped.RefKey, false, string.Empty, null, NomenclatureMatchStatus.
 214            }
 215
 9216            var created = await CreateNewCardAsync(order, article, creationArticle, producerGuid, producerName, producer
 217
 218            // Новая карточка должна находиться сразу, а не через 10 минут: сбрасываем индекс и кэш живых промахов.
 9219            _matcher?.ForgetMisses();
 9220            _indexProvider?.Invalidate();
 9221            return new NomenclatureResolution(created.RefKey, true, created.DisplayName, match.Reason, match.Status, fal
 0222        }
 223        finally
 224        {
 11225            gate.Release();
 226        }
 11227    }
 228
 229    private async Task<(Guid? Guid, string? Name)> ResolveProducerForCreationAsync(Order order, NomenclatureMatch match)
 230    {
 11231        var orderName = order.EtProducer?.Name?.Trim();
 232
 11233        if (match.Producer.Kind == ProducerResolutionKind.Known && _producerValues != null)
 234        {
 11235            var index = _indexProvider != null ? await _indexProvider.GetAsync() : null;
 11236            var value = await _producerValues.FindForGroupsAsync(match.Producer.Groups, index?.ProducerUsage, orderName)
 11237            if (value != null)
 9238                return (value.RefKey, value.Name);
 239        }
 240
 241        // Заглушка («Daihatsu» без префикса) и общий префикс без выбора — бренд неизвестен, карточка без производителя.
 2242        if (match.Producer.Source == "placeholder" || match.Producer.Kind == ProducerResolutionKind.Ambiguous || string.
 0243            return (null, null);
 244
 2245        var (aliasGuid, _, aliasedName) = await ResolveProducerAliasAsync(NormalizeProducerName(orderName), orderName);
 2246        var name = aliasedName ?? orderName;
 2247        return (aliasGuid ?? await GetOrCreateProducerAsync(name), name);
 11248    }
 249
 250    /// <summary>
 251    /// Артикул для новой карточки: написание с разделителями из ключа товара (<c>5048-6</c>), если оно совпадает с очищ
 252    /// артикулом, иначе нормализованный. Хвост <c>=БРЕНД</c>, теги <c>[N]</c> и мусор в карточку не попадают.
 253    /// </summary>
 254    private static string CreationArticleFor(Order order, ParsedProductRef parsed, string normalized)
 255    {
 11256        var keyArticle = order.ProductKey?.Split('=', 2)[0].Trim();
 11257        if (!string.IsNullOrWhiteSpace(keyArticle))
 258        {
 11259            keyArticle = System.Text.RegularExpressions.Regex.Replace(keyArticle, @"\[[^\]]*\]", string.Empty).Trim();
 11260            if (ArticleKey.Key(keyArticle) == parsed.ArticleKey
 87261                && keyArticle.Any(c => !char.IsLetterOrDigit(c) && !char.IsWhiteSpace(c)))
 5262                return keyArticle;
 263        }
 264
 6265        return normalized;
 266    }
 267}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataConfig.cs

#LineLine coverage
 1namespace Services.ApiServices._1CApi.OData;
 2
 3public class OneCODataConfig
 4{
 6905    public string BaseUrl { get; set; } = string.Empty;
 6376    public string User { get; set; } = string.Empty;
 6377    public string Password { get; set; } = string.Empty;
 8
 9    /// <summary>
 10    /// Если карточка с тем же артикулом найдена, но без производителя — дописать в неё производителя
 11    /// из заказа вместо создания второй карточки. Выключать только если правки справочника из
 12    /// интеграции недопустимы: тогда такие позиции снова начнут порождать дубли.
 13    /// </summary>
 26214    public bool BackfillProducerOnReuse { get; set; } = true;
 15
 16    /// <summary>Режим нового поиска номенклатуры (<c>OneCNomenclature:ResolverMode</c>): Off, Shadow или On.</summary>
 4717    public Services.ApiServices._1CApi.Nomenclature.NomenclatureResolverMode ResolverMode { get; set; }
 18        = Services.ApiServices._1CApi.Nomenclature.NomenclatureResolverMode.Off;
 19}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataConstants.cs

#LineLine coverage
 1namespace Services.ApiServices._1CApi.OData;
 2
 3public static class OneCODataConstants
 4{
 5    public const string ZeroGuid = "00000000-0000-0000-0000-000000000000";
 6
 7    /// <summary>
 8    /// Номер ГТД для товара без таможенной декларации (ЕАЭС, внутренние поставки). Записи в Catalog_НомераГТД может не 
 9    /// тогда в строку приходной уходит пустая ссылка, то есть «без ГТД».
 10    /// </summary>
 11    public const string DefaultGtdNumber = "00000/000/00000";
 12
 13    public static bool IsDefaultGtd(string? number)
 514        => string.Equals(number?.Trim(), DefaultGtdNumber, StringComparison.Ordinal);
 15
 16    /// <summary>Свойство «Производитель» в плане видов характеристик доп. реквизитов номенклатуры.</summary>
 17    public const string ProducerPropertyKey = "abba39bc-6cc0-11e8-418f-005056890939";
 18
 19    public const string NomenclatureEndpoint = "Catalog_Номенклатура";
 20    public const string NomenclaturePropertiesEndpoint = "Catalog_Номенклатура_ДополнительныеРеквизиты";
 21    public const string PropertyValuesEndpoint = "Catalog_ЗначенияСвойствОбъектов";
 22
 23    public const string UnitShtukaGuid = "fe004ea0-4871-11e8-4680-fa163e49a0ee";
 24    public const string UnitType = "StandardODATA.Catalog_КлассификаторЕдиницИзмерения";
 25    public const string OperationTypeReceipt = "ПоступлениеОтПоставщика";
 26    public const string OrderType = "StandardODATA.Document_ЗаказПоставщику";
 27    public const string OperationTypeExpense = "ПродажаПокупателю";
 28    public const string OrderTypeExpenseBuyer = "StandardODATA.Document_ЗаказПокупателя";
 29}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataError.cs

#LineLine coverage
 1using System.Net;
 2using Services.ApiServices._1CApi.OData.Models;
 3
 4namespace Services.ApiServices._1CApi.OData;
 5
 6/// <summary>
 7/// Ошибка обмена с 1С OData в одном формате для любого метода клиента: что вызывали, что вернула 1С и
 8/// текст ответа целиком (без усечения до 500 символов, которое остаётся только у пользовательских сообщений).
 9/// </summary>
 2410public sealed record OneCODataError(
 1411    string Method,
 1212    string Endpoint,
 1413    int StatusCode,
 014    string? ReasonPhrase,
 2215    string? Code,
 1516    string Message,
 2717    string? RawBody)
 18{
 19    /// <summary>Потолок сырого тела: защита от многомегабайтной HTML-страницы вместо OData-ошибки.</summary>
 20    public const int RawBodyMaxLength = 16000;
 21
 22    /// <summary>Подпись для логов и поля «ответ 1С»: статус, код 1С и полный текст причины.</summary>
 23    public string ToDetailText()
 24    {
 1025        var code = string.IsNullOrWhiteSpace(Code) ? string.Empty : $" [{Code}]";
 1026        return $"HTTP {StatusCode}{code} {Method} {Endpoint}: {Message}";
 27    }
 28
 29    public static OneCODataError From(string method, string endpoint, HttpStatusCode status, string? reason, string? bod
 30    {
 1731        var message = OneCODataErrorFormatter.Format(body);
 1732        if (string.IsNullOrWhiteSpace(message))
 233            message = "(пустое тело)";
 34
 1735        var raw = string.IsNullOrEmpty(body)
 1736            ? null
 1737            : body.Length <= RawBodyMaxLength ? body : body[..RawBodyMaxLength] + "…";
 38
 1739        return new OneCODataError(method, endpoint, (int)status, reason,
 1740            OneCODataErrorFormatter.TryExtractODataCode(body), message, raw);
 41    }
 42}
 43
 44/// <summary>
 45/// Любая ошибка ответа 1С OData. Наследуется от <see cref="InvalidOperationException"/>, чтобы не ломать
 46/// код, который уже ловит ошибки клиента по базовому типу; полные данные ответа — в <see cref="Error"/>.
 47/// </summary>
 48public class OneCODataException(string message, OneCODataError? error = null)
 1949    : InvalidOperationException(message)
 50{
 4551    public OneCODataError? Error { get; } = error;
 52}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataErrorFormatter.cs

#LineLine coverage
 1using Newtonsoft.Json;
 2using Services.ApiServices._1CApi.OData.Models;
 3
 4namespace Services.ApiServices._1CApi.OData;
 5
 6/// <summary>
 7/// Извлекает читаемое сообщение из OData-ошибки (JSON или технический текст исключения).
 8/// </summary>
 9public static class OneCODataErrorFormatter
 10{
 11    public static string Format(string? raw)
 12    {
 4813        if (string.IsNullOrWhiteSpace(raw))
 714            return string.Empty;
 15
 4116        var trimmed = raw.Trim();
 17
 4118        var jsonStart = trimmed.IndexOf('{');
 4119        if (jsonStart >= 0)
 20        {
 1721            var fromJson = TryExtractODataMessage(trimmed[jsonStart..]);
 1722            if (!string.IsNullOrWhiteSpace(fromJson))
 1123                return fromJson.Trim();
 24        }
 25
 3026        if (trimmed.Contains("OData /Post action", StringComparison.Ordinal))
 27        {
 028            var colonIdx = trimmed.LastIndexOf(':');
 029            if (colonIdx >= 0 && colonIdx < trimmed.Length - 1)
 30            {
 031                var tail = trimmed[(colonIdx + 1)..].Trim();
 032                var fromTail = TryExtractODataMessage(tail);
 033                return !string.IsNullOrWhiteSpace(fromTail) ? fromTail.Trim() : tail;
 34            }
 35        }
 36
 3037        return trimmed;
 38    }
 39
 40    /// <summary>Код ошибки OData (<c>odata.error.code</c>), если тело — OData-ошибка в JSON.</summary>
 41    internal static string? TryExtractODataCode(string? body)
 42    {
 1743        if (string.IsNullOrWhiteSpace(body))
 244            return null;
 45
 1546        var jsonStart = body.IndexOf('{');
 1547        if (jsonStart < 0)
 748            return null;
 49
 50        try
 51        {
 852            var code = JsonConvert.DeserializeObject<ODataErrorResponse>(body[jsonStart..])?.Error?.Code;
 853            return string.IsNullOrWhiteSpace(code) ? null : code.Trim();
 54        }
 055        catch
 56        {
 057            return null;
 58        }
 859    }
 60
 61    internal static string? TryExtractODataMessage(string? json)
 62    {
 1763        if (string.IsNullOrWhiteSpace(json))
 064            return null;
 65
 66        try
 67        {
 1768            var err = JsonConvert.DeserializeObject<ODataErrorResponse>(json);
 1769            return err?.Error?.Message?.Value;
 70        }
 071        catch
 72        {
 073            return null;
 74        }
 1775    }
 76}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataHttpClient.cs

#LineLine coverage
 1using System.Net;
 2using System.Net.Http.Headers;
 3using System.Text;
 4using Microsoft.Extensions.Logging;
 5using Newtonsoft.Json;
 6using Services.ApiServices._1CApi.OData.Models;
 7
 8namespace Services.ApiServices._1CApi.OData;
 9
 10public class OneCODataHttpClient : IOneCODataClient
 11{
 12    private readonly IHttpClientFactory _httpClientFactory;
 13    private readonly string _baseUrl;
 14    private readonly string _base64Auth;
 15    private readonly ILogger<OneCODataHttpClient>? _logger;
 16    private const int PageSize = 1000;
 117    private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60);
 18
 20419    public OneCODataHttpClient(IHttpClientFactory httpClientFactory, OneCODataConfig config,
 20420        ILogger<OneCODataHttpClient>? logger = null)
 21    {
 20422        _httpClientFactory = httpClientFactory;
 20423        _logger = logger;
 20424        _baseUrl = config.BaseUrl.TrimEnd('/');
 20425        var authString = $"{config.User}:{config.Password}";
 20426        _base64Auth = Convert.ToBase64String(Encoding.UTF8.GetBytes(authString));
 20427    }
 28
 29    private HttpClient CreateClient()
 30    {
 67431        var client = _httpClientFactory.CreateClient("OneCOData");
 67432        client.Timeout = DefaultTimeout;
 67433        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", _base64Auth);
 67434        return client;
 35    }
 36
 37    /// <summary>
 38    /// Загружает все записи с автоматической пагинацией.
 39    /// </summary>
 40    public async Task<List<T>> GetListAsync<T>(
 41        string endpoint,
 42        Dictionary<string, string>? queryParams = null,
 43        CancellationToken ct = default)
 44    {
 11445        var result = new List<T>();
 11446        int skip = 0;
 47        const int MaxIterations = 500; // не более 500 000 записей; защита от зависания
 48
 23049        for (int iteration = 0; iteration < MaxIterations; iteration++)
 50        {
 11551            var query = BuildQuery(queryParams, skip, endpoint);
 11552            var url = $"{_baseUrl}/{endpoint}?{query}";
 11553            var client = CreateClient();
 24254            var response = await ExecuteWithRetryAsync(() => client.GetAsync(url, ct), ct);
 11255            var json = await response.Content.ReadAsStringAsync(ct);
 11256            EnsureSuccess(response, json, "GET", endpoint);
 57
 11058            var page = JsonConvert.DeserializeObject<ODataListResponse<T>>(json);
 11059            var batch = page?.Value ?? new List<T>();
 11060            result.AddRange(batch);
 61
 11062            if (batch.Count < PageSize)
 63                break;
 64
 165            skip += batch.Count;
 66
 167            if (iteration == MaxIterations - 1)
 068                throw new InvalidOperationException(
 069                    $"[OData] GetListAsync<{typeof(T).Name}> для {endpoint}: превышено максимальное число итераций ({Max
 170        }
 71
 10972        return result;
 10973    }
 74
 75    /// <summary>
 76    /// GET одного документа по GUID — для получения номера после проведения.
 77    /// </summary>
 78    public async Task<T?> GetByGuidAsync<T>(string endpoint, Guid refKey, string select = "Ref_Key,Number,Date,Posted") 
 79    {
 5680        var url = $"{_baseUrl}/{endpoint}(guid'{refKey}')?$format=json&$select={Uri.EscapeDataString(select)}";
 5681        var client = CreateClient();
 11282        var response = await ExecuteWithRetryAsync(() => client.GetAsync(url));
 5683        var body = await response.Content.ReadAsStringAsync();
 5684        if (!response.IsSuccessStatusCode)
 85        {
 186            LogSwallowed(response, body, "GET", endpoint);
 187            return null;
 88        }
 5589        return JsonConvert.DeserializeObject<T>(body);
 5690    }
 91
 92    /// <summary>
 93    /// Загружает ОДНУ страницу (без автопагинации). Используется для прямого $filter-поиска.
 94    /// </summary>
 95    public async Task<List<T>> GetSinglePageAsync<T>(
 96        string endpoint,
 97        int top = 100,
 98        Dictionary<string, string>? queryParams = null)
 99    {
 113100        var parts = new List<string> { "$format=json", $"$top={top}" };
 113101        if (queryParams != null)
 102        {
 658103            foreach (var kv in queryParams)
 104            {
 216105                if (kv.Key == "$top" || kv.Key == "$skip") continue;
 216106                parts.Add($"{kv.Key}={Uri.EscapeDataString(kv.Value)}");
 107            }
 108        }
 109
 113110        var url = $"{_baseUrl}/{endpoint}?{string.Join("&", parts)}";
 113111        var client = CreateClient();
 229112        var response = await ExecuteWithRetryAsync(() => client.GetAsync(url));
 113113        var json = await response.Content.ReadAsStringAsync();
 113114        EnsureSuccess(response, json, "GET", endpoint);
 115
 112116        var page = JsonConvert.DeserializeObject<ODataListResponse<T>>(json);
 112117        return page?.Value ?? new List<T>();
 112118    }
 119
 120    /// <summary>
 121    /// Загружает страницу документов с поддержкой пагинации (top + skip).
 122    /// </summary>
 123    public async Task<List<T>> GetPageAsync<T>(
 124        string endpoint,
 125        int top = 100,
 126        int skip = 0,
 127        Dictionary<string, string>? queryParams = null)
 128    {
 265129        var parts = new List<string> { "$format=json", $"$top={top}", $"$skip={skip}" };
 265130        if (queryParams != null)
 131        {
 2112132            foreach (var kv in queryParams)
 133            {
 792134                if (kv.Key == "$top" || kv.Key == "$skip") continue;
 792135                parts.Add($"{kv.Key}={Uri.EscapeDataString(kv.Value)}");
 136            }
 137        }
 138
 265139        var url = $"{_baseUrl}/{endpoint}?{string.Join("&", parts)}";
 265140        var client = CreateClient();
 650141        var response = await ExecuteWithRetryAsync(() => client.GetAsync(url));
 225142        var json = await response.Content.ReadAsStringAsync();
 225143        EnsureSuccess(response, json, "GET", endpoint);
 144
 224145        var page = JsonConvert.DeserializeObject<ODataListResponse<T>>(json);
 224146        return page?.Value ?? new List<T>();
 224147    }
 148
 149    /// <summary>
 150    /// Получает общее количество записей ($count).
 151    /// </summary>
 152    public async Task<int> GetCountAsync(string endpoint, string? filter = null)
 153    {
 1154        var url = $"{_baseUrl}/{endpoint}/$count?$format=json";
 1155        if (!string.IsNullOrWhiteSpace(filter))
 0156            url += $"&$filter={Uri.EscapeDataString(filter)}";
 157
 1158        var client = CreateClient();
 2159        var response = await ExecuteWithRetryAsync(() => client.GetAsync(url));
 1160        var body = await response.Content.ReadAsStringAsync();
 1161        if (!response.IsSuccessStatusCode)
 162        {
 1163            LogSwallowed(response, body, "GET", endpoint + "/$count");
 1164            return 0;
 165        }
 0166        return int.TryParse(body.Trim(), out var count) ? count : 0;
 1167    }
 168
 169    /// <summary>
 170    /// Возвращает сырой JSON документа по GUID (без $select — включая вложенные табличные части).
 171    /// </summary>
 172    public async Task<string> GetRawJsonAsync(string endpoint, Guid refKey)
 173    {
 43174        var url = $"{_baseUrl}/{endpoint}(guid'{refKey}')?$format=json";
 43175        var client = CreateClient();
 86176        var response = await ExecuteWithRetryAsync(() => client.GetAsync(url));
 43177        var body = await response.Content.ReadAsStringAsync();
 43178        if (!response.IsSuccessStatusCode)
 179        {
 1180            LogSwallowed(response, body, "GET", endpoint);
 1181            return "{}";
 182        }
 42183        return body;
 43184    }
 185
 186    /// <summary>
 187    /// POST — создаёт документ или запись. Возвращает десериализованный ответ.
 188    /// </summary>
 189    public async Task<T> PostAsync<TReq, T>(string endpoint, TReq body)
 190    {
 22191        var url = $"{_baseUrl}/{endpoint}?$format=json";
 22192        var json = JsonConvert.SerializeObject(body);
 22193        using var content = new StringContent(json, Encoding.UTF8, "application/json");
 194
 22195        var response = await CreateClient().PostAsync(url, content);
 22196        var responseBody = await response.Content.ReadAsStringAsync();
 22197        EnsureSuccess(response, responseBody, "POST", endpoint);
 198
 22199        return JsonConvert.DeserializeObject<T>(responseBody)
 22200               ?? throw new InvalidOperationException($"Пустой ответ от OData POST {endpoint}");
 22201    }
 202
 203    /// <summary>
 204    /// Проводит документ через action-эндпоинт /Post. Бросает исключение при ошибке.
 205    /// </summary>
 206    public async Task PostDocumentAsync(string endpoint, Guid refKey)
 207    {
 18208        var url = $"{_baseUrl}/{endpoint}(guid'{refKey}')/Post?$format=json";
 18209        var body = JsonConvert.SerializeObject(new { PostingModeOperational = false });
 210
 18211        var response = await ExecuteWithRetryAsync(() =>
 18212        {
 18213            var content = new StringContent(body, Encoding.UTF8, "application/json");
 18214            return CreateClient().PostAsync(url, content);
 18215        });
 18216        var responseBody = await response.Content.ReadAsStringAsync();
 217
 18218        if (!response.IsSuccessStatusCode)
 219        {
 9220            var error = OneCODataError.From("POST", $"{endpoint}/Post", response.StatusCode, response.ReasonPhrase, resp
 9221            var detail = OneCODataErrorFormatter.Format(responseBody);
 9222            if (string.IsNullOrWhiteSpace(detail))
 1223                detail = TruncateError(responseBody);
 8224            else if (detail.Length > 500)
 2225                detail = detail[..500] + "…";
 226            // Сообщение исключения уходит пользователю в алерт транзакции (PostingError), поэтому сразу
 227            // собираем его читаемым — без имени эндпоинта и голой фразы 1С «Не удалось провести "…"!».
 9228            throw new OneCDocumentPostingException(
 9229                OneCDocumentPostingMessage.Build(endpoint, detail), endpoint, detail, error);
 230        }
 9231    }
 232
 233    /// <summary>
 234    /// PATCH — обновляет поля документа. Возвращает обновлённый документ.
 235    /// </summary>
 236    public async Task<T?> PatchAsync<T>(string endpoint, Guid refKey, object patchBody) where T : class
 237    {
 21238        var url = $"{_baseUrl}/{endpoint}(guid'{refKey}')?$format=json";
 21239        var json = JsonConvert.SerializeObject(patchBody);
 240
 21241        var response = await ExecuteWithRetryAsync(() =>
 21242        {
 21243            var content = new StringContent(json, Encoding.UTF8, "application/json");
 21244            var request = new HttpRequestMessage(HttpMethod.Patch, url) { Content = content };
 21245            return CreateClient().SendAsync(request);
 21246        });
 21247        var body = await response.Content.ReadAsStringAsync();
 248
 21249        if (!response.IsSuccessStatusCode)
 1250            throw new OneCODataException(
 1251                $"OData PATCH {endpoint}({refKey}) вернул {(int)response.StatusCode}: {TruncateError(body)}",
 1252                OneCODataError.From("PATCH", endpoint, response.StatusCode, response.ReasonPhrase, body));
 253
 20254        if (string.IsNullOrWhiteSpace(body) || response.StatusCode == System.Net.HttpStatusCode.NoContent)
 20255            return null;
 256
 0257        return JsonConvert.DeserializeObject<T>(body);
 20258    }
 259
 260    /// <summary>
 261    /// Проводит или отменяет проведение документа через PATCH Posted (не через /Post): ошибка приходит в том же
 262    /// формате <see cref="OneCODataException"/>, что и у остальных методов.
 263    /// </summary>
 264    public Task SetPostedAsync(string endpoint, Guid refKey, bool posted)
 0265        => PatchAsync<object>(endpoint, refKey, new { Posted = posted });
 266
 267    /// <summary>PUT — заменяет объект OData целиком, включая табличные части.</summary>
 268    public async Task PutAsync(string endpoint, Guid refKey, object body)
 269    {
 20270        var url = $"{_baseUrl}/{endpoint}(guid'{refKey}')?$format=json";
 20271        var json = JsonConvert.SerializeObject(body);
 20272        var response = await ExecuteWithRetryAsync(() =>
 20273        {
 20274            var content = new StringContent(json, Encoding.UTF8, "application/json");
 20275            return CreateClient().PutAsync(url, content);
 20276        });
 20277        var responseBody = await response.Content.ReadAsStringAsync();
 20278        EnsureSuccess(response, responseBody, "PUT", endpoint);
 18279    }
 280
 281    private static string BuildQuery(Dictionary<string, string>? queryParams, int skip, string endpoint)
 282    {
 115283        var parts = new List<string> { "$format=json", $"$top={PageSize}", $"$skip={skip}" };
 115284        var hasOrderBy = false;
 115285        if (queryParams != null)
 286        {
 474287            foreach (var kv in queryParams)
 288            {
 152289                if (kv.Key == "$top" || kv.Key == "$skip") continue;
 196290                if (kv.Key == "$orderby") hasOrderBy = true;
 152291                parts.Add($"{kv.Key}={Uri.EscapeDataString(kv.Value)}");
 292            }
 293        }
 294
 295        // 1С Fresh не гарантирует стабильный порядок строк между независимыми $skip-запросами без
 296        // явного $orderby: при выборках, не помещающихся в одну страницу (> PageSize), часть записей
 297        // (напр. "JAPANPARTS" среди >1000 значений производителя) может выпасть между страницами и
 298        // не попасть в результат GetListAsync — что порождало дубли номенклатуры/производителей.
 299        // Регистры и их виртуальные таблицы поля Ref_Key не имеют: там такой $orderby даёт
 300        // "HTTP 400 Bad request: Сегмент пути Ref_Key не найден!".
 115301        if (!hasOrderBy && HasRefKey(endpoint))
 41302            parts.Add("$orderby=Ref_Key");
 303
 115304        return string.Join("&", parts);
 305    }
 306
 307    /// <summary>
 308    /// Есть ли у сущности OData поле Ref_Key. Справочники, документы, их табличные части,
 309    /// планы видов характеристик и перечисления — да; регистры и их виртуальные таблицы — нет.
 310    /// </summary>
 311    private static bool HasRefKey(string endpoint)
 312    {
 71313        var name = endpoint.TrimStart('/');
 71314        return !name.StartsWith("AccumulationRegister_", StringComparison.OrdinalIgnoreCase)
 71315               && !name.StartsWith("InformationRegister_", StringComparison.OrdinalIgnoreCase)
 71316               && !name.StartsWith("AccountingRegister_", StringComparison.OrdinalIgnoreCase)
 71317               && !name.StartsWith("CalculationRegister_", StringComparison.OrdinalIgnoreCase);
 318    }
 319
 320    private static void EnsureSuccess(HttpResponseMessage response, string body, string method, string endpoint)
 321    {
 492322        if (response.IsSuccessStatusCode)
 486323            return;
 324
 6325        var detail = OneCODataErrorFormatter.Format(body);
 6326        if (string.IsNullOrWhiteSpace(detail))
 1327            detail = TruncateError(body);
 328
 329        // Формат текста ("1С OData HTTP 500 …") сохраняем: по нему ветвится код вызывающей стороны.
 6330        throw new OneCODataException(
 6331            $"1С OData HTTP {(int)response.StatusCode} {response.ReasonPhrase}: {detail}",
 6332            OneCODataError.From(method, endpoint, response.StatusCode, response.ReasonPhrase, body));
 333    }
 334
 335    /// <summary>
 336    /// Методы, которые на не-2xx возвращают null/0/"{}" (поведение вызывающих не меняем), раньше теряли причину
 337    /// без следа. Пишем её полностью, но как Information: «не найдено» здесь штатная ситуация.
 338    /// </summary>
 339    private void LogSwallowed(HttpResponseMessage response, string body, string method, string endpoint)
 340    {
 3341        if (_logger is null)
 3342            return;
 343
 0344        var error = OneCODataError.From(method, endpoint, response.StatusCode, response.ReasonPhrase, body);
 0345        _logger.LogInformation("[OData] Ответ 1С без исключения: {Error}", error.ToDetailText());
 0346    }
 347
 348    private static async Task<HttpResponseMessage> ExecuteWithRetryAsync(
 349        Func<Task<HttpResponseMessage>> action,
 350        CancellationToken ct = default)
 351    {
 652352        int[] retryDelays = { 2000, 4000, 8000 };
 652353        HttpResponseMessage? lastResponse = null;
 354
 1574355        for (int attempt = 0; attempt <= retryDelays.Length; attempt++)
 356        {
 357            try
 358            {
 787359                lastResponse = await action();
 360
 615361                if (ShouldRetry(lastResponse.StatusCode) && attempt < retryDelays.Length)
 362                {
 6363                    await Task.Delay(retryDelays[attempt], ct);
 6364                    continue;
 365                }
 366
 609367                return lastResponse;
 368            }
 172369            catch (HttpRequestException) when (attempt < retryDelays.Length)
 370            {
 129371                await Task.Delay(retryDelays[attempt], ct);
 129372            }
 0373            catch (TaskCanceledException ex) when (attempt < retryDelays.Length && !ex.CancellationToken.IsCancellationR
 374            {
 0375                await Task.Delay(retryDelays[attempt], ct);
 376            }
 377        }
 378
 0379        return lastResponse!;
 609380    }
 381
 382    private static bool ShouldRetry(HttpStatusCode code)
 615383        => code is HttpStatusCode.InternalServerError
 615384            or HttpStatusCode.BadGateway
 615385            or HttpStatusCode.ServiceUnavailable
 615386            or HttpStatusCode.GatewayTimeout;
 387
 388    private static string TruncateError(string? text, int maxLen = 500)
 389    {
 5390        if (string.IsNullOrEmpty(text)) return "(пустое тело)";
 1391        return text.Length <= maxLen ? text : text[..maxLen] + "…";
 392    }
 393}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataReferenceService.cs

#LineLine coverage
 1using System.Text.RegularExpressions;
 2using Microsoft.Extensions.Caching.Memory;
 3using Microsoft.Extensions.Logging;
 4using Services.ApiServices._1CApi.OData.Models;
 5
 6namespace Services.ApiServices._1CApi.OData;
 7
 8public interface IOneCODataReferenceService
 9{
 10    Task<Guid?> GetOrganizationGuidByInnAsync(string inn);
 11    Task<List<ODataOrganization>> GetOrganizationsAsync();
 12    Task<Guid?> GetCounterpartyGuidByInnAsync(string inn);
 13    /// <summary>
 14    /// Ищет GUID контрагента по ИНН. Сначала кеш (DeletionMark=false),
 15    /// затем прямой OData $filter (находит в т.ч. помеченных на удаление).
 16    /// </summary>
 17    Task<Guid?> FindCounterpartyGuidByInnAsync(string inn);
 18    /// <summary>Возвращает контрагента по GUID: из кеша или прямым GET-запросом.</summary>
 19    Task<ODataCounterparty?> GetCounterpartyByGuidAsync(Guid guid);
 20    Task<Guid?> GetWarehouseGuidByNameAsync(string name);
 21    Task<Guid?> GetNomenclatureGuidByArticleAsync(string article);
 22    Task<Guid?> GetCountryGuidByNameAsync(string countryName);
 23    Task<Guid?> GetGtdGuidByNumberAsync(string gtdNumber);
 24    Task<Guid?> GetVatRateGuidByRateAsync(int? vatRatePercent);
 25    Task<Guid?> GetNoVatRateGuidAsync();
 26    Task<List<ODataWarehouse>> GetWarehousesAsync();
 27    Task<List<ODataContract>> GetAllContractsAsync();
 28    Task<List<ODataContract>> GetContractsByCounterpartyAsync(Guid counterpartyGuid);
 29    Task<List<ODataCounterparty>> GetCounterpartiesAsync();
 30}
 31
 32public class OneCODataReferenceService : IOneCODataReferenceService
 33{
 34    private readonly IOneCODataClient _client;
 35    private readonly IMemoryCache _cache;
 36    private readonly ILogger<OneCODataReferenceService> _logger;
 37
 138    private static readonly TimeSpan OrgTtl = TimeSpan.FromMinutes(60);
 139    private static readonly TimeSpan CounterpartyTtl = TimeSpan.FromMinutes(30);
 140    private static readonly TimeSpan WarehouseTtl = TimeSpan.FromMinutes(60);
 141    private static readonly TimeSpan NomenclatureTtl = TimeSpan.FromMinutes(30);
 142    private static readonly TimeSpan CountryTtl = TimeSpan.FromMinutes(60);
 143    private static readonly TimeSpan GtdTtl = TimeSpan.FromMinutes(20);
 144    private static readonly TimeSpan VatRateTtl = TimeSpan.FromMinutes(120);
 145    private static readonly TimeSpan ContractTtl = TimeSpan.FromMinutes(20);
 46
 47    private const string OrgCacheKey = "odata_orgs";
 48    private const string CounterpartyCacheKey = "odata_counterparties";
 49    private const string WarehouseCacheKey = "odata_warehouses";
 50    private const string VatRateCacheKey = "odata_vatrates";
 51    private const string ContractAllCacheKey = "odata_contracts_all";
 52    private const string CountryCacheKey = "odata_countries";
 53    private const string GtdCacheKey = "odata_gtd_numbers";
 54
 155    private readonly SemaphoreSlim _orgSemaphore = new(1, 1);
 156    private readonly SemaphoreSlim _counterpartySemaphore = new(1, 1);
 157    private readonly SemaphoreSlim _warehouseSemaphore = new(1, 1);
 158    private readonly SemaphoreSlim _vatRateSemaphore = new(1, 1);
 159    private readonly SemaphoreSlim _contractSemaphore = new(1, 1);
 160    private readonly SemaphoreSlim _countrySemaphore = new(1, 1);
 161    private readonly SemaphoreSlim _gtdSemaphore = new(1, 1);
 62
 163    public OneCODataReferenceService(
 164        IOneCODataClient client,
 165        IMemoryCache cache,
 166        ILogger<OneCODataReferenceService> logger)
 67    {
 168        _client = client;
 169        _cache = cache;
 170        _logger = logger;
 171    }
 72
 73    public async Task<Guid?> GetOrganizationGuidByInnAsync(string inn)
 74    {
 075        if (string.IsNullOrWhiteSpace(inn)) return null;
 076        var trimmed = inn.Trim();
 077        var orgs = await GetOrganizationsAsync();
 078        return orgs.FirstOrDefault(o =>
 079            string.Equals(o.Inn?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase))?.RefKey;
 080    }
 81
 82    public async Task<Guid?> GetCounterpartyGuidByInnAsync(string inn)
 83    {
 184        return await FindCounterpartyGuidByInnAsync(inn);
 185    }
 86
 87    public async Task<Guid?> GetWarehouseGuidByNameAsync(string name)
 88    {
 089        if (string.IsNullOrWhiteSpace(name)) return null;
 090        var warehouses = await GetWarehousesAsync();
 091        return warehouses.FirstOrDefault(w =>
 092            string.Equals(w.Description, name.Trim(), StringComparison.OrdinalIgnoreCase))?.RefKey;
 093    }
 94
 95    public async Task<Guid?> GetNomenclatureGuidByArticleAsync(string article)
 96    {
 097        if (string.IsNullOrWhiteSpace(article)) return null;
 098        var trimmed = article.Trim();
 099        var cleaned = CleanArticle(trimmed);
 0100        var cacheKey = $"odata_nom_{cleaned}";
 101
 0102        return await _cache.GetOrCreateAsync<Guid?>(cacheKey, async entry =>
 0103        {
 0104            entry.AbsoluteExpirationRelativeToNow = NomenclatureTtl;
 0105            try
 0106            {
 0107                // Используем $filter по артикулу — один запрос вместо полной загрузки каталога
 0108                var filter = $"Артикул eq '{EscapeOData(trimmed)}' and IsFolder eq false";
 0109                var items = await _client.GetSinglePageAsync<ODataNomenclature>(
 0110                    "Catalog_Номенклатура",
 0111                    top: 10,
 0112                    queryParams: new Dictionary<string, string>
 0113                    {
 0114                        ["$select"] = "Ref_Key,Артикул",
 0115                        ["$filter"] = filter
 0116                    });
 0117
 0118                // Точное совпадение после нормализации
 0119                var found = items.FirstOrDefault(i =>
 0120                    string.Equals(CleanArticle(i.Article ?? ""), cleaned, StringComparison.OrdinalIgnoreCase));
 0121
 0122                return (Guid?)found?.RefKey;
 0123            }
 0124            catch (Exception ex)
 0125            {
 0126                _logger.LogError(ex, "[OData] Ошибка поиска номенклатуры '{Article}'", article);
 0127                return null;
 0128            }
 0129        });
 0130    }
 131
 132    public async Task<Guid?> GetCountryGuidByNameAsync(string countryName)
 133    {
 0134        if (string.IsNullOrWhiteSpace(countryName)) return null;
 0135        var normalized = countryName.Trim();
 0136        var countries = await GetCountriesAsync();
 0137        return countries.FirstOrDefault(country =>
 0138            string.Equals(country.Description?.Trim(), normalized, StringComparison.OrdinalIgnoreCase))?.RefKey;
 0139    }
 140
 141    public async Task<Guid?> GetGtdGuidByNumberAsync(string gtdNumber)
 142    {
 0143        if (string.IsNullOrWhiteSpace(gtdNumber)) return null;
 0144        var normalized = gtdNumber.Trim();
 0145        var gtdNumbers = await GetGtdNumbersAsync();
 0146        return gtdNumbers.FirstOrDefault(gtd =>
 0147            string.Equals(gtd.RegistrationNumber?.Trim(), normalized, StringComparison.OrdinalIgnoreCase))?.RefKey;
 0148    }
 149
 150    private static string EscapeOData(string value)
 2151        => value.Replace("'", "''");
 152
 153    public async Task<Guid?> GetVatRateGuidByRateAsync(int? vatRatePercent)
 154    {
 0155        if (!vatRatePercent.HasValue) return await GetNoVatRateGuidAsync();
 0156        var rates = await GetVatRatesAsync();
 0157        var description = $"{vatRatePercent}%";
 0158        return rates.FirstOrDefault(r =>
 0159            string.Equals(r.Description, description, StringComparison.OrdinalIgnoreCase))?.RefKey;
 0160    }
 161
 162    public async Task<Guid?> GetNoVatRateGuidAsync()
 163    {
 0164        var rates = await GetVatRatesAsync();
 0165        return rates.FirstOrDefault(r =>
 0166            (r.Description ?? "").Contains("без", StringComparison.OrdinalIgnoreCase) ||
 0167            (r.Description ?? "").Contains("0%", StringComparison.OrdinalIgnoreCase))?.RefKey;
 0168    }
 169
 170    public Task<List<ODataWarehouse>> GetWarehousesAsync()
 0171        => GetCachedListAsync(WarehouseCacheKey, WarehouseTtl, _warehouseSemaphore,
 0172            () => _client.GetListAsync<ODataWarehouse>(
 0173                "Catalog_СтруктурныеЕдиницы",
 0174                new Dictionary<string, string> { ["$select"] = "Ref_Key,Description" }),
 0175            "Ошибка загрузки складов");
 176
 177    public Task<List<ODataCountry>> GetCountriesAsync()
 0178        => GetCachedListAsync(CountryCacheKey, CountryTtl, _countrySemaphore,
 0179            () => _client.GetListAsync<ODataCountry>(
 0180                "Catalog_СтраныМира",
 0181                new Dictionary<string, string> { ["$select"] = "Ref_Key,Description" }),
 0182            "Ошибка загрузки стран происхождения");
 183
 184    private Task<List<ODataGtdCode>> GetGtdNumbersAsync()
 0185        => GetCachedListAsync(GtdCacheKey, GtdTtl, _gtdSemaphore,
 0186            () => _client.GetListAsync<ODataGtdCode>(
 0187                "Catalog_НомераГТД",
 0188                new Dictionary<string, string> { ["$select"] = "Ref_Key,РегистрационныйНомер" }),
 0189            "Ошибка загрузки номеров ГТД");
 190
 191    public Task<List<ODataContract>> GetAllContractsAsync()
 0192        => GetAllContractsInternalAsync();
 193
 194    public async Task<List<ODataContract>> GetContractsByCounterpartyAsync(Guid counterpartyGuid)
 195    {
 0196        var all = await GetAllContractsInternalAsync();
 0197        return all.Where(c => c.Owner == counterpartyGuid).ToList();
 0198    }
 199
 200    public async Task<List<ODataCounterparty>> GetCounterpartiesAsync()
 0201        => await GetAllCounterpartiesAsync();
 202
 203    public async Task<Guid?> FindCounterpartyGuidByInnAsync(string inn)
 204    {
 1205        if (string.IsNullOrWhiteSpace(inn)) return null;
 1206        var trimmed = inn.Trim();
 207
 208        // Сначала общий кеш: в форме Studio2 поле Supplier.INNCode хранит ИНН или регистрационный номер.
 1209        var cached = await GetAllCounterpartiesAsync();
 1210        var found = cached.FirstOrDefault(c =>
 1211            string.Equals(c.Inn?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase) ||
 1212            string.Equals(c.RegistrationNumber?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase));
 1213        if (found != null) return found.RefKey;
 214
 215        // Прямые запросы также находят контрагентов, отсутствующих в кеше активных записей.
 5216        foreach (var field in new[] { "ИНН", "РегистрационныйНомер" })
 217        {
 218            try
 219            {
 2220                var items = await _client.GetSinglePageAsync<ODataCounterparty>(
 2221                    "Catalog_Контрагенты",
 2222                    top: 5,
 2223                    queryParams: new Dictionary<string, string>
 2224                    {
 2225                        ["$select"] = field == "ИНН"
 2226                            ? "Ref_Key,Description,ИНН"
 2227                            : "Ref_Key,Description,ИНН,РегистрационныйНомер",
 2228                        ["$filter"] = $"{field} eq '{EscapeOData(trimmed)}' and IsFolder eq false"
 2229                    });
 2230                var match = items.FirstOrDefault(c =>
 3231                    string.Equals(c.Inn?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase) ||
 3232                    string.Equals(c.RegistrationNumber?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase));
 3233                if (match != null) return match.RefKey;
 1234            }
 0235            catch (Exception ex)
 236            {
 0237                _logger.LogWarning(ex, "[OData] Поиск контрагента по полю {Field} недоступен для идентификатора '{Identi
 0238                    field, trimmed);
 0239            }
 1240        }
 241
 0242        return null;
 1243    }
 244
 245    public async Task<ODataCounterparty?> GetCounterpartyByGuidAsync(Guid guid)
 246    {
 247        // Кеш сначала
 0248        var cached = await GetAllCounterpartiesAsync();
 0249        var found = cached.FirstOrDefault(c => c.RefKey == guid);
 0250        if (found != null) return found;
 251
 252        // Прямой GET если не в кеше (например, контрагент помечен на удаление)
 253        try
 254        {
 0255            var counterparty = await _client.GetByGuidAsync<ODataCounterparty>(
 0256                "Catalog_Контрагенты",
 0257                guid,
 0258                select: "Ref_Key,Description,ИНН,РегистрационныйНомер");
 0259            if (counterparty != null) return counterparty;
 260
 0261            return await _client.GetByGuidAsync<ODataCounterparty>(
 0262                "Catalog_Контрагенты",
 0263                guid,
 0264                select: "Ref_Key,Description,ИНН");
 265        }
 0266        catch (Exception ex)
 267        {
 0268            _logger.LogError(ex, "[OData] Ошибка GetCounterpartyByGuidAsync '{Guid}'", guid);
 0269            return null;
 270        }
 0271    }
 272
 273    // ─── Внутренние методы загрузки с кешем ───────────────────────────────────
 274
 275    public Task<List<ODataOrganization>> GetOrganizationsAsync()
 0276        => GetCachedListAsync(OrgCacheKey, OrgTtl, _orgSemaphore,
 0277            () => _client.GetListAsync<ODataOrganization>(
 0278                "Catalog_Организации",
 0279                new Dictionary<string, string> { ["$select"] = "Ref_Key,Description,ИНН,DeletionMark" }),
 0280            "Ошибка загрузки организаций");
 281
 282    private Task<List<ODataCounterparty>> GetAllCounterpartiesAsync()
 1283        => GetCachedListAsync(CounterpartyCacheKey, CounterpartyTtl, _counterpartySemaphore,
 1284            LoadCounterpartiesAsync,
 1285            "Ошибка загрузки контрагентов");
 286
 287    private async Task<List<ODataCounterparty>> LoadCounterpartiesAsync()
 288    {
 289        try
 290        {
 1291            return await _client.GetListAsync<ODataCounterparty>(
 1292                "Catalog_Контрагенты",
 1293                new Dictionary<string, string>
 1294                {
 1295                    ["$select"] = "Ref_Key,Description,ИНН,РегистрационныйНомер",
 1296                    ["$filter"] = "IsFolder eq false and DeletionMark eq false"
 1297                });
 298        }
 299        catch (Exception ex)
 300        {
 0301            _logger.LogWarning(ex,
 0302                "[OData] РегистрационныйНомер недоступен в списке контрагентов; загружаю ИНН без него");
 0303            return await _client.GetListAsync<ODataCounterparty>(
 0304                "Catalog_Контрагенты",
 0305                new Dictionary<string, string>
 0306                {
 0307                    ["$select"] = "Ref_Key,Description,ИНН",
 0308                    ["$filter"] = "IsFolder eq false and DeletionMark eq false"
 0309                });
 310        }
 1311    }
 312
 313    private Task<List<ODataVatRate>> GetVatRatesAsync()
 0314        => GetCachedListAsync(VatRateCacheKey, VatRateTtl, _vatRateSemaphore,
 0315            () => _client.GetListAsync<ODataVatRate>(
 0316                "Catalog_СтавкиНДС",
 0317                new Dictionary<string, string> { ["$select"] = "Ref_Key,Description" }),
 0318            "Ошибка загрузки ставок НДС");
 319
 320    private Task<List<ODataContract>> GetAllContractsInternalAsync()
 0321        => GetCachedListAsync(ContractAllCacheKey, ContractTtl, _contractSemaphore,
 0322            LoadContractsAsync,
 0323            "Ошибка загрузки договоров");
 324
 325    /// <summary>
 326    /// Договоры вместе с организацией (нужна для фильтра «договоры организации»). Если конфигурация 1С не отдаёт
 327    /// поле Организация_Key — один раз откатываемся на прежний набор полей, а не оставляем список пустым.
 328    /// </summary>
 329    private async Task<List<ODataContract>> LoadContractsAsync()
 330    {
 331        try
 332        {
 0333            return await _client.GetListAsync<ODataContract>(
 0334                "Catalog_ДоговорыКонтрагентов",
 0335                new Dictionary<string, string>
 0336                {
 0337                    ["$select"] = "Ref_Key,Description,Owner,Организация_Key",
 0338                    ["$orderby"] = "Ref_Key"
 0339                });
 340        }
 341        catch (Exception ex)
 342        {
 0343            _logger.LogWarning(ex, "[OData] Договоры без Организация_Key: поле недоступно, фильтр по организации отключё
 0344            return await _client.GetListAsync<ODataContract>(
 0345                "Catalog_ДоговорыКонтрагентов",
 0346                new Dictionary<string, string>
 0347                {
 0348                    ["$select"] = "Ref_Key,Description,Owner",
 0349                    ["$orderby"] = "Ref_Key"
 0350                });
 351        }
 0352    }
 353
 354    private async Task<List<T>> GetCachedListAsync<T>(
 355        string cacheKey,
 356        TimeSpan ttl,
 357        SemaphoreSlim semaphore,
 358        Func<Task<List<T>>> factory,
 359        string errorContext)
 360    {
 1361        if (_cache.TryGetValue(cacheKey, out List<T>? cached))
 0362            return cached!;
 363
 1364        await semaphore.WaitAsync();
 365        try
 366        {
 1367            if (_cache.TryGetValue(cacheKey, out cached))
 0368                return cached!;
 369
 1370            var result = await factory();
 1371            _cache.Set(cacheKey, result, ttl);
 1372            return result;
 373        }
 0374        catch (Exception ex)
 375        {
 0376            _logger.LogError(ex, "[OData] {Context}", errorContext);
 0377            return new List<T>();
 378        }
 379        finally
 380        {
 1381            semaphore.Release();
 382        }
 1383    }
 384
 385
 386    private static string CleanArticle(string article)
 0387        => Regex.Replace(article, @"[^a-zA-Zа-яА-Я0-9]", "").ToUpperInvariant();
 388}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCReceiptDuplicateFinder.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Newtonsoft.Json;
 3using Services.ApiServices._1CApi.OData.Models;
 4
 5namespace Services.ApiServices._1CApi.OData;
 6
 7/// <summary>Приходная накладная, уже существующая в 1С и совпавшая с создаваемой.</summary>
 38public sealed record ExistingReceipt(Guid RefKey, string? Number, bool Posted);
 9
 10/// <summary>
 11/// Ищет в 1С приходную накладную, которую собираются создать повторно: повтор проведения транзакции,
 12/// ретрай после таймаута. Ключ — «НомерВходящегоДокумента + контрагент»; склад-получатель и состав строк
 13/// уточняют совпадение, иначе под защиту попадали бы законные документы:
 14/// <list type="bullet">
 15/// <item>одна транзакция режется по клиентам Ozon, и у всех документов один входящий номер и один поставщик;</item>
 16/// <item>одну фактуру поставщика проводят частями — другими позициями или другим количеством.</item>
 17/// </list>
 18/// Отбор в 1С Fresh ограничен: <c>eq</c> по guid отдаёт 500, а guid-условие после
 19/// <c>and</c> молча отбрасывается. Поэтому в запросе только <c>substringof</c> по номеру и булев <c>DeletionMark</c>,
 20/// а контрагента, склад и точное равенство номера проверяет код по ответу.
 21/// </summary>
 22public sealed class OneCReceiptDuplicateFinder
 23{
 24    private const string ReceiptEndpoint = "Document_ПриходнаяНакладная";
 25    private const int ProbeTop = 100;
 26
 27    private const string Select =
 28        "Ref_Key,Number,Date,Posted,DeletionMark,НомерВходящегоДокумента,Контрагент_Key,СтруктурнаяЕдиница_Key,"
 29        + "Запасы/Номенклатура_Key,Запасы/Количество";
 30
 31    private readonly IOneCODataClient _client;
 32    private readonly ILogger _logger;
 33
 434    public OneCReceiptDuplicateFinder(IOneCODataClient client, ILogger logger)
 35    {
 436        _client = client;
 437        _logger = logger;
 438    }
 39
 40    /// <summary>
 41    /// Возвращает уже существующую накладную или null. Сбой поиска дубль не подтверждает: документ создаётся как раньше
 42    /// иначе недоступный запрос отбора остановил бы весь приход, хотя сам POST мог бы пройти.
 43    /// </summary>
 44    public async Task<ExistingReceipt?> FindAsync(
 45        string? incomingNumber,
 46        Guid counterpartyKey,
 47        Guid warehouseKey,
 48        IReadOnlyCollection<ODataReceiptLine> lines)
 49    {
 450        var number = incomingNumber?.Trim();
 451        if (string.IsNullOrEmpty(number) || lines.Count == 0)
 152            return null;
 53
 54        List<ReceiptCandidate> candidates;
 55        try
 56        {
 357            candidates = await _client.GetSinglePageAsync<ReceiptCandidate>(ReceiptEndpoint, ProbeTop, new Dictionary<st
 358            {
 359                ["$filter"] = $"substringof('{number.Replace("'", "''")}', НомерВходящегоДокумента) and DeletionMark eq 
 360                ["$select"] = Select,
 361                ["$orderby"] = "Date desc"
 362            });
 263        }
 164        catch (Exception ex)
 65        {
 166            _logger.LogWarning(ex, "[OData] Не удалось проверить дубль приходной по входящему номеру {Number}; документ 
 167            return null;
 68        }
 69
 470        var duplicate = candidates.FirstOrDefault(c => IsDuplicate(c, number, counterpartyKey, warehouseKey, lines));
 271        return duplicate == null ? null : new ExistingReceipt(duplicate.RefKey, duplicate.Number, duplicate.Posted);
 472    }
 73
 74    /// <summary>
 75    /// Дубль — тот же входящий номер (точно, без регистра), поставщик и склад, и каждая новая строка уже есть в докумен
 76    /// с тем же количеством. Если 1С не вернула строки, совпадение шапки считается дублем.
 77    /// </summary>
 78    public static bool IsDuplicate(
 79        ReceiptCandidate candidate,
 80        string incomingNumber,
 81        Guid counterpartyKey,
 82        Guid warehouseKey,
 83        IReadOnlyCollection<ODataReceiptLine> newLines)
 84    {
 1285        if (candidate.DeletionMark
 1286            || candidate.CounterpartyKey != counterpartyKey
 1287            || candidate.WarehouseKey != warehouseKey
 1288            || !string.Equals(candidate.IncomingDocNumber?.Trim(), incomingNumber.Trim(), StringComparison.OrdinalIgnore
 589            return false;
 90
 791        if (candidate.Lines.Count == 0)
 192            return true;
 93
 694        var unused = candidate.Lines.ToList();
 2795        foreach (var line in newLines)
 96        {
 1797            var index = unused.FindIndex(x => x.NomenclatureKey == line.NomenclatureKey && x.Quantity == line.Quantity);
 998            if (index < 0)
 399                return false;
 6100            unused.RemoveAt(index);
 101        }
 102
 3103        return true;
 3104    }
 105
 106    public sealed class ReceiptCandidate
 107    {
 13108        [JsonProperty("Ref_Key")] public Guid RefKey { get; set; }
 12109        [JsonProperty("Number")] public string? Number { get; set; }
 2110        [JsonProperty("Posted")] public bool Posted { get; set; }
 13111        [JsonProperty("DeletionMark")] public bool DeletionMark { get; set; }
 19112        [JsonProperty("НомерВходящегоДокумента")] public string? IncomingDocNumber { get; set; }
 22113        [JsonProperty("Контрагент_Key")] public Guid CounterpartyKey { get; set; }
 20114        [JsonProperty("СтруктурнаяЕдиница_Key")] public Guid WarehouseKey { get; set; }
 35115        [JsonProperty("Запасы")] public List<ReceiptCandidateLine> Lines { get; set; } = [];
 116    }
 117
 118    public sealed class ReceiptCandidateLine
 119    {
 20120        [JsonProperty("Номенклатура_Key")] public Guid NomenclatureKey { get; set; }
 19121        [JsonProperty("Количество")] public decimal Quantity { get; set; }
 122    }
 123}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCRegisterEndpoint.cs

#LineLine coverage
 1namespace Services.ApiServices._1CApi.OData;
 2
 3/// <summary>
 4/// Сборка адресов виртуальных таблиц регистров 1С (<c>/Balance</c>, <c>/Turnovers</c>, <c>/SliceLast</c>).
 5/// </summary>
 6public static class OneCRegisterEndpoint
 7{
 8    /// <summary>
 9    /// Добавляет отбор к виртуальной таблице. 1С Fresh не разрешает <c>$filter</c> по её измерениям —
 10    /// отвечает HTTP 500 «Операция не разрешена в предложении "ГДЕ"», из-за чего остатки в UI
 11    /// приходили пустыми. Условие должно уходить параметром <c>Condition</c> самой таблицы,
 12    /// то есть частью URL-фрагмента, а не строкой запроса.
 13    /// </summary>
 14    /// <param name="endpoint">
 15    /// Адрес виртуальной таблицы, при необходимости с уже заданными параметрами
 16    /// (у <c>/Turnovers</c> это <c>StartPeriod</c> и <c>EndPeriod</c>) — тогда отбор дописывается к ним.
 17    /// </param>
 18    /// <param name="condition">Условие в синтаксисе OData; пустое значение оставляет адрес как есть.</param>
 19    public static string WithCondition(string endpoint, string? condition)
 20    {
 6321        if (string.IsNullOrWhiteSpace(condition))
 322            return endpoint;
 23
 24        // Кириллица в именах измерений и пробелы вокруг eq/or обязаны быть закодированы:
 25        // без этого 1С либо не разбирает адрес, либо молча теряет отбор.
 6026        var parameter = $"Condition={Uri.EscapeDataString(condition.Trim())}";
 27
 6028        return endpoint.EndsWith(')')
 6029            ? $"{endpoint[..^1]},{parameter})"
 6030            : $"{endpoint}({parameter})";
 31    }
 32}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCResponseErrors.cs

#LineLine coverage
 1using Services.ApiServices._1CApi.Models;
 2
 3namespace Services.ApiServices._1CApi.OData;
 4
 5/// <summary>
 6/// Единое место, где причина сбоя 1С попадает в <see cref="OneCResponse.ErrorDetail"/>: менеджеры
 7/// документов не собирают этот текст сами, чтобы формат был одинаковым для перемещения, расходной и приходной.
 8/// </summary>
 9public static class OneCResponseErrors
 10{
 11    /// <summary>Потолок под <c>BackgroundTransactionResults.ErrorMessage</c> (text) и DetailsJson.</summary>
 12    public const int MaxLength = 8000;
 13
 14    /// <summary>
 15    /// Полный текст ответа 1С без усечения до 500 символов: статус, код, метод, эндпоинт и причина.
 16    /// Для исключений, не пришедших из ответа 1С (сеть, таймаут), — текст исключения.
 17    /// </summary>
 18    public static string Describe(Exception ex)
 19    {
 420        var text = ex switch
 421        {
 222            OneCDocumentPostingException posting => posting.FullDetail,
 123            OneCODataException { Error: not null } odata => odata.Error.ToDetailText(),
 124            _ => ex.Message
 425        };
 26
 427        return text.Length <= MaxLength ? text : text[..MaxLength] + "…";
 28    }
 29
 30    /// <summary>
 31    /// Дописывает к краткому тексту ошибки полный ответ 1С из <see cref="OneCResponse.ErrorDetail"/> —
 32    /// только тот, чьей причины в тексте ещё нет (не дублирует, когда <c>PostingError</c> уже содержит её целиком;
 33    /// дописывает, когда причину усекли или подменили догадкой про остаток). Если дописывать нечего,
 34    /// возвращает <paramref name="text"/> как есть, поэтому существующие сводки не меняются.
 35    /// </summary>
 36    public static string? AppendOneCDetails(string? text, IEnumerable<OneCResponse> responses)
 37    {
 1338        var extra = responses
 1539            .Select(r => r.ErrorDetail)
 1540            .Where(d => !string.IsNullOrWhiteSpace(d))
 741            .Select(d => d!)
 1342            .Distinct()
 643            .Where(d => string.IsNullOrEmpty(text) || !text.Contains(Reason(d), StringComparison.Ordinal))
 1344            .ToList();
 45
 1346        if (extra.Count == 0)
 947            return text;
 48
 449        var tail = "Ответ 1С: " + string.Join("; ", extra);
 450        return string.IsNullOrWhiteSpace(text) ? tail : $"{text} | {tail}";
 51    }
 52
 53    /// <summary>Причина без служебного префикса «HTTP … метод эндпоинт: » (см. <see cref="OneCODataError.ToDetailText"/
 54    private static string Reason(string detail)
 55    {
 556        var idx = detail.IndexOf(": ", StringComparison.Ordinal);
 557        return idx >= 0 && detail.StartsWith("HTTP ", StringComparison.Ordinal) ? detail[(idx + 2)..] : detail;
 58    }
 59}

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCTransferShortage.cs

#LineLine coverage
 1namespace Services.ApiServices._1CApi.OData;
 2
 3/// <summary>Позиция перемещения: сколько нужно и сколько реально лежит на складе-отправителе и на других складах.</summ
 54public sealed record TransferShortageLine(
 35    string Article,
 86    decimal Required,
 87    decimal OnSender,
 108    IReadOnlyList<(string Warehouse, decimal Quantity)> Elsewhere);
 9
 10/// <summary>
 11/// Причина отказа 1С провести перемещение, восстановленная по остаткам. На /Post 1С при нехватке остатка
 12/// отвечает общей фразой «Не удалось провести "…"!», поэтому причину считаем сами по регистру остатков.
 13/// </summary>
 14public static class OneCTransferShortage
 15{
 16    private const int MaxLines = 10;
 17    private const int MaxWarehouses = 5;
 18
 19    /// <summary>Сообщение о нехватке или <c>null</c>, если по остаткам всё в порядке (причина в другом).</summary>
 20    public static string? Build(string senderWarehouse, IReadOnlyList<TransferShortageLine> lines)
 21    {
 922        var lacking = lines.Where(l => l.Required > l.OnSender).ToList();
 423        if (lacking.Count == 0)
 124            return null;
 25
 626        var parts = lacking.Take(MaxLines).Select(l => $"{l.Article} — нужно {Fmt(l.Required)}, есть {Fmt(l.OnSender)} (
 327        var more = lacking.Count > MaxLines ? $"; и ещё {lacking.Count - MaxLines}" : string.Empty;
 328        return $"Не хватает остатка на складе «{senderWarehouse}»: {string.Join("; ", parts)}{more}. " +
 329               "Оприходуйте товар на этом складе или переместите с другого и проведите документ вручную.";
 30    }
 31
 32    private static string Where(TransferShortageLine l)
 33    {
 334        if (l.Elsewhere.Count == 0)
 235            return "нигде нет";
 36
 437        var shown = l.Elsewhere.OrderByDescending(e => e.Quantity).Take(MaxWarehouses)
 438            .Select(e => $"{e.Warehouse}: {Fmt(e.Quantity)}");
 139        var rest = l.Elsewhere.Count > MaxWarehouses ? $", ещё {l.Elsewhere.Count - MaxWarehouses}" : string.Empty;
 140        return $"есть на {string.Join(", ", shown)}{rest}";
 41    }
 42
 943    private static string Fmt(decimal value) => value.ToString("0.###", System.Globalization.CultureInfo.InvariantCultur
 44}

Methods/Properties

get_RefKey()
get_Description()
get_Inn()
get_DeletionMark()
get_RefKey()
get_Description()
get_Inn()
get_RegistrationNumber()
get_Identifier()
get_RefKey()
get_Description()
get_RefKey()
get_Description()
get_Article()
get_IsFolder()
get_StockAccountKey()
get_RefKey()
get_PropertyKey()
get_Value()
get_RefKey()
get_Description()
get_OwnerKey()
get_DeletionMark()
get_RefKey()
get_Description()
get_RefKey()
get_Description()
get_Owner()
get_OrganizationKey()
get_RefKey()
get_Description()
get_RefKey()
get_Description()
get_RegistrationNumber()
get_Value()
get_Error()
get_Code()
get_Message()
get_Lang()
get_Value()
get_RefKey()
get_Number()
get_Date()
get_Posted()
get_DeletionMark()
get_CounterpartyKey()
get_IncomingDocNumber()
get_IncomingDocDate()
get_OrganizationKey()
get_WarehouseKey()
get_RefKey()
get_Number()
get_Date()
get_Posted()
get_DeletionMark()
get_WarehouseSenderKey()
get_WarehouseRecipientKey()
get_Comment()
get_AuthorKey()
get_OrganizationKey()
get_RefKey()
get_Number()
get_Date()
get_Posted()
get_DeletionMark()
get_CounterpartyKey()
get_Amount()
get_Purpose()
get_OperationType()
get_RefKey()
get_Description()
get_Inn()
get_Kpp()
get_DeletionMark()
get_IsFolder()
get_RefKey()
get_Description()
get_Article()
get_IsFolder()
get_DeletionMark()
get_RefKey()
get_Description()
get_DeletionMark()
get_RefKey()
get_Description()
get_Inn()
get_Kpp()
get_DeletionMark()
get_RefKey()
get_Description()
get_Owner()
get_DeletionMark()
get_Recorder()
get_RecorderType()
get_RecordSet()
get_Period()
get_RecordType()
get_NomenclatureKey()
get_WarehouseKey()
get_Quantity()
get_OrganizationKey()
get_NomenclatureKey()
get_WarehouseKey()
get_QuantityBalance()
get_CostBalance()
get_NomenclatureKey()
get_WarehouseKey()
get_QuantityReceipt()
get_QuantityExpense()
get_NomenclatureKey()
get_RefKey()
get_Quantity()
get_Price()
get_RefKey()
get_LineNumber()
get_NomenclatureKey()
get_Quantity()
get_SourceReservePlaceKey()
get_SourceReservePlaceType()
get_TargetReservePlaceKey()
get_TargetReservePlaceType()
get_RefKey()
get_Number()
get_Date()
get_DeletionMark()
get_Posted()
get_WarehouseKey()
get_CounterpartyKey()
get_Lines()
get_OperationKey()
get_Date()
get_OrganizationKey()
get_WarehouseKey()
get_OperationKey()
get_Comment()
get_Lines()
get_LineNumber()
get_NomenclatureKey()
get_CharacteristicKey()
get_BatchKey()
get_UnitKey()
get_UnitType()
get_ActualQuantity()
get_BookQuantity()
get_Difference()
get_Surplus()
get_Shortage()
get_Price()
get_Amount()
get_BookAmount()
get_CellKey()
get_WarehouseRecipientKey()
get_Comment()
get_Lines()
get_CounterpartyKey()
get_RefKey()
get_Number()
get_Date()
get_Posted()
get_DeletionMark()
get_RefKey()
get_Number()
get_Posted()
get_Comment()
get_RefKey()
get_Number()
get_Date()
get_Posted()
get_DeletionMark()
get_CounterpartyKey()
get_ContractKey()
get_OrganizationKey()
get_WarehouseKey()
get_OperationKind()
get_TotalAmount()
get_VatAmount()
get_Comment()
get_CustomerOrderKey()
get_RefKey()
get_Number()
get_Date()
get_Posted()
get_DeletionMark()
get_OrganizationKey()
get_WarehouseSenderKey()
get_WarehouseRecipientKey()
get_OperationKind()
get_TotalAmount()
get_Comment()
get_LineNumber()
get_NomenclatureKey()
get_CharacteristicKey()
get_BatchKey()
get_WarehouseKey()
get_Quantity()
get_Price()
get_Amount()
get_LineNumber()
get_NomenclatureKey()
get_CharacteristicKey()
get_BatchKey()
get_WarehouseKey()
get_Quantity()
get_PackageQuantity()
get_Price()
get_Amount()
get_VatAmount()
get_VatRateKey()
get_PackageCoefficient()
get_UnitKey()
get_Date()
get_OrganizationKey()
get_CounterpartyKey()
get_ContractKey()
get_WarehouseKey()
get_DivisionKey()
get_OperationType()
get_VatIncludedInCost()
get_AmountIncludesVat()
get_IsUniversalDocument()
get_TotalAmount()
get_Comment()
get_Lines()
get_LineNumber()
get_NomenclatureKey()
get_CharacteristicKey()
get_UnitKey()
get_UnitType()
get_Quantity()
get_Price()
get_Amount()
get_VatAmount()
get_TotalAmount()
get_DiscountPercent()
get_DiscountAmount()
get_VatRateKey()
get_WarehouseKey()
get_OrderKey()
get_OrderType()
get_ReceiptDocKey()
get_BatchKey()
get_ProjectKey()
get_BuyerOrderKey()
get_Date()
get_IncomingDocNumber()
get_IncomingDocDate()
get_OrganizationKey()
get_CounterpartyKey()
get_ContractKey()
get_WarehouseKey()
get_OperationType()
get_AmountIncludesVat()
get_Comment()
get_Lines()
get_LineNumber()
get_NomenclatureKey()
get_UnitKey()
get_UnitType()
get_CharacteristicKey()
get_CountryOfOriginKey()
get_GtdNumberKey()
get_Quantity()
get_Price()
get_DiscountPercent()
get_DiscountAmount()
get_Amount()
get_VatRateKey()
get_VatAmount()
get_TotalAmount()
get_WarehouseKey()
get_OrderKey()
get_OrderType()
get_Date()
get_OrganizationKey()
get_WarehouseSenderKey()
get_WarehouseRecipientKey()
get_Comment()
get_Lines()
get_LineNumber()
get_NomenclatureKey()
get_CharacteristicKey()
get_UnitKey()
get_UnitType()
get_Quantity()
get_WarehouseSenderKey()
.ctor(System.String,System.String,System.String,Services.ApiServices._1CApi.OData.OneCODataError)
get_Endpoint()
get_Detail()
get_FullDetail()
.cctor()
IsUninformative(System.String)
DocumentTitle(System.String)
Build(System.String,System.String)
.cctor()
.ctor(Services.ApiServices._1CApi.OData.IOneCODataClient,Services.ApiServices._1CApi.OData.IOneCODataReferenceService)
GetDocumentAsync()
FindByNumberAsync()
TryFetchDocumentAsync()
ResolveNameAsync()
ParseDate(System.String)
get_RefKey()
get_Number()
get_Date()
get_Posted()
get_DeletionMark()
get_OrganizationKey()
get_WarehouseKey()
get_OperationKind()
get_TotalAmount()
get_Comment()
get_RefKey()
get_CounterpartyKey()
get_ContractKey()
get_VatAmount()
get_Description()
get_RefKey()
get_DocTypeLabel()
get_Number()
get_Date()
get_Posted()
get_DeletionMark()
get_CounterpartyName()
get_ContractName()
get_OrganizationName()
get_WarehouseName()
get_WarehouseRecipientName()
get_OperationKind()
get_Comment()
get_TotalAmount()
get_VatAmount()
get_Lines()
get_LineNumber()
get_NomenclatureName()
get_NomenclatureArticle()
get_CharacteristicName()
get_BatchName()
get_WarehouseName()
get_Quantity()
get_Price()
get_Amount()
get_VatAmount()
.ctor(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory)
AcquireAsync()
BuildLockName(System.String,System.String)
AddParameter(System.Data.IDbCommand,System.String,System.Object)
.ctor(Microsoft.Extensions.DependencyInjection.AsyncServiceScope,System.Data.Common.DbConnection,System.String)
DisposeAsync()
get_RefKey()
get_RefKey()
.ctor(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory)
LoadAllAsync()
FindAsync()
FindByArticleAsync()
FindByRefKeysAsync()
UpsertAsync()
BulkUpsertAsync()
GetAllKeysAsync()
FindProducerAliasAsync()
UpsertProducerAliasAsync()
BuildPlaceholders(System.Collections.Generic.IReadOnlyList`1<System.String>,System.Collections.Generic.List`1<System.Object>)
ToEntry(OzonDomains.Models.OneCNomenclatureMap)
Truncate(System.String,System.Int32)
.ctor(Services.ApiServices._1CApi.OData.IOneCODataClient,Services.ApiServices._1CApi.OData.IOneCNomenclatureMapStore,Microsoft.Extensions.Logging.ILogger`1<Services.ApiServices._1CApi.OData.OneCNomenclatureMapSyncJob>)
ExecuteAsync()
PickWinner(System.Collections.Generic.List`1<Services.ApiServices._1CApi.OData.OneCNomenclatureMapSyncJob/CardInfo>)
IsPostable(Services.ApiServices._1CApi.OData.Models.ODataNomenclature)
get_RefKey()
get_TransactionType()
ResolveManyAsync()
get_RefKey()
.cctor()
.ctor(Services.ApiServices._1CApi.OData.IOneCODataClient,Microsoft.Extensions.Logging.ILogger`1<Services.ApiServices._1CApi.OData.OneCNomenclatureService>,Services.ApiServices._1CApi.OData.IOneCNomenclatureCreationLock,Services.ApiServices._1CApi.OData.IOneCNomenclatureMapStore,Services.ApiServices._1CApi.OData.OneCODataConfig,Services.SystemMonitoringServices.ISystemMonitoringSignalWriter,Services.ApiServices._1CApi.Nomenclature.NomenclatureMatcher,Services.ApiServices._1CApi.Nomenclature.ProducerValueResolver,Services.ApiServices._1CApi.Nomenclature.INomenclatureIndexProvider,Services.ApiServices._1CApi.Nomenclature.INomenclatureNotificationSink)
GetOrCreateAsync()
SignalAsync()
AuditAsync()
ShadowMatchAsync()
LogShadow(Services.ApiServices._1CApi.Nomenclature.NomenclatureMatch,System.Nullable`1<System.Guid>,System.Boolean,Services.ApiServices._1CApi.Nomenclature.NomenclatureMode,System.String,System.String)
Services-ApiServices-_1CApi-Nomenclature-INomenclatureLiveLookup-FindByArticleAsync()
CreateNewCardAsync()
GetOrCreateCoreAsync()
GetOrCreateManyAsync()
<GetOrCreateManyAsync()
FindAsync()
FindCoreAsync()
FindManyAsync()
<FindManyAsync()
NormalizeArticle(System.String)
ResolveArticle(OzonDomains.Models.Order)
NormalizeProducerName(System.String)
get_RefKey()
ResolveProducerAliasAsync()
RememberAsync()
FindExistingAsync()
FetchCandidatesAsync()
ReadProducersAsync()
ReadProducerAsync()
TryFindProducerAsync()
GetOrCreateProducerAsync()
InferProducerByContainment(System.Collections.Generic.IReadOnlyCollection`1<Services.ApiServices._1CApi.OData.Models.ODataPropertyValue>,System.String)
SetProducerAsync()
EnsureRequiredFieldsAsync()
BuildOriginComment(Services.ApiServices._1CApi.OData.OneCNomenclatureOrigin,System.Nullable`1<System.DateTime>)
BuildNomenclatureBody(System.String,System.String)
EscapeOData(System.String)
ResolveManyAsync()
<ResolveManyAsync()
ResolveOneAsync()
RejectionNotificationKind(Services.ApiServices._1CApi.Nomenclature.NomenclatureMatchStatus)
NotificationScope(Services.ApiServices._1CApi.Nomenclature.NomenclatureNotificationKind)
NotifyAsync()
LogResolver(OzonDomains.Models.Order,Services.ApiServices._1CApi.Nomenclature.NomenclatureMatch,Services.ApiServices._1CApi.Nomenclature.NomenclatureDecision)
AfterReuseAsync()
CreateResolvedAsync()
ResolveProducerForCreationAsync()
CreationArticleFor(OzonDomains.Models.Order,OzonDomains.Nomenclature.ParsedProductRef,System.String)
get_BaseUrl()
get_User()
get_Password()
get_BackfillProducerOnReuse()
get_ResolverMode()
IsDefaultGtd(System.String)
.ctor(System.String,System.String,System.Int32,System.String,System.String,System.String,System.String)
get_Method()
get_Endpoint()
get_StatusCode()
get_ReasonPhrase()
get_Code()
get_Message()
get_RawBody()
ToDetailText()
From(System.String,System.String,System.Net.HttpStatusCode,System.String,System.String)
.ctor(System.String,Services.ApiServices._1CApi.OData.OneCODataError)
get_Error()
Format(System.String)
TryExtractODataCode(System.String)
TryExtractODataMessage(System.String)
.cctor()
.ctor(System.Net.Http.IHttpClientFactory,Services.ApiServices._1CApi.OData.OneCODataConfig,Microsoft.Extensions.Logging.ILogger`1<Services.ApiServices._1CApi.OData.OneCODataHttpClient>)
CreateClient()
GetListAsync()
GetByGuidAsync()
GetSinglePageAsync()
GetPageAsync()
GetCountAsync()
GetRawJsonAsync()
PostAsync()
PostDocumentAsync()
PatchAsync()
SetPostedAsync(System.String,System.Guid,System.Boolean)
PutAsync()
BuildQuery(System.Collections.Generic.Dictionary`2<System.String,System.String>,System.Int32,System.String)
HasRefKey(System.String)
EnsureSuccess(System.Net.Http.HttpResponseMessage,System.String,System.String,System.String)
LogSwallowed(System.Net.Http.HttpResponseMessage,System.String,System.String,System.String)
ExecuteWithRetryAsync()
ShouldRetry(System.Net.HttpStatusCode)
TruncateError(System.String,System.Int32)
.cctor()
.ctor(Services.ApiServices._1CApi.OData.IOneCODataClient,Microsoft.Extensions.Caching.Memory.IMemoryCache,Microsoft.Extensions.Logging.ILogger`1<Services.ApiServices._1CApi.OData.OneCODataReferenceService>)
GetOrganizationGuidByInnAsync()
GetCounterpartyGuidByInnAsync()
GetWarehouseGuidByNameAsync()
GetNomenclatureGuidByArticleAsync()
<GetNomenclatureGuidByArticleAsync()
GetCountryGuidByNameAsync()
GetGtdGuidByNumberAsync()
EscapeOData(System.String)
GetVatRateGuidByRateAsync()
GetNoVatRateGuidAsync()
GetWarehousesAsync()
GetCountriesAsync()
GetGtdNumbersAsync()
GetAllContractsAsync()
GetContractsByCounterpartyAsync()
GetCounterpartiesAsync()
FindCounterpartyGuidByInnAsync()
GetCounterpartyByGuidAsync()
GetOrganizationsAsync()
GetAllCounterpartiesAsync()
LoadCounterpartiesAsync()
GetVatRatesAsync()
GetAllContractsInternalAsync()
LoadContractsAsync()
GetCachedListAsync()
CleanArticle(System.String)
get_RefKey()
.ctor(Services.ApiServices._1CApi.OData.IOneCODataClient,Microsoft.Extensions.Logging.ILogger)
FindAsync()
IsDuplicate(Services.ApiServices._1CApi.OData.OneCReceiptDuplicateFinder/ReceiptCandidate,System.String,System.Guid,System.Guid,System.Collections.Generic.IReadOnlyCollection`1<Services.ApiServices._1CApi.OData.Models.ODataReceiptLine>)
get_RefKey()
get_Number()
get_Posted()
get_DeletionMark()
get_IncomingDocNumber()
get_CounterpartyKey()
get_WarehouseKey()
get_Lines()
get_NomenclatureKey()
get_Quantity()
WithCondition(System.String,System.String)
Describe(System.Exception)
AppendOneCDetails(System.String,System.Collections.Generic.IEnumerable`1<Services.ApiServices._1CApi.Models.OneCResponse>)
Reason(System.String)
.ctor(System.String,System.Decimal,System.Decimal,System.Collections.Generic.IReadOnlyList`1<System.ValueTuple`2<System.String,System.Decimal>>)
get_Article()
get_Required()
get_OnSender()
get_Elsewhere()
Build(System.String,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices._1CApi.OData.TransferShortageLine>)
Where(Services.ApiServices._1CApi.OData.TransferShortageLine)
Fmt(System.Decimal)