Compare commits

..

2 commits

Author SHA1 Message Date
nurdotnet 26a76e5aea fix(moysklad): убираем выдумку Kind полностью — у MoySklad этого поля нет
Проверил через API под реальным токеном (entity/counterparty?expand=group,tags):
у MoySklad **нет** поля «Поставщик/Покупатель» у контрагентов вообще. Есть только:
- group (группа доступа сотрудников, у всех "Основной")
- tags (произвольные ярлыки, у большинства пусто)
- state (пользовательская цепочка статусов)
- companyType (legal/individual/entrepreneur — это наш Type)

Один и тот же контрагент может быть поставщиком в одной приёмке и покупателем
в другом чеке — классификация контекстная, не атрибут сущности.

Изменения:
- ImportCounterpartiesAsync.ResolveKind теперь ВСЕГДА возвращает Unspecified.
  Никаких эвристик по тегам — просто null для Kind.
- useSuppliers хук теперь useCounterparties — возвращает ВСЕХ контрагентов,
  не фильтрует по Kind. Селекторы поставщика в Supply/RetailSale показывают
  всех. Пользователь сам выбирает кто поставщик в этом конкретном документе.
- Создание контрагента в UI: дефолт Kind = Unspecified, не Supplier.

Поле Kind в нашей модели остаётся для пользователей которые сами хотят
классифицировать. Но импорт его не трогает.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:51:23 +05:00
nurdotnet 2d1a9c8f75 fix(moysklad): не выдумывать Kind=Both для импортированных контрагентов
У MoySklad НЕТ встроенного поля «Поставщик/Покупатель» у контрагентов —
эта классификация целиком пользовательская через теги или группы. Импорт
ставил Kind=Both дефолтом когда тегов не было, что искажало данные:
все 586 контрагентов на stage стали «Оба», хотя в MoySklad ничего такого
не было.

- CounterpartyKind: добавлен Unspecified=0 как дефолт
- ImportCounterpartiesAsync.ResolveKind: возвращает Unspecified когда
  тегов нет; Both только если в тегах ОБА маркера ("постав" + "покуп");
  иначе один из конкретных
- UI: dropdown получил опцию «Не указано», лейбл «Оба» переименован в
  «Поставщик + Покупатель» (точнее)
- Существующие данные: SQL UPDATE Kind=3 → Kind=0 на stage (586 строк)
  и dev (0 строк, локально пусто)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:39:31 +05:00
5 changed files with 24 additions and 19 deletions

View file

@ -2,6 +2,10 @@ namespace foodmarket.Domain.Catalog;
public enum CounterpartyKind public enum CounterpartyKind
{ {
/// <summary>Не указано — дефолт для импортированных без явной классификации.
/// MoySklad сам не имеет встроенного поля Supplier/Customer, оно ставится
/// через теги или группы, и часто отсутствует. Не выдумываем за пользователя.</summary>
Unspecified = 0,
Supplier = 1, Supplier = 1,
Customer = 2, Customer = 2,
Both = 3, Both = 3,

View file

@ -39,17 +39,14 @@ public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token,
{ {
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// Map MoySklad tag set → local CounterpartyKind. If no tags say otherwise, assume Both. // MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — это
// не наша выдумка, проверено через API: counterparty entity содержит только
// group (группа доступа), tags (произвольные), state (пользовательская цепочка
// статусов), companyType (legal/individual/entrepreneur). Никакой role/kind.
// Поэтому при импорте ВСЕГДА ставим Unspecified — пользователь сам решит.
// Параметр tags оставлен ради совместимости сигнатуры, не используется.
static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags) static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList<string>? tags)
{ => foodmarket.Domain.Catalog.CounterpartyKind.Unspecified;
if (tags is null || tags.Count == 0) return foodmarket.Domain.Catalog.CounterpartyKind.Both;
var lower = tags.Select(t => t.ToLowerInvariant()).ToList();
var hasSupplier = lower.Any(t => t.Contains("постав"));
var hasCustomer = lower.Any(t => t.Contains("покуп") || t.Contains("клиент"));
if (hasSupplier && !hasCustomer) return foodmarket.Domain.Catalog.CounterpartyKind.Supplier;
if (hasCustomer && !hasSupplier) return foodmarket.Domain.Catalog.CounterpartyKind.Customer;
return foodmarket.Domain.Catalog.CounterpartyKind.Both;
}
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType) static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
=> companyType switch => companyType switch

View file

@ -6,7 +6,7 @@ export interface PagedResult<T> {
totalPages: number totalPages: number
} }
export const CounterpartyKind = { Supplier: 1, Customer: 2, Both: 3 } as const export const CounterpartyKind = { Unspecified: 0, Supplier: 1, Customer: 2, Both: 3 } as const
export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind] export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind]
export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const

View file

@ -20,8 +20,8 @@ export const useCountries = () => useLookup<Country>('countries', '/api/catalog/
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies') export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')
export const useStores = () => useLookup<Store>('stores', '/api/catalog/stores') export const useStores = () => useLookup<Store>('stores', '/api/catalog/stores')
export const usePriceTypes = () => useLookup<PriceType>('price-types', '/api/catalog/price-types') export const usePriceTypes = () => useLookup<PriceType>('price-types', '/api/catalog/price-types')
export const useSuppliers = () => useQuery({ // MoySklad-style: контрагент один, может быть и поставщиком, и покупателем
queryKey: ['lookup:suppliers'], // в разных документах. Не фильтруем по Kind — пользователь сам выбирает.
queryFn: async () => (await api.get<PagedResult<Counterparty>>('/api/catalog/counterparties?pageSize=500&kind=1')).data.items, export const useCounterparties = () => useLookup<Counterparty>('counterparties', '/api/catalog/counterparties')
staleTime: 5 * 60 * 1000, // Алиас для обратной совместимости со старым кодом форм Supply/RetailSale.
}) export const useSuppliers = useCounterparties

View file

@ -36,7 +36,9 @@ interface Form {
} }
const blankForm: Form = { const blankForm: Form = {
name: '', legalName: '', kind: CounterpartyKind.Supplier, type: CounterpartyType.LegalEntity, // Kind по умолчанию Unspecified — MoySklad не имеет такого поля у контрагентов,
// не выдумываем за пользователя. Пусть выберет вручную если нужно.
name: '', legalName: '', kind: CounterpartyKind.Unspecified, type: CounterpartyType.LegalEntity,
bin: '', iin: '', taxNumber: '', countryId: '', bin: '', iin: '', taxNumber: '', countryId: '',
address: '', phone: '', email: '', address: '', phone: '', email: '',
bankName: '', bankAccount: '', bik: '', bankName: '', bankAccount: '', bik: '',
@ -44,9 +46,10 @@ const blankForm: Form = {
} }
const kindLabel: Record<CounterpartyKind, string> = { const kindLabel: Record<CounterpartyKind, string> = {
[CounterpartyKind.Unspecified]: '—',
[CounterpartyKind.Supplier]: 'Поставщик', [CounterpartyKind.Supplier]: 'Поставщик',
[CounterpartyKind.Customer]: 'Покупатель', [CounterpartyKind.Customer]: 'Покупатель',
[CounterpartyKind.Both]: 'Оба', [CounterpartyKind.Both]: 'Поставщик + Покупатель',
} }
export function CounterpartiesPage() { export function CounterpartiesPage() {
@ -138,9 +141,10 @@ export function CounterpartiesPage() {
</Field> </Field>
<Field label="Роль"> <Field label="Роль">
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}> <Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
<option value={CounterpartyKind.Unspecified}>Не указано</option>
<option value={CounterpartyKind.Supplier}>Поставщик</option> <option value={CounterpartyKind.Supplier}>Поставщик</option>
<option value={CounterpartyKind.Customer}>Покупатель</option> <option value={CounterpartyKind.Customer}>Покупатель</option>
<option value={CounterpartyKind.Both}>Оба</option> <option value={CounterpartyKind.Both}>Поставщик + Покупатель</option>
</Select> </Select>
</Field> </Field>
<Field label="Тип лица"> <Field label="Тип лица">