using System;
using System.Collections.Generic;
using System.Configuration;
using System.Data;
using System.Data.SqlClient;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace XLAB
{
    internal sealed class PsvDataService
    {
        public bool DocumentNumberExists(string documentNumber, string excludeDocumentNumber)
        {
            var normalizedNumber = NormalizeDocumentNumber(documentNumber);
            if (normalizedNumber == null)
            {
                return false;
            }

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(
                @"
SELECT COUNT(1)
FROM dbo.EKZMK
WHERE NNZVPV = @DocumentNumber
  AND (@ExcludeDocumentNumber = N'' OR NNZVPV <> @ExcludeDocumentNumber);",
                connection))
            {
                connection.Open();
                command.CommandTimeout = 60;
                command.Parameters.Add("@DocumentNumber", SqlDbType.NVarChar, 60).Value = normalizedNumber;
                command.Parameters.Add("@ExcludeDocumentNumber", SqlDbType.NVarChar, 60).Value = excludeDocumentNumber ?? string.Empty;
                return Convert.ToInt32(command.ExecuteScalar()) > 0;
            }
        }

        public Task<bool> DocumentNumberExistsAsync(string documentNumber, string excludeDocumentNumber, CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                return DocumentNumberExists(documentNumber, excludeDocumentNumber);
            }, cancellationToken);
        }

        public IReadOnlyList<CustomerReference> LoadCustomers()
        {
            const string sql = @"
SELECT
    z.IDFRPDV AS CustomerId,
    fr.NMFRPD AS CustomerName
FROM dbo.EKZ z
JOIN dbo.FRPD fr ON fr.IDFRPD = z.IDFRPDV
WHERE z.IDFRPDV IS NOT NULL
GROUP BY z.IDFRPDV, fr.NMFRPD
ORDER BY fr.NMFRPD;";

            var customers = new List<CustomerReference>();

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                connection.Open();
                command.CommandTimeout = 60;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        customers.Add(new CustomerReference
                        {
                            CustomerId = GetInt32(reader, "CustomerId"),
                            CustomerName = GetString(reader, "CustomerName")
                        });
                    }
                }
            }

            return customers;
        }

        public IReadOnlyList<PersonReference> LoadVerifiers()
        {
            const string sql = @"
SELECT
    p.IDPRSN AS PersonId,
    p.PRFIO AS FullName
FROM dbo.PRSN p
WHERE NULLIF(LTRIM(RTRIM(p.PRFIO)), N'') IS NOT NULL
ORDER BY p.PRFIO;";

            var verifiers = new List<PersonReference>();

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                connection.Open();
                command.CommandTimeout = 60;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        verifiers.Add(new PersonReference
                        {
                            PersonId = GetInt32(reader, "PersonId"),
                            FullName = GetString(reader, "FullName")
                        });
                    }
                }
            }

            return verifiers;
        }

        public IReadOnlyList<DocumentFormReference> LoadVerificationDocumentForms(bool isPassed)
        {
            const string sql = @"
SELECT DISTINCT
    fr.IDFRDMS AS DocumentFormId,
    v.IDVDODVDD AS LinkTypeId,
    sp.NMVDD AS DocumentKindName,
    fr.NMFRD AS DocumentFormName
FROM dbo.FRDMS fr
JOIN dbo.SPVDD sp ON sp.IDSPVDD = fr.IDSPVDD
JOIN dbo.VDODVDD v ON v.IDSPVDD = fr.IDSPVDD
WHERE v.IDSPVDOD = 2
  AND fr.IDSPVDD = @DocumentTypeId
ORDER BY fr.NMFRD, v.IDVDODVDD;";

            var forms = new List<DocumentFormReference>();

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                connection.Open();
                command.CommandTimeout = 60;
                command.Parameters.Add("@DocumentTypeId", SqlDbType.Int).Value = isPassed ? 6 : 2;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        forms.Add(new DocumentFormReference
                        {
                            DocumentFormId = GetInt32(reader, "DocumentFormId"),
                            LinkTypeId = GetInt32(reader, "LinkTypeId"),
                            DocumentKindName = GetString(reader, "DocumentKindName"),
                            DocumentFormName = GetString(reader, "DocumentFormName")
                        });
                    }
                }
            }

            return forms;
        }

        public IReadOnlyList<SpoiDirectoryItem> LoadSpoiItems()
        {
            const string sql = @"
SELECT
    s.IDSPOI AS Id,
    s.KDOI AS Code,
    s.NMOI AS Name
FROM dbo.SPOI s
ORDER BY s.NMOI, s.KDOI, s.IDSPOI;";

            var items = new List<SpoiDirectoryItem>();

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                connection.Open();
                command.CommandTimeout = 60;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        items.Add(new SpoiDirectoryItem
                        {
                            Id = GetInt32(reader, "Id"),
                            Code = GetString(reader, "Code"),
                            Name = GetString(reader, "Name")
                        });
                    }
                }
            }

            return items;
        }

        public int AddSpoiItem(SpoiDirectoryItem item)
        {
            var normalizedItem = NormalizeSpoiItem(item);

            const string sql = @"
INSERT INTO dbo.SPOI
(
    KDOI,
    NMOI
)
VALUES
(
    @Code,
    @Name
);

SELECT CAST(SCOPE_IDENTITY() AS int);";

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                connection.Open();
                EnsureSpoiCodeIsUnique(connection, normalizedItem.Code, null);
                EnsureSpoiNameIsUnique(connection, normalizedItem.Name, null);
                command.CommandTimeout = 60;
                command.Parameters.Add("@Code", SqlDbType.VarChar, SpoiDirectoryRules.CodeMaxLength).Value = normalizedItem.Code;
                command.Parameters.Add("@Name", SqlDbType.VarChar, SpoiDirectoryRules.NameMaxLength).Value = normalizedItem.Name;

                try
                {
                    return Convert.ToInt32(command.ExecuteScalar());
                }
                catch (SqlException ex) when (IsSpoiDuplicateCodeViolation(ex))
                {
                    throw CreateSpoiDuplicateCodeException(normalizedItem.Code, ex);
                }
                catch (SqlException ex) when (IsSpoiDuplicateNameViolation(ex))
                {
                    throw CreateSpoiDuplicateNameException(normalizedItem.Name, ex);
                }
            }
        }

        public void UpdateSpoiItem(SpoiDirectoryItem item)
        {
            var normalizedItem = NormalizeSpoiItem(item);
            if (normalizedItem.Id <= 0)
            {
                throw new InvalidOperationException("Не выбрана запись SPOI для изменения.");
            }

            const string sql = @"
UPDATE dbo.SPOI
SET KDOI = @Code,
    NMOI = @Name
WHERE IDSPOI = @Id;

SELECT @@ROWCOUNT;";

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                connection.Open();
                EnsureSpoiCodeIsUnique(connection, normalizedItem.Code, normalizedItem.Id);
                EnsureSpoiNameIsUnique(connection, normalizedItem.Name, normalizedItem.Id);
                command.CommandTimeout = 60;
                command.Parameters.Add("@Id", SqlDbType.Int).Value = normalizedItem.Id;
                command.Parameters.Add("@Code", SqlDbType.VarChar, SpoiDirectoryRules.CodeMaxLength).Value = normalizedItem.Code;
                command.Parameters.Add("@Name", SqlDbType.VarChar, SpoiDirectoryRules.NameMaxLength).Value = normalizedItem.Name;

                try
                {
                    if (Convert.ToInt32(command.ExecuteScalar()) == 0)
                    {
                        throw new InvalidOperationException("Запись SPOI для изменения не найдена.");
                    }
                }
                catch (SqlException ex) when (IsSpoiDuplicateCodeViolation(ex))
                {
                    throw CreateSpoiDuplicateCodeException(normalizedItem.Code, ex);
                }
                catch (SqlException ex) when (IsSpoiDuplicateNameViolation(ex))
                {
                    throw CreateSpoiDuplicateNameException(normalizedItem.Name, ex);
                }
            }
        }

        public SpoiDeleteResult DeleteSpoiItem(int id)
        {
            if (id <= 0)
            {
                throw new InvalidOperationException("Не выбрана запись SPOI для удаления.");
            }

            const string sql = @"
DELETE FROM dbo.SPOI
WHERE IDSPOI = @Id;

SELECT @@ROWCOUNT;";

            try
            {
                using (var connection = CreateConnection())
                {
                    connection.Open();
                    var blockers = LoadSpoiDeleteBlockers(connection, id);
                    if (blockers.Count > 0)
                    {
                        return new SpoiDeleteResult
                        {
                            IsDeleted = false,
                            WarningMessage = CreateSpoiDeleteBlockedMessage(blockers)
                        };
                    }

                    using (var command = new SqlCommand(sql, connection))
                    {
                        command.CommandTimeout = 60;
                        command.Parameters.Add("@Id", SqlDbType.Int).Value = id;

                        if (Convert.ToInt32(command.ExecuteScalar()) == 0)
                        {
                            throw new InvalidOperationException("Запись SPOI для удаления не найдена.");
                        }
                    }

                    return new SpoiDeleteResult
                    {
                        IsDeleted = true
                    };
                }
            }
            catch (SqlException ex) when (ex.Number == 547)
            {
                return new SpoiDeleteResult
                {
                    IsDeleted = false,
                    WarningMessage = CreateSpoiDeleteBlockedMessage(ex)
                };
            }
        }

        public IReadOnlyList<SpnmtpDirectoryItem> LoadSpnmtpItems()
        {
            const string sql = @"
SELECT
    s.IDSPNMTP AS Id,
    s.NMTP AS Name,
    s.NMTPSP AS SpecialName
FROM dbo.SPNMTP s
ORDER BY s.NMTP, s.IDSPNMTP;";

            var items = new List<SpnmtpDirectoryItem>();

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                connection.Open();
                command.CommandTimeout = 60;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        items.Add(new SpnmtpDirectoryItem
                        {
                            Id = GetInt32(reader, "Id"),
                            Name = GetString(reader, "Name"),
                            SpecialName = GetString(reader, "SpecialName")
                        });
                    }
                }
            }

            return items;
        }

        public int AddSpnmtpItem(SpnmtpDirectoryItem item)
        {
            var normalizedItem = NormalizeSpnmtpItem(item);

            const string sql = @"
INSERT INTO dbo.SPNMTP
(
    NMTP,
    NMTPSP
)
VALUES
(
    @Name,
    @SpecialName
);

SELECT CAST(SCOPE_IDENTITY() AS int);";

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                connection.Open();
                EnsureSpnmtpNameIsUnique(connection, normalizedItem.Name, null);
                command.CommandTimeout = 60;
                command.Parameters.Add("@Name", SqlDbType.NVarChar, SpnmtpDirectoryRules.NameMaxLength).Value = normalizedItem.Name;
                command.Parameters.Add("@SpecialName", SqlDbType.VarChar, SpnmtpDirectoryRules.SpecialNameMaxLength).Value = (object)normalizedItem.SpecialName ?? DBNull.Value;

                try
                {
                    return Convert.ToInt32(command.ExecuteScalar());
                }
                catch (SqlException ex) when (IsSpnmtpDuplicateNameViolation(ex))
                {
                    throw CreateSpnmtpDuplicateNameException(normalizedItem.Name, ex);
                }
            }
        }

        public void UpdateSpnmtpItem(SpnmtpDirectoryItem item)
        {
            var normalizedItem = NormalizeSpnmtpItem(item);
            if (normalizedItem.Id <= 0)
            {
                throw new InvalidOperationException("Не выбрана запись SPNMTP для изменения.");
            }

            const string sql = @"
UPDATE dbo.SPNMTP
SET NMTP = @Name,
    NMTPSP = @SpecialName
WHERE IDSPNMTP = @Id;

SELECT @@ROWCOUNT;";

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                connection.Open();
                EnsureSpnmtpNameIsUnique(connection, normalizedItem.Name, normalizedItem.Id);
                command.CommandTimeout = 60;
                command.Parameters.Add("@Id", SqlDbType.Int).Value = normalizedItem.Id;
                command.Parameters.Add("@Name", SqlDbType.NVarChar, SpnmtpDirectoryRules.NameMaxLength).Value = normalizedItem.Name;
                command.Parameters.Add("@SpecialName", SqlDbType.VarChar, SpnmtpDirectoryRules.SpecialNameMaxLength).Value = (object)normalizedItem.SpecialName ?? DBNull.Value;

                try
                {
                    if (Convert.ToInt32(command.ExecuteScalar()) == 0)
                    {
                        throw new InvalidOperationException("Запись SPNMTP для изменения не найдена.");
                    }
                }
                catch (SqlException ex) when (IsSpnmtpDuplicateNameViolation(ex))
                {
                    throw CreateSpnmtpDuplicateNameException(normalizedItem.Name, ex);
                }
            }
        }

        public SpnmtpDeleteResult DeleteSpnmtpItem(int id)
        {
            if (id <= 0)
            {
                throw new InvalidOperationException("Не выбрана запись SPNMTP для удаления.");
            }

            const string sql = @"
DELETE FROM dbo.SPNMTP
WHERE IDSPNMTP = @Id;

SELECT @@ROWCOUNT;";

            try
            {
                using (var connection = CreateConnection())
                {
                    connection.Open();
                    var blockers = LoadSpnmtpDeleteBlockers(connection, id);
                    if (blockers.Count > 0)
                    {
                        return new SpnmtpDeleteResult
                        {
                            IsDeleted = false,
                            WarningMessage = CreateSpnmtpDeleteBlockedMessage(blockers)
                        };
                    }

                    using (var command = new SqlCommand(sql, connection))
                    {
                        command.CommandTimeout = 60;
                        command.Parameters.Add("@Id", SqlDbType.Int).Value = id;

                        if (Convert.ToInt32(command.ExecuteScalar()) == 0)
                        {
                            throw new InvalidOperationException("Запись SPNMTP для удаления не найдена.");
                        }
                    }

                    return new SpnmtpDeleteResult
                    {
                        IsDeleted = true
                    };
                }
            }
            catch (SqlException ex) when (ex.Number == 547)
            {
                return new SpnmtpDeleteResult
                {
                    IsDeleted = false,
                    WarningMessage = CreateSpnmtpDeleteBlockedMessage(ex)
                };
            }
        }

        public IReadOnlyList<PsvDocumentSummary> LoadDocuments(bool loadClosedDocumentsForCurrentYear)
        {
            var sql = @"
SELECT
    m.NNZVPV AS DocumentNumber,
    MAX(m.DTPRM) AS AcceptedOn,
    MAX(m.DTVDM) AS IssuedOn,
    MIN(dep.NMFRPD) AS DepartmentName,
    CASE WHEN COUNT(DISTINCT z.IDFRPDV) = 1 THEN MIN(z.IDFRPDV) ELSE NULL END AS CustomerId,
    CASE WHEN COUNT(DISTINCT z.IDFRPDV) = 1 THEN MIN(ownerOrg.NMFRPD) ELSE N'' END AS CustomerName,
    COUNT(*) AS ItemCount,
    SUM(CASE WHEN m.DTVDM IS NOT NULL THEN 1 ELSE 0 END) AS IssuedCount,
    SUM(CASE WHEN m.GDN = 1 THEN 1 ELSE 0 END) AS PassedCount,
    SUM(CASE WHEN m.GDN = 0 THEN 1 ELSE 0 END) AS FailedCount
FROM dbo.EKZMK m
JOIN dbo.EKZ z ON z.IDEKZ = m.IDEKZ
LEFT JOIN dbo.FRPD dep ON dep.IDFRPD = m.IDFRPD
LEFT JOIN dbo.FRPD ownerOrg ON ownerOrg.IDFRPD = z.IDFRPDV
WHERE NULLIF(LTRIM(RTRIM(m.NNZVPV)), N'') IS NOT NULL
GROUP BY m.NNZVPV
HAVING " + (loadClosedDocumentsForCurrentYear
                    ? "MAX(m.DTVDM) >= @IssuedFrom AND MAX(m.DTVDM) < @IssuedTo"
                    : "MAX(m.DTVDM) IS NULL") + @"
ORDER BY " + (loadClosedDocumentsForCurrentYear
                    ? "MAX(m.DTVDM) DESC"
                    : "MAX(m.DTPRM) DESC") + @", m.NNZVPV DESC;";

            var documents = new List<PsvDocumentSummary>();
            var today = DateTime.Today;
            var currentYearStart = new DateTime(today.Year, 1, 1);
            var nextYearStart = currentYearStart.AddYears(1);

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                if (loadClosedDocumentsForCurrentYear)
                {
                    command.Parameters.Add("@IssuedFrom", SqlDbType.DateTime).Value = currentYearStart;
                    command.Parameters.Add("@IssuedTo", SqlDbType.DateTime).Value = nextYearStart;
                }

                connection.Open();
                command.CommandTimeout = 60;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        var documentNumber = GetString(reader, "DocumentNumber");
                        documents.Add(new PsvDocumentSummary
                        {
                            DocumentKey = documentNumber,
                            DocumentNumber = documentNumber,
                            AcceptedOn = GetNullableDateTime(reader, "AcceptedOn"),
                            IssuedOn = GetNullableDateTime(reader, "IssuedOn"),
                            DepartmentName = GetString(reader, "DepartmentName"),
                            CustomerId = GetNullableInt32(reader, "CustomerId"),
                            CustomerName = GetString(reader, "CustomerName"),
                            ItemCount = GetInt32(reader, "ItemCount"),
                            IssuedCount = GetInt32(reader, "IssuedCount"),
                            PassedCount = GetInt32(reader, "PassedCount"),
                            FailedCount = GetInt32(reader, "FailedCount"),
                            IsDraft = false
                        });
                    }
                }

                var serialNumbersByDocument = LoadDocumentSerialNumbers(connection, loadClosedDocumentsForCurrentYear, currentYearStart, nextYearStart);
                foreach (var document in documents)
                {
                    string serialNumbersText;
                    if (serialNumbersByDocument.TryGetValue(document.DocumentNumber, out serialNumbersText))
                    {
                        document.SerialNumbersText = serialNumbersText;
                    }
                }
            }

            return documents;
        }

        private Dictionary<string, string> LoadDocumentSerialNumbers(SqlConnection connection, bool loadClosedDocumentsForCurrentYear, DateTime currentYearStart, DateTime nextYearStart)
        {
            var sql = @"
WITH filteredDocuments AS
(
    SELECT m.NNZVPV
    FROM dbo.EKZMK m
    WHERE NULLIF(LTRIM(RTRIM(m.NNZVPV)), N'') IS NOT NULL
    GROUP BY m.NNZVPV
    HAVING " + (loadClosedDocumentsForCurrentYear
                        ? "MAX(m.DTVDM) >= @IssuedFrom AND MAX(m.DTVDM) < @IssuedTo"
                        : "MAX(m.DTVDM) IS NULL") + @"
)
SELECT
    m.NNZVPV AS DocumentNumber,
    z.NNZV AS SerialNumber
FROM dbo.EKZMK m
JOIN filteredDocuments documents ON documents.NNZVPV = m.NNZVPV
JOIN dbo.EKZ z ON z.IDEKZ = m.IDEKZ
WHERE NULLIF(LTRIM(RTRIM(z.NNZV)), N'') IS NOT NULL
ORDER BY m.NNZVPV, z.NNZV;";

            var serialNumbersByDocument = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);

            using (var command = new SqlCommand(sql, connection))
            {
                command.CommandTimeout = 60;

                if (loadClosedDocumentsForCurrentYear)
                {
                    command.Parameters.Add("@IssuedFrom", SqlDbType.DateTime).Value = currentYearStart;
                    command.Parameters.Add("@IssuedTo", SqlDbType.DateTime).Value = nextYearStart;
                }

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        var documentNumber = GetString(reader, "DocumentNumber");
                        var serialNumber = GetString(reader, "SerialNumber");
                        if (string.IsNullOrWhiteSpace(documentNumber) || string.IsNullOrWhiteSpace(serialNumber))
                        {
                            continue;
                        }

                        SortedSet<string> serialNumbers;
                        if (!serialNumbersByDocument.TryGetValue(documentNumber, out serialNumbers))
                        {
                            serialNumbers = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
                            serialNumbersByDocument[documentNumber] = serialNumbers;
                        }

                        serialNumbers.Add(serialNumber.Trim());
                    }
                }
            }

            return serialNumbersByDocument.ToDictionary(
                delegate(KeyValuePair<string, SortedSet<string>> pair) { return pair.Key; },
                delegate(KeyValuePair<string, SortedSet<string>> pair) { return string.Join(", ", pair.Value); },
                StringComparer.OrdinalIgnoreCase);
        }

        public IReadOnlyList<PsvDocumentLine> LoadDocumentLines(string documentNumber)
        {
            const string sql = @"
SELECT
    m.IDEKZMK AS CardId,
    z.IDEKZ AS InstrumentId,
    z.IDTPRZ AS TypeSizeId,
    z.NNZV AS SerialNumber,
    z.NNIN AS InventoryNumber,
    ownerOrg.NMFRPD AS CustomerName,
    tips.TP AS InstrumentType,
    names.NMTP AS InstrumentName,
    areas.NMOI AS MeasurementArea,
    sizeInfo.DPZN AS RangeText,
    sizeInfo.NNGSRS AS RegistryNumber,
    sizeInfo.HRTC AS AccuracyText,
    verificationType.NMVDMK AS VerificationType,
    m.PRMK AS PeriodMonths,
    m.DTPRM AS AcceptedOn,
    m.DTVDM AS IssuedOn,
    m.GDN AS IsPassed,
    m.DTMKFK AS VerificationPerformedOn,
    m.IDPRSN AS VerifierId,
    verifier.PRFIO AS VerifierName,
    m.NNNKL AS StickerNumber,
    verificationDocument.IDFRDMS AS VerificationDocumentFormId,
    verificationDocument.IDVDODVDD AS VerificationDocumentLinkTypeId,
    verificationDocument.NNDMS AS VerificationDocumentNumber,
    verificationDocument.DTDMS AS VerificationDocumentDate,
    m.PRCHNPGDN AS RejectionReason,
    m.DSEKZMK AS Notes
FROM dbo.EKZMK m
JOIN dbo.EKZ z ON z.IDEKZ = m.IDEKZ
LEFT JOIN dbo.FRPD ownerOrg ON ownerOrg.IDFRPD = z.IDFRPDV
LEFT JOIN dbo.TPRZ sizeInfo ON sizeInfo.IDTPRZ = z.IDTPRZ
LEFT JOIN dbo.TIPS tips ON tips.IDTIPS = sizeInfo.IDTIPS
LEFT JOIN dbo.SPNMTP names ON names.IDSPNMTP = tips.IDSPNMTP
LEFT JOIN dbo.SPOI areas ON areas.IDSPOI = tips.IDSPOI
LEFT JOIN dbo.SPVDMK verificationType ON verificationType.IDSPVDMK = m.IDSPVDMK
LEFT JOIN dbo.PRSN verifier ON verifier.IDPRSN = m.IDPRSN
OUTER APPLY
(
    SELECT TOP (1)
        d.IDFRDMS,
        d.IDVDODVDD,
        d.NND AS NNDMS,
        d.DTD AS DTDMS
    FROM dbo.DMS d
    JOIN dbo.VDODVDD vdd ON vdd.IDVDODVDD = d.IDVDODVDD
    JOIN dbo.FRDMS frdms ON frdms.IDFRDMS = d.IDFRDMS
    JOIN dbo.SPVDD spvdd ON spvdd.IDSPVDD = frdms.IDSPVDD
    WHERE d.IDOD = m.IDEKZMK
      AND vdd.IDSPVDOD = 2
      AND spvdd.IDSPVDD IN (2, 6, 8)
    ORDER BY d.DTD DESC
) verificationDocument
WHERE m.NNZVPV = @DocumentNumber
ORDER BY areas.NMOI, names.NMTP, tips.TP, z.NNZV;";

            var lines = new List<PsvDocumentLine>();

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                connection.Open();
                command.CommandTimeout = 60;
                command.Parameters.Add("@DocumentNumber", SqlDbType.NVarChar, 60).Value = documentNumber;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        lines.Add(new PsvDocumentLine
                        {
                            CardId = GetInt32(reader, "CardId"),
                            InstrumentId = GetInt32(reader, "InstrumentId"),
                            TypeSizeId = GetInt32(reader, "TypeSizeId"),
                            SerialNumber = GetString(reader, "SerialNumber"),
                            InventoryNumber = GetString(reader, "InventoryNumber"),
                            CustomerName = GetString(reader, "CustomerName"),
                            InstrumentType = GetString(reader, "InstrumentType"),
                            InstrumentName = GetString(reader, "InstrumentName"),
                            MeasurementArea = GetString(reader, "MeasurementArea"),
                            RangeText = GetString(reader, "RangeText"),
                            RegistryNumber = GetString(reader, "RegistryNumber"),
                            AccuracyText = GetString(reader, "AccuracyText"),
                            VerificationType = GetString(reader, "VerificationType"),
                            PeriodMonths = GetInt32(reader, "PeriodMonths"),
                            AcceptedOn = GetNullableDateTime(reader, "AcceptedOn"),
                            IssuedOn = GetNullableDateTime(reader, "IssuedOn"),
                            IsPassed = GetNullableBoolean(reader, "IsPassed"),
                            VerificationPerformedOn = GetNullableDateTime(reader, "VerificationPerformedOn"),
                            VerifierId = GetNullableInt32(reader, "VerifierId"),
                            VerifierName = GetString(reader, "VerifierName"),
                            StickerNumber = GetString(reader, "StickerNumber"),
                            VerificationDocumentFormId = GetNullableInt32(reader, "VerificationDocumentFormId"),
                            VerificationDocumentLinkTypeId = GetNullableInt32(reader, "VerificationDocumentLinkTypeId"),
                            VerificationDocumentNumber = GetString(reader, "VerificationDocumentNumber"),
                            VerificationDocumentDate = GetNullableDateTime(reader, "VerificationDocumentDate"),
                            RejectionReason = GetString(reader, "RejectionReason"),
                            Notes = GetString(reader, "Notes"),
                            IsPendingInsert = false
                        });
                    }
                }
            }

            return lines;
        }

        public IReadOnlyList<AvailableInstrumentItem> LoadCustomerInstruments(int customerId)
        {
            const string sql = @"
SELECT
    z.IDEKZ AS InstrumentId,
    z.IDTPRZ AS TypeSizeId,
    z.NNZV AS SerialNumber,
    z.NNIN AS InventoryNumber,
    ownerOrg.NMFRPD AS CustomerName,
    tips.TP AS InstrumentType,
    names.NMTP AS InstrumentName,
    areas.NMOI AS MeasurementArea,
    sizeInfo.NNGSRS AS RegistryNumber,
    sizeInfo.DPZN AS RangeText,
    sizeInfo.HRTC AS AccuracyText,
    CASE
        WHEN instrumentTemplate.LastDocumentNumber IS NOT NULL
             OR typeTemplate.LastDocumentNumber IS NOT NULL
             OR COALESCE(periodByInstrument.PRMK, periodByType.PRMK, tips.PRMKGR) IS NOT NULL
            THEN CAST(1 AS bit)
        ELSE CAST(0 AS bit)
    END AS HasTemplate,
    COALESCE(instrumentTemplate.LastDocumentNumber, typeTemplate.LastDocumentNumber) AS LastDocumentNumber,
    COALESCE(instrumentTemplate.LastAcceptedOn, typeTemplate.LastAcceptedOn) AS LastAcceptedOn,
    CASE
        WHEN instrumentTemplate.LastDocumentNumber IS NOT NULL THEN N'История прибора'
        WHEN typeTemplate.LastDocumentNumber IS NOT NULL THEN N'Шаблон по типоразмеру'
        WHEN COALESCE(periodByInstrument.PRMK, periodByType.PRMK) IS NOT NULL THEN N'Период из TPRMCP'
        WHEN tips.PRMKGR IS NOT NULL THEN N'Регистрационный период из TIPS'
        ELSE N''
    END AS TemplateSource
FROM dbo.EKZ z
LEFT JOIN dbo.FRPD ownerOrg ON ownerOrg.IDFRPD = z.IDFRPDV
LEFT JOIN dbo.TPRZ sizeInfo ON sizeInfo.IDTPRZ = z.IDTPRZ
LEFT JOIN dbo.TIPS tips ON tips.IDTIPS = sizeInfo.IDTIPS
LEFT JOIN dbo.SPNMTP names ON names.IDSPNMTP = tips.IDSPNMTP
LEFT JOIN dbo.SPOI areas ON areas.IDSPOI = tips.IDSPOI
OUTER APPLY
(
    SELECT TOP (1)
        history.NNZVPV AS LastDocumentNumber,
        history.DTPRM AS LastAcceptedOn
    FROM dbo.EKZMK history
    WHERE history.IDEKZ = z.IDEKZ
    ORDER BY ISNULL(history.DTPRM, CONVERT(datetime, '19000101', 112)) DESC, history.IDEKZMK DESC
) instrumentTemplate
OUTER APPLY
(
    SELECT TOP (1)
        history.NNZVPV AS LastDocumentNumber,
        history.DTPRM AS LastAcceptedOn
    FROM dbo.EKZMK history
    JOIN dbo.EKZ instrumentOfSameType ON instrumentOfSameType.IDEKZ = history.IDEKZ
    WHERE instrumentOfSameType.IDTPRZ = z.IDTPRZ
    ORDER BY ISNULL(history.DTPRM, CONVERT(datetime, '19000101', 112)) DESC, history.IDEKZMK DESC
) typeTemplate
OUTER APPLY
(
    SELECT TOP (1)
        t.PRMK
    FROM dbo.EKZMCP e
    JOIN dbo.TPRMCP t ON t.IDTPRMCP = e.IDTPRMCP
    WHERE e.IDEKZ = z.IDEKZ
    ORDER BY e.IDEKZMCP DESC, t.IDTPRMCP DESC
) periodByInstrument
OUTER APPLY
(
    SELECT TOP (1)
        t.PRMK
    FROM dbo.TPRMCP t
    WHERE t.IDTPRZ = z.IDTPRZ
    ORDER BY t.IDTPRMCP DESC
) periodByType
WHERE z.IDFRPDV = @CustomerId
ORDER BY names.NMTP, tips.TP, sizeInfo.DPZN, z.NNZV;";

            var instruments = new List<AvailableInstrumentItem>();

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                connection.Open();
                command.CommandTimeout = 60;
                command.Parameters.Add("@CustomerId", SqlDbType.Int).Value = customerId;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        instruments.Add(new AvailableInstrumentItem
                        {
                            InstrumentId = GetInt32(reader, "InstrumentId"),
                            TypeSizeId = GetInt32(reader, "TypeSizeId"),
                            SerialNumber = GetString(reader, "SerialNumber"),
                            InventoryNumber = GetString(reader, "InventoryNumber"),
                            CustomerName = GetString(reader, "CustomerName"),
                            InstrumentType = GetString(reader, "InstrumentType"),
                            InstrumentName = GetString(reader, "InstrumentName"),
                            MeasurementArea = GetString(reader, "MeasurementArea"),
                            RegistryNumber = GetString(reader, "RegistryNumber"),
                            RangeText = GetString(reader, "RangeText"),
                            AccuracyText = GetString(reader, "AccuracyText"),
                            HasTemplate = GetBoolean(reader, "HasTemplate"),
                            LastDocumentNumber = GetString(reader, "LastDocumentNumber"),
                            LastAcceptedOn = GetNullableDateTime(reader, "LastAcceptedOn"),
                            TemplateSource = GetString(reader, "TemplateSource")
                        });
                    }
                }
            }

            return instruments;
        }

        public IReadOnlyList<AvailableInstrumentItem> LoadInstrumentTypes()
        {
            const string sql = @"
SELECT
    0 AS InstrumentId,
    sizeInfo.IDTPRZ AS TypeSizeId,
    CAST(N'' AS nvarchar(30)) AS SerialNumber,
    CAST(N'' AS nvarchar(30)) AS InventoryNumber,
    CAST(N'' AS nvarchar(255)) AS CustomerName,
    tips.TP AS InstrumentType,
    names.NMTP AS InstrumentName,
    areas.NMOI AS MeasurementArea,
    sizeInfo.NNGSRS AS RegistryNumber,
    sizeInfo.DPZN AS RangeText,
    sizeInfo.HRTC AS AccuracyText,
    CASE
        WHEN typeTemplate.LastDocumentNumber IS NOT NULL
             OR periodByType.PRMK IS NOT NULL
             OR tips.PRMKGR IS NOT NULL
            THEN CAST(1 AS bit)
        ELSE CAST(0 AS bit)
    END AS HasTemplate,
    typeTemplate.LastDocumentNumber AS LastDocumentNumber,
    typeTemplate.LastAcceptedOn AS LastAcceptedOn,
    CASE
        WHEN typeTemplate.LastDocumentNumber IS NOT NULL THEN N'Шаблон по типоразмеру'
        WHEN periodByType.PRMK IS NOT NULL THEN N'Период из TPRMCP'
        WHEN tips.PRMKGR IS NOT NULL THEN N'Регистрационный период из TIPS'
        ELSE N''
    END AS TemplateSource
FROM dbo.TPRZ sizeInfo
LEFT JOIN dbo.TIPS tips ON tips.IDTIPS = sizeInfo.IDTIPS
LEFT JOIN dbo.SPNMTP names ON names.IDSPNMTP = tips.IDSPNMTP
LEFT JOIN dbo.SPOI areas ON areas.IDSPOI = tips.IDSPOI
OUTER APPLY
(
    SELECT TOP (1)
        history.NNZVPV AS LastDocumentNumber,
        history.DTPRM AS LastAcceptedOn
    FROM dbo.EKZMK history
    JOIN dbo.EKZ instrumentOfSameType ON instrumentOfSameType.IDEKZ = history.IDEKZ
    WHERE instrumentOfSameType.IDTPRZ = sizeInfo.IDTPRZ
    ORDER BY ISNULL(history.DTPRM, CONVERT(datetime, '19000101', 112)) DESC, history.IDEKZMK DESC
) typeTemplate
OUTER APPLY
(
    SELECT TOP (1)
        t.PRMK
    FROM dbo.TPRMCP t
    WHERE t.IDTPRZ = sizeInfo.IDTPRZ
    ORDER BY t.IDTPRMCP DESC
) periodByType
ORDER BY names.NMTP, tips.TP, sizeInfo.DPZN, sizeInfo.NNGSRS;";

            var instrumentTypes = new List<AvailableInstrumentItem>();

            using (var connection = CreateConnection())
            using (var command = new SqlCommand(sql, connection))
            {
                connection.Open();
                command.CommandTimeout = 60;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        instrumentTypes.Add(new AvailableInstrumentItem
                        {
                            InstrumentId = GetInt32(reader, "InstrumentId"),
                            TypeSizeId = GetInt32(reader, "TypeSizeId"),
                            SerialNumber = GetString(reader, "SerialNumber"),
                            InventoryNumber = GetString(reader, "InventoryNumber"),
                            CustomerName = GetString(reader, "CustomerName"),
                            InstrumentType = GetString(reader, "InstrumentType"),
                            InstrumentName = GetString(reader, "InstrumentName"),
                            MeasurementArea = GetString(reader, "MeasurementArea"),
                            RegistryNumber = GetString(reader, "RegistryNumber"),
                            RangeText = GetString(reader, "RangeText"),
                            AccuracyText = GetString(reader, "AccuracyText"),
                            HasTemplate = GetBoolean(reader, "HasTemplate"),
                            LastDocumentNumber = GetString(reader, "LastDocumentNumber"),
                            LastAcceptedOn = GetNullableDateTime(reader, "LastAcceptedOn"),
                            TemplateSource = GetString(reader, "TemplateSource")
                        });
                    }
                }
            }

            return instrumentTypes;
        }

        public IReadOnlyList<OpenDocumentConflictInfo> FindOpenDocumentConflicts(int customerId, string currentDocumentNumber, IEnumerable<PsvDocumentLine> candidateLines)
        {
            if (customerId <= 0 || candidateLines == null)
            {
                return new List<OpenDocumentConflictInfo>();
            }

            using (var connection = CreateConnection())
            {
                connection.Open();
                return LoadOpenDocumentConflicts(connection, null, customerId, currentDocumentNumber, candidateLines);
            }
        }

        public void ResetLineVerification(int cardId)
        {
            if (cardId <= 0)
            {
                throw new InvalidOperationException("Не выбрана строка EKZMK для отмены проверки.");
            }

            using (var connection = CreateConnection())
            {
                connection.Open();

                using (var transaction = connection.BeginTransaction())
                {
                    ClearLineVerificationCore(connection, transaction, cardId);
                    DeleteVerificationDocuments(connection, transaction, cardId);
                    transaction.Commit();
                }
            }
        }

        public void SaveLineVerification(int cardId, VerificationEditResult result)
        {
            if (cardId <= 0)
            {
                throw new InvalidOperationException("Не выбрана строка EKZMK для сохранения результата поверки.");
            }

            if (result == null)
            {
                throw new ArgumentNullException("result");
            }

            using (var connection = CreateConnection())
            {
                connection.Open();

                using (var transaction = connection.BeginTransaction())
                {
                    SaveLineVerificationCore(connection, transaction, cardId, result);
                    DeleteVerificationDocuments(connection, transaction, cardId);

                    if (!string.IsNullOrWhiteSpace(result.VerificationDocumentNumber))
                    {
                        if (result.DocumentFormId <= 0 || result.DocumentLinkTypeId <= 0)
                        {
                            throw new InvalidOperationException("Для документа поверки не определена форма или связь с объектом.");
                        }

                        InsertVerificationDocument(
                            connection,
                            transaction,
                            cardId,
                            result.DocumentLinkTypeId,
                            result.DocumentFormId,
                            result.VerificationDocumentNumber,
                            result.VerificationDate);
                    }

                    transaction.Commit();
                }
            }
        }

        public void ResetLineVerification(IEnumerable<int> cardIds)
        {
            var normalizedCardIds = NormalizeCardIds(
                cardIds,
                "Не выбрана строка EKZMK для отмены поверки.",
                "Не выбрано ни одной строки EKZMK для отмены поверки.");

            using (var connection = CreateConnection())
            {
                connection.Open();

                using (var transaction = connection.BeginTransaction())
                {
                    foreach (var cardId in normalizedCardIds)
                    {
                        ClearLineVerificationCore(connection, transaction, cardId);
                        DeleteVerificationDocuments(connection, transaction, cardId);
                    }

                    transaction.Commit();
                }
            }
        }

        public void SaveLineVerification(IEnumerable<int> cardIds, VerificationEditResult result)
        {
            if (result == null)
            {
                throw new ArgumentNullException("result");
            }

            var normalizedCardIds = NormalizeCardIds(
                cardIds,
                "Не выбрана строка EKZMK для сохранения результата поверки.",
                "Не выбрано ни одной строки EKZMK для сохранения результата поверки.");

            using (var connection = CreateConnection())
            {
                connection.Open();

                using (var transaction = connection.BeginTransaction())
                {
                    foreach (var cardId in normalizedCardIds)
                    {
                        SaveLineVerificationCore(connection, transaction, cardId, result);
                        DeleteVerificationDocuments(connection, transaction, cardId);

                        if (!string.IsNullOrWhiteSpace(result.VerificationDocumentNumber))
                        {
                            if (result.DocumentFormId <= 0 || result.DocumentLinkTypeId <= 0)
                            {
                                throw new InvalidOperationException("Для документа поверки не определена форма или связь с объектом.");
                            }

                            InsertVerificationDocument(
                                connection,
                                transaction,
                                cardId,
                                result.DocumentLinkTypeId,
                                result.DocumentFormId,
                                result.VerificationDocumentNumber,
                                result.VerificationDate);
                        }
                    }

                    transaction.Commit();
                }
            }
        }

        public DocumentSaveResult SaveDocument(string currentDocumentNumber, DocumentEditorResult document, IEnumerable<PsvDocumentLine> pendingLines)
        {
            if (document == null)
            {
                throw new ArgumentNullException("document");
            }

            var normalizedNumber = NormalizeDocumentNumber(document.DocumentNumber);
            if (normalizedNumber == null)
            {
                throw new InvalidOperationException("Номер ПСВ не заполнен.");
            }

            document.DocumentNumber = normalizedNumber;

            var distinctPendingLines = pendingLines == null
                ? new List<PsvDocumentLine>()
                : pendingLines
                    .Where(IsPendingLineReadyForSave)
                    .GroupBy(GetPendingLineSaveKey, StringComparer.OrdinalIgnoreCase)
                    .Select(delegate(IGrouping<string, PsvDocumentLine> group) { return group.First(); })
                    .ToList();

            using (var connection = CreateConnection())
            {
                connection.Open();

                using (var transaction = connection.BeginTransaction())
                {
                    if (DocumentNumberExists(connection, transaction, normalizedNumber, currentDocumentNumber))
                    {
                        throw new InvalidOperationException(string.Format("ПСВ с номером \"{0}\" уже существует.", normalizedNumber));
                    }

                    if (document.CustomerId.HasValue)
                    {
                        var openDocumentConflicts = LoadOpenDocumentConflicts(
                            connection,
                            transaction,
                            document.CustomerId.Value,
                            currentDocumentNumber,
                            distinctPendingLines);
                        if (openDocumentConflicts.Count > 0)
                        {
                            throw new InvalidOperationException(BuildOpenDocumentConflictMessage(openDocumentConflicts));
                        }
                    }

                    var updatedEkzMkCount = 0;
                    if (!string.IsNullOrWhiteSpace(currentDocumentNumber))
                    {
                        updatedEkzMkCount = UpdateDocumentHeader(connection, transaction, currentDocumentNumber, document);
                        if (updatedEkzMkCount == 0)
                        {
                            throw new InvalidOperationException("Строки EKZMK для выбранного ПСВ не найдены.");
                        }
                    }

                    var insertedEkzMkCount = 0;
                    var skippedDuplicateCount = 0;
                    var skippedWithoutTemplateCount = 0;
                    var duplicateKeys = LoadDocumentDuplicateKeys(connection, transaction, normalizedNumber);
                    var verificationTypeId = 0;
                    var verificationTypeLoaded = false;

                    foreach (var pendingLine in distinctPendingLines)
                    {
                        var duplicateKey = pendingLine.DuplicateKey;
                        if (string.IsNullOrWhiteSpace(duplicateKey))
                        {
                            throw new InvalidOperationException("Не удалось определить ключ дубликата для строки ПСВ.");
                        }

                        var instrumentId = pendingLine.InstrumentId;
                        var instrumentIdentity = new InstrumentIdentityInfo
                        {
                            DuplicateKey = duplicateKey
                        };
                        if (string.IsNullOrWhiteSpace(duplicateKey))
                        {
                            throw new InvalidOperationException(string.Format("Прибор IDEKZ={0} не найден.", instrumentId));
                        }

                        if (duplicateKeys.Contains(instrumentIdentity.DuplicateKey))
                        {
                            skippedDuplicateCount++;
                            continue;
                        }

                        instrumentId = ResolveInstrumentIdForPendingLine(connection, transaction, document, pendingLine);
                        var template = LoadTemplate(connection, transaction, instrumentId);
                        if (template == null)
                        {
                            skippedWithoutTemplateCount++;
                            continue;
                        }

                        if (!verificationTypeLoaded)
                        {
                            verificationTypeId = LoadVerificationTypeId(connection, transaction);
                            verificationTypeLoaded = true;
                        }

                        var cardId = InsertEkzMk(connection, transaction, verificationTypeId, document, normalizedNumber, instrumentId, template, pendingLine);
                        if (!string.IsNullOrWhiteSpace(pendingLine.VerificationDocumentNumber))
                        {
                            if (!pendingLine.VerificationDocumentFormId.HasValue || !pendingLine.VerificationDocumentLinkTypeId.HasValue)
                            {
                                throw new InvalidOperationException("Для новой строки EKZMK не определена форма документа поверки.");
                            }

                            InsertVerificationDocument(
                                connection,
                                transaction,
                                cardId,
                                pendingLine.VerificationDocumentLinkTypeId.GetValueOrDefault(),
                                pendingLine.VerificationDocumentFormId.GetValueOrDefault(),
                                pendingLine.VerificationDocumentNumber,
                                pendingLine.VerificationDocumentDate ?? pendingLine.VerificationPerformedOn ?? document.AcceptedOn);
                        }

                        duplicateKeys.Add(instrumentIdentity.DuplicateKey);
                        insertedEkzMkCount++;
                    }

                    if (string.IsNullOrWhiteSpace(currentDocumentNumber) && insertedEkzMkCount == 0)
                    {
                        throw new InvalidOperationException("Черновик нельзя сохранить без строк EKZMK.");
                    }

                    transaction.Commit();

                    return new DocumentSaveResult
                    {
                        DocumentNumber = normalizedNumber,
                        InsertedEkzMkCount = insertedEkzMkCount,
                        SkippedDuplicateCount = skippedDuplicateCount,
                        SkippedWithoutTemplateCount = skippedWithoutTemplateCount,
                        UpdatedEkzMkCount = updatedEkzMkCount
                    };
                }
            }
        }

        public DocumentDeleteResult DeleteDocument(string documentNumber)
        {
            if (string.IsNullOrWhiteSpace(documentNumber))
            {
                throw new InvalidOperationException("Не выбран документ для удаления.");
            }

            using (var connection = CreateConnection())
            {
                connection.Open();

                using (var transaction = connection.BeginTransaction())
                {
                    var blockers = LoadDeleteBlockers(connection, transaction, documentNumber);
                    if (blockers.Count > 0)
                    {
                        var details = string.Join(", ", blockers.Select(delegate(DeleteBlockerInfo blocker)
                        {
                            return string.Format("{0}: {1}", blocker.TableName, blocker.RowCount);
                        }));

                        throw new InvalidOperationException(
                            string.Format(
                                "ПСВ не может быть удалён, потому что на его строки EKZMK есть ссылки в таблицах: {0}. По вашему правилу приложение не трогает эти таблицы.",
                                details));
                    }

                    var deletedEkzMkFctvlCount = DeleteDocumentEkzMkFctvl(connection, transaction, documentNumber);
                    var deletedDmsCount = DeleteDocumentDms(connection, transaction, documentNumber);
                    var deletedEkzMkCount = DeleteDocumentEkzMk(connection, transaction, documentNumber);

                    if (deletedEkzMkCount == 0)
                    {
                        throw new InvalidOperationException("Строки EKZMK для выбранного ПСВ не найдены.");
                    }

                    transaction.Commit();

                    return new DocumentDeleteResult
                    {
                        DeletedEkzMkFctvlCount = deletedEkzMkFctvlCount,
                        DeletedDmsCount = deletedDmsCount,
                        DeletedEkzMkCount = deletedEkzMkCount
                    };
                }
            }
        }

        public DocumentGroupDeleteResult DeleteDocumentGroups(string documentNumber, IEnumerable<int> cardIds)
        {
            if (string.IsNullOrWhiteSpace(documentNumber))
            {
                throw new InvalidOperationException("РќРµ РІС‹Р±СЂР°РЅ РґРѕРєСѓРјРµРЅС‚ РґР»СЏ СѓРґР°Р»РµРЅРёСЏ РіСЂСѓРїРї.");
            }

            var normalizedCardIds = NormalizeCardIds(
                cardIds,
                "Р’ СЃРїРёСЃРєРµ СѓРґР°Р»СЏРµРјС‹С… СЃС‚СЂРѕРє РµСЃС‚СЊ РЅРµРєРѕСЂСЂРµРєС‚РЅС‹Р№ IDEKZMK.",
                "РќРµ РІС‹Р±СЂР°РЅРѕ РЅРё РѕРґРЅРѕР№ СЃС‚СЂРѕРєРё EKZMK РґР»СЏ СѓРґР°Р»РµРЅРёСЏ.");

            using (var connection = CreateConnection())
            {
                connection.Open();

                using (var transaction = connection.BeginTransaction())
                {
                    var blockers = LoadDeleteBlockers(connection, transaction, normalizedCardIds);
                    if (blockers.Count > 0)
                    {
                        var details = string.Join(", ", blockers.Select(delegate(DeleteBlockerInfo blocker)
                        {
                            return string.Format("{0}: {1}", blocker.TableName, blocker.RowCount);
                        }));

                        throw new InvalidOperationException(
                            string.Format(
                                "Выбранные строки EKZMK не могут быть удалены, потому что на них есть ссылки в таблицах: {0}. По вашему правилу приложение не трогает эти таблицы.",
                                details));
                    }

                    var deletedEkzMkFctvlCount = 0;
                    var deletedDmsCount = 0;
                    var deletedEkzMkCount = 0;

                    foreach (var cardId in normalizedCardIds)
                    {
                        deletedEkzMkFctvlCount += DeleteLineEkzMkFctvl(connection, transaction, documentNumber, cardId);
                        deletedDmsCount += DeleteLineDms(connection, transaction, documentNumber, cardId);
                        deletedEkzMkCount += DeleteLineEkzMk(connection, transaction, documentNumber, cardId);
                    }

                    if (deletedEkzMkCount == 0)
                    {
                        throw new InvalidOperationException("Строки EKZMK для выбранных групп не найдены.");
                    }

                    transaction.Commit();

                    return new DocumentGroupDeleteResult
                    {
                        DeletedEkzMkFctvlCount = deletedEkzMkFctvlCount,
                        DeletedEkzMkCount = deletedEkzMkCount,
                        DeletedDmsCount = deletedDmsCount
                    };
                }
            }
        }

        public Task<IReadOnlyList<CustomerReference>> LoadCustomersAsync(CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run<IReadOnlyList<CustomerReference>>(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                return LoadCustomers();
            }, cancellationToken);
        }

        public Task<IReadOnlyList<PersonReference>> LoadVerifiersAsync(CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run<IReadOnlyList<PersonReference>>(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                return LoadVerifiers();
            }, cancellationToken);
        }

        public Task<IReadOnlyList<DocumentFormReference>> LoadVerificationDocumentFormsAsync(bool isPassed, CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run<IReadOnlyList<DocumentFormReference>>(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                return LoadVerificationDocumentForms(isPassed);
            }, cancellationToken);
        }

        public Task<IReadOnlyList<PsvDocumentSummary>> LoadDocumentsAsync(bool loadClosedDocumentsForCurrentYear, CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run<IReadOnlyList<PsvDocumentSummary>>(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                return LoadDocuments(loadClosedDocumentsForCurrentYear);
            }, cancellationToken);
        }

        public Task<IReadOnlyList<PsvDocumentLine>> LoadDocumentLinesAsync(string documentNumber, CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run<IReadOnlyList<PsvDocumentLine>>(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                return LoadDocumentLines(documentNumber);
            }, cancellationToken);
        }

        public Task<IReadOnlyList<AvailableInstrumentItem>> LoadCustomerInstrumentsAsync(int customerId, CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run<IReadOnlyList<AvailableInstrumentItem>>(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                return LoadCustomerInstruments(customerId);
            }, cancellationToken);
        }

        public Task<IReadOnlyList<AvailableInstrumentItem>> LoadInstrumentTypesAsync(CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run<IReadOnlyList<AvailableInstrumentItem>>(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                return LoadInstrumentTypes();
            }, cancellationToken);
        }

        public Task ResetLineVerificationAsync(int cardId, CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                ResetLineVerification(cardId);
            }, cancellationToken);
        }

        public Task SaveLineVerificationAsync(int cardId, VerificationEditResult result, CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                SaveLineVerification(cardId, result);
            }, cancellationToken);
        }

        public Task ResetLineVerificationAsync(IEnumerable<int> cardIds, CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                ResetLineVerification(cardIds);
            }, cancellationToken);
        }

        public Task SaveLineVerificationAsync(IEnumerable<int> cardIds, VerificationEditResult result, CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                SaveLineVerification(cardIds, result);
            }, cancellationToken);
        }

        public Task<DocumentDeleteResult> DeleteDocumentAsync(string documentNumber, CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                return DeleteDocument(documentNumber);
            }, cancellationToken);
        }

        public Task<DocumentGroupDeleteResult> DeleteDocumentGroupsAsync(string documentNumber, IEnumerable<int> cardIds, CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.Run(delegate
            {
                cancellationToken.ThrowIfCancellationRequested();
                return DeleteDocumentGroups(documentNumber, cardIds);
            }, cancellationToken);
        }

        private static string GetPendingLineSaveKey(PsvDocumentLine line)
        {
            if (line == null)
            {
                return string.Empty;
            }

            return line.InstrumentId > 0
                ? string.Format("ID:{0}", line.InstrumentId)
                : string.Format("NEW:{0}", line.DuplicateKey ?? string.Empty);
        }

        private static bool IsPendingLineReadyForSave(PsvDocumentLine line)
        {
            return line != null
                   && (line.InstrumentId > 0
                       || (line.TypeSizeId > 0 && !string.IsNullOrWhiteSpace(line.SerialNumber)));
        }

        private static int ResolveInstrumentIdForPendingLine(SqlConnection connection, SqlTransaction transaction, DocumentEditorResult document, PsvDocumentLine pendingLine)
        {
            if (pendingLine == null)
            {
                throw new InvalidOperationException("Строка ПСВ для сохранения не задана.");
            }

            if (pendingLine.InstrumentId > 0)
            {
                return pendingLine.InstrumentId;
            }

            if (pendingLine.TypeSizeId <= 0)
            {
                throw new InvalidOperationException("Для новой строки ПСВ не указан типоразмер прибора.");
            }

            var serialNumber = string.IsNullOrWhiteSpace(pendingLine.SerialNumber) ? null : pendingLine.SerialNumber.Trim();
            if (serialNumber == null)
            {
                throw new InvalidOperationException("Для новой строки ПСВ не указан заводской номер.");
            }

            if (!document.CustomerId.HasValue)
            {
                throw new InvalidOperationException("Для добавления прибора по типу должен быть выбран заказчик ПСВ.");
            }

            var existingInstrumentId = FindExistingInstrumentId(connection, transaction, pendingLine.TypeSizeId, document.CustomerId.Value, serialNumber);
            if (existingInstrumentId.HasValue)
            {
                return existingInstrumentId.Value;
            }

            return InsertInstrument(connection, transaction, pendingLine.TypeSizeId, document.CustomerId.Value, serialNumber);
        }

        private static List<int> NormalizeCardIds(IEnumerable<int> cardIds, string invalidCardMessage, string emptyListMessage)
        {
            if (cardIds == null)
            {
                throw new InvalidOperationException(emptyListMessage);
            }

            var normalizedCardIds = cardIds.Distinct().ToList();
            if (normalizedCardIds.Count == 0)
            {
                throw new InvalidOperationException(emptyListMessage);
            }

            if (normalizedCardIds.Any(delegate(int cardId) { return cardId <= 0; }))
            {
                throw new InvalidOperationException(invalidCardMessage);
            }

            return normalizedCardIds;
        }

        private static int? FindExistingInstrumentId(SqlConnection connection, SqlTransaction transaction, int typeSizeId, int customerId, string serialNumber)
        {
            const string sql = @"
SELECT TOP (1) z.IDEKZ
FROM dbo.EKZ z
WHERE z.IDTPRZ = @TypeSizeId
  AND z.IDFRPDV = @CustomerId
  AND z.NNZV = @SerialNumber
  AND ISNULL(z.IsDeleted, 0) = 0
ORDER BY z.IDEKZ DESC;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.CommandTimeout = 60;
                command.Parameters.Add("@TypeSizeId", SqlDbType.Int).Value = typeSizeId;
                command.Parameters.Add("@CustomerId", SqlDbType.Int).Value = customerId;
                command.Parameters.Add("@SerialNumber", SqlDbType.VarChar, 30).Value = serialNumber;

                var result = command.ExecuteScalar();
                return result == null || result == DBNull.Value ? (int?)null : Convert.ToInt32(result);
            }
        }

        private static int InsertInstrument(SqlConnection connection, SqlTransaction transaction, int typeSizeId, int customerId, string serialNumber)
        {
            const string sql = @"
INSERT INTO dbo.EKZ
(
    IDTPRZ,
    IDFRPDV,
    KLSIPR,
    NNZV,
    GUIDEKZ,
    IsDeleted
)
VALUES
(
    @TypeSizeId,
    @CustomerId,
    @Klsipr,
    @SerialNumber,
    @GuidEkz,
    0
);

SELECT CAST(SCOPE_IDENTITY() AS int);";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.CommandTimeout = 60;
                command.Parameters.Add("@TypeSizeId", SqlDbType.Int).Value = typeSizeId;
                command.Parameters.Add("@CustomerId", SqlDbType.Int).Value = customerId;
                command.Parameters.Add("@Klsipr", SqlDbType.Int).Value = 1;
                command.Parameters.Add("@SerialNumber", SqlDbType.VarChar, 30).Value = serialNumber;
                command.Parameters.Add("@GuidEkz", SqlDbType.UniqueIdentifier).Value = Guid.NewGuid();
                return Convert.ToInt32(command.ExecuteScalar());
            }
        }

        private static string BuildOpenDocumentConflictMessage(IEnumerable<OpenDocumentConflictInfo> conflicts)
        {
            var materializedConflicts = conflicts == null
                ? new List<OpenDocumentConflictInfo>()
                : conflicts
                    .Where(delegate(OpenDocumentConflictInfo conflict) { return conflict != null; })
                    .GroupBy(delegate(OpenDocumentConflictInfo conflict)
                    {
                        return string.Format(
                            "{0}|{1}|{2}",
                            conflict.DocumentNumber ?? string.Empty,
                            conflict.TypeSizeId,
                            conflict.SerialNumber ?? string.Empty);
                    }, StringComparer.OrdinalIgnoreCase)
                    .Select(delegate(IGrouping<string, OpenDocumentConflictInfo> group) { return group.First(); })
                    .OrderBy(delegate(OpenDocumentConflictInfo conflict) { return conflict.SerialNumber ?? string.Empty; })
                    .ThenBy(delegate(OpenDocumentConflictInfo conflict) { return conflict.DocumentNumber ?? string.Empty; })
                    .ToList();

            if (materializedConflicts.Count == 0)
            {
                return "Прибор уже находится в другой открытой ПСВ этого заказчика.";
            }

            var preview = materializedConflicts
                .Take(5)
                .Select(delegate(OpenDocumentConflictInfo conflict)
                {
                    return string.Format("зав. № {0} -> {1}", conflict.SerialNumber, conflict.DocumentNumber);
                })
                .ToList();

            var suffix = materializedConflicts.Count > preview.Count
                ? string.Format(" Еще конфликтов: {0}.", materializedConflicts.Count - preview.Count)
                : string.Empty;

            return string.Format(
                "Приборы уже находятся в других открытых ПСВ этого заказчика: {0}.{1}",
                string.Join("; ", preview.ToArray()),
                suffix);
        }

        private static List<OpenDocumentConflictInfo> LoadOpenDocumentConflicts(
            SqlConnection connection,
            SqlTransaction transaction,
            int customerId,
            string currentDocumentNumber,
            IEnumerable<PsvDocumentLine> candidateLines)
        {
            var normalizedCandidates = candidateLines == null
                ? new List<PsvDocumentLine>()
                : candidateLines
                    .Where(delegate(PsvDocumentLine line)
                    {
                        return line != null
                               && line.TypeSizeId > 0
                               && !string.IsNullOrWhiteSpace(line.SerialNumber);
                    })
                    .GroupBy(delegate(PsvDocumentLine line) { return line.OpenDocumentConflictKey; }, StringComparer.OrdinalIgnoreCase)
                    .Select(delegate(IGrouping<string, PsvDocumentLine> group) { return group.First(); })
                    .ToList();

            if (customerId <= 0 || normalizedCandidates.Count == 0)
            {
                return new List<OpenDocumentConflictInfo>();
            }

            const string sql = @"
WITH ActiveDocuments AS
(
    SELECT m.NNZVPV
    FROM dbo.EKZMK m
    WHERE NULLIF(LTRIM(RTRIM(m.NNZVPV)), N'') IS NOT NULL
    GROUP BY m.NNZVPV
    HAVING MAX(m.DTVDM) IS NULL
)
SELECT DISTINCT
    m.NNZVPV AS DocumentNumber,
    z.IDTPRZ AS TypeSizeId,
    LTRIM(RTRIM(z.NNZV)) AS SerialNumber
FROM ActiveDocuments activeDocuments
JOIN dbo.EKZMK m ON m.NNZVPV = activeDocuments.NNZVPV
JOIN dbo.EKZ z ON z.IDEKZ = m.IDEKZ
WHERE z.IDFRPDV = @CustomerId
  AND z.IDTPRZ = @TypeSizeId
  AND LTRIM(RTRIM(z.NNZV)) = @SerialNumber
  AND (@CurrentDocumentNumber = N'' OR m.NNZVPV <> @CurrentDocumentNumber)
ORDER BY m.NNZVPV;";

            var conflicts = new List<OpenDocumentConflictInfo>();

            foreach (var candidateLine in normalizedCandidates)
            {
                using (var command = new SqlCommand(sql, connection, transaction))
                {
                    command.CommandTimeout = 60;
                    command.Parameters.Add("@CustomerId", SqlDbType.Int).Value = customerId;
                    command.Parameters.Add("@TypeSizeId", SqlDbType.Int).Value = candidateLine.TypeSizeId;
                    command.Parameters.Add("@SerialNumber", SqlDbType.VarChar, 30).Value = candidateLine.SerialNumber.Trim();
                    command.Parameters.Add("@CurrentDocumentNumber", SqlDbType.NVarChar, 60).Value = currentDocumentNumber ?? string.Empty;

                    using (var reader = command.ExecuteReader())
                    {
                        while (reader.Read())
                        {
                            conflicts.Add(new OpenDocumentConflictInfo
                            {
                                DocumentNumber = GetString(reader, "DocumentNumber"),
                                TypeSizeId = GetInt32(reader, "TypeSizeId"),
                                SerialNumber = GetString(reader, "SerialNumber")
                            });
                        }
                    }
                }
            }

            return conflicts
                .GroupBy(delegate(OpenDocumentConflictInfo conflict)
                {
                    return string.Format(
                        "{0}|{1}|{2}",
                        conflict.DocumentNumber ?? string.Empty,
                        conflict.TypeSizeId,
                        conflict.SerialNumber ?? string.Empty);
                }, StringComparer.OrdinalIgnoreCase)
                .Select(delegate(IGrouping<string, OpenDocumentConflictInfo> group) { return group.First(); })
                .OrderBy(delegate(OpenDocumentConflictInfo conflict) { return conflict.SerialNumber ?? string.Empty; })
                .ThenBy(delegate(OpenDocumentConflictInfo conflict) { return conflict.DocumentNumber ?? string.Empty; })
                .ToList();
        }

        private static SqlConnection CreateConnection()
        {
            var connectionString = ConfigurationManager.ConnectionStrings["AsumsSql"];
            if (connectionString == null || string.IsNullOrWhiteSpace(connectionString.ConnectionString))
            {
                throw new InvalidOperationException("В App.config не найдено подключение AsumsSql.");
            }

            return new SqlConnection(connectionString.ConnectionString);
        }

        private static void ClearLineVerificationCore(SqlConnection connection, SqlTransaction transaction, int cardId)
        {
            const string sql = @"
UPDATE dbo.EKZMK
SET IDPRSN = NULL,
    NNNKL = NULL,
    GDN = NULL,
    DTMKFK = NULL,
    PRCHNPGDN = NULL
WHERE IDEKZMK = @CardId;

SELECT @@ROWCOUNT;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.CommandTimeout = 60;
                command.Parameters.Add("@CardId", SqlDbType.Int).Value = cardId;
                if (Convert.ToInt32(command.ExecuteScalar()) == 0)
                {
                    throw new InvalidOperationException("Строка EKZMK для изменения результата поверки не найдена.");
                }
            }
        }

        private static void DeleteVerificationDocuments(SqlConnection connection, SqlTransaction transaction, int cardId)
        {
            const string sql = @"
DELETE d
FROM dbo.DMS d
JOIN dbo.VDODVDD vdd ON vdd.IDVDODVDD = d.IDVDODVDD
JOIN dbo.FRDMS frdms ON frdms.IDFRDMS = d.IDFRDMS
JOIN dbo.SPVDD spvdd ON spvdd.IDSPVDD = frdms.IDSPVDD
WHERE d.IDOD = @CardId
  AND vdd.IDSPVDOD = 2
  AND spvdd.IDSPVDD IN (2, 6, 8);";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.CommandTimeout = 60;
                command.Parameters.Add("@CardId", SqlDbType.Int).Value = cardId;
                command.ExecuteNonQuery();
            }
        }

        private static void InsertVerificationDocument(
            SqlConnection connection,
            SqlTransaction transaction,
            int cardId,
            int linkTypeId,
            int formId,
            string documentNumber,
            DateTime documentDate)
        {
            const string sql = @"
INSERT INTO dbo.DMS
(
    IDOD,
    IDVDODVDD,
    IDFRDMS,
    NND,
    DTD,
    PTTXDMS,
    GUIDDMS
)
VALUES
(
    @CardId,
    @LinkTypeId,
    @FormId,
    @DocumentNumber,
    @DocumentDate,
    NULL,
    @Guid
);";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.CommandTimeout = 60;
                command.Parameters.Add("@CardId", SqlDbType.Int).Value = cardId;
                command.Parameters.Add("@LinkTypeId", SqlDbType.Int).Value = linkTypeId;
                command.Parameters.Add("@FormId", SqlDbType.Int).Value = formId;
                command.Parameters.Add("@DocumentNumber", SqlDbType.NVarChar, 60).Value = documentNumber.Trim();
                command.Parameters.Add("@DocumentDate", SqlDbType.DateTime).Value = documentDate;
                command.Parameters.Add("@Guid", SqlDbType.UniqueIdentifier).Value = Guid.NewGuid();
                command.ExecuteNonQuery();
            }
        }

        private static void SaveLineVerificationCore(SqlConnection connection, SqlTransaction transaction, int cardId, VerificationEditResult result)
        {
            const string sql = @"
UPDATE dbo.EKZMK
SET IDPRSN = @VerifierId,
    NNNKL = @StickerNumber,
    GDN = @IsPassed,
    DTMKFK = @VerificationDate,
    PRCHNPGDN = @RejectionReason
WHERE IDEKZMK = @CardId;

SELECT @@ROWCOUNT;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.CommandTimeout = 60;
                command.Parameters.Add("@CardId", SqlDbType.Int).Value = cardId;
                command.Parameters.Add("@VerifierId", SqlDbType.Int).Value = result.VerifierId;
                command.Parameters.Add("@StickerNumber", SqlDbType.NVarChar, 30).Value = result.IsPassed && !string.IsNullOrWhiteSpace(result.StickerNumber)
                    ? (object)result.StickerNumber.Trim()
                    : DBNull.Value;
                command.Parameters.Add("@IsPassed", SqlDbType.Bit).Value = result.IsPassed;
                command.Parameters.Add("@VerificationDate", SqlDbType.DateTime).Value = result.VerificationDate;
                command.Parameters.Add("@RejectionReason", SqlDbType.NVarChar, 1024).Value = !result.IsPassed && !string.IsNullOrWhiteSpace(result.RejectionReason)
                    ? (object)result.RejectionReason.Trim()
                    : DBNull.Value;

                if (Convert.ToInt32(command.ExecuteScalar()) == 0)
                {
                    throw new InvalidOperationException("Строка EKZMK для сохранения результата поверки не найдена.");
                }
            }
        }

        private static int DeleteLineDms(SqlConnection connection, SqlTransaction transaction, string documentNumber, int cardId)
        {
            const string sql = @"
DELETE d
FROM dbo.DMS d
JOIN dbo.VDODVDD vdd ON vdd.IDVDODVDD = d.IDVDODVDD
JOIN dbo.EKZMK m ON m.IDEKZMK = d.IDOD
WHERE m.NNZVPV = @DocumentNumber
  AND m.IDEKZMK = @CardId
  AND vdd.IDSPVDOD = 2;

SELECT @@ROWCOUNT;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@DocumentNumber", SqlDbType.NVarChar, 60).Value = documentNumber;
                command.Parameters.Add("@CardId", SqlDbType.Int).Value = cardId;
                return Convert.ToInt32(command.ExecuteScalar());
            }
        }

        private static int DeleteLineEkzMk(SqlConnection connection, SqlTransaction transaction, string documentNumber, int cardId)
        {
            const string sql = @"
DELETE FROM dbo.EKZMK
WHERE NNZVPV = @DocumentNumber
  AND IDEKZMK = @CardId;

SELECT @@ROWCOUNT;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@DocumentNumber", SqlDbType.NVarChar, 60).Value = documentNumber;
                command.Parameters.Add("@CardId", SqlDbType.Int).Value = cardId;
                return Convert.ToInt32(command.ExecuteScalar());
            }
        }

        private static int DeleteLineEkzMkFctvl(SqlConnection connection, SqlTransaction transaction, string documentNumber, int cardId)
        {
            const string sql = @"
DELETE child
FROM dbo.EKZMKFCTVL child
JOIN dbo.EKZMK parent ON parent.IDEKZMK = child.IDEKZMK
WHERE parent.NNZVPV = @DocumentNumber
  AND parent.IDEKZMK = @CardId;

SELECT @@ROWCOUNT;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@DocumentNumber", SqlDbType.NVarChar, 60).Value = documentNumber;
                command.Parameters.Add("@CardId", SqlDbType.Int).Value = cardId;
                return Convert.ToInt32(command.ExecuteScalar());
            }
        }

        private static int DeleteDocumentDms(SqlConnection connection, SqlTransaction transaction, string documentNumber)
        {
            const string sql = @"
DELETE d
FROM dbo.DMS d
JOIN dbo.VDODVDD vdd ON vdd.IDVDODVDD = d.IDVDODVDD
JOIN dbo.EKZMK m ON m.IDEKZMK = d.IDOD
WHERE m.NNZVPV = @DocumentNumber
  AND vdd.IDSPVDOD = 2;

SELECT @@ROWCOUNT;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@DocumentNumber", SqlDbType.NVarChar, 60).Value = documentNumber;
                return Convert.ToInt32(command.ExecuteScalar());
            }
        }

        private static int DeleteDocumentEkzMk(SqlConnection connection, SqlTransaction transaction, string documentNumber)
        {
            const string sql = @"
DELETE FROM dbo.EKZMK
WHERE NNZVPV = @DocumentNumber;

SELECT @@ROWCOUNT;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@DocumentNumber", SqlDbType.NVarChar, 60).Value = documentNumber;
                return Convert.ToInt32(command.ExecuteScalar());
            }
        }

        private static int DeleteDocumentEkzMkFctvl(SqlConnection connection, SqlTransaction transaction, string documentNumber)
        {
            const string sql = @"
DELETE child
FROM dbo.EKZMKFCTVL child
JOIN dbo.EKZMK parent ON parent.IDEKZMK = child.IDEKZMK
WHERE parent.NNZVPV = @DocumentNumber;

SELECT @@ROWCOUNT;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@DocumentNumber", SqlDbType.NVarChar, 60).Value = documentNumber;
                return Convert.ToInt32(command.ExecuteScalar());
            }
        }

        private static bool DocumentNumberExists(SqlConnection connection, SqlTransaction transaction, string documentNumber, string excludeDocumentNumber)
        {
            const string sql = @"
SELECT COUNT(1)
FROM dbo.EKZMK
WHERE NNZVPV = @DocumentNumber
  AND (@ExcludeDocumentNumber = N'' OR NNZVPV <> @ExcludeDocumentNumber);";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@DocumentNumber", SqlDbType.NVarChar, 60).Value = documentNumber;
                command.Parameters.Add("@ExcludeDocumentNumber", SqlDbType.NVarChar, 60).Value = excludeDocumentNumber ?? string.Empty;
                return Convert.ToInt32(command.ExecuteScalar()) > 0;
            }
        }

        private static bool GetBoolean(IDataRecord record, string columnName)
        {
            return Convert.ToBoolean(record[columnName]);
        }

        private static int GetInt32(IDataRecord record, string columnName)
        {
            return Convert.ToInt32(record[columnName]);
        }

        private static string GetString(IDataRecord record, string columnName)
        {
            return record[columnName] == DBNull.Value ? string.Empty : Convert.ToString(record[columnName]);
        }

        private static DateTime? GetNullableDateTime(IDataRecord record, string columnName)
        {
            return record[columnName] == DBNull.Value ? (DateTime?)null : Convert.ToDateTime(record[columnName]);
        }

        private static decimal? GetNullableDecimal(IDataRecord record, string columnName)
        {
            return record[columnName] == DBNull.Value ? (decimal?)null : Convert.ToDecimal(record[columnName]);
        }

        private static bool? GetNullableBoolean(IDataRecord record, string columnName)
        {
            return record[columnName] == DBNull.Value ? (bool?)null : Convert.ToBoolean(record[columnName]);
        }

        private static int? GetNullableInt32(IDataRecord record, string columnName)
        {
            return record[columnName] == DBNull.Value ? (int?)null : Convert.ToInt32(record[columnName]);
        }

        private static int InsertEkzMk(
            SqlConnection connection,
            SqlTransaction transaction,
            int verificationTypeId,
            DocumentEditorResult document,
            string normalizedNumber,
            int instrumentId,
            EkzMkTemplate template,
            PsvDocumentLine pendingLine)
        {
            const string sql = @"
INSERT INTO dbo.EKZMK
(
    IDEKZ,
    IDSPMU,
    IDGRSI,
    IDKSPRL,
    IDSPVDMK,
    IDSPVDMC,
    IDFRPD,
    IDSPMPOB,
    IDPRSN,
    IDSPKMMK,
    IDEKZRM,
    IDSPVDKL,
    IDPRSNVD,
    IDEKZTO,
    IDEKZOT,
    NNZVPV,
    SHFKL,
    NNNKL,
    PRMK,
    DTMKFK,
    DTMKPL,
    DTPRM,
    DTVDM,
    GDN,
    PZMC,
    STMK,
    STMKDP,
    NCSRMK,
    idprsnsd,
    idprsnpr,
    idprsnvy,
    idsptsmp,
    idspssmp,
    GUIDEKZMK,
    DSEKZMK,
    DTOTM,
    DTVZM,
    IDPRSNOTM,
    IDPRSNVZM,
    NRVRMNDmp,
    NRVRMmp,
    VRMKFK,
    IDKSP,
    DPZNmp,
    HRTCmp,
    IDSPVDSBMK,
    PRCHNPGDN,
    IDEKZETL
)
VALUES
(
    @IDEKZ,
    @IDSPMU,
    @IDGRSI,
    @IDKSPRL,
    @IDSPVDMK,
    @IDSPVDMC,
    @IDFRPD,
    @IDSPMPOB,
    @IDPRSN,
    @IDSPKMMK,
    NULL,
    @IDSPVDKL,
    @IDPRSNVD,
    NULL,
    NULL,
    @NNZVPV,
    NULL,
    @NNNKL,
    @PRMK,
    @DTMKFK,
    NULL,
    @DTPRM,
    @DTVDM,
    @GDN,
    NULL,
    @STMK,
    @STMKDP,
    @NCSRMK,
    @idprsnsd,
    @idprsnpr,
    @idprsnvy,
    @idsptsmp,
    @idspssmp,
    @GUIDEKZMK,
    NULL,
    NULL,
    NULL,
    NULL,
    NULL,
    @NRVRMNDmp,
    @NRVRMmp,
    @VRMKFK,
    @IDKSP,
    @DPZNmp,
    @HRTCmp,
    @IDSPVDSBMK,
    @PRCHNPGDN,
    @IDEKZETL
);

SELECT CAST(SCOPE_IDENTITY() AS int);";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.CommandTimeout = 60;

                command.Parameters.Add("@IDEKZ", SqlDbType.Int).Value = instrumentId;
                command.Parameters.Add("@IDSPMU", SqlDbType.Int).Value = (object)template.IdSpmu ?? DBNull.Value;
                command.Parameters.Add("@IDGRSI", SqlDbType.Int).Value = (object)template.IdGrsi ?? DBNull.Value;
                command.Parameters.Add("@IDKSPRL", SqlDbType.Int).Value = (object)template.IdKsprl ?? DBNull.Value;
                command.Parameters.Add("@IDSPVDMK", SqlDbType.Int).Value = verificationTypeId;
                command.Parameters.Add("@IDSPVDMC", SqlDbType.Int).Value = (object)template.IdSpvdmc ?? DBNull.Value;
                command.Parameters.Add("@IDFRPD", SqlDbType.Int).Value = template.IdFrpd;
                command.Parameters.Add("@IDSPMPOB", SqlDbType.Int).Value = (object)template.IdSpmpob ?? DBNull.Value;
                command.Parameters.Add("@IDPRSN", SqlDbType.Int).Value = pendingLine == null ? DBNull.Value : (object)pendingLine.VerifierId ?? DBNull.Value;
                command.Parameters.Add("@IDSPKMMK", SqlDbType.Int).Value = (object)template.IdSpkmmk ?? DBNull.Value;
                command.Parameters.Add("@IDSPVDKL", SqlDbType.Int).Value = (object)template.IdSpvdkl ?? DBNull.Value;
                command.Parameters.Add("@IDPRSNVD", SqlDbType.Int).Value = (object)template.IdPrsnvd ?? DBNull.Value;
                command.Parameters.Add("@NNZVPV", SqlDbType.NVarChar, 60).Value = normalizedNumber;
                command.Parameters.Add("@NNNKL", SqlDbType.NVarChar, 30).Value = pendingLine != null && pendingLine.IsPassed == true
                    ? (object)(string.IsNullOrWhiteSpace(pendingLine.StickerNumber) ? null : pendingLine.StickerNumber.Trim())
                    : DBNull.Value;
                command.Parameters.Add("@PRMK", SqlDbType.Int).Value = template.Prmk;
                command.Parameters.Add("@DTMKFK", SqlDbType.DateTime).Value = pendingLine == null
                    ? DBNull.Value
                    : (object)(pendingLine.VerificationPerformedOn ?? pendingLine.VerificationDocumentDate) ?? DBNull.Value;
                command.Parameters.Add("@DTPRM", SqlDbType.DateTime).Value = document.AcceptedOn;
                command.Parameters.Add("@DTVDM", SqlDbType.DateTime).Value = (object)document.IssuedOn ?? DBNull.Value;
                command.Parameters.Add("@GDN", SqlDbType.Bit).Value = pendingLine == null ? DBNull.Value : (object)pendingLine.IsPassed ?? DBNull.Value;
                command.Parameters.Add("@STMK", SqlDbType.Money).Value = (object)template.Stmk ?? DBNull.Value;
                command.Parameters.Add("@STMKDP", SqlDbType.Money).Value = (object)template.Stmkdp ?? DBNull.Value;
                command.Parameters.Add("@NCSRMK", SqlDbType.Money).Value = (object)template.NcSrmk ?? DBNull.Value;
                command.Parameters.Add("@idprsnsd", SqlDbType.Int).Value = (object)template.IdPrsnsd ?? DBNull.Value;
                command.Parameters.Add("@idprsnpr", SqlDbType.Int).Value = (object)template.IdPrsnpr ?? DBNull.Value;
                command.Parameters.Add("@idprsnvy", SqlDbType.Int).Value = (object)template.IdPrsnvy ?? DBNull.Value;
                command.Parameters.Add("@idsptsmp", SqlDbType.Int).Value = (object)template.IdSptsmp ?? DBNull.Value;
                command.Parameters.Add("@idspssmp", SqlDbType.Int).Value = (object)template.IdSpssmp ?? DBNull.Value;
                command.Parameters.Add("@GUIDEKZMK", SqlDbType.UniqueIdentifier).Value = Guid.NewGuid();
                command.Parameters.Add("@NRVRMNDmp", SqlDbType.Decimal).Value = (object)template.Nrvrmndmp ?? DBNull.Value;
                command.Parameters["@NRVRMNDmp"].Precision = 10;
                command.Parameters["@NRVRMNDmp"].Scale = 2;
                command.Parameters.Add("@NRVRMmp", SqlDbType.Decimal).Value = (object)template.Nrvrmmp ?? DBNull.Value;
                command.Parameters["@NRVRMmp"].Precision = 10;
                command.Parameters["@NRVRMmp"].Scale = 2;
                command.Parameters.Add("@VRMKFK", SqlDbType.Decimal).Value = (object)template.Vrmkfk ?? DBNull.Value;
                command.Parameters["@VRMKFK"].Precision = 10;
                command.Parameters["@VRMKFK"].Scale = 2;
                command.Parameters.Add("@IDKSP", SqlDbType.Int).Value = (object)template.IdKsp ?? DBNull.Value;
                command.Parameters.Add("@DPZNmp", SqlDbType.NVarChar, 100).Value = (object)template.Dpznmp ?? DBNull.Value;
                command.Parameters.Add("@HRTCmp", SqlDbType.NVarChar, 80).Value = (object)template.Hrtcmp ?? DBNull.Value;
                command.Parameters.Add("@IDSPVDSBMK", SqlDbType.Int).Value = (object)template.IdSpvdsbmk ?? DBNull.Value;
                command.Parameters.Add("@PRCHNPGDN", SqlDbType.NVarChar, 1024).Value = pendingLine != null && pendingLine.IsPassed == false
                    ? (object)(string.IsNullOrWhiteSpace(pendingLine.RejectionReason) ? null : pendingLine.RejectionReason.Trim())
                    : DBNull.Value;
                command.Parameters.Add("@IDEKZETL", SqlDbType.Int).Value = (object)template.IdEkzetl ?? DBNull.Value;

                return Convert.ToInt32(command.ExecuteScalar());
            }
        }

        private static HashSet<string> LoadDocumentDuplicateKeys(SqlConnection connection, SqlTransaction transaction, string documentNumber)
        {
            const string sql = @"
SELECT DISTINCT
    tips.TP AS InstrumentType,
    sizeInfo.DPZN AS RangeText,
    sizeInfo.NNGSRS AS RegistryNumber,
    z.NNZV AS SerialNumber
FROM dbo.EKZMK m
JOIN dbo.EKZ z ON z.IDEKZ = m.IDEKZ
LEFT JOIN dbo.TPRZ sizeInfo ON sizeInfo.IDTPRZ = z.IDTPRZ
LEFT JOIN dbo.TIPS tips ON tips.IDTIPS = sizeInfo.IDTIPS
WHERE m.NNZVPV = @DocumentNumber;";

            var keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@DocumentNumber", SqlDbType.NVarChar, 60).Value = documentNumber;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        keys.Add(PsvDocumentLine.BuildDuplicateKey(
                            GetString(reader, "InstrumentType"),
                            GetString(reader, "RangeText"),
                            GetString(reader, "RegistryNumber"),
                            GetString(reader, "SerialNumber")));
                    }
                }
            }

            return keys;
        }

        private static List<DeleteBlockerInfo> LoadDeleteBlockers(SqlConnection connection, SqlTransaction transaction, IEnumerable<int> cardIds)
        {
            var aggregated = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

            foreach (var cardId in cardIds)
            {
                foreach (var blocker in LoadDeleteBlockers(connection, transaction, cardId))
                {
                    int currentCount;
                    if (!aggregated.TryGetValue(blocker.TableName, out currentCount))
                    {
                        currentCount = 0;
                    }

                    aggregated[blocker.TableName] = currentCount + blocker.RowCount;
                }
            }

            return aggregated
                .OrderBy(delegate(KeyValuePair<string, int> blocker) { return blocker.Key; })
                .Select(delegate(KeyValuePair<string, int> blocker)
                {
                    return new DeleteBlockerInfo
                    {
                        TableName = blocker.Key,
                        RowCount = blocker.Value
                    };
                })
                .ToList();
        }

        private static List<DeleteBlockerInfo> LoadDeleteBlockers(SqlConnection connection, SqlTransaction transaction, int cardId)
        {
            const string sql = @"
SELECT blocker.TableName, blocker.LinkCount
FROM
(
    SELECT N'EKZMKDH' AS TableName, COUNT(*) AS LinkCount
    FROM dbo.EKZMKDH child
    JOIN dbo.EKZMK parent ON parent.IDEKZMK = child.IDEKZMK
    WHERE parent.IDEKZMK = @CardId

    UNION ALL

    SELECT N'EKZMKEKZK', COUNT(*)
    FROM dbo.EKZMKEKZK child
    JOIN dbo.EKZMK parent ON parent.IDEKZMK = child.IDEKZMK
    WHERE parent.IDEKZMK = @CardId

    UNION ALL

    SELECT N'EKZMKND', COUNT(*)
    FROM dbo.EKZMKND child
    JOIN dbo.EKZMK parent ON parent.IDEKZMK = child.IDEKZMK
    WHERE parent.IDEKZMK = @CardId

    UNION ALL

    SELECT N'FRPTMK', COUNT(*)
    FROM dbo.FRPTMK child
    JOIN dbo.EKZMK parent ON parent.IDEKZMK = child.IDEKZMK
    WHERE parent.IDEKZMK = @CardId

    UNION ALL

    SELECT N'KSPELEKZMK', COUNT(*)
    FROM dbo.KSPELEKZMK child
    JOIN dbo.EKZMK parent ON parent.IDEKZMK = child.IDEKZMK
    WHERE parent.IDEKZMK = @CardId
) blocker
WHERE blocker.LinkCount > 0
ORDER BY blocker.TableName;";

            var blockers = new List<DeleteBlockerInfo>();

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@CardId", SqlDbType.Int).Value = cardId;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        blockers.Add(new DeleteBlockerInfo
                        {
                            TableName = GetString(reader, "TableName"),
                            RowCount = GetInt32(reader, "LinkCount")
                        });
                    }
                }
            }

            return blockers;
        }

        private static List<DeleteBlockerInfo> LoadDeleteBlockers(SqlConnection connection, SqlTransaction transaction, string documentNumber)
        {
            const string sql = @"
SELECT blocker.TableName, blocker.LinkCount
FROM
(
    SELECT N'EKZMKDH' AS TableName, COUNT(*) AS LinkCount
    FROM dbo.EKZMKDH child
    JOIN dbo.EKZMK parent ON parent.IDEKZMK = child.IDEKZMK
    WHERE parent.NNZVPV = @DocumentNumber

    UNION ALL

    SELECT N'EKZMKEKZK', COUNT(*)
    FROM dbo.EKZMKEKZK child
    JOIN dbo.EKZMK parent ON parent.IDEKZMK = child.IDEKZMK
    WHERE parent.NNZVPV = @DocumentNumber

    UNION ALL

    SELECT N'EKZMKND', COUNT(*)
    FROM dbo.EKZMKND child
    JOIN dbo.EKZMK parent ON parent.IDEKZMK = child.IDEKZMK
    WHERE parent.NNZVPV = @DocumentNumber

    UNION ALL

    SELECT N'FRPTMK', COUNT(*)
    FROM dbo.FRPTMK child
    JOIN dbo.EKZMK parent ON parent.IDEKZMK = child.IDEKZMK
    WHERE parent.NNZVPV = @DocumentNumber

    UNION ALL

    SELECT N'KSPELEKZMK', COUNT(*)
    FROM dbo.KSPELEKZMK child
    JOIN dbo.EKZMK parent ON parent.IDEKZMK = child.IDEKZMK
    WHERE parent.NNZVPV = @DocumentNumber
) blocker
WHERE blocker.LinkCount > 0
ORDER BY blocker.TableName;";

            var blockers = new List<DeleteBlockerInfo>();

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@DocumentNumber", SqlDbType.NVarChar, 60).Value = documentNumber;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        blockers.Add(new DeleteBlockerInfo
                        {
                            TableName = GetString(reader, "TableName"),
                            RowCount = GetInt32(reader, "LinkCount")
                        });
                    }
                }
            }

            return blockers;
        }

        private static InstrumentIdentityInfo LoadInstrumentIdentity(SqlConnection connection, SqlTransaction transaction, int instrumentId)
        {
            const string sql = @"
SELECT
    tips.TP AS InstrumentType,
    sizeInfo.DPZN AS RangeText,
    sizeInfo.NNGSRS AS RegistryNumber,
    z.NNZV AS SerialNumber
FROM dbo.EKZ z
LEFT JOIN dbo.TPRZ sizeInfo ON sizeInfo.IDTPRZ = z.IDTPRZ
LEFT JOIN dbo.TIPS tips ON tips.IDTIPS = sizeInfo.IDTIPS
WHERE z.IDEKZ = @InstrumentId;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@InstrumentId", SqlDbType.Int).Value = instrumentId;

                using (var reader = command.ExecuteReader())
                {
                    if (!reader.Read())
                    {
                        return null;
                    }

                    return new InstrumentIdentityInfo
                    {
                        DuplicateKey = PsvDocumentLine.BuildDuplicateKey(
                            GetString(reader, "InstrumentType"),
                            GetString(reader, "RangeText"),
                            GetString(reader, "RegistryNumber"),
                            GetString(reader, "SerialNumber"))
                    };
                }
            }
        }

        private static int LoadVerificationTypeId(SqlConnection connection, SqlTransaction transaction)
        {
            const string sql = @"
SELECT TOP (1) IDSPVDMK
FROM dbo.SPVDMK
WHERE OBVDMK = N'П' OR NMVDMK = N'Поверка'
ORDER BY CASE WHEN OBVDMK = N'П' THEN 0 ELSE 1 END, IDSPVDMK;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                var result = command.ExecuteScalar();
                if (result == null || result == DBNull.Value)
                {
                    throw new InvalidOperationException("В справочнике SPVDMK не найден вид МК 'Поверка'.");
                }

                return Convert.ToInt32(result);
            }
        }

        private static EkzMkTemplate LoadTemplate(SqlConnection connection, SqlTransaction transaction, int instrumentId)
        {
            const string sql = @"
DECLARE @TypeSizeId int;
SELECT @TypeSizeId = IDTPRZ FROM dbo.EKZ WHERE IDEKZ = @InstrumentId;

WITH TemplateCandidates AS
(
    SELECT *
    FROM
    (
        SELECT TOP (1)
            1 AS Priority,
            m.IDSPMU,
            m.IDGRSI,
            m.IDKSPRL,
            m.IDSPVDMC,
            m.IDFRPD,
            m.IDSPMPOB,
            m.IDPRSN,
            m.IDSPKMMK,
            m.IDSPVDKL,
            m.IDPRSNVD,
            m.PRMK,
            m.STMK,
            m.STMKDP,
            m.NCSRMK,
            m.idprsnsd,
            m.idprsnpr,
            m.idprsnvy,
            m.idsptsmp,
            m.idspssmp,
            m.NRVRMNDmp,
            m.NRVRMmp,
            m.VRMKFK,
            m.IDKSP,
            m.DPZNmp,
            m.HRTCmp,
            m.IDSPVDSBMK,
            m.IDEKZETL,
            CAST(N'История прибора' AS nvarchar(60)) AS SourceDescription
        FROM dbo.EKZMK m
        WHERE m.IDEKZ = @InstrumentId
        ORDER BY ISNULL(m.DTPRM, CONVERT(datetime, '19000101', 112)) DESC, m.IDEKZMK DESC
    ) instrumentTemplate
    UNION ALL
    SELECT *
    FROM
    (
        SELECT TOP (1)
            2 AS Priority,
            m.IDSPMU,
            m.IDGRSI,
            m.IDKSPRL,
            m.IDSPVDMC,
            m.IDFRPD,
            m.IDSPMPOB,
            m.IDPRSN,
            m.IDSPKMMK,
            m.IDSPVDKL,
            m.IDPRSNVD,
            m.PRMK,
            m.STMK,
            m.STMKDP,
            m.NCSRMK,
            m.idprsnsd,
            m.idprsnpr,
            m.idprsnvy,
            m.idsptsmp,
            m.idspssmp,
            m.NRVRMNDmp,
            m.NRVRMmp,
            m.VRMKFK,
            m.IDKSP,
            m.DPZNmp,
            m.HRTCmp,
            m.IDSPVDSBMK,
            m.IDEKZETL,
            CAST(N'Шаблон по типоразмеру' AS nvarchar(60)) AS SourceDescription
        FROM dbo.EKZMK m
        JOIN dbo.EKZ z ON z.IDEKZ = m.IDEKZ
        WHERE z.IDTPRZ = @TypeSizeId
        ORDER BY ISNULL(m.DTPRM, CONVERT(datetime, '19000101', 112)) DESC, m.IDEKZMK DESC
    ) typeTemplate
)
SELECT TOP (1) *
FROM TemplateCandidates
ORDER BY Priority;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@InstrumentId", SqlDbType.Int).Value = instrumentId;

                using (var reader = command.ExecuteReader())
                {
                    if (reader.Read())
                    {
                        return new EkzMkTemplate
                        {
                            IdSpmu = GetNullableInt32(reader, "IDSPMU"),
                            IdGrsi = GetNullableInt32(reader, "IDGRSI"),
                            IdKsprl = GetNullableInt32(reader, "IDKSPRL"),
                            IdSpvdmc = GetNullableInt32(reader, "IDSPVDMC"),
                            IdFrpd = GetInt32(reader, "IDFRPD"),
                            IdSpmpob = GetNullableInt32(reader, "IDSPMPOB"),
                            IdPrsn = GetNullableInt32(reader, "IDPRSN"),
                            IdSpkmmk = GetNullableInt32(reader, "IDSPKMMK"),
                            IdSpvdkl = GetNullableInt32(reader, "IDSPVDKL"),
                            IdPrsnvd = GetNullableInt32(reader, "IDPRSNVD"),
                            Prmk = GetInt32(reader, "PRMK"),
                            Stmk = GetNullableDecimal(reader, "STMK"),
                            Stmkdp = GetNullableDecimal(reader, "STMKDP"),
                            NcSrmk = GetNullableDecimal(reader, "NCSRMK"),
                            IdPrsnsd = GetNullableInt32(reader, "idprsnsd"),
                            IdPrsnpr = GetNullableInt32(reader, "idprsnpr"),
                            IdPrsnvy = GetNullableInt32(reader, "idprsnvy"),
                            IdSptsmp = GetNullableInt32(reader, "idsptsmp"),
                            IdSpssmp = GetNullableInt32(reader, "idspssmp"),
                            Nrvrmndmp = GetNullableDecimal(reader, "NRVRMNDmp"),
                            Nrvrmmp = GetNullableDecimal(reader, "NRVRMmp"),
                            Vrmkfk = GetNullableDecimal(reader, "VRMKFK"),
                            IdKsp = GetNullableInt32(reader, "IDKSP"),
                            Dpznmp = GetString(reader, "DPZNmp"),
                            Hrtcmp = GetString(reader, "HRTCmp"),
                            IdSpvdsbmk = GetNullableInt32(reader, "IDSPVDSBMK"),
                            IdEkzetl = GetNullableInt32(reader, "IDEKZETL"),
                            SourceDescription = GetString(reader, "SourceDescription")
                        };
                    }
                }
            }

            return LoadFallbackTemplate(connection, transaction, instrumentId);
        }

        private static EkzMkTemplate LoadFallbackTemplate(SqlConnection connection, SqlTransaction transaction, int instrumentId)
        {
            const string sql = @"
WITH DefaultLab AS
(
    SELECT
        CASE WHEN COUNT(DISTINCT m.IDFRPD) = 1 THEN MIN(m.IDFRPD) ELSE NULL END AS DefaultIdFrpd
    FROM dbo.EKZMK m
)
SELECT TOP (1)
    COALESCE(periodByInstrument.IDGRSI, periodByType.IDGRSI) AS IDGRSI,
    COALESCE(periodByInstrument.IDSPVDMC, periodByType.IDSPVDMC) AS IDSPVDMC,
    defaultLab.DefaultIdFrpd AS IDFRPD,
    tprz.IDSPKMMK,
    COALESCE(periodByInstrument.PRMK, periodByType.PRMK, tips.PRMKGR) AS PRMK,
    tprz.DPZN AS DPZNmp,
    tprz.HRTC AS HRTCmp,
    CAST(
        CASE
            WHEN COALESCE(periodByInstrument.PRMK, periodByType.PRMK) IS NOT NULL THEN N'Период из TPRMCP'
            WHEN tips.PRMKGR IS NOT NULL THEN N'Регистрационный период из TIPS'
            ELSE N''
        END AS nvarchar(60)) AS SourceDescription
FROM dbo.EKZ z
JOIN dbo.TPRZ tprz ON tprz.IDTPRZ = z.IDTPRZ
JOIN dbo.TIPS tips ON tips.IDTIPS = tprz.IDTIPS
CROSS JOIN DefaultLab defaultLab
OUTER APPLY
(
    SELECT TOP (1)
        t.IDGRSI,
        t.IDSPVDMC,
        t.PRMK
    FROM dbo.EKZMCP e
    JOIN dbo.TPRMCP t ON t.IDTPRMCP = e.IDTPRMCP
    WHERE e.IDEKZ = z.IDEKZ
    ORDER BY e.IDEKZMCP DESC, t.IDTPRMCP DESC
) periodByInstrument
OUTER APPLY
(
    SELECT TOP (1)
        t.IDGRSI,
        t.IDSPVDMC,
        t.PRMK
    FROM dbo.TPRMCP t
    WHERE t.IDTPRZ = z.IDTPRZ
    ORDER BY t.IDTPRMCP DESC
) periodByType
WHERE z.IDEKZ = @InstrumentId
  AND defaultLab.DefaultIdFrpd IS NOT NULL
  AND COALESCE(periodByInstrument.PRMK, periodByType.PRMK, tips.PRMKGR) IS NOT NULL;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.Parameters.Add("@InstrumentId", SqlDbType.Int).Value = instrumentId;

                using (var reader = command.ExecuteReader())
                {
                    if (!reader.Read())
                    {
                        return null;
                    }

                    return new EkzMkTemplate
                    {
                        IdSpmu = null,
                        IdGrsi = GetNullableInt32(reader, "IDGRSI"),
                        IdKsprl = null,
                        IdSpvdmc = GetNullableInt32(reader, "IDSPVDMC"),
                        IdFrpd = GetInt32(reader, "IDFRPD"),
                        IdSpmpob = null,
                        IdPrsn = null,
                        IdSpkmmk = GetNullableInt32(reader, "IDSPKMMK"),
                        IdSpvdkl = null,
                        IdPrsnvd = null,
                        Prmk = GetInt32(reader, "PRMK"),
                        Stmk = null,
                        Stmkdp = null,
                        NcSrmk = null,
                        IdPrsnsd = null,
                        IdPrsnpr = null,
                        IdPrsnvy = null,
                        IdSptsmp = null,
                        IdSpssmp = null,
                        Nrvrmndmp = null,
                        Nrvrmmp = null,
                        Vrmkfk = null,
                        IdKsp = null,
                        Dpznmp = GetString(reader, "DPZNmp"),
                        Hrtcmp = GetString(reader, "HRTCmp"),
                        IdSpvdsbmk = null,
                        IdEkzetl = null,
                        SourceDescription = GetString(reader, "SourceDescription")
                    };
                }
            }
        }

        private static string NormalizeDocumentNumber(string value)
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                return null;
            }

            return value.Trim();
        }

        private static SpoiDirectoryItem NormalizeSpoiItem(SpoiDirectoryItem item)
        {
            if (item == null)
            {
                throw new ArgumentNullException("item");
            }

            var code = NormalizeRequiredSpoiValue(
                item.Code,
                "Код ОИ не заполнен.",
                SpoiDirectoryRules.CodeMaxLength,
                "Код ОИ превышает допустимую длину.");
            var name = NormalizeRequiredSpoiValue(
                item.Name,
                "Область измерений не заполнена.",
                SpoiDirectoryRules.NameMaxLength,
                "Область измерений превышает допустимую длину.");

            return new SpoiDirectoryItem
            {
                Id = item.Id,
                Code = code,
                Name = name
            };
        }

        private static void EnsureSpoiCodeIsUnique(SqlConnection connection, string code, int? excludeId)
        {
            const string sql = @"
SELECT TOP (1) IDSPOI
FROM dbo.SPOI
WHERE KDOI = @Code
  AND (@ExcludeId IS NULL OR IDSPOI <> @ExcludeId);";

            using (var command = new SqlCommand(sql, connection))
            {
                command.CommandTimeout = 60;
                command.Parameters.Add("@Code", SqlDbType.VarChar, SpoiDirectoryRules.CodeMaxLength).Value = code;
                command.Parameters.Add("@ExcludeId", SqlDbType.Int).Value = (object)excludeId ?? DBNull.Value;

                var existingId = command.ExecuteScalar();
                if (existingId != null && existingId != DBNull.Value)
                {
                    throw CreateSpoiDuplicateCodeException(code, null);
                }
            }
        }

        private static void EnsureSpoiNameIsUnique(SqlConnection connection, string name, int? excludeId)
        {
            const string sql = @"
SELECT TOP (1) IDSPOI
FROM dbo.SPOI
WHERE NMOI = @Name
  AND (@ExcludeId IS NULL OR IDSPOI <> @ExcludeId);";

            using (var command = new SqlCommand(sql, connection))
            {
                command.CommandTimeout = 60;
                command.Parameters.Add("@Name", SqlDbType.VarChar, SpoiDirectoryRules.NameMaxLength).Value = name;
                command.Parameters.Add("@ExcludeId", SqlDbType.Int).Value = (object)excludeId ?? DBNull.Value;

                var existingId = command.ExecuteScalar();
                if (existingId != null && existingId != DBNull.Value)
                {
                    throw CreateSpoiDuplicateNameException(name, null);
                }
            }
        }

        private static InvalidOperationException CreateSpoiDuplicateCodeException(string code, Exception innerException)
        {
            return new InvalidOperationException(
                string.Format("Код ОИ \"{0}\" уже существует в справочнике.", code),
                innerException);
        }

        private static InvalidOperationException CreateSpoiDuplicateNameException(string name, Exception innerException)
        {
            return new InvalidOperationException(
                string.Format("Область измерений \"{0}\" уже существует в справочнике.", name),
                innerException);
        }

        private static List<DeleteBlockerInfo> LoadSpoiDeleteBlockers(SqlConnection connection, int id)
        {
            const string sql = @"
SELECT blocker.TableName, blocker.LinkCount
FROM
(
    SELECT N'FRATGR' AS TableName, COUNT(*) AS LinkCount
    FROM dbo.FRATGR
    WHERE IDSPOI = @Id

    UNION ALL

    SELECT N'PRATGR', COUNT(*)
    FROM dbo.PRATGR
    WHERE IDSPOI = @Id

    UNION ALL

    SELECT N'PRSTOI', COUNT(*)
    FROM dbo.PRSTOI
    WHERE IDSPOI = @Id

    UNION ALL

    SELECT N'TIPS', COUNT(*)
    FROM dbo.TIPS
    WHERE IDSPOI = @Id
) blocker
WHERE blocker.LinkCount > 0
ORDER BY blocker.TableName;";

            var blockers = new List<DeleteBlockerInfo>();

            using (var command = new SqlCommand(sql, connection))
            {
                command.CommandTimeout = 60;
                command.Parameters.Add("@Id", SqlDbType.Int).Value = id;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        blockers.Add(new DeleteBlockerInfo
                        {
                            TableName = GetString(reader, "TableName"),
                            RowCount = GetInt32(reader, "LinkCount")
                        });
                    }
                }
            }

            return blockers;
        }

        private static string CreateSpoiDeleteBlockedMessage(IEnumerable<DeleteBlockerInfo> blockers)
        {
            var blockerList = blockers == null
                ? new List<DeleteBlockerInfo>()
                : blockers.Where(delegate(DeleteBlockerInfo blocker) { return blocker != null; }).ToList();
            var details = blockerList.Count == 0
                ? "FRATGR, PRATGR, PRSTOI, TIPS"
                : string.Join(", ", blockerList.Select(delegate(DeleteBlockerInfo blocker)
                {
                    return string.Format("{0}: {1}", blocker.TableName, blocker.RowCount);
                }));

            return string.Format(
                "Запись SPOI не может быть удалена, потому что на неё есть ссылки в таблицах: {0}. Подтверждённые ограничения БД: FK_FRATGR_SPOI, FK_PRATGR_SPOI, FK_PRSTOI_SPOI, FK_TIPS_SPOI.",
                details);
        }

        private static string CreateSpoiDeleteBlockedMessage(SqlException ex)
        {
            if (ex != null && ex.Message.IndexOf("FK_FRATGR_SPOI", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                return "Запись SPOI не может быть удалена, потому что на неё есть ссылки в таблице FRATGR. Ограничение БД: FK_FRATGR_SPOI (dbo.FRATGR.IDSPOI -> dbo.SPOI.IDSPOI).";
            }

            if (ex != null && ex.Message.IndexOf("FK_PRATGR_SPOI", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                return "Запись SPOI не может быть удалена, потому что на неё есть ссылки в таблице PRATGR. Ограничение БД: FK_PRATGR_SPOI (dbo.PRATGR.IDSPOI -> dbo.SPOI.IDSPOI).";
            }

            if (ex != null && ex.Message.IndexOf("FK_PRSTOI_SPOI", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                return "Запись SPOI не может быть удалена, потому что на неё есть ссылки в таблице PRSTOI. Ограничение БД: FK_PRSTOI_SPOI (dbo.PRSTOI.IDSPOI -> dbo.SPOI.IDSPOI).";
            }

            if (ex != null && ex.Message.IndexOf("FK_TIPS_SPOI", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                return "Запись SPOI не может быть удалена, потому что на неё есть ссылки в таблице TIPS. Ограничение БД: FK_TIPS_SPOI (dbo.TIPS.IDSPOI -> dbo.SPOI.IDSPOI).";
            }

            return "Запись SPOI не может быть удалена из-за ограничения ссылочной целостности в БД.";
        }

        private static bool IsSpoiDuplicateCodeViolation(SqlException ex)
        {
            return ex != null
                   && (ex.Number == 2601 || ex.Number == 2627)
                   && ex.Message.IndexOf("XAK2SPOI", StringComparison.OrdinalIgnoreCase) >= 0;
        }

        private static bool IsSpoiDuplicateNameViolation(SqlException ex)
        {
            return ex != null
                   && (ex.Number == 2601 || ex.Number == 2627)
                   && ex.Message.IndexOf("XAK1SPOI", StringComparison.OrdinalIgnoreCase) >= 0;
        }

        private static string NormalizeRequiredSpoiValue(string value, string emptyErrorMessage, int maxLength, string tooLongErrorMessage)
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                throw new InvalidOperationException(emptyErrorMessage);
            }

            var normalizedValue = value.Trim();
            if (normalizedValue.Length > maxLength)
            {
                throw new InvalidOperationException(tooLongErrorMessage);
            }

            return normalizedValue;
        }

        private static SpnmtpDirectoryItem NormalizeSpnmtpItem(SpnmtpDirectoryItem item)
        {
            if (item == null)
            {
                throw new ArgumentNullException("item");
            }

            var name = NormalizeRequiredSpnmtpValue(
                item.Name,
                "Наименование типа СИ не заполнено.",
                SpnmtpDirectoryRules.NameMaxLength,
                "Наименование типа СИ превышает допустимую длину.");
            var specialName = NormalizeOptionalSpnmtpValue(
                item.SpecialName,
                SpnmtpDirectoryRules.SpecialNameMaxLength,
                "Специальное наименование типа превышает допустимую длину.");

            return new SpnmtpDirectoryItem
            {
                Id = item.Id,
                Name = name,
                SpecialName = specialName
            };
        }

        private static void EnsureSpnmtpNameIsUnique(SqlConnection connection, string name, int? excludeId)
        {
            const string sql = @"
SELECT TOP (1) IDSPNMTP
FROM dbo.SPNMTP
WHERE NMTP = @Name
  AND (@ExcludeId IS NULL OR IDSPNMTP <> @ExcludeId);";

            using (var command = new SqlCommand(sql, connection))
            {
                command.CommandTimeout = 60;
                command.Parameters.Add("@Name", SqlDbType.NVarChar, SpnmtpDirectoryRules.NameMaxLength).Value = name;
                command.Parameters.Add("@ExcludeId", SqlDbType.Int).Value = (object)excludeId ?? DBNull.Value;

                var existingId = command.ExecuteScalar();
                if (existingId != null && existingId != DBNull.Value)
                {
                    throw CreateSpnmtpDuplicateNameException(name, null);
                }
            }
        }

        private static InvalidOperationException CreateSpnmtpDuplicateNameException(string name, Exception innerException)
        {
            return new InvalidOperationException(
                string.Format("Наименование типа СИ \"{0}\" уже существует в справочнике.", name),
                innerException);
        }

        private static List<DeleteBlockerInfo> LoadSpnmtpDeleteBlockers(SqlConnection connection, int id)
        {
            const string sql = @"
SELECT blocker.TableName, blocker.LinkCount
FROM
(
    SELECT N'TIPS' AS TableName, COUNT(*) AS LinkCount
    FROM dbo.TIPS
    WHERE IDSPNMTP = @Id
) blocker
WHERE blocker.LinkCount > 0
ORDER BY blocker.TableName;";

            var blockers = new List<DeleteBlockerInfo>();

            using (var command = new SqlCommand(sql, connection))
            {
                command.CommandTimeout = 60;
                command.Parameters.Add("@Id", SqlDbType.Int).Value = id;

                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        blockers.Add(new DeleteBlockerInfo
                        {
                            TableName = GetString(reader, "TableName"),
                            RowCount = GetInt32(reader, "LinkCount")
                        });
                    }
                }
            }

            return blockers;
        }

        private static string CreateSpnmtpDeleteBlockedMessage(IEnumerable<DeleteBlockerInfo> blockers)
        {
            var blockerList = blockers == null
                ? new List<DeleteBlockerInfo>()
                : blockers.Where(delegate(DeleteBlockerInfo blocker) { return blocker != null; }).ToList();
            var details = blockerList.Count == 0
                ? "TIPS"
                : string.Join(", ", blockerList.Select(delegate(DeleteBlockerInfo blocker)
                {
                    return string.Format("{0}: {1}", blocker.TableName, blocker.RowCount);
                }));

            return string.Format(
                "Запись SPNMTP не может быть удалена, потому что на неё есть ссылки в таблицах: {0}. Подтверждённое ограничение БД: FK_TIPS_SPNMTP (dbo.TIPS.IDSPNMTP -> dbo.SPNMTP.IDSPNMTP).",
                details);
        }

        private static string CreateSpnmtpDeleteBlockedMessage(SqlException ex)
        {
            if (ex != null && ex.Message.IndexOf("FK_TIPS_SPNMTP", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                return "Запись SPNMTP не может быть удалена, потому что на неё есть ссылки в таблице TIPS. Ограничение БД: FK_TIPS_SPNMTP (dbo.TIPS.IDSPNMTP -> dbo.SPNMTP.IDSPNMTP).";
            }

            return "Запись SPNMTP не может быть удалена из-за ограничения ссылочной целостности в БД.";
        }

        private static bool IsSpnmtpDuplicateNameViolation(SqlException ex)
        {
            return ex != null
                   && (ex.Number == 2601 || ex.Number == 2627)
                   && ex.Message.IndexOf("XAK1SPNMTP", StringComparison.OrdinalIgnoreCase) >= 0;
        }

        private static string NormalizeRequiredSpnmtpValue(string value, string emptyErrorMessage, int maxLength, string tooLongErrorMessage)
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                throw new InvalidOperationException(emptyErrorMessage);
            }

            var normalizedValue = value.Trim();
            if (normalizedValue.Length > maxLength)
            {
                throw new InvalidOperationException(tooLongErrorMessage);
            }

            return normalizedValue;
        }

        private static string NormalizeOptionalSpnmtpValue(string value, int maxLength, string tooLongErrorMessage)
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                return null;
            }

            var normalizedValue = value.Trim();
            if (normalizedValue.Length > maxLength)
            {
                throw new InvalidOperationException(tooLongErrorMessage);
            }

            return normalizedValue;
        }

        private static int UpdateDocumentHeader(SqlConnection connection, SqlTransaction transaction, string currentDocumentNumber, DocumentEditorResult document)
        {
            const string sql = @"
UPDATE dbo.EKZMK
SET NNZVPV = @NewDocumentNumber,
    DTPRM = @AcceptedOn,
    DTVDM = @IssuedOn
WHERE NNZVPV = @CurrentDocumentNumber;

SELECT @@ROWCOUNT;";

            using (var command = new SqlCommand(sql, connection, transaction))
            {
                command.CommandTimeout = 60;
                command.Parameters.Add("@NewDocumentNumber", SqlDbType.NVarChar, 60).Value = document.DocumentNumber;
                command.Parameters.Add("@AcceptedOn", SqlDbType.DateTime).Value = document.AcceptedOn;
                command.Parameters.Add("@IssuedOn", SqlDbType.DateTime).Value = (object)document.IssuedOn ?? DBNull.Value;
                command.Parameters.Add("@CurrentDocumentNumber", SqlDbType.NVarChar, 60).Value = currentDocumentNumber;
                return Convert.ToInt32(command.ExecuteScalar());
            }
        }

        private sealed class DeleteBlockerInfo
        {
            public int RowCount { get; set; }

            public string TableName { get; set; }
        }

        private sealed class InstrumentIdentityInfo
        {
            public string DuplicateKey { get; set; }
        }
    }
}
