Compare commits
44 Commits
c8243d25bf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bab74f58f4 | |||
| d7ee89e3b3 | |||
| 5bcccd8aa7 | |||
| fd29ef6ff8 | |||
| 4563a3bede | |||
| 288245f6f0 | |||
| 8f26b8ba6d | |||
| 23a4f007fc | |||
| a94cbe6710 | |||
| 1460732ba2 | |||
| 8dad392451 | |||
| 969ef726eb | |||
| 504e5fce29 | |||
| b8ce09f7ec | |||
| 2f4a973b45 | |||
| 52f1280c3c | |||
| 2352ebb942 | |||
| 60e9de0275 | |||
| 58ee1821f6 | |||
| ea9126e005 | |||
| b87e784012 | |||
| e91eec7aeb | |||
| a224f249d5 | |||
| 71a47fa135 | |||
| 308d48b948 | |||
| 3d5b88ab99 | |||
| 5c8f238fb8 | |||
| be6aec73df | |||
| 6044e116f8 | |||
| 6acb3dc199 | |||
| 205e4cef57 | |||
| 7e03a59737 | |||
| 470e07c62b | |||
| 821b18d30f | |||
| f69f52032d | |||
| 7fdc40c07a | |||
| b43f70a894 | |||
| d18d1d44dd | |||
| 4ceeba1608 | |||
| 5a49956661 | |||
| cc69847c4b | |||
| dd639995bd | |||
| a0b36ba83d | |||
| a1e767217b |
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@ database
|
||||
bin
|
||||
*.xml
|
||||
Makefile
|
||||
config.toml
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
namespace internal.encoding.fbs;
|
||||
|
||||
struct Dimensions {
|
||||
width:float;
|
||||
height:float;
|
||||
length:float;
|
||||
}
|
||||
|
||||
table GoodItem {
|
||||
sku:string;
|
||||
photo:string;
|
||||
@ -16,6 +22,7 @@ table GoodItem {
|
||||
stock:short;
|
||||
parameters:string;
|
||||
created_at:long;
|
||||
sizes:Dimensions;
|
||||
}
|
||||
|
||||
table GoodItems {
|
||||
|
||||
101
cmd/cli/commands/categories.go
Normal file
101
cmd/cli/commands/categories.go
Normal file
@ -0,0 +1,101 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.loyso.art/frx/eway/cmd/cli/components"
|
||||
|
||||
"github.com/rodaine/table"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func CategoriesCommandTree() *cli.Command {
|
||||
var h categoriesHandlers
|
||||
|
||||
return &cli.Command{
|
||||
Name: "categories",
|
||||
Usage: "Interact with stored categories",
|
||||
Commands: []*cli.Command{
|
||||
newCategoriesListCommand(h),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newCategoriesListCommand(h categoriesHandlers) *cli.Command {
|
||||
cmd := cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List categories, stories in db",
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Usage: "limits output to selected items",
|
||||
Value: 20,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "page",
|
||||
Usage: "in case of limit, selects page",
|
||||
Value: 0,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "with-total",
|
||||
Usage: "prints total count of categories",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return cmdWithAction(cmd, h.List)
|
||||
}
|
||||
|
||||
type categoriesHandlers struct{}
|
||||
|
||||
func (categoriesHandlers) List(ctx context.Context, c *cli.Command) error {
|
||||
r, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
|
||||
categories, err := r.Category().List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing categories: %w", err)
|
||||
}
|
||||
|
||||
limit := int(c.Int("limit"))
|
||||
page := int(c.Int("page"))
|
||||
total := len(categories)
|
||||
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
offset := (page - 1) * limit
|
||||
if offset > len(categories) {
|
||||
offset = len(categories) - 1
|
||||
}
|
||||
|
||||
limit = offset + limit
|
||||
if limit > len(categories) {
|
||||
limit = len(categories)
|
||||
}
|
||||
|
||||
categories = categories[offset:limit]
|
||||
}
|
||||
|
||||
tbl := table.New("ID", "Name")
|
||||
for _, category := range categories {
|
||||
if category.ID == 0 && category.Name == "" {
|
||||
continue
|
||||
}
|
||||
tbl.AddRow(category.ID, category.Name)
|
||||
}
|
||||
|
||||
tbl.Print()
|
||||
|
||||
if c.Bool("with-total") {
|
||||
zerolog.Ctx(ctx).Info().Int("count", total).Msg("total categories stats")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
55
cmd/cli/commands/channeliter.go
Normal file
55
cmd/cli/commands/channeliter.go
Normal file
@ -0,0 +1,55 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.loyso.art/frx/eway/internal/entity"
|
||||
)
|
||||
|
||||
type chanIter[T any] struct {
|
||||
in <-chan T
|
||||
err error
|
||||
next T
|
||||
}
|
||||
|
||||
var errChannelClosed = errors.New("channel closed")
|
||||
|
||||
func (i *chanIter[T]) Next() (ok bool) {
|
||||
if i.err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
i.next, ok = <-i.in
|
||||
if !ok {
|
||||
i.err = errChannelClosed
|
||||
}
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
func (i *chanIter[T]) Get() T {
|
||||
return i.next
|
||||
}
|
||||
|
||||
func (i *chanIter[T]) Err() error {
|
||||
if errors.Is(i.err, errChannelClosed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return i.err
|
||||
}
|
||||
|
||||
func (i *chanIter[T]) Close() {
|
||||
for range i.in {
|
||||
}
|
||||
}
|
||||
|
||||
func getItemsIter(ctx context.Context, r entity.GoodsItemRepository) *chanIter[entity.GoodsItem] {
|
||||
in, err := r.ListIter(ctx, 3)
|
||||
|
||||
return &chanIter[entity.GoodsItem]{
|
||||
in: in,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
72
cmd/cli/commands/dimensiondispatcher.go
Normal file
72
cmd/cli/commands/dimensiondispatcher.go
Normal file
@ -0,0 +1,72 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.loyso.art/frx/eway/internal/dimension"
|
||||
"git.loyso.art/frx/eway/internal/entity"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type dimensionDispatcher struct {
|
||||
m *dimension.Matcher
|
||||
}
|
||||
|
||||
func pickFirst[T, V any](t T, v V) T {
|
||||
return t
|
||||
}
|
||||
|
||||
func (d dimensionDispatcher) isDimensionParam(value string) bool {
|
||||
return pickFirst(d.m.Match(value)) != dimension.MatchResultMiss
|
||||
}
|
||||
|
||||
func (d dimensionDispatcher) applyDimensionValue(ctx context.Context, key, value string, in *entity.GoodsItemSize) (updated bool) {
|
||||
matchResult, priority := d.m.Match(key)
|
||||
if matchResult == dimension.MatchResultMiss {
|
||||
return false
|
||||
}
|
||||
|
||||
log := zerolog.Ctx(ctx).With().Str("key", key).Str("value", value).Logger()
|
||||
|
||||
if strings.Contains(value, "/") {
|
||||
dimensionValues := strings.Split(value, "/")
|
||||
for _, dv := range dimensionValues {
|
||||
updated = updated || d.applyDimensionValue(ctx, key, dv, in)
|
||||
}
|
||||
} else {
|
||||
out, err := entity.ParseDimention(value, entity.DimensionLocalRU)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("unable to parse key, skipping")
|
||||
return false
|
||||
}
|
||||
|
||||
out = out.AdjustTo(entity.DimensionKindCentimeter)
|
||||
|
||||
switch matchResult {
|
||||
case dimension.MatchResultHeight:
|
||||
if !priority && in.Height.Value != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
in.Height = out
|
||||
case dimension.MatchResultLength:
|
||||
if !priority && in.Length.Value != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
in.Length = out
|
||||
case dimension.MatchResultWidth:
|
||||
if !priority && in.Width.Value != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
in.Width = out
|
||||
case dimension.MatchResultDepth:
|
||||
in.UnmatchedDepth = out
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
353
cmd/cli/commands/exports.go
Normal file
353
cmd/cli/commands/exports.go
Normal file
@ -0,0 +1,353 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.loyso.art/frx/eway/cmd/cli/components"
|
||||
"git.loyso.art/frx/eway/internal/entity"
|
||||
"git.loyso.art/frx/eway/internal/export"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func ExportCommandTree() *cli.Command {
|
||||
var h exportHandlers
|
||||
return &cli.Command{
|
||||
Name: "export",
|
||||
Usage: "Provide actions to export data from the database",
|
||||
Commands: []*cli.Command{
|
||||
newExportYMLCatalogCommand(h),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newExportYMLCatalogCommand(h exportHandlers) *cli.Command {
|
||||
cmd := cli.Command{
|
||||
Name: "yml-catalog",
|
||||
Usage: "Export data as yml_catalog",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "out",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Output to file or stdout/stderr",
|
||||
Value: "yml_catalog.xml",
|
||||
TakesFile: true,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "limits amount of items to save",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "pretty",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "pretty-prints output",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return cmdWithAction(cmd, h.YMLCatalog)
|
||||
}
|
||||
|
||||
type exportHandlers struct{}
|
||||
|
||||
func (h exportHandlers) YMLCatalog(ctx context.Context, cmd *cli.Command) error {
|
||||
const defaultCurrency = "RUR"
|
||||
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
path := cmd.String("out")
|
||||
limit := cmd.Int("limit")
|
||||
pretty := cmd.Bool("pretty")
|
||||
|
||||
r, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("path", path).
|
||||
Int64("limit", limit).
|
||||
Bool("pretty", pretty).
|
||||
Msg("executing with parameters")
|
||||
|
||||
categories, err := r.Category().List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing categories: %w", err)
|
||||
}
|
||||
|
||||
shop := export.Shop{
|
||||
Currencies: []export.Currency{{
|
||||
ID: defaultCurrency,
|
||||
Rate: 1,
|
||||
}},
|
||||
Categories: make([]export.Category, 0, len(categories)),
|
||||
}
|
||||
|
||||
categoryByNameIdx := make(map[string]int64, len(categories))
|
||||
for _, category := range categories {
|
||||
categoryByNameIdx[category.Name] = category.ID
|
||||
shop.Categories = append(shop.Categories, export.Category{
|
||||
ID: category.ID,
|
||||
Name: category.Name,
|
||||
})
|
||||
}
|
||||
|
||||
matcher, err := components.GetDimensionMatcher()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting dimension matcher: %w", err)
|
||||
}
|
||||
|
||||
dimensionDispatcher := dimensionDispatcher{m: matcher}
|
||||
|
||||
log.Info().Any("patterns", matcher.GetRegisteredPatterns()).Msg("configured patterns")
|
||||
|
||||
const (
|
||||
reasonNoSize = "no_size"
|
||||
reasonTooLarge = "too_large"
|
||||
reasonNoDescription = "no_description"
|
||||
reasonNoPhoto = "no_photo"
|
||||
)
|
||||
|
||||
skippedByReasons := map[string]int{
|
||||
reasonNoSize: 0,
|
||||
reasonTooLarge: 0,
|
||||
reasonNoDescription: 0,
|
||||
reasonNoPhoto: 0,
|
||||
}
|
||||
|
||||
addToSkip := func(condition bool, name string, log zerolog.Logger) bool {
|
||||
if !condition {
|
||||
return false
|
||||
}
|
||||
|
||||
log.Debug().Str("reason", name).Msg("skipping item")
|
||||
skippedByReasons[name]++
|
||||
|
||||
return condition
|
||||
}
|
||||
iter := getItemsIter(ctx, r.GoodsItem())
|
||||
for iter.Next() {
|
||||
const maximumAllowedSizes = 160
|
||||
|
||||
item := iter.Get()
|
||||
sublog := log.With().Int64("cart", item.Cart).Logger()
|
||||
|
||||
switch {
|
||||
case addToSkip(item.Description == "", reasonNoDescription, sublog):
|
||||
continue
|
||||
case addToSkip(!item.Sizes.AllSizesSet(), reasonNoSize, sublog):
|
||||
continue
|
||||
case addToSkip(item.Sizes.GetSum(entity.DimensionKindCentimeter) > maximumAllowedSizes, reasonTooLarge, sublog):
|
||||
continue
|
||||
}
|
||||
|
||||
offer := h.goodsItemAsOffer(item, categoryByNameIdx, dimensionDispatcher, sublog)
|
||||
|
||||
if addToSkip(len(offer.PictureURLs) == 0, reasonNoPhoto, sublog) {
|
||||
continue
|
||||
}
|
||||
|
||||
shop.Offers = append(shop.Offers, offer)
|
||||
}
|
||||
if err = iter.Err(); err != nil {
|
||||
return fmt.Errorf("iterating over items: %w", err)
|
||||
}
|
||||
|
||||
var f io.WriteCloser
|
||||
switch path {
|
||||
case "stdout":
|
||||
f = os.Stdout
|
||||
case "stderr":
|
||||
f = os.Stderr
|
||||
default:
|
||||
f, err = os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
errClose := f.Close()
|
||||
if err == nil {
|
||||
err = errClose
|
||||
return
|
||||
}
|
||||
|
||||
log.Err(errClose).Msg("file closed or not")
|
||||
}()
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
shop.Offers = shop.Offers[:limit]
|
||||
}
|
||||
|
||||
container := export.YmlContainer{
|
||||
YmlCatalog: export.YmlCatalog{
|
||||
Shop: shop,
|
||||
Date: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
_, err = f.Write([]byte(xml.Header))
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing header: %w", err)
|
||||
}
|
||||
_, err = f.Write([]byte("<!DOCTYPE yml_catalog SYSTEM \"shops.dtd\">\n"))
|
||||
enc := xml.NewEncoder(f)
|
||||
if pretty {
|
||||
enc.Indent("", " ")
|
||||
}
|
||||
|
||||
log.Info().Int("processed", len(shop.Offers)).Any("skipped", skippedByReasons).Msg("completed")
|
||||
|
||||
return enc.Encode(container)
|
||||
}
|
||||
|
||||
func (h exportHandlers) goodsItemAsOffer(in entity.GoodsItem, categoryIDByName map[string]int64, d dimensionDispatcher, log zerolog.Logger) (out export.Offer) {
|
||||
const defaultType = "vendor.model"
|
||||
const defaultCurrency = "RUR"
|
||||
const defaultAvailable = true
|
||||
const quantityParamName = "Количество на складе «Москва»"
|
||||
const basePictureURL = "https://eway.elevel.ru"
|
||||
|
||||
imgurl := func(path string) string {
|
||||
return basePictureURL + path
|
||||
}
|
||||
|
||||
categoryID := categoryIDByName[in.Type]
|
||||
|
||||
pictureURLs := make([]string, 0, len(in.PhotoURLs))
|
||||
for _, url := range in.PhotoURLs {
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
outurl := imgurl(url)
|
||||
if outurl == basePictureURL {
|
||||
continue
|
||||
}
|
||||
|
||||
pictureURLs = append(pictureURLs, imgurl(url))
|
||||
}
|
||||
|
||||
params := make([]export.Param, 0, len(in.Parameters))
|
||||
for k, v := range in.Parameters {
|
||||
if k == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if d.isDimensionParam(k) {
|
||||
continue
|
||||
}
|
||||
|
||||
params = append(params, export.Param{
|
||||
Name: k,
|
||||
Value: v,
|
||||
})
|
||||
}
|
||||
params = append(params, export.Param{
|
||||
Name: quantityParamName,
|
||||
Value: strconv.Itoa(in.Stock),
|
||||
})
|
||||
|
||||
dimensions := h.formatSizeAsDimensions(in.Sizes, log)
|
||||
out = export.Offer{
|
||||
ID: in.Cart,
|
||||
VendorCode: in.Articul,
|
||||
Price: int(in.Price),
|
||||
Model: in.Name,
|
||||
Vendor: in.Producer,
|
||||
TypePrefix: in.Name,
|
||||
Description: in.Description,
|
||||
Dimensions: dimensions,
|
||||
|
||||
CategoryID: categoryID,
|
||||
PictureURLs: pictureURLs,
|
||||
Params: params,
|
||||
|
||||
Type: defaultType,
|
||||
CurrencyID: defaultCurrency,
|
||||
Available: defaultAvailable,
|
||||
ManufacturerWarrany: true,
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// check cart id 126584
|
||||
func (exportHandlers) formatSizeAsDimensions(size entity.GoodsItemSize, log zerolog.Logger) string {
|
||||
const delimeter = "/"
|
||||
makeFloat := func(d entity.Dimension) string {
|
||||
value := d.AdjustTo(entity.DimensionKindCentimeter).Value
|
||||
value = float64(int(value*100)) / 100.0
|
||||
|
||||
return strconv.FormatFloat(value, 'f', 1, 64)
|
||||
}
|
||||
|
||||
knownSizes := make([]entity.Dimension, 0, 3)
|
||||
|
||||
dimensions := []entity.Dimension{
|
||||
size.Length,
|
||||
size.Width,
|
||||
size.Height,
|
||||
}
|
||||
for i, d := range dimensions {
|
||||
if d.IsZero() {
|
||||
continue
|
||||
}
|
||||
|
||||
knownSizes = append(knownSizes, dimensions[i])
|
||||
}
|
||||
|
||||
l := makeFloat(size.Length)
|
||||
w := makeFloat(size.Width)
|
||||
h := makeFloat(size.Height)
|
||||
switch len(knownSizes) {
|
||||
case 3:
|
||||
// go on
|
||||
case 2:
|
||||
var side string
|
||||
unknownDefaultSize := makeFloat(entity.NewCentimeterDimensionOrEmpty(30))
|
||||
switch {
|
||||
case size.Length.IsZero():
|
||||
side = "length"
|
||||
l = unknownDefaultSize
|
||||
case size.Width.IsZero():
|
||||
side = "width"
|
||||
w = unknownDefaultSize
|
||||
case size.Height.IsZero():
|
||||
side = "height"
|
||||
h = unknownDefaultSize
|
||||
}
|
||||
log.Debug().Str("size", side).Msg("setting to default value")
|
||||
case 1:
|
||||
var side string
|
||||
unknownDefaultSize := makeFloat(entity.NewCentimeterDimensionOrEmpty(30))
|
||||
switch {
|
||||
case !size.Length.IsZero():
|
||||
side = "width"
|
||||
w = unknownDefaultSize
|
||||
case !size.Width.IsZero():
|
||||
side = "length"
|
||||
l = unknownDefaultSize
|
||||
case !size.Height.IsZero():
|
||||
return ""
|
||||
}
|
||||
log.Debug().Str("size", side).Msg("setting to default value")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
// Output should be the following format:
|
||||
// length/width/height in centimeters
|
||||
return strings.Join([]string{
|
||||
l, w, h,
|
||||
}, delimeter)
|
||||
}
|
||||
55
cmd/cli/commands/helper.go
Normal file
55
cmd/cli/commands/helper.go
Normal file
@ -0,0 +1,55 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.loyso.art/frx/eway/cmd/cli/components"
|
||||
"git.loyso.art/frx/eway/internal/entity"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
type empty entity.Empty
|
||||
|
||||
type action func(ctx context.Context, c *cli.Command) error
|
||||
|
||||
func decorateAction(a action) cli.ActionFunc {
|
||||
return func(ctx context.Context, c *cli.Command) error {
|
||||
var data [3]byte
|
||||
_, _ = rand.Read(data[:])
|
||||
reqid := hex.EncodeToString(data[:])
|
||||
|
||||
log, err := components.GetLogger()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting logger: %w", err)
|
||||
}
|
||||
|
||||
log = log.With().Str("reqid", reqid).Logger()
|
||||
rctx := log.WithContext(ctx)
|
||||
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
log.Info().Float64("elapsed", time.Since(start).Seconds()).Msg("command completed")
|
||||
}()
|
||||
|
||||
log.Info().Msg("command execution started")
|
||||
return a(rctx, c)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdWithAction(cmd cli.Command, a action) *cli.Command {
|
||||
if a == nil {
|
||||
a = notImplementedAction
|
||||
}
|
||||
|
||||
cmd.Action = decorateAction(a)
|
||||
return &cmd
|
||||
}
|
||||
|
||||
func notImplementedAction(_ context.Context, _ *cli.Command) error {
|
||||
return entity.ErrNotImplemented
|
||||
}
|
||||
385
cmd/cli/commands/items.go
Normal file
385
cmd/cli/commands/items.go
Normal file
@ -0,0 +1,385 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.loyso.art/frx/eway/cmd/cli/components"
|
||||
"git.loyso.art/frx/eway/internal/entity"
|
||||
"git.loyso.art/frx/eway/internal/matcher"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func ItemsCommandTree() *cli.Command {
|
||||
var h itemsHandlers
|
||||
return &cli.Command{
|
||||
Name: "items",
|
||||
Usage: "Interact with items stored inside db",
|
||||
Commands: []*cli.Command{
|
||||
newItemsGetCommand(h),
|
||||
newItemsCountCommand(h),
|
||||
newItemsUniqueParamsCommand(h),
|
||||
newItemsAggregateParametersCommand(h),
|
||||
newItemsFixSizesCommand(h),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newItemsGetCommand(h itemsHandlers) *cli.Command {
|
||||
cmd := cli.Command{
|
||||
Name: "get",
|
||||
Usage: "gets goods item by its id",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "id",
|
||||
Usage: "id of the goods item. Either id or cart-id should be set",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "cart-id",
|
||||
Usage: "cart-id of the item. Either cart-id or id should be set",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return cmdWithAction(cmd, h.Get)
|
||||
}
|
||||
|
||||
func newItemsCountCommand(h itemsHandlers) *cli.Command {
|
||||
cmd := cli.Command{
|
||||
Name: "count",
|
||||
Usage: "iterates over collection and counts number of items",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "param-key-match",
|
||||
Usage: "filters by parameters with AND logic",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return cmdWithAction(cmd, h.Count)
|
||||
}
|
||||
|
||||
func newItemsUniqueParamsCommand(h itemsHandlers) *cli.Command {
|
||||
cmd := cli.Command{
|
||||
Name: "unique-params",
|
||||
Usage: "Show all stored unique param values",
|
||||
Description: "This command iterates over each item and collect keys of params in a dict and then" +
|
||||
" print it to the output. It's useful to find all unique parameters",
|
||||
}
|
||||
|
||||
return cmdWithAction(cmd, h.UniqueParameters)
|
||||
}
|
||||
|
||||
func newItemsAggregateParametersCommand(h itemsHandlers) *cli.Command {
|
||||
cmd := cli.Command{
|
||||
Name: "aggregate-parameters",
|
||||
Usage: "Show all values of requested parameters",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "case-insensitive",
|
||||
Usage: "Ignores cases of keys",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "regex",
|
||||
Usage: "Registers regex to match",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "keys-only",
|
||||
Usage: "prints only keys",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return cmdWithAction(cmd, h.AggregateParameters)
|
||||
}
|
||||
|
||||
func newItemsFixSizesCommand(h itemsHandlers) *cli.Command {
|
||||
cmd := cli.Command{
|
||||
Name: "fix-sizes",
|
||||
Usage: "Iterates over params and sets sizes from parameters",
|
||||
}
|
||||
|
||||
return cmdWithAction(cmd, h.FixSizes)
|
||||
}
|
||||
|
||||
type itemsHandlers struct{}
|
||||
|
||||
func (itemsHandlers) Get(ctx context.Context, cmd *cli.Command) error {
|
||||
r, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
|
||||
id := cmd.String("id")
|
||||
cartID := cmd.Int("cart-id")
|
||||
if id == "" && cartID == 0 {
|
||||
return cli.Exit("oneof: id or cart-id should be set", 1)
|
||||
} else if id != "" && cartID != 0 {
|
||||
return cli.Exit("oneof: id or cart-id should be set", 1)
|
||||
}
|
||||
|
||||
var item entity.GoodsItem
|
||||
if id != "" {
|
||||
item, err = r.GoodsItem().Get(ctx, id)
|
||||
} else {
|
||||
item, err = r.GoodsItem().GetByCart(ctx, cartID)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting item: %w", err)
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
|
||||
err = enc.Encode(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding item: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (itemsHandlers) Count(ctx context.Context, cmd *cli.Command) error {
|
||||
r, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
|
||||
filters := cmd.StringSlice("param-key-match")
|
||||
m := matcher.NewRadix()
|
||||
patternMapped := make(map[string]empty, len(filters))
|
||||
if len(filters) == 0 {
|
||||
m.Register("*")
|
||||
} else {
|
||||
for _, f := range filters {
|
||||
m.Register(f)
|
||||
}
|
||||
for _, pattern := range m.Patterns() {
|
||||
patternMapped[pattern] = empty{}
|
||||
}
|
||||
}
|
||||
|
||||
var count int
|
||||
items, err := r.GoodsItem().List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting items: %w", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
seenPatterns := map[string]empty{}
|
||||
|
||||
for k := range item.Parameters {
|
||||
pattern := m.MatchByPattern(k)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenPatterns[pattern]; ok {
|
||||
continue
|
||||
}
|
||||
seenPatterns[pattern] = empty{}
|
||||
}
|
||||
|
||||
if len(seenPatterns) == len(patternMapped) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (itemsHandlers) UniqueParameters(ctx context.Context, cmd *cli.Command) error {
|
||||
repository, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
|
||||
knownParams := map[string]empty{}
|
||||
iter, err := repository.GoodsItem().ListIter(ctx, 1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting list iter: %w", err)
|
||||
}
|
||||
for item := range iter {
|
||||
for k := range item.Parameters {
|
||||
knownParams[k] = empty{}
|
||||
}
|
||||
}
|
||||
|
||||
bw := bufio.NewWriter(cmd.Writer)
|
||||
for paramName := range knownParams {
|
||||
_, err = bw.WriteString(paramName + "\n")
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to write: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
func (itemsHandlers) AggregateParameters(ctx context.Context, cmd *cli.Command) error {
|
||||
repository, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
log, err := components.GetLogger()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting logger: %w", err)
|
||||
}
|
||||
|
||||
params := cmd.Args().Slice()
|
||||
opts := make([]matcher.RadixOpt, 0, 1)
|
||||
if cmd.Bool("case-insensitive") {
|
||||
opts = append(opts, matcher.RadixCaseInsensitive())
|
||||
}
|
||||
|
||||
m := matcher.NewRadix(opts...)
|
||||
for _, param := range params {
|
||||
log.Debug().Str("param", param).Msg("registering param")
|
||||
m.Register(param)
|
||||
}
|
||||
for _, regexp := range cmd.StringSlice("regex") {
|
||||
log.Debug().Str("regexp", regexp).Msg("registering regexp")
|
||||
m.RegisterRegexp(regexp)
|
||||
}
|
||||
|
||||
requestedValues := make(map[string]map[string]empty, len(params))
|
||||
requestedValuesByPattern := make(map[string]map[string]empty, len(params))
|
||||
iter := getItemsIter(ctx, repository.GoodsItem())
|
||||
for iter.Next() {
|
||||
item := iter.Get()
|
||||
for k, v := range item.Parameters {
|
||||
matchedPattern := m.MatchByPattern(k)
|
||||
if matchedPattern == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
values, ok := requestedValues[k]
|
||||
if !ok {
|
||||
values = make(map[string]empty)
|
||||
}
|
||||
values[v] = empty{}
|
||||
requestedValues[k] = values
|
||||
|
||||
values, ok = requestedValuesByPattern[matchedPattern]
|
||||
if !ok {
|
||||
values = map[string]empty{}
|
||||
}
|
||||
values[v] = empty{}
|
||||
requestedValuesByPattern[matchedPattern] = values
|
||||
}
|
||||
}
|
||||
|
||||
bw := bufio.NewWriter(cmd.Writer)
|
||||
_, _ = bw.WriteString("Matches:\n")
|
||||
|
||||
if cmd.Bool("keys-only") {
|
||||
for k := range requestedValues {
|
||||
_, _ = bw.WriteString(k + "\n")
|
||||
}
|
||||
} else {
|
||||
for k, v := range requestedValues {
|
||||
_, _ = bw.WriteString(k + ": ")
|
||||
values := make([]string, 0, len(v))
|
||||
for item := range v {
|
||||
values = append(values, strconv.Quote(item))
|
||||
}
|
||||
valuesStr := "[" + strings.Join(values, ",") + "]\n"
|
||||
_, _ = bw.WriteString(valuesStr)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = bw.WriteString("\nPatterns:\n")
|
||||
for _, pattern := range m.Patterns() {
|
||||
_, _ = bw.WriteString(pattern + "\n")
|
||||
}
|
||||
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
func (itemsHandlers) FixSizes(ctx context.Context, cmd *cli.Command) error {
|
||||
repository, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
|
||||
matcher, err := components.GetDimensionMatcher()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting dimension matcher: %w", err)
|
||||
}
|
||||
|
||||
dimensionDispatcher := dimensionDispatcher{m: matcher}
|
||||
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
toUpdate := make([]entity.GoodsItem, 0, 20_000)
|
||||
bus := getItemsIter(ctx, repository.GoodsItem())
|
||||
for bus.Next() {
|
||||
item := bus.Get()
|
||||
|
||||
var valueBeenUpdated bool
|
||||
for key, value := range item.Parameters {
|
||||
trimmedValue := strings.TrimSpace(value)
|
||||
trimmedKey := strings.TrimSpace(key)
|
||||
|
||||
if trimmedKey != key || trimmedValue != value {
|
||||
log.Warn().
|
||||
Str("old_key", key).
|
||||
Str("new_key", trimmedKey).
|
||||
Str("old_value", value).
|
||||
Str("new_value", trimmedValue).
|
||||
Msg("found mismatch")
|
||||
|
||||
delete(item.Parameters, key)
|
||||
|
||||
key = trimmedKey
|
||||
value = trimmedValue
|
||||
|
||||
item.Parameters[key] = value
|
||||
valueBeenUpdated = true
|
||||
}
|
||||
|
||||
updateValue := strings.HasSuffix(key, ":")
|
||||
if updateValue {
|
||||
key = strings.TrimSuffix(key, ":")
|
||||
item.Parameters[key] = value
|
||||
delete(item.Parameters, key+":")
|
||||
valueBeenUpdated = true
|
||||
}
|
||||
|
||||
if dimensionDispatcher.applyDimensionValue(ctx, key, value, &item.Sizes) {
|
||||
valueBeenUpdated = true
|
||||
log.Debug().Str("key", key).Any("sizes", item.Sizes).Msg("been updated")
|
||||
}
|
||||
}
|
||||
|
||||
var updated bool
|
||||
oldSizes := item.Sizes
|
||||
item.Sizes, updated = entity.FixupSizes(oldSizes)
|
||||
if updated {
|
||||
log.Info().Int64("cart", item.Cart).Any("old", oldSizes).Any("new", item.Sizes).Msg("sizes been fixed")
|
||||
}
|
||||
|
||||
valueBeenUpdated = valueBeenUpdated || updated
|
||||
|
||||
if valueBeenUpdated {
|
||||
toUpdate = append(toUpdate, item)
|
||||
}
|
||||
}
|
||||
if bus.Err() != nil {
|
||||
return fmt.Errorf("iterating: %w", bus.Err())
|
||||
}
|
||||
|
||||
_, err = repository.GoodsItem().UpsertMany(ctx, toUpdate...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating items: %w", err)
|
||||
}
|
||||
|
||||
log.Info().Int("count", len(toUpdate)).Msg("updated items")
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.loyso.art/frx/eway/internal/config"
|
||||
"git.loyso.art/frx/eway/internal/dimension"
|
||||
"git.loyso.art/frx/eway/internal/interconnect/eway"
|
||||
"git.loyso.art/frx/eway/internal/storage"
|
||||
xbadger "git.loyso.art/frx/eway/internal/storage/badger"
|
||||
@ -36,7 +37,16 @@ func GetRepository() (storage.Repository, error) {
|
||||
}
|
||||
|
||||
func GetLogger() (zerolog.Logger, error) {
|
||||
return do.Invoke[zerolog.Logger](diInjector)
|
||||
log, err := do.Invoke[*loggerAdapter](diInjector)
|
||||
if err != nil {
|
||||
return zerolog.Nop(), err
|
||||
}
|
||||
|
||||
return log.entity.log, nil
|
||||
}
|
||||
|
||||
func GetDimensionMatcher() (*dimension.Matcher, error) {
|
||||
return do.Invoke[*dimension.Matcher](diInjector)
|
||||
}
|
||||
|
||||
func SetupDI(ctx context.Context, cfgpath string, verbose bool, logAsJSON bool) error {
|
||||
@ -52,14 +62,34 @@ func SetupDI(ctx context.Context, cfgpath string, verbose bool, logAsJSON bool)
|
||||
|
||||
diInjector = do.New()
|
||||
|
||||
do.Provide(diInjector, func(i *do.Injector) (zerolog.Logger, error) {
|
||||
do.Provide(diInjector, func(i *do.Injector) (*loggerAdapter, error) {
|
||||
tsSet := func(wr *zerolog.ConsoleWriter) {
|
||||
wr.TimeFormat = time.RFC3339
|
||||
}
|
||||
|
||||
var writer io.Writer = zerolog.NewConsoleWriter(tsSet)
|
||||
var outfile *os.File
|
||||
var output io.Writer
|
||||
switch cfg.Log.Output {
|
||||
case "", "stdout":
|
||||
output = os.Stdout
|
||||
case "stderr":
|
||||
output = os.Stderr
|
||||
default:
|
||||
outfile, err = os.OpenFile(cfg.Log.Output, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating file for logging: %w", err)
|
||||
}
|
||||
|
||||
output = zerolog.SyncWriter(outfile)
|
||||
}
|
||||
|
||||
var writer io.Writer
|
||||
if logAsJSON {
|
||||
writer = os.Stdout
|
||||
writer = output
|
||||
} else {
|
||||
writer = zerolog.NewConsoleWriter(tsSet, func(w *zerolog.ConsoleWriter) {
|
||||
w.Out = output
|
||||
})
|
||||
}
|
||||
|
||||
log := zerolog.
|
||||
@ -68,12 +98,20 @@ func SetupDI(ctx context.Context, cfgpath string, verbose bool, logAsJSON bool)
|
||||
Timestamp().
|
||||
Str("app", "converter").
|
||||
Logger()
|
||||
if verbose {
|
||||
|
||||
return log.Level(zerolog.DebugLevel), nil
|
||||
if verbose {
|
||||
log = log.Level(zerolog.DebugLevel)
|
||||
} else {
|
||||
log = log.Level(zerolog.InfoLevel)
|
||||
}
|
||||
|
||||
return log.Level(zerolog.InfoLevel), nil
|
||||
out := &logger{
|
||||
log: log,
|
||||
underlyingFile: outfile,
|
||||
}
|
||||
return &loggerAdapter{
|
||||
entity: out,
|
||||
}, nil
|
||||
})
|
||||
|
||||
do.Provide[eway.Client](diInjector, func(i *do.Injector) (eway.Client, error) {
|
||||
@ -115,6 +153,12 @@ func SetupDI(ctx context.Context, cfgpath string, verbose bool, logAsJSON bool)
|
||||
return out, nil
|
||||
})
|
||||
|
||||
do.Provide[*dimension.Matcher](diInjector, func(i *do.Injector) (*dimension.Matcher, error) {
|
||||
matcher := dimension.New(cfg.DimensionMatcher)
|
||||
|
||||
return matcher, nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -139,6 +183,7 @@ type settings struct {
|
||||
Badger config.Badger `toml:"badger"`
|
||||
Log config.Log `toml:"log"`
|
||||
Eway config.Eway `toml:"eway"`
|
||||
DimensionMatcher config.DimensionMatcher `toml:"dimension_matcher"`
|
||||
}
|
||||
|
||||
func parseSettings(cfgpath string) (cfg settings, err error) {
|
||||
@ -150,6 +195,19 @@ func parseSettings(cfgpath string) (cfg settings, err error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
type logger struct {
|
||||
log zerolog.Logger
|
||||
underlyingFile *os.File
|
||||
}
|
||||
|
||||
func (l *logger) Close() error {
|
||||
if l.underlyingFile == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return l.underlyingFile.Close()
|
||||
}
|
||||
|
||||
type entityCloserAdapter[T io.Closer] struct {
|
||||
entity T
|
||||
}
|
||||
@ -160,3 +218,4 @@ func (a entityCloserAdapter[T]) Shutdown() error {
|
||||
|
||||
type storageRepositoryAdapter entityCloserAdapter[storage.Repository]
|
||||
type badgerDBAdapter entityCloserAdapter[*badger.DB]
|
||||
type loggerAdapter entityCloserAdapter[*logger]
|
||||
|
||||
78
cmd/cli/dimensiondispatcher.go
Normal file
78
cmd/cli/dimensiondispatcher.go
Normal file
@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.loyso.art/frx/eway/internal/entity"
|
||||
"git.loyso.art/frx/eway/internal/matcher"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type dimensionDispatcher struct {
|
||||
heigth matcher.Unit
|
||||
width matcher.Unit
|
||||
length matcher.Unit
|
||||
}
|
||||
|
||||
func (d dimensionDispatcher) isDimensionParam(value string) bool {
|
||||
return d.heigth.Match(value) || d.width.Match(value) || d.length.Match(value)
|
||||
}
|
||||
|
||||
func (d dimensionDispatcher) dispatch(ctx context.Context, key, value string, in *entity.GoodsItemSize) (updated bool) {
|
||||
if !d.isDimensionParam(key) {
|
||||
return false
|
||||
}
|
||||
|
||||
log := zerolog.Ctx(ctx).With().Str("key", key).Str("value", value).Logger()
|
||||
if strings.Contains(value, "/") {
|
||||
dimensionValues := strings.Split(value, "/")
|
||||
for _, dv := range dimensionValues {
|
||||
updated = updated || d.dispatch(ctx, key, dv, in)
|
||||
}
|
||||
} else {
|
||||
out, err := entity.ParseDimention(value, entity.DimensionLocalRU)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("unable to parse key, skipping")
|
||||
return false
|
||||
}
|
||||
|
||||
out = out.AdjustTo(entity.DimensionKindCentimeter)
|
||||
|
||||
updated = true
|
||||
switch {
|
||||
case d.heigth.Match(key):
|
||||
in.Height = out
|
||||
case d.width.Match(key):
|
||||
in.Width = out
|
||||
case d.length.Match(key):
|
||||
in.Length = out
|
||||
default:
|
||||
log.Error().Str("key", key).Msg("unable to find proper matcher")
|
||||
updated = false
|
||||
}
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
func makeDefaultDimensionDispatcher() dimensionDispatcher {
|
||||
h := matcher.NewRadix(matcher.RadixCaseInsensitive())
|
||||
h.Register("Высота")
|
||||
h.Register("Высота/*")
|
||||
|
||||
w := matcher.NewRadix(matcher.RadixCaseInsensitive())
|
||||
w.Register("Ширина")
|
||||
w.Register("Ширина/*")
|
||||
|
||||
l := matcher.NewRadix(matcher.RadixCaseInsensitive())
|
||||
l.Register("Длина")
|
||||
l.Register("Длина/*")
|
||||
l.Register("Общ. длина")
|
||||
|
||||
return dimensionDispatcher{
|
||||
heigth: h,
|
||||
width: w,
|
||||
length: l,
|
||||
}
|
||||
}
|
||||
770
cmd/cli/main.go
770
cmd/cli/main.go
@ -4,36 +4,35 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
rooteway "git.loyso.art/frx/eway"
|
||||
"git.loyso.art/frx/eway/cmd/cli/commands"
|
||||
"git.loyso.art/frx/eway/cmd/cli/components"
|
||||
"git.loyso.art/frx/eway/internal/crypto"
|
||||
"git.loyso.art/frx/eway/internal/entity"
|
||||
"git.loyso.art/frx/eway/internal/export"
|
||||
"git.loyso.art/frx/eway/internal/interconnect/eway"
|
||||
"git.loyso.art/frx/eway/internal/matcher"
|
||||
|
||||
"github.com/rodaine/table"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultItemType = "Электрика"
|
||||
)
|
||||
|
||||
type empty entity.Empty
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer func() {
|
||||
@ -65,6 +64,10 @@ func setupDI() cli.BeforeFunc {
|
||||
}
|
||||
|
||||
cfgpath := cmd.String("config")
|
||||
if cfgpath == "" {
|
||||
return errors.New("no config path provided")
|
||||
}
|
||||
|
||||
debugLevel := cmd.Bool("verbose")
|
||||
jsonFormat := cmd.Bool("json")
|
||||
|
||||
@ -100,6 +103,9 @@ func setupCLI() *cli.Command {
|
||||
Name: "config",
|
||||
Usage: "path to config in TOML format",
|
||||
Value: "config.toml",
|
||||
Sources: cli.NewValueSourceChain(
|
||||
cli.EnvVar("EWAY_CONFIG"),
|
||||
),
|
||||
TakesFile: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
@ -124,12 +130,13 @@ func setupCLI() *cli.Command {
|
||||
After: releaseDI(),
|
||||
|
||||
Commands: []*cli.Command{
|
||||
newAppCmd(),
|
||||
commands.CategoriesCommandTree(),
|
||||
commands.ItemsCommandTree(),
|
||||
commands.ExportCommandTree(),
|
||||
|
||||
newCryptoCmd(),
|
||||
newParseCmd(),
|
||||
newImportCmd(),
|
||||
newExportCmd(),
|
||||
newViewCmd(),
|
||||
},
|
||||
}
|
||||
|
||||
@ -162,52 +169,6 @@ func newCryptoEncyptCmd() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func newAppCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "app",
|
||||
Usage: "commands for running different applications",
|
||||
Commands: []*cli.Command{
|
||||
newAppYmlExporter(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newAppYmlExporter() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "ymlexporter",
|
||||
Usage: "server on given port a http api for accessing yml catalog",
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "port",
|
||||
Usage: "Port for accepting connections",
|
||||
Value: 9448,
|
||||
Validator: func(i int64) error {
|
||||
if i > 1<<16 || i == 0 {
|
||||
return cli.Exit("bad port value, allowed values should not exceed 65535", 1)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "src",
|
||||
TakesFile: true,
|
||||
Usage: "Source to catalog. Should be a valid xml file",
|
||||
Value: "yml_catalog.xml",
|
||||
Validator: func(s string) error {
|
||||
_, err := os.Stat(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
Action: appYMLExporterAction,
|
||||
}
|
||||
}
|
||||
|
||||
func newParseCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "parse",
|
||||
@ -308,430 +269,6 @@ func newImportFromFileCmd() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func newExportCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "export",
|
||||
Usage: "category for exporting stored data",
|
||||
Commands: []*cli.Command{
|
||||
newExportYMLCatalogCmd(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newExportYMLCatalogCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "yml_catalog",
|
||||
Usage: "export data into as yml_catalog in xml format",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "out",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "destination path",
|
||||
Value: "yml_catalog.xml",
|
||||
TakesFile: true,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Usage: "limits output offers to required value",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "pretty",
|
||||
Usage: "prettify output",
|
||||
},
|
||||
},
|
||||
Action: decorateAction(exportYMLCatalogAction),
|
||||
}
|
||||
}
|
||||
|
||||
func newViewCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "view",
|
||||
Usage: "Set of commands to view the data inside db",
|
||||
Commands: []*cli.Command{
|
||||
newViewCategoriesCmd(),
|
||||
newViewItemsCmd(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newViewCategoriesCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "categories",
|
||||
Usage: "Set of commands to work with categories",
|
||||
Commands: []*cli.Command{
|
||||
newViewCategoriesListCmd(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newViewItemsCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "items",
|
||||
Usage: "Set of command to work with items",
|
||||
Commands: []*cli.Command{
|
||||
newViewItemsGetCmd(),
|
||||
newViewItemsCountCmd(),
|
||||
newViewItemsUniqueParams(),
|
||||
newViewItemsParamsKnownValues(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newViewItemsUniqueParams() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "unique-params",
|
||||
Usage: "Show all stored unique param values",
|
||||
Description: "This command iterates over each item and collect keys of params in a dict and then" +
|
||||
" print it to the output. It's useful to find all unique parameters",
|
||||
Action: decorateAction(viewItemsUniqueParamsAction),
|
||||
}
|
||||
}
|
||||
|
||||
func newViewItemsParamsKnownValues() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "params-values",
|
||||
Usage: "Show all values of requested parameters",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "case-insensitive",
|
||||
Usage: "Ignores cases of keys",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "regex",
|
||||
Usage: "Registers regex to match",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "keys-only",
|
||||
Usage: "prints only keys",
|
||||
},
|
||||
},
|
||||
Action: decorateAction(viewItemsParamsKnownValuesAction),
|
||||
}
|
||||
}
|
||||
|
||||
func newViewItemsCountCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "count",
|
||||
Usage: "iterates over collection and counts number of items",
|
||||
Action: decorateAction(viewItemsCountAction),
|
||||
}
|
||||
}
|
||||
|
||||
func newViewItemsGetCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "get",
|
||||
Usage: "gets goods item by its id",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "id",
|
||||
Usage: "id of the goods item. Either id or cart-id should be set",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "cart-id",
|
||||
Usage: "cart-id of the item. Either cart-id or id should be set",
|
||||
},
|
||||
},
|
||||
Action: decorateAction(viewItemsGetAction),
|
||||
}
|
||||
}
|
||||
|
||||
func newViewCategoriesListCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "list",
|
||||
Usage: "lists stored categories stored in database",
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Usage: "limits output to selected items",
|
||||
Value: 20,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "page",
|
||||
Usage: "in case of limit, selects page",
|
||||
Value: 0,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "with-total",
|
||||
Usage: "prints total count of categories",
|
||||
},
|
||||
},
|
||||
Action: decorateAction(viewCategoriesListAction),
|
||||
}
|
||||
}
|
||||
|
||||
func viewItemsGetAction(ctx context.Context, c *cli.Command) error {
|
||||
r, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
|
||||
id := c.String("id")
|
||||
cartID := c.Int("cart-id")
|
||||
if id == "" && cartID == 0 {
|
||||
return cli.Exit("oneof: id or cart-id should be set", 1)
|
||||
} else if id != "" && cartID != 0 {
|
||||
return cli.Exit("oneof: id or cart-id should be set", 1)
|
||||
}
|
||||
|
||||
var item entity.GoodsItem
|
||||
if id != "" {
|
||||
item, err = r.GoodsItem().Get(ctx, id)
|
||||
} else {
|
||||
item, err = r.GoodsItem().GetByCart(ctx, cartID)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting item: %w", err)
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
|
||||
err = enc.Encode(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding item: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewItemsUniqueParamsAction(ctx context.Context, c *cli.Command) error {
|
||||
repository, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
|
||||
knownParams := map[string]struct{}{}
|
||||
iter, err := repository.GoodsItem().ListIter(ctx, 1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting list iter: %w", err)
|
||||
}
|
||||
for item := range iter {
|
||||
for k := range item.Parameters {
|
||||
knownParams[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
bw := bufio.NewWriter(c.Writer)
|
||||
for paramName := range knownParams {
|
||||
_, err = bw.WriteString(paramName + "\n")
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to write: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
type chanIter[T any] struct {
|
||||
in <-chan T
|
||||
err error
|
||||
next T
|
||||
}
|
||||
|
||||
func (i *chanIter[T]) Next() (ok bool) {
|
||||
if i.err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
i.next, ok = <-i.in
|
||||
if !ok {
|
||||
i.err = errors.New("channel closed")
|
||||
}
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
func (i *chanIter[T]) Get() T {
|
||||
return i.next
|
||||
}
|
||||
|
||||
func (i *chanIter[T]) Err() error {
|
||||
return i.err
|
||||
}
|
||||
|
||||
func (i *chanIter[T]) Close() {
|
||||
for range i.in {
|
||||
}
|
||||
}
|
||||
|
||||
func getItemsIter(ctx context.Context, r entity.GoodsItemRepository) *chanIter[entity.GoodsItem] {
|
||||
in, err := r.ListIter(ctx, 3)
|
||||
|
||||
return &chanIter[entity.GoodsItem]{
|
||||
in: in,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func viewItemsParamsKnownValuesAction(ctx context.Context, c *cli.Command) error {
|
||||
repository, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
log, err := components.GetLogger()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting logger: %w", err)
|
||||
}
|
||||
|
||||
params := c.Args().Slice()
|
||||
opts := make([]matcher.RadixOpt, 0, 1)
|
||||
if c.Bool("case-insensitive") {
|
||||
opts = append(opts, matcher.RadixCaseInsensitive())
|
||||
}
|
||||
|
||||
m := matcher.NewRadix(opts...)
|
||||
for _, param := range params {
|
||||
log.Debug().Str("param", param).Msg("registering param")
|
||||
m.Register(param)
|
||||
}
|
||||
for _, regexp := range c.StringSlice("regex") {
|
||||
log.Debug().Str("regexp", regexp).Msg("registering regexp")
|
||||
m.RegisterRegexp(regexp)
|
||||
}
|
||||
|
||||
requestedValues := make(map[string]map[string]struct{}, len(params))
|
||||
requestedValuesByPattern := make(map[string]map[string]struct{}, len(params))
|
||||
iter := getItemsIter(ctx, repository.GoodsItem())
|
||||
for iter.Next() {
|
||||
item := iter.Get()
|
||||
for k, v := range item.Parameters {
|
||||
matchedPattern := m.MatchByPattern(k)
|
||||
if matchedPattern == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
values, ok := requestedValues[k]
|
||||
if !ok {
|
||||
values = make(map[string]struct{})
|
||||
}
|
||||
values[v] = struct{}{}
|
||||
requestedValues[k] = values
|
||||
|
||||
values, ok = requestedValuesByPattern[matchedPattern]
|
||||
if !ok {
|
||||
values = map[string]struct{}{}
|
||||
}
|
||||
values[v] = struct{}{}
|
||||
requestedValuesByPattern[matchedPattern] = values
|
||||
}
|
||||
}
|
||||
|
||||
bw := bufio.NewWriter(c.Writer)
|
||||
_, _ = bw.WriteString("Matches:\n")
|
||||
|
||||
if c.Bool("keys-only") {
|
||||
for k := range requestedValues {
|
||||
_, _ = bw.WriteString(k + "\n")
|
||||
}
|
||||
} else {
|
||||
for k, v := range requestedValues {
|
||||
_, _ = bw.WriteString(k + ": ")
|
||||
values := make([]string, 0, len(v))
|
||||
for item := range v {
|
||||
values = append(values, strconv.Quote(item))
|
||||
}
|
||||
valuesStr := "[" + strings.Join(values, ",") + "]\n"
|
||||
_, _ = bw.WriteString(valuesStr)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = bw.WriteString("\nPatterns:\n")
|
||||
for _, pattern := range m.Patterns() {
|
||||
_, _ = bw.WriteString(pattern + "\n")
|
||||
}
|
||||
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
func viewItemsCountAction(ctx context.Context, c *cli.Command) error {
|
||||
r, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
|
||||
itemChan, err := r.GoodsItem().ListIter(ctx, 10)
|
||||
if err != nil {
|
||||
if !errors.Is(err, entity.ErrNotImplemented) {
|
||||
return fmt.Errorf("getting list iter: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var count int
|
||||
if err == nil {
|
||||
var done bool
|
||||
for !done {
|
||||
select {
|
||||
case _, ok := <-itemChan:
|
||||
if !ok {
|
||||
done = true
|
||||
continue
|
||||
}
|
||||
count++
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items, err := r.GoodsItem().List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting list: %w", err)
|
||||
}
|
||||
|
||||
zerolog.Ctx(ctx).Info().Int("count", count).Int("list_count", len(items)).Msg("read all items")
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewCategoriesListAction(ctx context.Context, c *cli.Command) error {
|
||||
r, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
|
||||
categories, err := r.Category().List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing categories: %w", err)
|
||||
}
|
||||
|
||||
limit := int(c.Int("limit"))
|
||||
page := int(c.Int("page"))
|
||||
total := len(categories)
|
||||
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
offset := (page - 1) * limit
|
||||
if offset > len(categories) {
|
||||
offset = len(categories) - 1
|
||||
}
|
||||
|
||||
limit = offset + limit
|
||||
if limit > len(categories) {
|
||||
limit = len(categories)
|
||||
}
|
||||
|
||||
categories = categories[offset:limit]
|
||||
}
|
||||
|
||||
tbl := table.New("ID", "Name")
|
||||
for _, category := range categories {
|
||||
if category.ID == 0 && category.Name == "" {
|
||||
continue
|
||||
}
|
||||
tbl.AddRow(category.ID, category.Name)
|
||||
}
|
||||
|
||||
tbl.Print()
|
||||
|
||||
if c.Bool("with-total") {
|
||||
zerolog.Ctx(ctx).Info().Int("count", total).Msg("total categories stats")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func importFromFileAction(ctx context.Context, c *cli.Command) error {
|
||||
const maxBatch = 2000
|
||||
|
||||
@ -777,14 +314,14 @@ func importFromFileAction(ctx context.Context, c *cli.Command) error {
|
||||
failedToInsert int
|
||||
)
|
||||
|
||||
seenCategories := make(map[string]struct{})
|
||||
seenCategories := make(map[string]empty)
|
||||
categories, err := r.Category().List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing categories: %w", err)
|
||||
}
|
||||
|
||||
for _, category := range categories {
|
||||
seenCategories[category.Name] = struct{}{}
|
||||
seenCategories[category.Name] = empty{}
|
||||
}
|
||||
|
||||
bfile := bufio.NewReader(productsFile)
|
||||
@ -824,7 +361,7 @@ func importFromFileAction(ctx context.Context, c *cli.Command) error {
|
||||
return fmt.Errorf("unable to create new category: %w", err)
|
||||
}
|
||||
log.Debug().Any("category", goodsItem.Type).Msg("inserted new category")
|
||||
seenCategories[goodsItem.Type] = struct{}{}
|
||||
seenCategories[goodsItem.Type] = empty{}
|
||||
}
|
||||
|
||||
log.Debug().Int("count", len(goodsItems)).Int("failed", failedToInsert).Msg("preparing to upload")
|
||||
@ -862,7 +399,7 @@ func decorateAction(a action) cli.ActionFunc {
|
||||
}
|
||||
|
||||
log = log.With().Str("reqid", reqid).Logger()
|
||||
ctx = log.WithContext(ctx)
|
||||
rctx := log.WithContext(ctx)
|
||||
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
@ -870,97 +407,10 @@ func decorateAction(a action) cli.ActionFunc {
|
||||
}()
|
||||
|
||||
log.Info().Msg("command execution started")
|
||||
return a(ctx, c)
|
||||
return a(rctx, c)
|
||||
}
|
||||
}
|
||||
|
||||
func exportYMLCatalogAction(ctx context.Context, c *cli.Command) error {
|
||||
path := c.String("out")
|
||||
limit := c.Int("limit")
|
||||
pretty := c.Bool("pretty")
|
||||
|
||||
log, err := components.GetLogger()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting logger: %w", err)
|
||||
}
|
||||
|
||||
r, err := components.GetRepository()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting repository: %w", err)
|
||||
}
|
||||
|
||||
categories, err := r.Category().List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing categories: %w", err)
|
||||
}
|
||||
|
||||
shop := export.Shop{
|
||||
Currencies: []export.Currency{{
|
||||
ID: "RUR",
|
||||
Rate: 1,
|
||||
}},
|
||||
Categories: make([]export.Category, 0, len(categories)),
|
||||
}
|
||||
|
||||
categoryByNameIdx := make(map[string]int64, len(categories))
|
||||
for _, category := range categories {
|
||||
categoryByNameIdx[category.Name] = category.ID
|
||||
shop.Categories = append(shop.Categories, export.Category{
|
||||
ID: category.ID,
|
||||
Name: category.Name,
|
||||
})
|
||||
}
|
||||
|
||||
itemsIter, err := r.GoodsItem().ListIter(ctx, 1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting items iterator: %w", err)
|
||||
}
|
||||
|
||||
for item := range itemsIter {
|
||||
offer := goodsItemAsOffer(item, categoryByNameIdx)
|
||||
shop.Offers = append(shop.Offers, offer)
|
||||
}
|
||||
if err = ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
errClose := f.Close()
|
||||
if err == nil {
|
||||
err = errClose
|
||||
return
|
||||
}
|
||||
|
||||
log.Err(errClose).Msg("file closed or not")
|
||||
}()
|
||||
|
||||
if limit > 0 {
|
||||
shop.Offers = shop.Offers[:limit]
|
||||
}
|
||||
|
||||
container := export.YmlContainer{
|
||||
YmlCatalog: export.YmlCatalog{
|
||||
Shop: shop,
|
||||
Date: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
_, err = f.Write([]byte(xml.Header))
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing header: %w", err)
|
||||
}
|
||||
_, err = f.Write([]byte("<!DOCTYPE yml_catalog SYSTEM \"shops.dtd\">\n"))
|
||||
enc := xml.NewEncoder(f)
|
||||
if pretty {
|
||||
enc.Indent("", " ")
|
||||
}
|
||||
return enc.Encode(container)
|
||||
}
|
||||
|
||||
func parseEwayGetAction(ctx context.Context, cmd *cli.Command) error {
|
||||
cartID := cmd.Int("cart")
|
||||
|
||||
@ -1120,16 +570,16 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
|
||||
goodsItems := make([]entity.GoodsItem, 0, batchSize)
|
||||
productIDs := make([]int, 0, batchSize)
|
||||
|
||||
knownCategories := make(map[string]struct{})
|
||||
knownCategories := make(map[string]empty)
|
||||
err = entity.IterWithErr(repository.Category().List(ctx)).Do(func(c entity.Category) error {
|
||||
knownCategories[c.Name] = struct{}{}
|
||||
knownCategories[c.Name] = empty{}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("filling known categories: %w", err)
|
||||
}
|
||||
|
||||
itemsUpdated := make(map[string]struct{}, len(seenItems))
|
||||
itemsUpdated := make(map[string]empty, len(seenItems))
|
||||
stats := struct {
|
||||
fetchedInfo int
|
||||
handledAll int
|
||||
@ -1137,6 +587,8 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
|
||||
skippedItem int
|
||||
}{}
|
||||
|
||||
dimensionDispatcher := makeDefaultDimensionDispatcher()
|
||||
|
||||
startFrom := time.Now()
|
||||
for {
|
||||
select {
|
||||
@ -1153,7 +605,9 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
|
||||
RemmantsAtleast: 5,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting next goods batch: %w", err)
|
||||
logger.Warn().Err(err).Msg("unable to get items from catalog")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
productIDs = productIDs[:0]
|
||||
@ -1163,6 +617,7 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
|
||||
|
||||
remnants, err := client.GetGoodsRemnants(ctx, productIDs)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("unable to get goods remnants")
|
||||
return fmt.Errorf("getting goods remnants: %w", err)
|
||||
}
|
||||
|
||||
@ -1172,8 +627,9 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
|
||||
seenItem := seenItems[item.SKU]
|
||||
if time.Since(seenItem.CreatedAt) < time.Hour*24 {
|
||||
logger.Debug().Str("sku", item.SKU).Msg("skipping item because it's too fresh")
|
||||
|
||||
stats.skippedItem++
|
||||
itemsUpdated[item.SKU] = struct{}{}
|
||||
itemsUpdated[item.SKU] = empty{}
|
||||
|
||||
continue
|
||||
}
|
||||
@ -1185,23 +641,30 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
|
||||
} else {
|
||||
pi, err = client.GetProductInfo(ctx, int64(item.Cart))
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting product info: %w", err)
|
||||
logger.Warn().Err(err).Msg("unable to get product info, skipping")
|
||||
continue
|
||||
}
|
||||
stats.fetchedInfo++
|
||||
}
|
||||
|
||||
goodsItem, err := entity.MakeGoodsItem(item, remnants, pi)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Any("item", item).Msg("unable to make goods item")
|
||||
logger.Err(err).Any("item", item).Msg("unable to make goods item")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
itemsUpdated[goodsItem.Articul] = struct{}{}
|
||||
for key, value := range goodsItem.Parameters {
|
||||
dimensionDispatcher.dispatch(ctx, key, value, &goodsItem.Sizes)
|
||||
}
|
||||
|
||||
itemsUpdated[goodsItem.Articul] = empty{}
|
||||
stats.handledAll++
|
||||
|
||||
goodsItems = append(goodsItems, goodsItem)
|
||||
if goodsItem.Type == "" {
|
||||
continue
|
||||
logger.Warn().Int64("cart_id", goodsItem.Cart).Msg("found item without category, setting default type")
|
||||
goodsItem.Type = defaultItemType
|
||||
}
|
||||
|
||||
if _, ok := knownCategories[goodsItem.Type]; ok {
|
||||
@ -1213,12 +676,12 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
|
||||
return fmt.Errorf("creating category: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
logger.Info().
|
||||
Str("name", category.Name).
|
||||
Int64("id", category.ID).
|
||||
Msg("created new category")
|
||||
|
||||
knownCategories[goodsItem.Type] = struct{}{}
|
||||
knownCategories[goodsItem.Type] = empty{}
|
||||
}
|
||||
|
||||
_, err = repository.GoodsItem().UpsertMany(ctx, goodsItems...)
|
||||
@ -1228,14 +691,14 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
|
||||
|
||||
progressFloat := float64(start) / float64(total)
|
||||
progress := big.NewFloat(progressFloat).Text('f', 3)
|
||||
|
||||
elapsed := time.Since(startFrom).Seconds()
|
||||
|
||||
var left int
|
||||
if progressFloat != 0 {
|
||||
left = int(((1 - progressFloat) / progressFloat) * elapsed)
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
logger.Info().
|
||||
Int("from", start).
|
||||
Int("to", start+batchSize).
|
||||
Int("total", total).
|
||||
@ -1266,140 +729,15 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
|
||||
for k := range seenItems {
|
||||
_, err := repository.GoodsItem().Delete(ctx, k)
|
||||
if err != nil {
|
||||
if errors.Is(err, entity.ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
logger.Warn().Err(err).Str("sku", k).Msg("unable to delete item")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debug().Str("sku", k).Msg("deleted item")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func goodsItemAsOffer(in entity.GoodsItem, categoryIDByName map[string]int64) (out export.Offer) {
|
||||
const defaultType = "vendor.model"
|
||||
const defaultCurrency = "RUR"
|
||||
const defaultAvailable = true
|
||||
const quantityParamName = "Количество на складе «Москва»"
|
||||
const basePictureURL = "https://eway.elevel.ru"
|
||||
|
||||
imgurl := func(path string) string {
|
||||
return basePictureURL + path
|
||||
}
|
||||
|
||||
categoryID := categoryIDByName[in.Type]
|
||||
|
||||
pictureURLs := make([]string, 0, len(in.PhotoURLs))
|
||||
for _, url := range in.PhotoURLs {
|
||||
pictureURLs = append(pictureURLs, imgurl(url))
|
||||
}
|
||||
params := make([]export.Param, len(in.Parameters))
|
||||
for k, v := range in.Parameters {
|
||||
params = append(params, export.Param{
|
||||
Name: k,
|
||||
Value: v,
|
||||
})
|
||||
}
|
||||
params = append(params, export.Param{
|
||||
Name: quantityParamName,
|
||||
Value: strconv.Itoa(in.Stock),
|
||||
})
|
||||
out = export.Offer{
|
||||
ID: in.Cart,
|
||||
VendorCode: in.Articul,
|
||||
Price: int(in.TariffPrice),
|
||||
CategoryID: categoryID,
|
||||
PictureURLs: pictureURLs,
|
||||
|
||||
Model: in.Name,
|
||||
Vendor: in.Producer,
|
||||
TypePrefix: in.Name,
|
||||
Description: in.Description,
|
||||
ManufacturerWarrany: true,
|
||||
Params: params,
|
||||
|
||||
Type: defaultType,
|
||||
CurrencyID: defaultCurrency,
|
||||
Available: defaultAvailable,
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func appYMLExporterAction(ctx context.Context, cmd *cli.Command) error {
|
||||
port := cmd.Int("port")
|
||||
src := cmd.String("src")
|
||||
|
||||
log, err := components.GetLogger()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting logger: %w", err)
|
||||
}
|
||||
|
||||
mw := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
remoteAddr := r.RemoteAddr
|
||||
xff := r.Header.Get("x-forwarded-for")
|
||||
method := r.Method
|
||||
ua := r.UserAgent()
|
||||
xreqid := r.Header.Get("x-request-id")
|
||||
if xreqid == "" {
|
||||
const reqsize = 4
|
||||
var reqidRaw [reqsize]byte
|
||||
_, _ = rand.Read(reqidRaw[:])
|
||||
value := binary.BigEndian.Uint32(reqidRaw[:])
|
||||
xreqid = fmt.Sprintf("%x", value)
|
||||
w.Header().Set("x-request-id", xreqid)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
xlog := log.With().Str("request_id", xreqid).Logger()
|
||||
xlog.Debug().
|
||||
Str("path", path).
|
||||
Str("remote_addr", remoteAddr).
|
||||
Str("xff", xff).
|
||||
Str("method", method).
|
||||
Str("user_agent", ua).
|
||||
Msg("incoming request")
|
||||
|
||||
xctx := xlog.WithContext(r.Context())
|
||||
r = r.WithContext(xctx)
|
||||
next(w, r)
|
||||
|
||||
elapsed := time.Since(start).Truncate(time.Millisecond).Seconds()
|
||||
xlog.Info().
|
||||
Float64("elapsed", elapsed).
|
||||
Msg("request completed")
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/yml_catalog.xml", mw(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, src)
|
||||
}))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: net.JoinHostPort("0.0.0.0", strconv.Itoa(int(port))),
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
|
||||
sdctx, sdcancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer sdcancel()
|
||||
|
||||
errShutdown := srv.Shutdown(sdctx)
|
||||
if errShutdown != nil {
|
||||
log.Warn().Err(errShutdown).Msg("unable to shutdown server")
|
||||
}
|
||||
}()
|
||||
|
||||
log.Info().Str("listen_addr", srv.Addr).Msg("running server")
|
||||
|
||||
err = srv.ListenAndServe()
|
||||
if err != nil && errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("serving http api: %w", err)
|
||||
logger.Info().Str("sku", k).Msg("deleted item")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
16
config.toml
16
config.toml
@ -1,16 +0,0 @@
|
||||
[badger]
|
||||
debug = false
|
||||
dir = "database/"
|
||||
|
||||
[log]
|
||||
level = "info"
|
||||
format = "text"
|
||||
|
||||
[eway]
|
||||
login = "leci@yandex.ru"
|
||||
password = "2a136e113854cc5d46b868919a7d6e939156ccb55ff12e87861513f7767af98be79e62407410"
|
||||
_session_id = "19b98ed56cc144f47e040e68dbcd8481"
|
||||
_session_user = "1490"
|
||||
owner_id = "26476"
|
||||
debug = false
|
||||
workers_pool = 3
|
||||
5
go.mod
5
go.mod
@ -13,12 +13,14 @@ require (
|
||||
github.com/rodaine/table v1.1.1
|
||||
github.com/rs/zerolog v1.31.0
|
||||
github.com/samber/do v1.6.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/urfave/cli/v3 v3.0.0-alpha8
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/glog v1.0.0 // indirect
|
||||
@ -26,12 +28,15 @@ require (
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/golang/snappy v0.0.3 // indirect
|
||||
github.com/klauspost/compress v1.12.3 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect
|
||||
go.opencensus.io v0.22.5 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
7
go.sum
7
go.sum
@ -13,6 +13,7 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@ -53,6 +54,10 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU=
|
||||
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
@ -184,6 +189,8 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
|
||||
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@ -52,4 +52,5 @@ func (l *LogFormat) UnmarshalText(data []byte) (err error) {
|
||||
type Log struct {
|
||||
Level string `json:"level" toml:"level"`
|
||||
Format string `json:"format" toml:"format"`
|
||||
Output string `json:"output" toml:"output"`
|
||||
}
|
||||
|
||||
42
internal/config/matcher.go
Normal file
42
internal/config/matcher.go
Normal file
@ -0,0 +1,42 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MatcherPredicateType uint8
|
||||
|
||||
const (
|
||||
MatcherPredicateTypeUnknown MatcherPredicateType = iota
|
||||
MatcherPredicateTypePattern
|
||||
MatcherPredicateTypeRegexp
|
||||
)
|
||||
|
||||
func (t *MatcherPredicateType) UnmarshalText(data []byte) error {
|
||||
switch dataStr := strings.ToLower(string(data)); dataStr {
|
||||
case "":
|
||||
*t = MatcherPredicateTypeUnknown
|
||||
case "pattern":
|
||||
*t = MatcherPredicateTypePattern
|
||||
case "regexp":
|
||||
*t = MatcherPredicateTypeRegexp
|
||||
default:
|
||||
return errors.New("unsupported type " + dataStr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type MatcherPredicate struct {
|
||||
Value string `toml:"value"`
|
||||
Type MatcherPredicateType `toml:"type"`
|
||||
}
|
||||
|
||||
type DimensionMatcher struct {
|
||||
CaseInsensitive bool `toml:"case_insensitive"`
|
||||
|
||||
Length []MatcherPredicate `toml:"length"`
|
||||
Width []MatcherPredicate `toml:"width"`
|
||||
Height []MatcherPredicate `toml:"height"`
|
||||
}
|
||||
96
internal/dimension/matcher.go
Normal file
96
internal/dimension/matcher.go
Normal file
@ -0,0 +1,96 @@
|
||||
package dimension
|
||||
|
||||
import (
|
||||
"git.loyso.art/frx/eway/internal/config"
|
||||
"git.loyso.art/frx/eway/internal/matcher"
|
||||
)
|
||||
|
||||
type MatchResult uint8
|
||||
|
||||
const (
|
||||
MatchResultMiss MatchResult = iota
|
||||
MatchResultLength
|
||||
MatchResultHeight
|
||||
MatchResultWidth
|
||||
MatchResultDepth
|
||||
)
|
||||
|
||||
type Matcher struct {
|
||||
length matcher.Unit
|
||||
width matcher.Unit
|
||||
height matcher.Unit
|
||||
}
|
||||
|
||||
func New(cfg config.DimensionMatcher) *Matcher {
|
||||
return &Matcher{
|
||||
length: makeMatcherByConig(cfg.CaseInsensitive, cfg.Length...),
|
||||
width: makeMatcherByConig(cfg.CaseInsensitive, cfg.Width...),
|
||||
height: makeMatcherByConig(cfg.CaseInsensitive, cfg.Height...),
|
||||
}
|
||||
}
|
||||
|
||||
func makeMatcherByConig(insensitive bool, cfgs ...config.MatcherPredicate) matcher.Unit {
|
||||
opts := make([]matcher.RadixOpt, 0, 1)
|
||||
if insensitive {
|
||||
opts = append(opts, matcher.RadixCaseInsensitive())
|
||||
}
|
||||
m := matcher.NewRadix(opts...)
|
||||
|
||||
for _, cfg := range cfgs {
|
||||
switch cfg.Type {
|
||||
case config.MatcherPredicateTypePattern:
|
||||
m.Register(cfg.Value)
|
||||
case config.MatcherPredicateTypeRegexp:
|
||||
m.RegisterRegexp(cfg.Value)
|
||||
default:
|
||||
panic("unsupported matcher type")
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Matcher) Match(value string) (r MatchResult, priority bool) {
|
||||
switch {
|
||||
case value == "Высота":
|
||||
priority = true
|
||||
fallthrough
|
||||
case m.height.Match(value):
|
||||
return MatchResultHeight, priority
|
||||
case value == "Глубина":
|
||||
priority = true
|
||||
return MatchResultDepth, priority
|
||||
case value == "Длина":
|
||||
priority = true
|
||||
fallthrough
|
||||
case m.length.Match(value):
|
||||
return MatchResultLength, priority
|
||||
case value == "Ширина":
|
||||
priority = true
|
||||
fallthrough
|
||||
case m.width.Match(value):
|
||||
return MatchResultWidth, priority
|
||||
}
|
||||
|
||||
return MatchResultMiss, false
|
||||
}
|
||||
|
||||
func (m *Matcher) GetRegisteredPatterns() map[string][]string {
|
||||
out := map[string][]string{
|
||||
"length": nil,
|
||||
"width": nil,
|
||||
"height": nil,
|
||||
}
|
||||
|
||||
if m.height != nil {
|
||||
out["height"] = m.height.Patterns()
|
||||
}
|
||||
if m.width != nil {
|
||||
out["width"] = m.width.Patterns()
|
||||
}
|
||||
if m.length != nil {
|
||||
out["length"] = m.length.Patterns()
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
49
internal/encoding/fbs/Dimensions.go
Normal file
49
internal/encoding/fbs/Dimensions.go
Normal file
@ -0,0 +1,49 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package fbs
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type Dimensions struct {
|
||||
_tab flatbuffers.Struct
|
||||
}
|
||||
|
||||
func (rcv *Dimensions) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *Dimensions) Table() flatbuffers.Table {
|
||||
return rcv._tab.Table
|
||||
}
|
||||
|
||||
func (rcv *Dimensions) Width() float32 {
|
||||
return rcv._tab.GetFloat32(rcv._tab.Pos + flatbuffers.UOffsetT(0))
|
||||
}
|
||||
func (rcv *Dimensions) MutateWidth(n float32) bool {
|
||||
return rcv._tab.MutateFloat32(rcv._tab.Pos+flatbuffers.UOffsetT(0), n)
|
||||
}
|
||||
|
||||
func (rcv *Dimensions) Height() float32 {
|
||||
return rcv._tab.GetFloat32(rcv._tab.Pos + flatbuffers.UOffsetT(4))
|
||||
}
|
||||
func (rcv *Dimensions) MutateHeight(n float32) bool {
|
||||
return rcv._tab.MutateFloat32(rcv._tab.Pos+flatbuffers.UOffsetT(4), n)
|
||||
}
|
||||
|
||||
func (rcv *Dimensions) Length() float32 {
|
||||
return rcv._tab.GetFloat32(rcv._tab.Pos + flatbuffers.UOffsetT(8))
|
||||
}
|
||||
func (rcv *Dimensions) MutateLength(n float32) bool {
|
||||
return rcv._tab.MutateFloat32(rcv._tab.Pos+flatbuffers.UOffsetT(8), n)
|
||||
}
|
||||
|
||||
func CreateDimensions(builder *flatbuffers.Builder, width float32, height float32, length float32) flatbuffers.UOffsetT {
|
||||
builder.Prep(4, 12)
|
||||
builder.PrependFloat32(length)
|
||||
builder.PrependFloat32(height)
|
||||
builder.PrependFloat32(width)
|
||||
return builder.Offset()
|
||||
}
|
||||
@ -189,8 +189,21 @@ func (rcv *GoodItem) MutateCreatedAt(n int64) bool {
|
||||
return rcv._tab.MutateInt64Slot(32, n)
|
||||
}
|
||||
|
||||
func (rcv *GoodItem) Sizes(obj *Dimensions) *Dimensions {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(34))
|
||||
if o != 0 {
|
||||
x := o + rcv._tab.Pos
|
||||
if obj == nil {
|
||||
obj = new(Dimensions)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GoodItemStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(15)
|
||||
builder.StartObject(16)
|
||||
}
|
||||
func GoodItemAddSku(builder *flatbuffers.Builder, sku flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(sku), 0)
|
||||
@ -237,6 +250,9 @@ func GoodItemAddParameters(builder *flatbuffers.Builder, parameters flatbuffers.
|
||||
func GoodItemAddCreatedAt(builder *flatbuffers.Builder, createdAt int64) {
|
||||
builder.PrependInt64Slot(14, createdAt, 0)
|
||||
}
|
||||
func GoodItemAddSizes(builder *flatbuffers.Builder, sizes flatbuffers.UOffsetT) {
|
||||
builder.PrependStructSlot(15, flatbuffers.UOffsetT(sizes), 0)
|
||||
}
|
||||
func GoodItemEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@ -82,6 +82,13 @@ func makeDomainGoodItem(builder *flatbuffers.Builder, in entity.GoodsItem) flatb
|
||||
|
||||
producer := builder.CreateString(in.Producer)
|
||||
|
||||
var w, h, l float32
|
||||
if in.Sizes != (entity.GoodsItemSize{}) {
|
||||
w = float32(in.Sizes.Width.AdjustTo(entity.DimensionKindCentimeter).Value)
|
||||
h = float32(in.Sizes.Height.AdjustTo(entity.DimensionKindCentimeter).Value)
|
||||
l = float32(in.Sizes.Length.AdjustTo(entity.DimensionKindCentimeter).Value)
|
||||
}
|
||||
|
||||
GoodItemStart(builder)
|
||||
GoodItemAddSku(builder, sku)
|
||||
GoodItemAddPhoto(builder, photo)
|
||||
@ -100,6 +107,7 @@ func makeDomainGoodItem(builder *flatbuffers.Builder, in entity.GoodsItem) flatb
|
||||
GoodItemAddStock(builder, int16(in.Stock))
|
||||
GoodItemAddParameters(builder, parameters)
|
||||
GoodItemAddCreatedAt(builder, in.CreatedAt.Unix())
|
||||
GoodItemAddSizes(builder, CreateDimensions(builder, w, h, l))
|
||||
|
||||
return GoodItemEnd(builder)
|
||||
}
|
||||
@ -107,7 +115,10 @@ func makeDomainGoodItem(builder *flatbuffers.Builder, in entity.GoodsItem) flatb
|
||||
func ParseGoodsItem(data []byte) (item entity.GoodsItem, err error) {
|
||||
itemFBS := GetRootAsGoodItem(data, 0)
|
||||
item.Articul = string(itemFBS.Sku())
|
||||
item.PhotoURLs = strings.Split(string(itemFBS.Photo()), ";")
|
||||
photoURLs := string(itemFBS.Photo())
|
||||
if len(photoURLs) > 0 {
|
||||
item.PhotoURLs = strings.Split(photoURLs, ";")
|
||||
}
|
||||
item.Name = string(itemFBS.Name())
|
||||
|
||||
description, err := base64.RawStdEncoding.DecodeString(string(itemFBS.Description()))
|
||||
@ -136,6 +147,20 @@ func ParseGoodsItem(data []byte) (item entity.GoodsItem, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
var w, h, l float64
|
||||
sizes := itemFBS.Sizes(nil)
|
||||
if sizes != nil {
|
||||
w = float64(sizes.Width())
|
||||
h = float64(sizes.Height())
|
||||
l = float64(sizes.Length())
|
||||
}
|
||||
|
||||
item.Sizes = entity.GoodsItemSize{
|
||||
Width: entity.NewCentimeterDimensionOrEmpty(w),
|
||||
Height: entity.NewCentimeterDimensionOrEmpty(h),
|
||||
Length: entity.NewCentimeterDimensionOrEmpty(l),
|
||||
}
|
||||
|
||||
createdAt := itemFBS.CreatedAt()
|
||||
if createdAt > 0 {
|
||||
item.CreatedAt = time.Unix(createdAt, 0)
|
||||
|
||||
154
internal/entity/dimension.go
Normal file
154
internal/entity/dimension.go
Normal file
@ -0,0 +1,154 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var DefaultLocale = DimensionLocalRU
|
||||
|
||||
type DimensionLocale uint8
|
||||
|
||||
const (
|
||||
DimensionLocalUnspecified DimensionLocale = iota
|
||||
DimensionLocalRU
|
||||
|
||||
dimensionLocalEnd
|
||||
)
|
||||
|
||||
type DimensionKind uint8
|
||||
|
||||
func (k DimensionKind) GetPos() float64 {
|
||||
switch k {
|
||||
case DimensionKindMilimeter:
|
||||
return 1
|
||||
case DimensionKindCentimeter:
|
||||
return 10
|
||||
case DimensionKindMeter:
|
||||
return 1000
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (k DimensionKind) String() string {
|
||||
m := getLocaleKindToStringMap()[DefaultLocale]
|
||||
return m[k]
|
||||
}
|
||||
|
||||
const (
|
||||
DimensionKindUnspecified DimensionKind = iota
|
||||
DimensionKindMilimeter
|
||||
DimensionKindCentimeter
|
||||
DimensionKindMeter
|
||||
)
|
||||
|
||||
type Dimension struct {
|
||||
Value float64
|
||||
Kind DimensionKind
|
||||
}
|
||||
|
||||
func (d Dimension) IsZero() bool {
|
||||
return d.Value == 0
|
||||
}
|
||||
|
||||
func (d Dimension) MarshalText() ([]byte, error) {
|
||||
if d.Value == 0 && d.Kind == DimensionKindUnspecified {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
value := strconv.FormatFloat(d.Value, 'f', 4, 64) + " " + d.Kind.String()
|
||||
return []byte(value), nil
|
||||
}
|
||||
|
||||
func (d *Dimension) UnmarshalText(data []byte) (err error) {
|
||||
*d, err = ParseDimention(string(data), DefaultLocale)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d Dimension) AdjustTo(kind DimensionKind) Dimension {
|
||||
from := d.Kind.GetPos()
|
||||
to := kind.GetPos()
|
||||
|
||||
switch {
|
||||
case from < to:
|
||||
mult := to / from
|
||||
return Dimension{
|
||||
Kind: kind,
|
||||
Value: d.Value / float64(mult),
|
||||
}
|
||||
case from > to:
|
||||
mult := from / to
|
||||
return Dimension{
|
||||
Kind: kind,
|
||||
Value: d.Value * float64(mult),
|
||||
}
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func ParseDimention(value string, locale DimensionLocale) (Dimension, error) {
|
||||
switch locale {
|
||||
case DimensionLocalRU:
|
||||
default:
|
||||
return Dimension{}, SimpleError("unknown locale for parse")
|
||||
}
|
||||
|
||||
dimensionStrToKind := getLocaleToKindMap()[locale]
|
||||
lastSpaceIdx := strings.LastIndex(value, " ")
|
||||
if lastSpaceIdx == -1 {
|
||||
return Dimension{}, SimpleError("expected 2 values after split for value " + value)
|
||||
}
|
||||
|
||||
var splitted [2]string
|
||||
splitted[0] = strings.ReplaceAll(value[:lastSpaceIdx], " ", "")
|
||||
splitted[1] = value[lastSpaceIdx+1:]
|
||||
|
||||
var out Dimension
|
||||
var ok bool
|
||||
out.Kind, ok = dimensionStrToKind[splitted[1]]
|
||||
if !ok {
|
||||
return Dimension{}, SimpleError("dimension map not found for kind " + splitted[1])
|
||||
}
|
||||
|
||||
var err error
|
||||
out.Value, err = strconv.ParseFloat(splitted[0], 64)
|
||||
if err != nil {
|
||||
return Dimension{}, fmt.Errorf("parsing value: %w", err)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func NewCentimeterDimensionOrEmpty(value float64) Dimension {
|
||||
if value == 0 {
|
||||
return Dimension{}
|
||||
}
|
||||
|
||||
return Dimension{
|
||||
Value: value,
|
||||
Kind: DimensionKindCentimeter,
|
||||
}
|
||||
}
|
||||
|
||||
func getLocaleToKindMap() map[DimensionLocale]map[string]DimensionKind {
|
||||
return map[DimensionLocale]map[string]DimensionKind{
|
||||
DimensionLocalRU: {
|
||||
"мм": DimensionKindMilimeter,
|
||||
"см": DimensionKindCentimeter,
|
||||
"м": DimensionKindMeter,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getLocaleKindToStringMap() map[DimensionLocale]map[DimensionKind]string {
|
||||
return map[DimensionLocale]map[DimensionKind]string{
|
||||
DimensionLocalRU: {
|
||||
DimensionKindMilimeter: "мм",
|
||||
DimensionKindCentimeter: "см",
|
||||
DimensionKindMeter: "м",
|
||||
},
|
||||
}
|
||||
}
|
||||
31
internal/entity/dimension_inner_test.go
Normal file
31
internal/entity/dimension_inner_test.go
Normal file
@ -0,0 +1,31 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLocaleMap(t *testing.T) {
|
||||
kindToStr := getLocaleKindToStringMap()
|
||||
strToKind := getLocaleToKindMap()
|
||||
|
||||
assert := assert.New(t)
|
||||
for locale := DimensionLocalUnspecified + 1; locale < dimensionLocalEnd; locale++ {
|
||||
localeKinds, ok := kindToStr[locale]
|
||||
assert.True(ok)
|
||||
localeStrs, ok := strToKind[locale]
|
||||
assert.True(ok)
|
||||
|
||||
assert.Equal(len(localeKinds), len(localeStrs))
|
||||
|
||||
for kindKey, kindValue := range localeKinds {
|
||||
strKey := kindValue
|
||||
strValue, ok := localeStrs[strKey]
|
||||
assert.True(ok)
|
||||
|
||||
assert.Equal(kindKey, strValue)
|
||||
assert.Equal(strKey, kindValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
72
internal/entity/dimension_test.go
Normal file
72
internal/entity/dimension_test.go
Normal file
@ -0,0 +1,72 @@
|
||||
package entity_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.loyso.art/frx/eway/internal/entity"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDimension_AdjustTo(t *testing.T) {
|
||||
// Test adjusting from smaller dimension to larger one
|
||||
d := entity.Dimension{Value: 5.0, Kind: entity.DimensionKindCentimeter}
|
||||
expected := entity.Dimension{Value: 0.05, Kind: entity.DimensionKindMeter}
|
||||
actual := d.AdjustTo(entity.DimensionKindMeter)
|
||||
|
||||
assert.EqualValues(t, expected.Value, actual.Value)
|
||||
assert.Equal(t, expected.Kind, actual.Kind)
|
||||
|
||||
// Test adjusting from larger dimension to smaller one
|
||||
d = entity.Dimension{Value: 0.05, Kind: entity.DimensionKindMeter}
|
||||
expected = entity.Dimension{Value: 50.0, Kind: entity.DimensionKindMilimeter}
|
||||
actual = d.AdjustTo(entity.DimensionKindMilimeter)
|
||||
|
||||
assert.EqualValues(t, expected.Value, actual.Value)
|
||||
assert.Equal(t, expected.Kind, actual.Kind)
|
||||
}
|
||||
|
||||
func TestParseDimension_Success(t *testing.T) {
|
||||
// Test parsing a valid dimension string with RU locale
|
||||
input := "10 см"
|
||||
expected := entity.Dimension{Value: 10.0, Kind: entity.DimensionKindCentimeter}
|
||||
|
||||
actual, err := entity.ParseDimention(input, entity.DimensionLocalRU)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.EqualValues(t, expected.Value, actual.Value)
|
||||
assert.Equal(t, expected.Kind, actual.Kind)
|
||||
}
|
||||
|
||||
func TestParseDimensionComplex_Success(t *testing.T) {
|
||||
// Test parsing a valid dimension string with RU locale
|
||||
input := "10 256.20 см"
|
||||
expected := entity.Dimension{Value: 10256.20, Kind: entity.DimensionKindCentimeter}
|
||||
|
||||
actual, err := entity.ParseDimention(input, entity.DimensionLocalRU)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.EqualValues(t, expected.Value, actual.Value)
|
||||
assert.Equal(t, expected.Kind, actual.Kind)
|
||||
}
|
||||
|
||||
func TestParseDimension_InvalidInputFormat(t *testing.T) {
|
||||
// Test parsing an invalid dimension string with RU locale
|
||||
input := "invalid value 2"
|
||||
expectedErr := errors.New("expected 2 values after split for value invalid value 2")
|
||||
|
||||
_, err := entity.ParseDimention(input, entity.DimensionLocalRU)
|
||||
assert.Error(t, err)
|
||||
assert.EqualError(t, err, expectedErr.Error())
|
||||
}
|
||||
|
||||
func TestParseDimension_InvalidLocale(t *testing.T) {
|
||||
// Test parsing a dimension string with an unsupported locale
|
||||
input := "10 мм"
|
||||
expectedErr := errors.New("unknown locale for parse")
|
||||
|
||||
_, err := entity.ParseDimention(input, 3) // An invalid locale value is used here for demonstration purposes
|
||||
assert.EqualError(t, err, expectedErr.Error())
|
||||
}
|
||||
3
internal/entity/empty.go
Normal file
3
internal/entity/empty.go
Normal file
@ -0,0 +1,3 @@
|
||||
package entity
|
||||
|
||||
type Empty struct{}
|
||||
@ -8,6 +8,80 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type GoodsItemSize struct {
|
||||
Width Dimension
|
||||
Height Dimension
|
||||
Length Dimension
|
||||
|
||||
UnmatchedDepth Dimension
|
||||
}
|
||||
|
||||
func FixupSizes(s GoodsItemSize) (GoodsItemSize, bool) {
|
||||
// Nothing to substitute
|
||||
if s.UnmatchedDepth.IsZero() {
|
||||
return s, false
|
||||
}
|
||||
|
||||
var count int
|
||||
for _, d := range []Dimension{
|
||||
s.Width,
|
||||
s.Height,
|
||||
s.Length,
|
||||
} {
|
||||
if d.IsZero() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
// Can only replace one dimension
|
||||
if count != 1 {
|
||||
return s, false
|
||||
}
|
||||
|
||||
switch {
|
||||
case s.Width.IsZero():
|
||||
s.Width = s.UnmatchedDepth
|
||||
case s.Height.IsZero():
|
||||
s.Height = s.UnmatchedDepth
|
||||
case s.Length.IsZero():
|
||||
s.Length = s.UnmatchedDepth
|
||||
}
|
||||
|
||||
s.UnmatchedDepth = Dimension{}
|
||||
|
||||
return s, true
|
||||
}
|
||||
|
||||
func (s GoodsItemSize) AllSizesSet() bool {
|
||||
var count int
|
||||
for _, d := range []Dimension{
|
||||
s.Width,
|
||||
s.Height,
|
||||
s.Length,
|
||||
s.UnmatchedDepth,
|
||||
} {
|
||||
if d.IsZero() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count >= 3
|
||||
}
|
||||
|
||||
func (s GoodsItemSize) GetSum(kind DimensionKind) float64 {
|
||||
var value float64
|
||||
sum := func(ds ...Dimension) {
|
||||
for _, d := range ds {
|
||||
value += d.AdjustTo(kind).Value
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
sum(s.Height, s.Length, s.Length)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
type GoodsItem struct {
|
||||
Articul string `json:"sku"`
|
||||
PhotoURLs []string `json:"photo"`
|
||||
@ -22,6 +96,7 @@ type GoodsItem struct {
|
||||
TariffPrice float64 `json:"tariff_price"`
|
||||
Cart int64 `json:"cart"`
|
||||
Stock int `json:"stock"`
|
||||
Sizes GoodsItemSize `json:"sizes"`
|
||||
Parameters map[string]string `json:"parameters"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@ -23,7 +23,8 @@ type Offer struct {
|
||||
TypePrefix string `xml:"typePrefix"`
|
||||
Description string `xml:"description"`
|
||||
ManufacturerWarrany bool `xml:"manufacturer_warranty"`
|
||||
Params []Param `xml:"param"`
|
||||
Dimensions string `xml:"dimensions"`
|
||||
Params []Param `xml:"param,omitempty"`
|
||||
}
|
||||
|
||||
type Currency struct {
|
||||
|
||||
@ -355,8 +355,6 @@ func (c *client) getProductInfo(ctx context.Context, cartID int64) (pi entity.Go
|
||||
return strings.Contains(err.Error(), "pipe")
|
||||
})
|
||||
|
||||
c.log.Debug().Msg("using go query")
|
||||
|
||||
pi.Parameters = map[string]string{}
|
||||
resp, err := c.do(ctx, "getProductInfo", req, resty.MethodGet, reqpath)
|
||||
if err != nil {
|
||||
@ -385,7 +383,7 @@ func (c *client) getProductInfo(ctx context.Context, cartID int64) (pi entity.Go
|
||||
}
|
||||
|
||||
cleanText := func(t string) string {
|
||||
return strings.TrimSuffix(strings.TrimSpace(t), ":")
|
||||
return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(t), ":"))
|
||||
}
|
||||
|
||||
const parametersSelector = "body > div.page-container > div.page-content > div.content-wrapper > div.content > div.row > div.col-md-4 > div > div > div:nth-child(6)"
|
||||
@ -405,7 +403,10 @@ func (c *client) getProductInfo(ctx context.Context, cartID int64) (pi entity.Go
|
||||
Find(galleryPanelSelector).
|
||||
Find(galleryImageSelector).
|
||||
Each(func(i int, s *goquery.Selection) {
|
||||
imageURL, ok := s.Attr("src")
|
||||
imageURL, ok := s.Attr("data-src")
|
||||
if !ok {
|
||||
imageURL, ok = s.Attr("src")
|
||||
}
|
||||
if !ok || len(imageURL) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
13
internal/matcher/matcher.go
Normal file
13
internal/matcher/matcher.go
Normal file
@ -0,0 +1,13 @@
|
||||
package matcher
|
||||
|
||||
type Unit interface {
|
||||
MatchByPattern(value string) (pattern string)
|
||||
Match(value string) bool
|
||||
|
||||
// Move this to init stage because some of matchers
|
||||
// might not be applicable to it
|
||||
RegisterRegexp(regexpPattern string)
|
||||
Register(pattern string)
|
||||
|
||||
Patterns() []string
|
||||
}
|
||||
@ -71,7 +71,7 @@ func (r *radixMatcher) MatchByPattern(value string) (pattern string) {
|
||||
}
|
||||
|
||||
if !node.exact {
|
||||
return r.findByRegexp(value)
|
||||
return r.findByRegexp(originValue)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
|
||||
@ -11,6 +11,7 @@ func TestRadixMatcherWithPattern(t *testing.T) {
|
||||
m.Register("aloha")
|
||||
m.Register("hawaii")
|
||||
m.Register("te*")
|
||||
m.Register("Ширина")
|
||||
|
||||
var tt = []struct {
|
||||
name string
|
||||
@ -34,6 +35,12 @@ func TestRadixMatcherWithPattern(t *testing.T) {
|
||||
}, {
|
||||
name: "should not match 2",
|
||||
in: "whoa",
|
||||
}, {
|
||||
name: "should not match 3",
|
||||
in: "alohaya",
|
||||
}, {
|
||||
name: "should match exact 3",
|
||||
in: "Ширина",
|
||||
}}
|
||||
|
||||
for _, tc := range tt {
|
||||
|
||||
Reference in New Issue
Block a user