@ -163,6 +163,158 @@ string UniqueNumber(string baseName, int year)
return progress ;
}
// ── общие помощники ──────────────────────────────────────────────────────
private static Dictionary < Guid , ( decimal Qty , decimal Amount ) > Aggregate ( MsDocument d , Dictionary < string , Guid > products )
{
var byProduct = new Dictionary < Guid , ( decimal Qty , decimal Amount ) > ( ) ;
foreach ( var pos in d . Positions ? . Rows ? ? new ( ) )
{
var prodMs = IdFromHref ( pos . Assortment ? . Meta ? . Href ) ;
if ( prodMs is null | | ! products . TryGetValue ( prodMs , out var pid ) ) continue ;
var up = pos . Price / 100 m ;
var cur = byProduct . GetValueOrDefault ( pid ) ;
byProduct [ pid ] = ( cur . Qty + pos . Quantity , cur . Amount + pos . Quantity * up ) ;
}
return byProduct ;
}
private sealed record DocMaps ( Guid StoreId , Guid CurrencyId , Guid ? AdminUserId ,
Dictionary < string , Guid > Products , Dictionary < string , Guid > Counterparties , Guid ? FallbackCp ) ;
private async Task < DocMaps > LoadMapsAsync ( Guid orgId , CancellationToken ct )
{
var currencyId = await _db . Currencies . Select ( c = > c . Id ) . FirstAsync ( ct ) ;
var storeId = await EnsureStoreAsync ( orgId , ct ) ;
var admin = await _db . Organizations . Where ( o = > o . Id = = orgId ) . Select ( o = > o . AccountOwnerUserId ) . FirstOrDefaultAsync ( ct ) ;
var cps = await _db . Counterparties . Where ( c = > c . MoySkladExternalId ! = null )
. Select ( c = > new { c . Id , c . MoySkladExternalId } ) . ToDictionaryAsync ( x = > x . MoySkladExternalId ! , x = > x . Id , ct ) ;
var fb = await _db . Counterparties . Select ( c = > ( Guid ? ) c . Id ) . FirstOrDefaultAsync ( ct ) ;
var prods = await _db . Products . Where ( p = > p . MoySkladExternalId ! = null )
. Select ( p = > new { p . Id , p . MoySkladExternalId } ) . ToDictionaryAsync ( x = > x . MoySkladExternalId ! , x = > x . Id , ct ) ;
return new DocMaps ( storeId , currencyId , admin , prods , cps , fb ) ;
}
// ── Оприходования (enter) ────────────────────────────────────────────────
public async Task < DocImportProgress > ImportEntersAsync ( string token , DocImportProgress progress , CancellationToken ct )
{
var orgId = _tenant . OrganizationId ! . Value ; progress . Entity = "enter" ;
var m = await LoadMapsAsync ( orgId , ct ) ;
var done = ( await _db . Enters . Where ( x = > x . MoySkladExternalId ! = null ) . Select ( x = > x . MoySkladExternalId ! ) . ToListAsync ( ct ) ) . ToHashSet ( ) ;
var used = ( await _db . Enters . Select ( x = > x . Number ) . ToListAsync ( ct ) ) . ToHashSet ( StringComparer . OrdinalIgnoreCase ) ;
string Uniq ( string b , int y ) { if ( used . Add ( b ) ) return b ; var w = $"{b}-{y}" ; if ( used . Add ( w ) ) return w ; for ( var n = 2 ; ; n + + ) { var c = $"{b}-{y}-{n}" ; if ( used . Add ( c ) ) return c ; } }
await foreach ( var d in _client . StreamDocumentsAsync < MsDocument > ( token , "entity/enter" , "positions,store" , 0 , null , ct ) )
{
progress . Seen + + ; if ( d . Id is null | | done . Contains ( d . Id ) ) { progress . Skipped + + ; continue ; }
try
{
var mom = MomentToUtc ( d . Moment ) ; var posted = d . Applicable ;
var e = new foodmarket . Domain . Purchases . Enter { OrganizationId = orgId , Number = Uniq ( d . Name ? ? $"М С -{d.Id[..8]}" , mom . Year ) , Date = mom ,
Status = posted ? foodmarket . Domain . Purchases . EnterStatus . Posted : foodmarket . Domain . Purchases . EnterStatus . Draft ,
StoreId = m . StoreId , CurrencyId = m . CurrencyId , Notes = d . Description , Total = d . Sum / 100 m , MoySkladExternalId = d . Id ,
PostedAt = posted ? mom : null , PostedByUserId = posted ? m . AdminUserId : null } ;
foreach ( var ( pid , a ) in Aggregate ( d , m . Products ) )
e . Lines . Add ( new foodmarket . Domain . Purchases . EnterLine { OrganizationId = orgId , ProductId = pid , Quantity = a . Qty , UnitCost = a . Qty ! = 0 ? decimal . Round ( a . Amount / a . Qty , 4 ) : 0 m , LineTotal = decimal . Round ( a . Amount , 2 ) } ) ;
_db . Enters . Add ( e ) ;
if ( posted ) foreach ( var l in e . Lines ) await _stock . ApplyMovementAsync ( new StockMovementDraft ( l . ProductId , m . StoreId , l . Quantity , MovementType . Enter , "enter" , e . Id , e . Number , l . UnitCost , mom , m . AdminUserId ) , ct ) ;
await _db . SaveChangesAsync ( ct ) ; _db . ChangeTracker . Clear ( ) ; done . Add ( d . Id ) ; progress . Created + + ;
}
catch ( Exception ex ) { _db . ChangeTracker . Clear ( ) ; progress . Errors + + ; if ( progress . ErrorSamples . Count < 20 ) progress . ErrorSamples . Add ( $"{d.Name}: {ex.Message}" ) ; _log . LogWarning ( ex , "enter {Id}" , d . Id ) ; }
}
return progress ;
}
// ── Списания (loss) ──────────────────────────────────────────────────────
public async Task < DocImportProgress > ImportLossesAsync ( string token , DocImportProgress progress , CancellationToken ct )
{
var orgId = _tenant . OrganizationId ! . Value ; progress . Entity = "loss" ;
var m = await LoadMapsAsync ( orgId , ct ) ;
var done = ( await _db . Losses . Where ( x = > x . MoySkladExternalId ! = null ) . Select ( x = > x . MoySkladExternalId ! ) . ToListAsync ( ct ) ) . ToHashSet ( ) ;
var used = ( await _db . Losses . Select ( x = > x . Number ) . ToListAsync ( ct ) ) . ToHashSet ( StringComparer . OrdinalIgnoreCase ) ;
string Uniq ( string b , int y ) { if ( used . Add ( b ) ) return b ; var w = $"{b}-{y}" ; if ( used . Add ( w ) ) return w ; for ( var n = 2 ; ; n + + ) { var c = $"{b}-{y}-{n}" ; if ( used . Add ( c ) ) return c ; } }
await foreach ( var d in _client . StreamDocumentsAsync < MsDocument > ( token , "entity/loss" , "positions,store" , 0 , null , ct ) )
{
progress . Seen + + ; if ( d . Id is null | | done . Contains ( d . Id ) ) { progress . Skipped + + ; continue ; }
try
{
var mom = MomentToUtc ( d . Moment ) ; var posted = d . Applicable ;
var e = new foodmarket . Domain . Inventory . Loss { OrganizationId = orgId , Number = Uniq ( d . Name ? ? $"М С -{d.Id[..8]}" , mom . Year ) , Date = mom ,
Status = posted ? foodmarket . Domain . Inventory . LossStatus . Posted : foodmarket . Domain . Inventory . LossStatus . Draft ,
StoreId = m . StoreId , CurrencyId = m . CurrencyId , Reason = foodmarket . Domain . Inventory . LossReason . Other , Notes = d . Description ,
Total = d . Sum / 100 m , MoySkladExternalId = d . Id , PostedAt = posted ? mom : null , PostedByUserId = posted ? m . AdminUserId : null } ;
foreach ( var ( pid , a ) in Aggregate ( d , m . Products ) )
e . Lines . Add ( new foodmarket . Domain . Inventory . LossLine { OrganizationId = orgId , ProductId = pid , Quantity = a . Qty , UnitCost = a . Qty ! = 0 ? decimal . Round ( a . Amount / a . Qty , 4 ) : 0 m , LineTotal = decimal . Round ( a . Amount , 2 ) } ) ;
_db . Losses . Add ( e ) ;
if ( posted ) foreach ( var l in e . Lines ) await _stock . ApplyMovementAsync ( new StockMovementDraft ( l . ProductId , m . StoreId , - l . Quantity , MovementType . WriteOff , "loss" , e . Id , e . Number , l . UnitCost , mom , m . AdminUserId ) , ct ) ;
await _db . SaveChangesAsync ( ct ) ; _db . ChangeTracker . Clear ( ) ; done . Add ( d . Id ) ; progress . Created + + ;
}
catch ( Exception ex ) { _db . ChangeTracker . Clear ( ) ; progress . Errors + + ; if ( progress . ErrorSamples . Count < 20 ) progress . ErrorSamples . Add ( $"{d.Name}: {ex.Message}" ) ; _log . LogWarning ( ex , "loss {Id}" , d . Id ) ; }
}
return progress ;
}
// ── Возвраты поставщику (purchasereturn) ─────────────────────────────────
public async Task < DocImportProgress > ImportSupplierReturnsAsync ( string token , DocImportProgress progress , CancellationToken ct )
{
var orgId = _tenant . OrganizationId ! . Value ; progress . Entity = "purchasereturn" ;
var m = await LoadMapsAsync ( orgId , ct ) ;
var done = ( await _db . SupplierReturns . Where ( x = > x . MoySkladExternalId ! = null ) . Select ( x = > x . MoySkladExternalId ! ) . ToListAsync ( ct ) ) . ToHashSet ( ) ;
var used = ( await _db . SupplierReturns . Select ( x = > x . Number ) . ToListAsync ( ct ) ) . ToHashSet ( StringComparer . OrdinalIgnoreCase ) ;
string Uniq ( string b , int y ) { if ( used . Add ( b ) ) return b ; var w = $"{b}-{y}" ; if ( used . Add ( w ) ) return w ; for ( var n = 2 ; ; n + + ) { var c = $"{b}-{y}-{n}" ; if ( used . Add ( c ) ) return c ; } }
await foreach ( var d in _client . StreamDocumentsAsync < MsDocument > ( token , "entity/purchasereturn" , "positions,agent,store" , 0 , null , ct ) )
{
progress . Seen + + ; if ( d . Id is null | | done . Contains ( d . Id ) ) { progress . Skipped + + ; continue ; }
try
{
var mom = MomentToUtc ( d . Moment ) ; var posted = d . Applicable ;
var cpMs = IdFromHref ( d . Agent ? . Meta ? . Href ) ;
var cpId = cpMs is not null & & m . Counterparties . TryGetValue ( cpMs , out var c1 ) ? c1 : m . FallbackCp ! . Value ;
var e = new foodmarket . Domain . Purchases . SupplierReturn { OrganizationId = orgId , Number = Uniq ( d . Name ? ? $"М С -{d.Id[..8]}" , mom . Year ) , Date = mom ,
Status = posted ? foodmarket . Domain . Purchases . SupplierReturnStatus . Posted : foodmarket . Domain . Purchases . SupplierReturnStatus . Draft ,
SupplierId = cpId , StoreId = m . StoreId , CurrencyId = m . CurrencyId , Notes = d . Description , Total = d . Sum / 100 m ,
MoySkladExternalId = d . Id , PostedAt = posted ? mom : null , PostedByUserId = posted ? m . AdminUserId : null } ;
foreach ( var ( pid , a ) in Aggregate ( d , m . Products ) )
e . Lines . Add ( new foodmarket . Domain . Purchases . SupplierReturnLine { OrganizationId = orgId , ProductId = pid , Quantity = a . Qty , UnitPrice = a . Qty ! = 0 ? decimal . Round ( a . Amount / a . Qty , 4 ) : 0 m , LineTotal = decimal . Round ( a . Amount , 2 ) } ) ;
_db . SupplierReturns . Add ( e ) ;
if ( posted ) foreach ( var l in e . Lines ) await _stock . ApplyMovementAsync ( new StockMovementDraft ( l . ProductId , m . StoreId , - l . Quantity , MovementType . SupplierReturn , "supplier-return" , e . Id , e . Number , l . UnitPrice , mom , m . AdminUserId ) , ct ) ;
await _db . SaveChangesAsync ( ct ) ; _db . ChangeTracker . Clear ( ) ; done . Add ( d . Id ) ; progress . Created + + ;
}
catch ( Exception ex ) { _db . ChangeTracker . Clear ( ) ; progress . Errors + + ; if ( progress . ErrorSamples . Count < 20 ) progress . ErrorSamples . Add ( $"{d.Name}: {ex.Message}" ) ; _log . LogWarning ( ex , "purchasereturn {Id}" , d . Id ) ; }
}
return progress ;
}
// ── Розничные продажи (retaildemand) ─────────────────────────────────────
public async Task < DocImportProgress > ImportRetailSalesAsync ( string token , DocImportProgress progress , CancellationToken ct )
{
var orgId = _tenant . OrganizationId ! . Value ; progress . Entity = "retaildemand" ;
var m = await LoadMapsAsync ( orgId , ct ) ;
var done = ( await _db . RetailSales . Where ( x = > x . MoySkladExternalId ! = null ) . Select ( x = > x . MoySkladExternalId ! ) . ToListAsync ( ct ) ) . ToHashSet ( ) ;
var used = ( await _db . RetailSales . Select ( x = > x . Number ) . ToListAsync ( ct ) ) . ToHashSet ( StringComparer . OrdinalIgnoreCase ) ;
string Uniq ( string b , int y ) { if ( used . Add ( b ) ) return b ; var w = $"{b}-{y}" ; if ( used . Add ( w ) ) return w ; for ( var n = 2 ; ; n + + ) { var c = $"{b}-{y}-{n}" ; if ( used . Add ( c ) ) return c ; } }
await foreach ( var d in _client . StreamDocumentsAsync < MsDocument > ( token , "entity/retaildemand" , "positions,store" , 0 , null , ct ) )
{
progress . Seen + + ; if ( d . Id is null | | done . Contains ( d . Id ) ) { progress . Skipped + + ; continue ; }
try
{
var mom = MomentToUtc ( d . Moment ) ; var posted = d . Applicable ;
var cash = d . CashSum / 100 m ; var card = d . NoCashSum / 100 m ; var total = d . Sum / 100 m ;
var pay = cash > 0 & & card > 0 ? foodmarket . Domain . Sales . PaymentMethod . Mixed : ( card > 0 ? foodmarket . Domain . Sales . PaymentMethod . Card : foodmarket . Domain . Sales . PaymentMethod . Cash ) ;
var e = new foodmarket . Domain . Sales . RetailSale { OrganizationId = orgId , Number = Uniq ( d . Name ? ? $"М С -{d.Id[..8]}" , mom . Year ) , Date = mom ,
Status = posted ? foodmarket . Domain . Sales . RetailSaleStatus . Posted : foodmarket . Domain . Sales . RetailSaleStatus . Draft ,
StoreId = m . StoreId , CurrencyId = m . CurrencyId , CashierUserId = m . AdminUserId , Payment = pay , PaidCash = cash , PaidCard = card ,
Subtotal = total , Total = total , Notes = d . Description , MoySkladExternalId = d . Id , IsReturn = false ,
PostedAt = posted ? mom : null , PostedByUserId = posted ? m . AdminUserId : null } ;
foreach ( var ( pid , a ) in Aggregate ( d , m . Products ) )
e . Lines . Add ( new foodmarket . Domain . Sales . RetailSaleLine { OrganizationId = orgId , ProductId = pid , Quantity = a . Qty , UnitPrice = a . Qty ! = 0 ? decimal . Round ( a . Amount / a . Qty , 4 ) : 0 m , LineTotal = decimal . Round ( a . Amount , 2 ) } ) ;
_db . RetailSales . Add ( e ) ;
if ( posted ) foreach ( var l in e . Lines ) await _stock . ApplyMovementAsync ( new StockMovementDraft ( l . ProductId , m . StoreId , - l . Quantity , MovementType . RetailSale , "retail-sale" , e . Id , e . Number , l . UnitPrice , mom , m . AdminUserId ) , ct ) ;
await _db . SaveChangesAsync ( ct ) ; _db . ChangeTracker . Clear ( ) ; done . Add ( d . Id ) ; progress . Created + + ;
}
catch ( Exception ex ) { _db . ChangeTracker . Clear ( ) ; progress . Errors + + ; if ( progress . ErrorSamples . Count < 20 ) progress . ErrorSamples . Add ( $"{d.Name}: {ex.Message}" ) ; _log . LogWarning ( ex , "retaildemand {Id}" , d . Id ) ; }
}
return progress ;
}
/// <summary>Гарантирует склад о р г с MoySkladExternalId = склад МойСклада (магазин
/// с одним складом). Берём первый склад о р г и помечаем е г о msId при первом вызове.</summary>
private async Task < Guid > EnsureStoreAsync ( Guid orgId , CancellationToken ct )